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>2023-11-02 03:11:35 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-11-02 03:11:35 +0300
commitd1bc31b8d5eba0c27d888245d5c8d3b557ebd5c6 (patch)
tree8937df23daabbd96537415114ef694beca737225
parente415571a6e766e961cd49a0ac92576c460a49e4d (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue17
-rw-r--r--app/assets/javascripts/invite_members/utils/member_utils.js4
-rw-r--r--app/assets/javascripts/organizations/profile/preferences/index.js41
-rw-r--r--app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql9
-rw-r--r--app/assets/javascripts/pages/profiles/preferences/show/index.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/constants.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue150
-rw-r--r--app/helpers/organizations/organization_helper.rb7
-rw-r--r--app/services/spam/spam_action_service.rb19
-rw-r--r--app/views/profiles/preferences/show.html.haml3
-rw-r--r--app/views/projects/_home_panel.html.haml6
-rw-r--r--app/views/shared/_ci_catalog_badge.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml4
-rw-r--r--app/workers/abuse/spam_abuse_events_worker.rb60
-rw-r--r--app/workers/all_queues.yml9
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--doc/administration/geo/index.md2
-rw-r--r--doc/security/email_verification.md2
-rw-r--r--doc/user/application_security/sast/customize_rulesets.md4
-rw-r--r--doc/user/profile/comment_templates.md4
-rw-r--r--doc/user/profile/img/comment_template_v16_6.pngbin0 -> 15154 bytes
-rw-r--r--doc/user/profile/img/saved_replies_dropdown_v16_0.pngbin16149 -> 0 bytes
-rw-r--r--doc/user/project/merge_requests/reviews/index.md8
-rw-r--r--locale/gitlab.pot21
-rwxr-xr-xscripts/lint-doc.sh2
-rw-r--r--spec/features/projects/integrations/user_activates_issue_tracker_spec.rb6
-rw-r--r--spec/features/projects/integrations/user_activates_jira_spec.rb14
-rw-r--r--spec/frontend/invite_members/utils/member_utils_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/entity_select/entity_select_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/entity_select/organization_select_spec.js179
-rw-r--r--spec/helpers/organizations/organization_helper_spec.rb10
-rw-r--r--spec/services/spam/spam_action_service_spec.rb59
-rw-r--r--spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb2
-rw-r--r--spec/workers/abuse/spam_abuse_events_worker_spec.rb85
36 files changed, 767 insertions, 36 deletions
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 5ba46697496..2863f52bea9 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -27,5 +27,6 @@ export const TYPENAME_USER = 'User';
export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner';
export const TYPENAME_VULNERABILITY = 'Vulnerability';
export const TYPENAME_WORK_ITEM = 'WorkItem';
+export const TYPENAME_ORGANIZATION = 'Organization';
export const TYPE_USERS_SAVED_REPLY = 'Users::SavedReply';
export const TYPE_WORKSPACE = 'RemoteDevelopment::Workspace';
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 505612c59da..1a10130e969 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -7,7 +7,11 @@ import Api from '~/api';
import Tracking from '~/tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { n__, s__, sprintf } from '~/locale';
-import { memberName, triggerExternalAlert } from 'ee_else_ce/invite_members/utils/member_utils';
+import {
+ memberName,
+ triggerExternalAlert,
+ inviteMembersTrackingOptions,
+} from 'ee_else_ce/invite_members/utils/member_utils';
import { captureException } from '~/ci/runner/sentry_utils';
import {
USERS_FILTER_ALL,
@@ -135,6 +139,9 @@ export default {
isCelebration() {
return this.mode === 'celebrate';
},
+ baseTrackingDetails() {
+ return { label: this.source, celebrate: this.isCelebration };
+ },
isTextForAdmin() {
return this.isCurrentUserAdmin && Boolean(this.newUsersUrl);
},
@@ -252,7 +259,7 @@ export default {
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
- this.track('render', { label: this.source });
+ this.track('render', inviteMembersTrackingOptions(this.baseTrackingDetails));
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
@@ -321,10 +328,10 @@ export default {
return this.newUsersToInvite.find((member) => memberName(member) === username)?.name;
},
onCancel() {
- this.track('click_cancel', { label: this.source });
+ this.track('click_cancel', inviteMembersTrackingOptions(this.baseTrackingDetails));
},
onClose() {
- this.track('click_x', { label: this.source });
+ this.track('click_x', inviteMembersTrackingOptions(this.baseTrackingDetails));
},
resetFields() {
this.clearValidation();
@@ -333,7 +340,7 @@ export default {
this.newUsersToInvite = [];
},
onInviteSuccess() {
- this.track('invite_successful', { label: this.source });
+ this.track('invite_successful', inviteMembersTrackingOptions(this.baseTrackingDetails));
if (this.reloadPageOnSubmit) {
reloadOnInvitationSuccess();
diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js
index 7998cb69445..52fb5e98f27 100644
--- a/app/assets/javascripts/invite_members/utils/member_utils.js
+++ b/app/assets/javascripts/invite_members/utils/member_utils.js
@@ -6,3 +6,7 @@ export function memberName(member) {
export function triggerExternalAlert() {
return false;
}
+
+export function inviteMembersTrackingOptions(options) {
+ return { label: options.label };
+}
diff --git a/app/assets/javascripts/organizations/profile/preferences/index.js b/app/assets/javascripts/organizations/profile/preferences/index.js
new file mode 100644
index 00000000000..0b0dd313cd8
--- /dev/null
+++ b/app/assets/javascripts/organizations/profile/preferences/index.js
@@ -0,0 +1,41 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { s__ } from '~/locale';
+import OrganizationSelect from '~/vue_shared/components/entity_select/organization_select.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import resolvers from '../../shared/graphql/resolvers';
+
+export const initHomeOrganizationSetting = () => {
+ const el = document.getElementById('js-home-organization-setting');
+
+ if (!el) return false;
+
+ const {
+ dataset: { appData },
+ } = el;
+ const { initialSelection } = convertObjectPropsToCamelCase(JSON.parse(appData));
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers),
+ });
+
+ return new Vue({
+ el,
+ name: 'HomeOrganizationSetting',
+ apolloProvider,
+ render(createElement) {
+ return createElement(OrganizationSelect, {
+ props: {
+ block: true,
+ label: s__('Organization|Home organization'),
+ description: s__('Organization|Choose what organization you want to see by default.'),
+ inputName: 'home_organization',
+ inputId: 'home_organization',
+ initialSelection,
+ toggleClass: 'gl-form-input-xl',
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql
new file mode 100644
index 00000000000..1d95786fcb0
--- /dev/null
+++ b/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql
@@ -0,0 +1,9 @@
+query getOrganization($id: ID!) {
+ organization(id: $id) @client {
+ id
+ name
+ descriptionHtml
+ avatarUrl
+ webUrl
+ }
+}
diff --git a/app/assets/javascripts/pages/profiles/preferences/show/index.js b/app/assets/javascripts/pages/profiles/preferences/show/index.js
index 76939434680..3668811bec7 100644
--- a/app/assets/javascripts/pages/profiles/preferences/show/index.js
+++ b/app/assets/javascripts/pages/profiles/preferences/show/index.js
@@ -1,5 +1,7 @@
import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle';
import initProfilePreferencesDiffsColors from '~/profile/preferences/profile_preferences_diffs_colors';
+import { initHomeOrganizationSetting } from '~/organizations/profile/preferences';
initProfilePreferences();
initProfilePreferencesDiffsColors();
+initHomeOrganizationSetting();
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js
index 0fb5a2d5534..5bad907c9f9 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/constants.js
+++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js
@@ -14,3 +14,13 @@ export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project');
export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project');
export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.');
export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.');
+
+// Organizations
+export const ORGANIZATION_TOGGLE_TEXT = s__('Organization|Search for an organization');
+export const ORGANIZATION_HEADER_TEXT = s__('Organization|Select an organization');
+export const FETCH_ORGANIZATIONS_ERROR = s__(
+ 'Organization|Unable to fetch organizations. Reload the page to try again.',
+);
+export const FETCH_ORGANIZATION_ERROR = s__(
+ 'Organization|Unable to fetch organizations. Reload the page to try again.',
+);
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
index 970c24c6e87..1a215454ab6 100644
--- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
@@ -22,6 +22,11 @@ export default {
type: String,
required: true,
},
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
inputName: {
type: String,
required: true,
@@ -31,7 +36,7 @@ export default {
required: true,
},
initialSelection: {
- type: String,
+ type: [String, Number],
required: false,
default: null,
},
@@ -57,6 +62,11 @@ export default {
required: false,
default: null,
},
+ toggleClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -152,6 +162,7 @@ export default {
this.searching = true;
const name = await this.fetchInitialSelectionText(this.initialSelection);
+
this.selectedValue = this.initialSelection;
this.selectedText = name;
this.pristine = false;
@@ -178,7 +189,7 @@ export default {
</script>
<template>
- <gl-form-group :label="label">
+ <gl-form-group :label="label" :description="description">
<slot name="error"></slot>
<template v-if="Boolean($scopedSlots.label)" #label>
<slot name="label"></slot>
@@ -196,6 +207,7 @@ export default {
:no-results-text="noResultsText"
:infinite-scroll="hasMoreItems"
:infinite-scroll-loading="infiniteScrollLoading"
+ :toggle-class="toggleClass"
searchable
@shown="onShown"
@search="search"
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue
new file mode 100644
index 00000000000..d068d86d95b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue
@@ -0,0 +1,150 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import getCurrentUserOrganizationsQuery from '~/organizations/index/graphql/organizations.query.graphql';
+import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql';
+import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_ORGANIZATION } from '~/graphql_shared/constants';
+import {
+ ORGANIZATION_TOGGLE_TEXT,
+ ORGANIZATION_HEADER_TEXT,
+ FETCH_ORGANIZATIONS_ERROR,
+ FETCH_ORGANIZATION_ERROR,
+} from './constants';
+import EntitySelect from './entity_select.vue';
+
+export default {
+ name: 'OrganizationSelect',
+ components: {
+ GlAlert,
+ EntitySelect,
+ },
+ props: {
+ block: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ inputName: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ initialSelection: {
+ type: [String, Number],
+ required: false,
+ default: null,
+ },
+ clearable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ toggleClass: {
+ type: [String, Array, Object],
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ errorMessage: '',
+ };
+ },
+ methods: {
+ async fetchOrganizations() {
+ try {
+ const {
+ data: {
+ currentUser: {
+ organizations: { nodes },
+ },
+ },
+ } = await this.$apollo.query({
+ query: getCurrentUserOrganizationsQuery,
+ // TODO: implement search support - https://gitlab.com/gitlab-org/gitlab/-/issues/429999.
+ });
+
+ return {
+ items: nodes.map((organization) => ({
+ text: organization.name,
+ value: getIdFromGraphQLId(organization.id),
+ })),
+ // TODO: implement pagination - https://gitlab.com/gitlab-org/gitlab/-/issues/429999.
+ totalPages: 1,
+ };
+ } catch (error) {
+ this.handleError({ message: FETCH_ORGANIZATIONS_ERROR, error });
+
+ return { items: [], totalPages: 0 };
+ }
+ },
+ async fetchOrganizationName(id) {
+ try {
+ const {
+ data: {
+ organization: { name },
+ },
+ } = await this.$apollo.query({
+ query: getOrganizationQuery,
+ variables: { id: convertToGraphQLId(TYPENAME_ORGANIZATION, id) },
+ });
+
+ return name;
+ } catch (error) {
+ this.handleError({ message: FETCH_ORGANIZATION_ERROR, error });
+
+ return '';
+ }
+ },
+ handleError({ message, error }) {
+ Sentry.captureException(error);
+ this.errorMessage = message;
+ },
+ dismissError() {
+ this.errorMessage = '';
+ },
+ },
+ i18n: {
+ toggleText: ORGANIZATION_TOGGLE_TEXT,
+ selectGroup: ORGANIZATION_HEADER_TEXT,
+ },
+};
+</script>
+
+<template>
+ <entity-select
+ :block="block"
+ :label="label"
+ :description="description"
+ :input-name="inputName"
+ :input-id="inputId"
+ :initial-selection="initialSelection"
+ :clearable="clearable"
+ :header-text="$options.i18n.selectGroup"
+ :default-toggle-text="$options.i18n.toggleText"
+ :fetch-items="fetchOrganizations"
+ :fetch-initial-selection-text="fetchOrganizationName"
+ :toggle-class="toggleClass"
+ v-on="$listeners"
+ >
+ <template #error>
+ <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{
+ errorMessage
+ }}</gl-alert>
+ </template>
+ </entity-select>
+</template>
diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb
index 312f55c11d2..61eb9b5c35f 100644
--- a/app/helpers/organizations/organization_helper.rb
+++ b/app/helpers/organizations/organization_helper.rb
@@ -48,6 +48,13 @@ module Organizations
}
end
+ def home_organization_setting_app_data
+ {
+ # TODO: use real setting - https://gitlab.com/gitlab-org/gitlab/-/issues/428668
+ initial_selection: 1
+ }.to_json
+ end
+
private
def shared_groups_and_projects_app_data
diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb
index 6ec8d09c37c..cca0bb709aa 100644
--- a/app/services/spam/spam_action_service.rb
+++ b/app/services/spam/spam_action_service.rb
@@ -78,14 +78,17 @@ module Spam
when BLOCK_USER
target.spam!
create_spam_log
+ create_spam_abuse_event(result)
ban_user!
when DISALLOW
target.spam!
create_spam_log
+ create_spam_abuse_event(result)
when CONDITIONAL_ALLOW
# This means "require a CAPTCHA to be solved"
target.needs_recaptcha!
create_spam_log
+ create_spam_abuse_event(result)
when OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM
create_spam_log
when ALLOW
@@ -118,6 +121,22 @@ module Spam
target.spam_log = spam_log
end
+ def create_spam_abuse_event(result)
+ params = {
+ user_id: user.id,
+ title: target.spam_title,
+ description: target.spam_description,
+ source_ip: spam_params&.ip_address,
+ user_agent: spam_params&.user_agent,
+ noteable_type: noteable_type,
+ verdict: result
+ }
+
+ target.run_after_commit_or_now do
+ Abuse::SpamAbuseEventsWorker.perform_async(params)
+ end
+ end
+
def ban_user!
UserCustomAttribute.set_banned_by_spam_log(target.spam_log)
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index a6534a16e86..4b0e030db24 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -78,6 +78,9 @@
= f.gitlab_ui_radio_component :layout, layout_choices[0][1], layout_choices[0][0], help_text: fixed_help_text
= f.gitlab_ui_radio_component :layout, layout_choices[1][1], layout_choices[1][0], help_text: fluid_help_text
+ - if Feature.enabled?(:ui_for_organizations, current_user)
+ #js-home-organization-setting{ data: { app_data: home_organization_setting_app_data } }
+
.js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, toggle_class: 'gl-form-input-xl' } }
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 21004b7eb8a..e8b78298eb4 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -10,10 +10,10 @@
%div
%h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { testid: 'project-name-content' }, itemprop: 'name' }
= @project.name
- = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon')
- = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2'
+ = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-mx-2', icon_css_class: 'icon')
+ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-mx-2'
- if @project.catalog_resource
- = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(@project, @project.catalog_resource) }
+ = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(@project, @project.catalog_resource), css_class: 'gl-mx-2' }
- if @project.group
= render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project'
.home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { testid: 'project-id-content' }, itemprop: 'identifier' }
diff --git a/app/views/shared/_ci_catalog_badge.html.haml b/app/views/shared/_ci_catalog_badge.html.haml
index 345bfb5e022..7f8f4f6143b 100644
--- a/app/views/shared/_ci_catalog_badge.html.haml
+++ b/app/views/shared/_ci_catalog_badge.html.haml
@@ -1 +1 @@
-= render Pajamas::BadgeComponent.new(s_('CiCatalog|CI/CD catalog resource'), variant: 'info', icon: 'catalog-checkmark', class: 'gl-mx-2', href: href)
+= render Pajamas::BadgeComponent.new(s_('CiCatalog|CI/CD catalog resource'), variant: 'info', icon: 'catalog-checkmark', class: css_class, href: href)
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index fc9522e3f32..e65dcd68f66 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -35,10 +35,10 @@
%span.project-name<
= project.name
- = visibility_level_content(project, css_class: 'gl-mr-3')
+ = visibility_level_content(project, css_class: 'gl-mr-2')
- if project.catalog_resource
- = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(project, project.catalog_resource) }
+ = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(project, project.catalog_resource), css_class: 'gl-mr-2' }
- if explore_projects_tab? && project_license_name(project)
%span.gl-display-inline-flex.gl-align-items-center.gl-mr-3
diff --git a/app/workers/abuse/spam_abuse_events_worker.rb b/app/workers/abuse/spam_abuse_events_worker.rb
new file mode 100644
index 00000000000..7d86e994ae4
--- /dev/null
+++ b/app/workers/abuse/spam_abuse_events_worker.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Abuse
+ class SpamAbuseEventsWorker
+ include ApplicationWorker
+
+ data_consistency :delayed
+
+ idempotent!
+ feature_category :instance_resiliency
+ urgency :low
+
+ def perform(params)
+ params = params.with_indifferent_access
+
+ @user = User.find_by_id(params[:user_id])
+ unless @user
+ logger.info(structured_payload(message: "User not found.", user_id: params[:user_id]))
+ return
+ end
+
+ report_user(params)
+ end
+
+ private
+
+ attr_reader :user
+
+ def report_user(params)
+ category = 'spam'
+ reporter = Users::Internal.security_bot
+ report_params = { user_id: params[:user_id],
+ reporter: reporter,
+ category: category,
+ message: 'User reported for abuse based on spam verdict' }
+
+ abuse_report = AbuseReport.by_category(category).by_reporter_id(reporter.id).by_user_id(params[:user_id]).first
+
+ abuse_report = AbuseReport.create!(report_params) if abuse_report.nil?
+
+ create_abuse_event(abuse_report.id, params)
+ end
+
+ # Associate the abuse report with an abuse event
+ def create_abuse_event(abuse_report_id, params)
+ Abuse::Event.create!(
+ abuse_report_id: abuse_report_id,
+ category: :spam,
+ metadata: { noteable_type: params[:noteable_type],
+ title: params[:title],
+ description: params[:description],
+ source_ip: params[:source_ip],
+ user_agent: params[:user_agent],
+ verdict: params[:verdict] },
+ source: :spamcheck,
+ user: user
+ )
+ end
+ end
+end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 96514f64c26..716cd494810 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -2280,6 +2280,15 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: abuse_spam_abuse_events
+ :worker_name: Abuse::SpamAbuseEventsWorker
+ :feature_category: :instance_resiliency
+ :has_external_dependencies: false
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: analytics_usage_trends_counter_job
:worker_name: Analytics::UsageTrends::CounterJobWorker
:feature_category: :devops_reports
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 65443935a54..6f1b5c544a9 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -25,6 +25,8 @@
:queues:
- - abuse_new_abuse_report
- 1
+- - abuse_spam_abuse_events
+ - 1
- - activity_pub
- 1
- - adjourned_project_deletion
diff --git a/doc/administration/geo/index.md b/doc/administration/geo/index.md
index bcf04651078..e8b2cb38563 100644
--- a/doc/administration/geo/index.md
+++ b/doc/administration/geo/index.md
@@ -19,8 +19,6 @@ Fetching large repositories can take a long time for teams located far from a si
Geo provides local, read-only sites of your GitLab instances. This can reduce the time it takes
to clone and fetch large repositories, speeding up development.
-For a video introduction to Geo, see [Introduction to GitLab Geo - GitLab Features](https://www.youtube.com/watch?v=-HDLxSjEh6w).
-
To make sure you're using the right version of the documentation, go to [the Geo page on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/geo/index.md) and choose the appropriate release from the **Switch branch/tag** dropdown list. For example, [`v13.7.6-ee`](https://gitlab.com/gitlab-org/gitlab/-/blob/v13.7.6-ee/doc/administration/geo/index.md).
Geo uses a set of defined terms that are described in the [Geo Glossary](glossary.md).
diff --git a/doc/security/email_verification.md b/doc/security/email_verification.md
index 9a698af67a1..67d8764a118 100644
--- a/doc/security/email_verification.md
+++ b/doc/security/email_verification.md
@@ -18,6 +18,8 @@ you must verify your identity or reset your password to sign in to GitLab.
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a demo, see [Require email verification - demo](https://www.youtube.com/watch?v=wU6BVEGB3Y0).
+On GitLab.com, if you don't receive a verification email, select **Resend Code** before you contact the support team.
+
## Accounts without two-factor authentication (2FA)
An account is locked when either:
diff --git a/doc/user/application_security/sast/customize_rulesets.md b/doc/user/application_security/sast/customize_rulesets.md
index 90731114303..ed3b33fc35b 100644
--- a/doc/user/application_security/sast/customize_rulesets.md
+++ b/doc/user/application_security/sast/customize_rulesets.md
@@ -597,7 +597,7 @@ rules:
The following example [enables SAST](index.md#configure-sast-in-your-cicd-yaml) and uses a shared ruleset customization file. The file is:
-- Downloaded from a private project that requires authentication, by using a [Group Access Token](../../group/settings/group_access_tokens.md).
+- Downloaded from a private project that requires authentication, by using a [Group Access Token](../../group/settings/group_access_tokens.md) securely stored within a CI variable.
- Checked out at a specific Git commit SHA instead of the default branch.
See [group access tokens](../../group/settings/group_access_tokens.md#bot-users-for-groups) for how to find the username associated with a group token.
@@ -607,5 +607,5 @@ include:
- template: Security/SAST.gitlab-ci.yml
variables:
- SAST_RULESET_GIT_REFERENCE: "group_2504721_bot_7c9311ffb83f2850e794d478ccee36f5:glpat-1234567@gitlab.com/example-group/example-ruleset-project@c8ea7e3ff126987fb4819cc35f2310755511c2ab"
+ SAST_RULESET_GIT_REFERENCE: "group_2504721_bot_7c9311ffb83f2850e794d478ccee36f5:$PERSONAL_ACCESS_TOKEN@gitlab.com/example-group/example-ruleset-project@c8ea7e3ff126987fb4819cc35f2310755511c2ab"
```
diff --git a/doc/user/profile/comment_templates.md b/doc/user/profile/comment_templates.md
index 50df5f8fdb4..5af93d7e2cf 100644
--- a/doc/user/profile/comment_templates.md
+++ b/doc/user/profile/comment_templates.md
@@ -25,7 +25,7 @@ With comment templates, create and reuse text for any text area in:
Comment templates can be small, like approving a merge request and unassigning yourself from it,
or large, like chunks of boilerplate text you use frequently:
-![Comment templates dropdown list](img/saved_replies_dropdown_v16_0.png)
+![Comment templates dropdown list](img/comment_template_v16_6.png)
## Use comment templates in a text area
@@ -65,4 +65,4 @@ To edit or delete a previously comment template:
1. On the left sidebar, select **Comment templates** (**{comment-lines}**).
1. Scroll to **My comment templates**, and identify the comment template you want to edit.
1. To edit, select **Edit** (**{pencil}**).
-1. To delete, select **Delete** (**{remove}**), then select **Delete** again from the modal window.
+1. To delete, select **Delete** (**{remove}**), then select **Delete** again on the dialog.
diff --git a/doc/user/profile/img/comment_template_v16_6.png b/doc/user/profile/img/comment_template_v16_6.png
new file mode 100644
index 00000000000..7990ca604ce
--- /dev/null
+++ b/doc/user/profile/img/comment_template_v16_6.png
Binary files differ
diff --git a/doc/user/profile/img/saved_replies_dropdown_v16_0.png b/doc/user/profile/img/saved_replies_dropdown_v16_0.png
deleted file mode 100644
index 4608484a496..00000000000
--- a/doc/user/profile/img/saved_replies_dropdown_v16_0.png
+++ /dev/null
Binary files differ
diff --git a/doc/user/project/merge_requests/reviews/index.md b/doc/user/project/merge_requests/reviews/index.md
index 1120733251f..da7c82b6558 100644
--- a/doc/user/project/merge_requests/reviews/index.md
+++ b/doc/user/project/merge_requests/reviews/index.md
@@ -341,6 +341,9 @@ from the command line by running `git checkout <branch-name>`.
### Checkout merge requests locally through the `head` ref
+> - Deleting `head` refs 14 days after a merge request closes or merges [enabled on self-managed and GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/130098) in GitLab 16.4.
+> - Deleting `head` refs 14 days after a merge request closes or merges [generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/336070) in GitLab 16.6. Feature flag `merge_request_refs_cleanup` removed.
+
A merge request contains all the history from a repository, plus the additional
commits added to the branch associated with the merge request. Here's a few
ways to check out a merge request locally.
@@ -352,9 +355,8 @@ This relies on the merge request `head` ref (`refs/merge-requests/:iid/head`)
that is available for each merge request. It allows checking out a merge
request by using its ID instead of its branch.
-[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223156) in GitLab
-13.4, 14 days after a merge request gets closed or merged, the merge request
-`head` ref is deleted. This means that the merge request isn't available
+In GitLab 16.6 and later, the merge request `head` ref is deleted 14 days after
+a merge request is closed or merged. The merge request is then no longer available
for local checkout from the merge request `head` ref anymore. The merge request
can still be re-opened. If the merge request's branch
exists, you can still check out the branch, as it isn't affected.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c0fb160431f..ead2e432b72 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -21623,9 +21623,6 @@ msgstr ""
msgid "Geo|URL must be a valid url (ex: https://gitlab.com)"
msgstr ""
-msgid "Geo|Undefined"
-msgstr ""
-
msgid "Geo|Unhealthy"
msgstr ""
@@ -33394,6 +33391,9 @@ msgstr ""
msgid "Organization|An error occurred updating your organization. Please try again."
msgstr ""
+msgid "Organization|Choose what organization you want to see by default."
+msgstr ""
+
msgid "Organization|Copy organization ID"
msgstr ""
@@ -33412,6 +33412,9 @@ msgstr ""
msgid "Organization|Get started with organizations"
msgstr ""
+msgid "Organization|Home organization"
+msgstr ""
+
msgid "Organization|Manage"
msgstr ""
@@ -33463,9 +33466,18 @@ msgstr ""
msgid "Organization|Public - The organization can be accessed without any authentication."
msgstr ""
+msgid "Organization|Search for an organization"
+msgstr ""
+
msgid "Organization|Search or filter list"
msgstr ""
+msgid "Organization|Select an organization"
+msgstr ""
+
+msgid "Organization|Unable to fetch organizations. Reload the page to try again."
+msgstr ""
+
msgid "Organization|Update your organization name, description, and avatar."
msgstr ""
@@ -57809,6 +57821,9 @@ msgstr ""
msgid "sign in"
msgstr ""
+msgid "site"
+msgstr ""
+
msgid "smartcn custom analyzer"
msgstr ""
diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh
index 57664240c11..46d7159d71f 100755
--- a/scripts/lint-doc.sh
+++ b/scripts/lint-doc.sh
@@ -143,7 +143,7 @@ then
ruby -r './tooling/lib/tooling/find_changes' -e "Tooling::FindChanges.new(
from: :api,
changed_files_pathname: '${DOC_CHANGES_FILE}',
- file_filter: ->(file) { !file['deleted_file'] && file['new_path'] =~ %r{doc/.*|lint-doc\.sh|docs\.gitlab-ci\.yml} }
+ file_filter: ->(file) { !file['deleted_file'] && file['new_path'] =~ %r{doc/.*\.md|lint-doc\.sh|docs\.gitlab-ci\.yml} }
).execute"
if grep -E "\.vale|\.markdownlint|lint-doc\.sh|docs\.gitlab-ci\.yml" < $DOC_CHANGES_FILE
then
diff --git a/spec/features/projects/integrations/user_activates_issue_tracker_spec.rb b/spec/features/projects/integrations/user_activates_issue_tracker_spec.rb
index 9fc91e03c94..944a2c164d5 100644
--- a/spec/features/projects/integrations/user_activates_issue_tracker_spec.rb
+++ b/spec/features/projects/integrations/user_activates_issue_tracker_spec.rb
@@ -38,7 +38,8 @@ RSpec.describe 'User activates issue tracker', :js, feature_category: :integrati
end
it 'shows the link in the menu' do
- page.within('.nav-sidebar') do
+ within_testid('super-sidebar') do
+ click_button 'Plan'
expect(page).to have_link(tracker, href: url)
end
end
@@ -77,7 +78,8 @@ RSpec.describe 'User activates issue tracker', :js, feature_category: :integrati
end
it 'does not show the external tracker link in the menu' do
- page.within('.nav-sidebar') do
+ within_testid('super-sidebar') do
+ click_button 'Plan'
expect(page).not_to have_link(tracker, href: url)
end
end
diff --git a/spec/features/projects/integrations/user_activates_jira_spec.rb b/spec/features/projects/integrations/user_activates_jira_spec.rb
index 0bd5020e9bf..cc0d4c6f564 100644
--- a/spec/features/projects/integrations/user_activates_jira_spec.rb
+++ b/spec/features/projects/integrations/user_activates_jira_spec.rb
@@ -25,10 +25,11 @@ RSpec.describe 'User activates Jira', :js, feature_category: :integrations do
unless Gitlab.ee?
it 'adds Jira link to sidebar menu' do
- page.within('.nav-sidebar') do
- expect(page).not_to have_link('Jira issues', visible: false)
- expect(page).not_to have_link('Open Jira', href: url, visible: false)
- expect(page).to have_link('Jira', href: url)
+ within_testid('super-sidebar') do
+ click_button 'Plan'
+ expect(page).not_to have_link('Jira issues')
+ expect(page).not_to have_link('Open Jira')
+ expect(page).to have_link(exact_text: 'Jira', href: url)
end
end
end
@@ -76,8 +77,9 @@ RSpec.describe 'User activates Jira', :js, feature_category: :integrations do
end
it 'does not show the Jira link in the menu' do
- page.within('.nav-sidebar') do
- expect(page).not_to have_link('Jira', href: url)
+ within_testid('super-sidebar') do
+ click_button 'Plan'
+ expect(page).not_to have_link('Jira')
end
end
end
diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js
index abae43c3dbb..4d71a35ff99 100644
--- a/spec/frontend/invite_members/utils/member_utils_spec.js
+++ b/spec/frontend/invite_members/utils/member_utils_spec.js
@@ -1,4 +1,8 @@
-import { memberName, triggerExternalAlert } from '~/invite_members/utils/member_utils';
+import {
+ memberName,
+ triggerExternalAlert,
+ inviteMembersTrackingOptions,
+} from '~/invite_members/utils/member_utils';
jest.mock('~/lib/utils/url_utility');
@@ -18,3 +22,13 @@ describe('Trigger External Alert', () => {
expect(triggerExternalAlert()).toBe(false);
});
});
+
+describe('inviteMembersTrackingOptions', () => {
+ it('returns options with a label', () => {
+ expect(inviteMembersTrackingOptions({ label: '_label_' })).toEqual({ label: '_label_' });
+ });
+
+ it('handles options that has no label', () => {
+ expect(inviteMembersTrackingOptions({})).toEqual({ label: undefined });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
index 36772ad03fe..72d9f699821 100644
--- a/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
+++ b/spec/frontend/vue_shared/components/entity_select/entity_select_spec.js
@@ -23,10 +23,13 @@ describe('EntitySelect', () => {
// Props
const label = 'label';
+ const description = 'description';
const inputName = 'inputName';
const inputId = 'inputId';
const headerText = 'headerText';
const defaultToggleText = 'defaultToggleText';
+ const toggleClass = 'foo-bar';
+ const block = true;
// Finders
const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
@@ -37,11 +40,14 @@ describe('EntitySelect', () => {
wrapper = shallowMountExtended(EntitySelect, {
propsData: {
label,
+ description,
inputName,
inputId,
headerText,
defaultToggleText,
fetchItems: fetchItemsMock,
+ toggleClass,
+ block,
...props,
},
stubs: {
@@ -65,6 +71,21 @@ describe('EntitySelect', () => {
fetchItemsMock = jest.fn().mockImplementation(() => ({ items: [itemMock], totalPages: 1 }));
});
+ describe('GlCollapsableListbox props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ prop | expectedValue
+ ${'block'} | ${block}
+ ${'toggleClass'} | ${toggleClass}
+ ${'headerText'} | ${headerText}
+ `('passes the $prop prop to GlCollapsableListbox', ({ prop, expectedValue }) => {
+ expect(findListbox().props(prop)).toBe(expectedValue);
+ });
+ });
+
describe('on mount', () => {
it('calls the fetch function when the listbox is opened', async () => {
createComponent();
@@ -114,6 +135,12 @@ describe('EntitySelect', () => {
expect(wrapper.findByTestId(testid).exists()).toBe(true);
});
+ it('passes description prop to GlFormGroup', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GlFormGroup).attributes('description')).toBe(description);
+ });
+
describe('selection', () => {
it('uses the default toggle text while no group is selected', () => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js
new file mode 100644
index 00000000000..ea029ba4f27
--- /dev/null
+++ b/spec/frontend/vue_shared/components/entity_select/organization_select_spec.js
@@ -0,0 +1,179 @@
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import OrganizationSelect from '~/vue_shared/components/entity_select/organization_select.vue';
+import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue';
+import {
+ ORGANIZATION_TOGGLE_TEXT,
+ ORGANIZATION_HEADER_TEXT,
+ FETCH_ORGANIZATIONS_ERROR,
+ FETCH_ORGANIZATION_ERROR,
+} from '~/vue_shared/components/entity_select/constants';
+import resolvers from '~/organizations/shared/graphql/resolvers';
+import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql';
+import { organizations as organizationsMock } from '~/organizations/mock_data';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+
+Vue.use(VueApollo);
+
+jest.useFakeTimers();
+
+describe('OrganizationSelect', () => {
+ let wrapper;
+ let mockApollo;
+
+ // Mocks
+ const [organizationMock] = organizationsMock;
+
+ // Stubs
+ const GlAlert = {
+ template: '<div><slot /></div>',
+ };
+
+ // Props
+ const label = 'label';
+ const description = 'description';
+ const inputName = 'inputName';
+ const inputId = 'inputId';
+ const toggleClass = 'foo-bar';
+
+ // Finders
+ const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findEntitySelect = () => wrapper.findComponent(EntitySelect);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ const handleInput = jest.fn();
+
+ // Helpers
+ const createComponent = ({ props = {}, mockResolvers = resolvers, handlers } = {}) => {
+ mockApollo = createMockApollo(
+ handlers || [
+ [
+ organizationsQuery,
+ jest.fn().mockResolvedValueOnce({
+ data: { currentUser: { id: 1, organizations: { nodes: organizationsMock } } },
+ }),
+ ],
+ ],
+ mockResolvers,
+ );
+
+ wrapper = shallowMountExtended(OrganizationSelect, {
+ apolloProvider: mockApollo,
+ propsData: {
+ label,
+ description,
+ inputName,
+ inputId,
+ toggleClass,
+ ...props,
+ },
+ stubs: {
+ GlAlert,
+ EntitySelect,
+ },
+ listeners: {
+ input: handleInput,
+ },
+ });
+ };
+ const openListbox = () => findListbox().vm.$emit('shown');
+
+ afterEach(() => {
+ mockApollo = null;
+ });
+
+ describe('entity_select props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ prop | expectedValue
+ ${'label'} | ${label}
+ ${'description'} | ${description}
+ ${'inputName'} | ${inputName}
+ ${'inputId'} | ${inputId}
+ ${'defaultToggleText'} | ${ORGANIZATION_TOGGLE_TEXT}
+ ${'headerText'} | ${ORGANIZATION_HEADER_TEXT}
+ ${'toggleClass'} | ${toggleClass}
+ `('passes the $prop prop to entity-select', ({ prop, expectedValue }) => {
+ expect(findEntitySelect().props(prop)).toBe(expectedValue);
+ });
+ });
+
+ describe('on mount', () => {
+ it('fetches organizations when the listbox is opened', async () => {
+ createComponent();
+ await nextTick();
+ jest.runAllTimers();
+ await waitForPromises();
+
+ openListbox();
+ jest.runAllTimers();
+ await waitForPromises();
+ expect(findListbox().props('items')).toEqual([
+ { text: organizationsMock[0].name, value: 1 },
+ { text: organizationsMock[1].name, value: 2 },
+ { text: organizationsMock[2].name, value: 3 },
+ ]);
+ });
+
+ describe('with an initial selection', () => {
+ it("fetches the initially selected value's name", async () => {
+ createComponent({ props: { initialSelection: organizationMock.id } });
+ await nextTick();
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(findListbox().props('toggleText')).toBe(organizationMock.name);
+ });
+
+ it('show an error if fetching initially selected fails', async () => {
+ const mockResolvers = {
+ Query: {
+ organization: jest.fn().mockRejectedValueOnce(new Error()),
+ },
+ };
+
+ createComponent({ props: { initialSelection: organizationMock.id }, mockResolvers });
+ await nextTick();
+ jest.runAllTimers();
+
+ expect(findAlert().exists()).toBe(false);
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(FETCH_ORGANIZATION_ERROR);
+ });
+ });
+ });
+
+ it('shows an error when fetching organizations fails', async () => {
+ createComponent({
+ handlers: [[organizationsQuery, jest.fn().mockRejectedValueOnce(new Error())]],
+ });
+ await nextTick();
+ jest.runAllTimers();
+ await waitForPromises();
+
+ openListbox();
+ expect(findAlert().exists()).toBe(false);
+
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(FETCH_ORGANIZATIONS_ERROR);
+ });
+
+ it('forwards events to the parent scope via `v-on="$listeners"`', () => {
+ createComponent();
+ findEntitySelect().vm.$emit('input');
+
+ expect(handleInput).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/helpers/organizations/organization_helper_spec.rb b/spec/helpers/organizations/organization_helper_spec.rb
index 77ff508b1c8..9d55d2a84f8 100644
--- a/spec/helpers/organizations/organization_helper_spec.rb
+++ b/spec/helpers/organizations/organization_helper_spec.rb
@@ -92,6 +92,16 @@ RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do
end
end
+ describe '#home_organization_setting_app_data' do
+ it 'returns expected json' do
+ expect(Gitlab::Json.parse(helper.home_organization_setting_app_data)).to eq(
+ {
+ 'initial_selection' => 1
+ }
+ )
+ end
+ end
+
describe '#organization_settings_general_app_data' do
it 'returns expected json' do
expect(Gitlab::Json.parse(helper.organization_settings_general_app_data(organization))).to eq(
diff --git a/spec/services/spam/spam_action_service_spec.rb b/spec/services/spam/spam_action_service_spec.rb
index 4133609d9ae..d8fd09ebd07 100644
--- a/spec/services/spam/spam_action_service_spec.rb
+++ b/spec/services/spam/spam_action_service_spec.rb
@@ -85,6 +85,26 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
end
end
+ shared_examples 'calls SpamAbuseEventsWorker with correct arguments' do
+ let(:params) do
+ {
+ user_id: user.id,
+ title: target.title,
+ description: target.spam_description,
+ source_ip: fake_ip,
+ user_agent: fake_user_agent,
+ noteable_type: target_type,
+ verdict: verdict
+ }
+ end
+
+ it do
+ expect(::Abuse::SpamAbuseEventsWorker).to receive(:perform_async).with(params)
+
+ subject
+ end
+ end
+
shared_examples 'execute spam action service' do |target_type|
let(:fake_captcha_verification_service) { double(:captcha_verification_service) }
let(:fake_verdict_service) { double(:spam_verdict_service) }
@@ -161,6 +181,12 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
it 'does not create a spam log' do
expect { subject }.not_to change(SpamLog, :count)
end
+
+ it 'does not call SpamAbuseEventsWorker' do
+ expect(::Abuse::SpamAbuseEventsWorker).not_to receive(:perform_async)
+
+ subject
+ end
end
context 'when spammable attributes have changed' do
@@ -213,6 +239,11 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
it_behaves_like 'creates a spam log', target_type
+ it_behaves_like 'calls SpamAbuseEventsWorker with correct arguments' do
+ let(:verdict) { DISALLOW }
+ let(:target_type) { target_type }
+ end
+
it 'marks as spam' do
response = subject
@@ -231,6 +262,11 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
it_behaves_like 'creates a spam log', target_type
+ it_behaves_like 'calls SpamAbuseEventsWorker with correct arguments' do
+ let(:verdict) { BLOCK_USER }
+ let(:target_type) { target_type }
+ end
+
it 'marks as spam' do
response = subject
@@ -254,6 +290,11 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
it_behaves_like 'creates a spam log', target_type
+ it_behaves_like 'calls SpamAbuseEventsWorker with correct arguments' do
+ let(:verdict) { CONDITIONAL_ALLOW }
+ let(:target_type) { target_type }
+ end
+
it 'does not mark as spam' do
response = subject
@@ -276,6 +317,12 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
it_behaves_like 'creates a spam log', target_type
+ it 'does not call SpamAbuseEventsWorker' do
+ expect(::Abuse::SpamAbuseEventsWorker).not_to receive(:perform_async)
+
+ subject
+ end
+
it 'does not mark as spam' do
response = subject
@@ -300,6 +347,12 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
expect { subject }.not_to change(SpamLog, :count)
end
+ it 'does not call SpamAbuseEventsWorker' do
+ expect(::Abuse::SpamAbuseEventsWorker).not_to receive(:perform_async)
+
+ subject
+ end
+
it 'clears spam flags' do
expect(target).to receive(:clear_spam_flags!)
@@ -316,6 +369,12 @@ RSpec.describe Spam::SpamActionService, feature_category: :instance_resiliency d
expect { subject }.not_to change(SpamLog, :count)
end
+ it 'does not call SpamAbuseEventsWorker' do
+ expect(::Abuse::SpamAbuseEventsWorker).not_to receive(:perform_async)
+
+ subject
+ end
+
it 'clears spam flags' do
expect(target).to receive(:clear_spam_flags!)
diff --git a/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb b/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb
index c3da9435e05..743a7cd26e0 100644
--- a/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb
+++ b/spec/support/shared_contexts/features/integrations/project_integrations_shared_context.rb
@@ -4,7 +4,7 @@ RSpec.shared_context 'project integration activation' do
include_context 'with integration activation'
let_it_be(:project) { create(:project) }
- let_it_be(:user) { create(:user, :no_super_sidebar) }
+ let_it_be(:user) { create(:user) }
before do
project.add_maintainer(user)
diff --git a/spec/workers/abuse/spam_abuse_events_worker_spec.rb b/spec/workers/abuse/spam_abuse_events_worker_spec.rb
new file mode 100644
index 00000000000..9198636e114
--- /dev/null
+++ b/spec/workers/abuse/spam_abuse_events_worker_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Abuse::SpamAbuseEventsWorker, :clean_gitlab_redis_shared_state, feature_category: :instance_resiliency do
+ let(:worker) { described_class.new }
+ let_it_be(:user) { create(:user) }
+
+ let(:params) do
+ {
+ user_id: user.id,
+ title: 'Test title',
+ description: 'Test description',
+ source_ip: '1.2.3.4',
+ user_agent: 'fake-user-agent',
+ noteable_type: 'Issue',
+ verdict: 'BLOCK_USER'
+ }
+ end
+
+ shared_examples 'creates an abuse event with the correct data' do
+ it do
+ expect { worker.perform(params) }.to change { Abuse::Event.count }.from(0).to(1)
+ expect(Abuse::Event.last.attributes).to include({
+ abuse_report_id: report_id,
+ category: "spam",
+ metadata: params.except(:user_id),
+ source: "spamcheck",
+ user_id: params[:user_id]
+ }.deep_stringify_keys)
+ end
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [params] }
+ end
+
+ context "when the user does not exist" do
+ let(:log_payload) { { 'message' => 'User not found.', 'user_id' => user.id } }
+
+ before do
+ allow(User).to receive(:find_by_id).with(user.id).and_return(nil)
+ end
+
+ it 'logs an error' do
+ expect(Sidekiq.logger).to receive(:info).with(hash_including(log_payload))
+
+ expect { worker.perform(params) }.not_to raise_exception
+ end
+
+ it 'does not report the user' do
+ expect(described_class).not_to receive(:report_user).with(user.id)
+
+ worker.perform(params)
+ end
+ end
+
+ context "when the user exists" do
+ context 'and there is an existing abuse report' do
+ let_it_be(:abuse_report) do
+ create(:abuse_report, user: user, reporter: Users::Internal.security_bot, message: 'Test report')
+ end
+
+ it_behaves_like 'creates an abuse event with the correct data' do
+ let(:report_id) { abuse_report.id }
+ end
+ end
+
+ context 'and there is no existing abuse report' do
+ it 'creates an abuse report with the correct data' do
+ expect { worker.perform(params) }.to change { AbuseReport.count }.from(0).to(1)
+ expect(AbuseReport.last.attributes).to include({
+ reporter_id: Users::Internal.security_bot.id,
+ user_id: user.id,
+ category: "spam",
+ message: "User reported for abuse based on spam verdict"
+ }.stringify_keys)
+ end
+
+ it_behaves_like 'creates an abuse event with the correct data' do
+ let(:report_id) { AbuseReport.last.attributes["id"] }
+ end
+ end
+ end
+end