diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-02 03:11:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-02 03:11:35 +0300 |
commit | d1bc31b8d5eba0c27d888245d5c8d3b557ebd5c6 (patch) | |
tree | 8937df23daabbd96537415114ef694beca737225 | |
parent | e415571a6e766e961cd49a0ac92576c460a49e4d (diff) |
Add latest changes from gitlab-org/gitlab@master
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 Binary files differnew file mode 100644 index 00000000000..7990ca604ce --- /dev/null +++ b/doc/user/profile/img/comment_template_v16_6.png diff --git a/doc/user/profile/img/saved_replies_dropdown_v16_0.png b/doc/user/profile/img/saved_replies_dropdown_v16_0.png Binary files differdeleted file mode 100644 index 4608484a496..00000000000 --- a/doc/user/profile/img/saved_replies_dropdown_v16_0.png +++ /dev/null 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 |