diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-24 21:09:14 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-24 21:09:14 +0300 |
commit | ca386bfc0cf083e0ccb477995378061fc2a15b66 (patch) | |
tree | f53d4a2f288ba64e9f440080817f14d62965398c | |
parent | 61ebd5753018a1f4b6032122f6ea625dc4e4fc8e (diff) |
Add latest changes from gitlab-org/gitlab@master
84 files changed, 1985 insertions, 571 deletions
diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md index 254e90d5fb0..3cb3f6473e6 100644 --- a/.gitlab/merge_request_templates/Default.md +++ b/.gitlab/merge_request_templates/Default.md @@ -1,8 +1,8 @@ ## What does this MR do and why? -_Describe in detail what your merge request does and why._ - <!-- +Describe in detail what your merge request does and why. + Please keep this description updated with any discussion that takes place so that reviewers can understand your intent. Keeping the description updated is especially important if they didn't participate in the discussion. diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue index 9355c1c788f..1490d7e64f5 100644 --- a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue @@ -1,12 +1,20 @@ <script> +import { GlAlert } from '@gitlab/ui'; import ReportHeader from './report_header.vue'; import UserDetails from './user_details.vue'; import ReportedContent from './reported_content.vue'; import HistoryItems from './history_items.vue'; +const alertDefaults = { + visible: false, + variant: '', + message: '', +}; + export default { name: 'AbuseReportApp', components: { + GlAlert, ReportHeader, UserDetails, ReportedContent, @@ -18,15 +26,34 @@ export default { required: true, }, }, + data() { + return { + alert: { ...alertDefaults }, + }; + }, + methods: { + showAlert(variant, message) { + this.alert.visible = true; + this.alert.variant = variant; + this.alert.message = message; + }, + closeAlert() { + this.alert = { ...alertDefaults }; + }, + }, }; </script> <template> <section> + <gl-alert v-if="alert.visible" :variant="alert.variant" class="gl-mt-4" @dismiss="closeAlert">{{ + alert.message + }}</gl-alert> <report-header v-if="abuseReport.user" :user="abuseReport.user" - :actions="abuseReport.actions" + :report="abuseReport.report" + @showAlert="showAlert" /> <user-details v-if="abuseReport.user" :user="abuseReport.user" /> <reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" /> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_actions.vue b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue new file mode 100644 index 00000000000..bb6e022da54 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/report_actions.vue @@ -0,0 +1,196 @@ +<script> +import { + GlForm, + GlFormGroup, + GlFormSelect, + GlFormCheckbox, + GlFormInput, + GlButton, + GlDrawer, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { + ACTIONS_I18N, + USER_ACTION_OPTIONS, + REASON_OPTIONS, + STATUS_OPEN, + SUCCESS_ALERT, + FAILED_ALERT, + ERROR_MESSAGE, +} from '../constants'; + +const formDefaults = { + user_action: '', + close: false, + comment: '', + reason: '', +}; + +export default { + name: 'ReportActions', + components: { + GlForm, + GlFormGroup, + GlFormSelect, + GlFormCheckbox, + GlFormInput, + GlButton, + GlDrawer, + }, + props: { + report: { + type: Object, + required: true, + }, + }, + data() { + return { + showActionsDrawer: false, + validationState: { + reason: true, + action: true, + }, + form: { ...formDefaults }, + }; + }, + computed: { + drawerOffsetTop() { + if (!this.showActionsDrawer || gon.use_new_navigation) return '0'; + return getContentWrapperHeight('.content-wrapper'); + }, + isFormValid() { + return Object.values(this.validationState).every(Boolean); + }, + isOpen() { + return this.report.status === STATUS_OPEN; + }, + }, + methods: { + toggleActionsDrawer() { + this.showActionsDrawer = !this.showActionsDrawer; + }, + validateReason() { + this.validationState.reason = Boolean(this.form.reason?.length); + }, + validateAction() { + this.validationState.action = Boolean(this.form.user_action?.length) || this.form.close; + }, + submitForm() { + this.triggerValidation(); + + if (!this.isFormValid) { + return; + } + + axios + .put(this.report.updatePath, this.form) + .then(this.handleResponse) + .catch(this.handleError); + }, + handleResponse({ data }) { + this.toggleActionsDrawer(); + this.$emit('showAlert', SUCCESS_ALERT, data.message); + if (this.form.close) { + this.$emit('closeReport'); + } + this.resetForm(); + }, + handleError({ response }) { + this.toggleActionsDrawer(); + const message = response?.data?.message || ERROR_MESSAGE; + this.$emit('showAlert', FAILED_ALERT, message); + }, + resetForm() { + this.form = { ...formDefaults }; + }, + triggerValidation() { + this.validateReason(); + this.validateAction(); + }, + }, + i18n: ACTIONS_I18N, + userActionOptions: USER_ACTION_OPTIONS, + reasonOptions: REASON_OPTIONS, + DRAWER_Z_INDEX, +}; +</script> + +<template> + <div> + <gl-button class="gl-w-full" data-testid="actions-button" @click="toggleActionsDrawer"> + {{ $options.i18n.actions }} + </gl-button> + <gl-drawer + :open="showActionsDrawer" + :header-height="drawerOffsetTop" + :z-index="$options.DRAWER_Z_INDEX" + @close="toggleActionsDrawer" + > + <template #title> + <div class="gl-font-weight-bold gl-font-size-h2">{{ $options.i18n.actions }}</div> + </template> + <template #default> + <gl-form @submit.prevent="submitForm"> + <gl-form-group + data-testid="action" + :label="$options.i18n.action" + label-for="action" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + :state="validationState.action" + > + <gl-form-select + id="action" + v-model="form.user_action" + data-testid="action-select" + :options="$options.userActionOptions" + :state="validationState.action" + @change="validateAction" + /> + </gl-form-group> + <gl-form-group v-if="isOpen"> + <gl-form-checkbox v-model="form.close" data-testid="close" @change="validateAction"> + {{ $options.i18n.closeReport }} + </gl-form-checkbox> + </gl-form-group> + <gl-form-group + data-testid="reason" + :label="$options.i18n.reason" + label-for="reason" + :invalid-feedback="$options.i18n.requiredFieldFeedback" + :state="validationState.reason" + > + <gl-form-select + id="reason" + v-model="form.reason" + data-testid="reason-select" + :options="$options.reasonOptions" + :state="validationState.reason" + @change="validateReason" + /> + </gl-form-group> + <gl-form-group + :optional="true" + optional-text="(optional)" + :label="$options.i18n.comment" + label-for="comment" + > + <gl-form-input id="comment" v-model="form.comment" data-testid="comment" /> + </gl-form-group> + </gl-form> + </template> + <template #footer> + <gl-button + variant="confirm" + block + :disabled="!isFormValid" + data-testid="submit-button" + @click="submitForm" + > + {{ $options.i18n.confirm }} + </gl-button> + </template> + </gl-drawer> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue index 54586041354..1b9a6a1df6a 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_header.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue @@ -1,26 +1,55 @@ <script> -import { GlAvatar, GlButton, GlLink } from '@gitlab/ui'; -import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; -import { REPORT_HEADER_I18N } from '../constants'; +import { GlBadge, GlIcon, GlAvatar, GlButton, GlLink } from '@gitlab/ui'; +import { REPORT_HEADER_I18N, STATUS_OPEN, STATUS_CLOSED } from '../constants'; +import ReportActions from './report_actions.vue'; export default { name: 'ReportHeader', components: { + GlBadge, + GlIcon, GlAvatar, GlButton, GlLink, - AbuseReportActions, + ReportActions, }, props: { user: { type: Object, required: true, }, - actions: { + report: { type: Object, required: true, }, }, + data() { + return { + state: this.report.status, + }; + }, + computed: { + isOpen() { + return this.state === STATUS_OPEN; + }, + badgeClass() { + return this.isOpen ? 'issuable-status-badge-open' : 'issuable-status-badge-closed'; + }, + badgeVariant() { + return this.isOpen ? 'success' : 'info'; + }, + badgeText() { + return REPORT_HEADER_I18N[this.state]; + }, + badgeIcon() { + return this.isOpen ? 'issues' : 'issue-closed'; + }, + }, + methods: { + closeReport() { + this.state = STATUS_CLOSED; + }, + }, i18n: REPORT_HEADER_I18N, }; </script> @@ -30,17 +59,33 @@ export default { class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" > <div class="gl-display-flex gl-align-items-center"> + <gl-badge + class="issuable-status-badge gl-mr-3" + :class="badgeClass" + :variant="badgeVariant" + :aria-label="badgeText" + > + <gl-icon :name="badgeIcon" class="gl-badge-icon" /> + <span class="gl-display-none gl-sm-display-block gl-ml-2">{{ badgeText }}</span> + </gl-badge> <gl-avatar :size="48" :src="user.avatarUrl" /> <h1 class="gl-font-size-h-display gl-my-0 gl-ml-3"> {{ user.name }} </h1> <gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link> </div> - <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0"> - <gl-button :href="user.adminPath" class="flex-grow-1"> + <nav + class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0 gl-xs-flex-direction-column" + > + <gl-button :href="user.adminPath"> {{ $options.i18n.adminProfile }} </gl-button> - <abuse-report-actions :report="actions" class="gl-sm-ml-3" /> + <report-actions + :report="report" + class="gl-sm-ml-3 gl-mt-3 gl-sm-mt-0" + @closeReport="closeReport" + v-on="$listeners" + /> </nav> </header> </template> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index a59e10b5d4a..fb2acd5921d 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -1,9 +1,55 @@ -import { s__, n__ } from '~/locale'; +import { s__, n__, __ } from '~/locale'; + +export const STATUS_OPEN = 'open'; +export const STATUS_CLOSED = 'closed'; + +export const SUCCESS_ALERT = 'success'; +export const FAILED_ALERT = 'danger'; + +export const ERROR_MESSAGE = __('Something went wrong. Please try again.'); export const REPORT_HEADER_I18N = { adminProfile: s__('AbuseReport|Admin profile'), + open: __('Open'), + closed: __('Closed'), +}; + +export const ACTIONS_I18N = { + actions: s__('AbuseReport|Actions'), + confirm: s__('AbuseReport|Confirm'), + action: s__('AbuseReport|Action'), + reason: s__('AbuseReport|Reason'), + comment: s__('AbuseReport|Comment'), + closeReport: s__('AbuseReport|Close report'), + requiredFieldFeedback: __('This field is required.'), }; +export const USER_ACTION_OPTIONS = [ + { value: '', text: s__('AbuseReport|No action') }, + { value: 'block_user', text: s__('AbuseReport|Block user') }, + { value: 'ban_user', text: s__('AbuseReport|Ban user') }, + { value: 'delete_user', text: s__('AbuseReport|Delete user') }, +]; + +export const REASON_OPTIONS = [ + { value: '', text: '' }, + { value: 'spam', text: s__('AbuseReport|Confirmed spam') }, + { value: 'offensive', text: s__('AbuseReport|Confirmed offensive or abusive behavior') }, + { value: 'phishing', text: s__('AbuseReport|Confirmed phishing') }, + { value: 'crypto', text: s__('AbuseReport|Confirmed crypto mining') }, + { + value: 'credentials', + text: s__('AbuseReport|Confirmed posting of personal information or credentials'), + }, + { + value: 'copyright', + text: s__('AbuseReport|Confirmed violation of a copyright or a trademark'), + }, + { value: 'malware', text: s__('AbuseReport|Confirmed posting of malware') }, + { value: 'other', text: s__('AbuseReport|Something else') }, + { value: 'unconfirmed', text: s__('AbuseReport|Abuse unconfirmed') }, +]; + export const USER_DETAILS_I18N = { createdAt: s__('AbuseReport|Member since'), email: s__('AbuseReport|Email'), diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue deleted file mode 100644 index 5d42caa75ab..00000000000 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue +++ /dev/null @@ -1,177 +0,0 @@ -<script> -import { GlDisclosureDropdown, GlModal } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { __, sprintf } from '~/locale'; -import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { ACTIONS_I18N } from '../constants'; - -const modalActionButtonAttributes = { - block: { - text: __('OK'), - attributes: { - variant: 'confirm', - }, - }, - removeUserAndReport: { - text: __('OK'), - attributes: { - variant: 'danger', - }, - }, - secondary: { - text: __('Cancel'), - attributes: { - variant: 'default', - }, - }, -}; -const BLOCK_ACTION = 'block'; -const REMOVE_USER_AND_REPORT_ACTION = 'removeUserAndReport'; - -export default { - name: 'AbuseReportActions', - components: { - GlDisclosureDropdown, - GlModal, - }, - modalId: 'abuse-report-row-action-confirm-modal', - modalActionButtonAttributes, - i18n: ACTIONS_I18N, - props: { - report: { - type: Object, - required: true, - }, - }, - data() { - return { - userBlocked: this.report.userBlocked, - confirmModalShown: false, - actionToConfirm: 'block', - }; - }, - computed: { - blockUserButtonText() { - const { alreadyBlocked, blockUser } = this.$options.i18n; - - return this.userBlocked ? alreadyBlocked : blockUser; - }, - removeUserAndReportConfirmText() { - return sprintf(this.$options.i18n.removeUserAndReportConfirm, { - user: this.report.reportedUser.name, - }); - }, - modalData() { - return { - [BLOCK_ACTION]: { - action: this.blockUser, - confirmText: this.$options.i18n.blockUserConfirm, - }, - [REMOVE_USER_AND_REPORT_ACTION]: { - action: this.removeUserAndReport, - confirmText: this.removeUserAndReportConfirmText, - }, - }; - }, - reportActionsDropdownItems() { - return [ - { - text: this.$options.i18n.removeUserAndReport, - action: () => { - this.showConfirmModal(REMOVE_USER_AND_REPORT_ACTION); - }, - extraAttrs: { class: 'gl-text-red-500!' }, - }, - { - text: this.blockUserButtonText, - action: () => { - this.showConfirmModal(BLOCK_ACTION); - }, - extraAttrs: { - disabled: this.userBlocked, - 'data-testid': 'block-user-button', - }, - }, - { - text: this.$options.i18n.removeReport, - action: () => { - this.removeReport(); - }, - }, - ]; - }, - }, - methods: { - showConfirmModal(action) { - this.confirmModalShown = true; - this.actionToConfirm = action; - }, - blockUser() { - axios - .put(this.report.blockUserPath) - .then(this.handleBlockUserResponse) - .catch(this.handleError); - }, - removeUserAndReport() { - axios - .delete(this.report.removeUserAndReportPath) - .then(this.handleRemoveReportResponse) - .catch(this.handleError); - }, - removeReport() { - axios - .delete(this.report.removeReportPath) - .then(this.handleRemoveReportResponse) - .catch(this.handleError); - }, - handleRemoveReportResponse() { - // eslint-disable-next-line import/no-deprecated - if (this.report.redirectPath) redirectTo(this.report.redirectPath); - else refreshCurrentPage(); - }, - handleBlockUserResponse({ data }) { - const message = data?.error || data?.notice; - const alertOptions = data?.notice ? { variant: VARIANT_SUCCESS } : {}; - - if (message) { - createAlert({ message, ...alertOptions }); - } - - if (!data?.error) { - this.userBlocked = true; - } - }, - handleError(error) { - createAlert({ - message: __('Something went wrong. Please try again.'), - captureError: true, - error, - }); - }, - }, -}; -</script> - -<template> - <div> - <gl-disclosure-dropdown - :toggle-text="$options.i18n.actionsToggleText" - text-sr-only - icon="ellipsis_v" - category="tertiary" - no-caret - placement="right" - :items="reportActionsDropdownItems" - /> - <gl-modal - v-model="confirmModalShown" - :modal-id="$options.modalId" - :title="modalData[actionToConfirm].confirmText" - size="sm" - :action-primary="$options.modalActionButtonAttributes[actionToConfirm]" - :action-secondary="$options.modalActionButtonAttributes.secondary" - @primary="modalData[actionToConfirm].action" - /> - </div> -</template> diff --git a/app/assets/javascripts/admin/abuse_reports/constants.js b/app/assets/javascripts/admin/abuse_reports/constants.js index 7dd60e9da95..9458aea299e 100644 --- a/app/assets/javascripts/admin/abuse_reports/constants.js +++ b/app/assets/javascripts/admin/abuse_reports/constants.js @@ -78,13 +78,3 @@ export const FILTERED_SEARCH_TOKENS = [ FILTERED_SEARCH_TOKEN_REPORTER, FILTERED_SEARCH_TOKEN_STATUS, ]; - -export const ACTIONS_I18N = { - blockUserConfirm: __('USER WILL BE BLOCKED! Are you sure?'), - blockUser: __('Block user'), - alreadyBlocked: __('Already blocked'), - removeUserAndReportConfirm: __('USER %{user} WILL BE REMOVED! Are you sure?'), - removeUserAndReport: __('Remove user & report'), - removeReport: __('Remove report'), - actionsToggleText: __('Actions'), -}; diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index 798ab301c90..cc52285dd81 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -1,6 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { SET_REVIEW_BAR_RENDERED } from '~/batch_comments/stores/modules/batch_comments/mutation_types'; import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants'; import PreviewDropdown from './preview_dropdown.vue'; import SubmitDropdown from './submit_dropdown.vue'; @@ -23,6 +24,7 @@ export default { }, mounted() { document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME); + this.$store.commit(`batchComments/${SET_REVIEW_BAR_RENDERED}`); }, beforeDestroy() { document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME); @@ -34,7 +36,7 @@ export default { </script> <template> <div> - <nav class="review-bar-component" data-testid="review_bar_component"> + <nav class="review-bar-component js-review-bar" data-testid="review_bar_component"> <div class="review-bar-content d-flex gl-justify-content-end" data-qa-selector="review_bar_content" diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js index 67bcc53ac7d..2000ee69bad 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutation_types.js @@ -16,3 +16,5 @@ export const RECEIVE_DRAFT_UPDATE_SUCCESS = 'RECEIVE_DRAFT_UPDATE_SUCCESS'; export const TOGGLE_RESOLVE_DISCUSSION = 'TOGGLE_RESOLVE_DISCUSSION'; export const CLEAR_DRAFTS = 'CLEAR_DRAFTS'; + +export const SET_REVIEW_BAR_RENDERED = 'SET_REVIEW_BAR_RENDERED'; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js index 7961cf134be..453dc861702 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/mutations.js @@ -68,4 +68,7 @@ export default { [types.CLEAR_DRAFTS](state) { state.drafts = []; }, + [types.SET_REVIEW_BAR_RENDERED](state) { + state.reviewBarRendered = true; + }, }; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js index 10033ba17f9..1efc00059d0 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/state.js @@ -5,4 +5,5 @@ export default () => ({ isPublishing: false, currentlyPublishingDrafts: [], shouldAnimateReviewButton: false, + reviewBarRendered: false, }); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue index 167937d8245..ce5b566ba20 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue @@ -43,7 +43,7 @@ export default { this.$emit('hidden', ...args); this.menuVisible = false; }, - appendTo: () => document.body, + strategy: 'fixed', maxWidth: 'auto', }, }), diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 54ce5f8b3c9..beb3497e250 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -224,37 +224,36 @@ export default { :class="{ 'is-focused': focused }" > <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" /> - <div class="gl-relative"> - <code-block-bubble-menu /> - <link-bubble-menu /> - <media-bubble-menu /> - <reference-bubble-menu /> - <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4"> - {{ placeholder }} - </div> - <tiptap-editor-content - class="md gl-px-5" - data-testid="content_editor_editablebox" - :editor="contentEditor.tiptapEditor" - /> - <loading-indicator v-if="isLoading" /> - <div - v-if="quickActionsDocsPath" - class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary" - > - <div class="gl-w-full gl-line-height-32 gl-font-sm"> - <gl-sprintf :message="$options.i18n.quickActionsText"> - <template #keyboard="{ content }"> - <kbd>{{ content }}</kbd> - </template> - <template #quickActionsDocsLink="{ content }"> - <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </div> - </div> + <div v-if="showPlaceholder" class="gl-absolute gl-text-gray-400 gl-px-5 gl-pt-4"> + {{ placeholder }} + </div> + <tiptap-editor-content + class="md gl-px-5" + data-testid="content_editor_editablebox" + :editor="contentEditor.tiptapEditor" + /> + <loading-indicator v-if="isLoading" /> + + <code-block-bubble-menu /> + <link-bubble-menu /> + <media-bubble-menu /> + <reference-bubble-menu /> + </div> + <div + v-if="quickActionsDocsPath" + class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary" + > + <div class="gl-w-full gl-line-height-32 gl-font-sm"> + <gl-sprintf :message="$options.i18n.quickActionsText"> + <template #keyboard="{ content }"> + <kbd>{{ content }}</kbd> + </template> + <template #quickActionsDocsLink="{ content }"> + <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ + content + }}</gl-link> + </template> + </gl-sprintf> </div> </div> </div> diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js index 06fecf8196d..d3fa4bb84bd 100644 --- a/app/assets/javascripts/content_editor/extensions/description_item.js +++ b/app/assets/javascripts/content_editor/extensions/description_item.js @@ -39,9 +39,13 @@ export default Node.create({ addKeyboardShortcuts() { return { Enter: () => { + if (!this.editor.isActive('descriptionItem')) return false; + return this.editor.commands.splitListItem('descriptionItem'); }, Tab: () => { + if (!this.editor.isActive('descriptionItem')) return false; + const { isTerm } = this.editor.getAttributes('descriptionItem'); if (isTerm) return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm }); @@ -49,6 +53,8 @@ export default Node.create({ return false; }, 'Shift-Tab': () => { + if (!this.editor.isActive('descriptionItem')) return false; + const { isTerm } = this.editor.getAttributes('descriptionItem'); if (isTerm) return this.editor.commands.liftListItem('descriptionItem'); diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js index fbe58664a10..61bef0729db 100644 --- a/app/assets/javascripts/content_editor/extensions/details_content.js +++ b/app/assets/javascripts/content_editor/extensions/details_content.js @@ -26,8 +26,16 @@ export default Node.create({ addKeyboardShortcuts() { return { - Enter: () => this.editor.commands.splitListItem('detailsContent'), - 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'), + Enter: () => { + if (!this.editor.isActive('detailsContent')) return false; + + return this.editor.commands.splitListItem('detailsContent'); + }, + 'Shift-Tab': () => { + if (!this.editor.isActive('detailsContent')) return false; + + return this.editor.commands.liftListItem('detailsContent'); + }, }; }, }); diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 544bbbfe9d8..4cc60df0ecb 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -6,9 +6,12 @@ import { debounce } from 'lodash'; import { getModifierKey } from '~/constants'; import { s__, sprintf } from '~/locale'; import { RecycleScroller } from 'vendor/vue-virtual-scroller'; +import { contentTop } from '~/lib/utils/common_utils'; import DiffFileRow from './diff_file_row.vue'; const MODIFIER_KEY = getModifierKey(); +const MAX_ITEMS_ON_NARROW_SCREEN = 8; +const BOTTOM_MARGIN = 16; export default { directives: { @@ -29,13 +32,16 @@ export default { return { search: '', scrollerHeight: 0, - resizeObserver: null, rowHeight: 0, debouncedHeightCalc: null, + reviewBarHeight: 0, + largeBreakpointSize: 0, }; }, computed: { ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']), + ...mapState('batchComments', ['reviewBarRendered']), + ...mapGetters('batchComments', ['draftsCount']), ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { let search = this.search.toLowerCase().trim(); @@ -88,21 +94,44 @@ export default { return result; }, + reviewBarEnabled() { + return this.draftsCount > 0; + }, + }, + watch: { + reviewBarEnabled() { + this.debouncedHeightCalc(); + }, + calculateReviewBarHeight() { + this.debouncedHeightCalc(); + }, }, created() { this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50); }, mounted() { const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height'); + const breakpointProp = getComputedStyle(window.document.body).getPropertyValue( + '--breakpoint-lg', + ); + this.largeBreakpointSize = parseInt(breakpointProp, 10); this.rowHeight = parseInt(heightProp, 10); this.calculateScrollerHeight(); - this.resizeObserver = new ResizeObserver(() => { - this.debouncedHeightCalc(); - }); - this.resizeObserver.observe(this.$refs.scrollRoot); + let stop; + // eslint-disable-next-line prefer-const + stop = this.$watch( + () => this.reviewBarRendered, + (enabled) => { + if (!enabled) return; + this.calculateReviewBarHeight(); + stop(); + }, + { immediate: true }, + ); + window.addEventListener('resize', this.debouncedHeightCalc, { passive: true }); }, beforeDestroy() { - this.resizeObserver.disconnect(); + window.removeEventListener('resize', this.debouncedHeightCalc, { passive: true }); }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), @@ -110,7 +139,20 @@ export default { this.search = ''; }, calculateScrollerHeight() { - this.scrollerHeight = this.$refs.scrollRoot.clientHeight; + if (window.matchMedia(`(max-width: ${this.largeBreakpointSize - 1}px)`).matches) { + this.calculateMobileScrollerHeight(); + } else { + let clipping = BOTTOM_MARGIN; + if (this.reviewBarEnabled) clipping += this.reviewBarHeight; + this.scrollerHeight = this.$refs.scrollRoot.clientHeight - clipping; + } + }, + calculateMobileScrollerHeight() { + const maxItems = Math.min(MAX_ITEMS_ON_NARROW_SCREEN, this.flatFilteredTreeList.length); + this.scrollerHeight = Math.min(maxItems * this.rowHeight, window.innerHeight - contentTop()); + }, + calculateReviewBarHeight() { + this.reviewBarHeight = document.querySelector('.js-review-bar')?.offsetHeight || 0; }, }, searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), { @@ -194,13 +236,6 @@ export default { margin-left: 12px; } -.diff-tree-search-shortcut { - top: 50%; - right: 10px; - transform: translateY(-50%); - pointer-events: none; -} - .tree-list-icon:not(button) { pointer-events: none; } diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 79ece99e6ec..650b60cba4f 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -23,6 +23,9 @@ export default { initialIsEnabled: { default: false, }, + isIssueTrackerEnabled: { + default: false, + }, endpoint: { default: '', }, @@ -163,6 +166,7 @@ export default { </gl-alert> <service-desk-setting :is-enabled="isEnabled" + :is-issue-tracker-enabled="isIssueTrackerEnabled" :incoming-email="incomingEmail" :custom-email="updatedCustomEmail" :custom-email-enabled="customEmailEnabled" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 5a3930b5df4..38a2c12d137 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -8,6 +8,7 @@ import { GlFormGroup, GlFormInput, GlLink, + GlAlert, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; @@ -17,6 +18,9 @@ import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue'; export default { i18n: { toggleLabel: __('Activate Service Desk'), + issueTrackerEnableMessage: __( + 'To use Service Desk in this project, you must %{linkStart}activate the issue tracker%{linkEnd}.', + ), }, components: { ClipboardButton, @@ -28,6 +32,7 @@ export default { GlFormGroup, GlFormInputGroup, GlLink, + GlAlert, ServiceDeskTemplateDropdown, }, props: { @@ -35,6 +40,10 @@ export default { type: Boolean, required: true, }, + isIssueTrackerEnabled: { + type: Boolean, + required: true, + }, incomingEmail: { type: String, required: false, @@ -110,6 +119,11 @@ export default { anchor: 'use-a-custom-email-address', }); }, + issuesHelpPagePath() { + return helpPagePath('user/project/settings/index.md', { + anchor: 'configure-project-visibility-features-and-permissions', + }); + }, }, methods: { onCheckboxToggle(isChecked) { @@ -141,9 +155,24 @@ export default { <template> <div> + <gl-alert v-if="!isIssueTrackerEnabled" class="mb-3" variant="info" :dismissible="false"> + <gl-sprintf :message="$options.i18n.issueTrackerEnableMessage"> + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + data-testid="issue-help-page" + :href="issuesHelpPagePath" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> <gl-toggle id="service-desk-checkbox" :value="isEnabled" + :disabled="!isIssueTrackerEnabled" class="d-inline-block align-middle mr-1" :label="$options.i18n.toggleLabel" label-position="hidden" @@ -194,6 +223,7 @@ export default { :label="__('Email address suffix')" :state="!projectKeyError" data-testid="suffix-form-group" + :disabled="!isIssueTrackerEnabled" > <gl-form-input v-if="hasProjectKeySupport" @@ -249,6 +279,7 @@ export default { :label="__('Template to append to all Service Desk issues')" :state="!projectKeyError" class="mt-3" + :disabled="!isIssueTrackerEnabled" > <service-desk-template-dropdown :selected-template="selectedTemplate" @@ -268,6 +299,7 @@ export default { id="service-desk-email-from-name" v-model.trim="outgoingName" data-testid="email-from-name" + :disabled="!isIssueTrackerEnabled" /> <template #description> @@ -280,7 +312,7 @@ export default { class="gl-mt-5" data-testid="save_service_desk_settings_button" data-qa-selector="save_service_desk_settings_button" - :disabled="isTemplateSaving" + :disabled="isTemplateSaving || !isIssueTrackerEnabled" @click="onSaveTemplate" > {{ __('Save changes') }} diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index 26435a5fac9..84229175c0b 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -13,6 +13,7 @@ export default () => { customEmail, customEmailEnabled, enabled, + issueTrackerEnabled, endpoint, incomingEmail, outgoingName, @@ -31,6 +32,7 @@ export default () => { endpoint, initialIncomingEmail: incomingEmail, initialIsEnabled: parseBoolean(enabled), + isIssueTrackerEnabled: parseBoolean(issueTrackerEnabled), outgoingName, projectKey, selectedTemplate, diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index f828129cdf1..2ec7c891197 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -8,6 +8,10 @@ --application-bar-left: 0px; --application-bar-right: 0px; + + @each $name, $size in $grid-breakpoints { + --breakpoint-#{$name}: #{$size}; + } } .with-performance-bar { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 503e22742ba..374db25065e 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -472,6 +472,8 @@ span.idiff { } .mr-tree-list:not(.tree-list-blobs) { + overflow: hidden; + .tree-list-parent::before { @include gl-content-empty; @include gl-absolute; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 4cda4a664b5..b7d9650ab27 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -327,15 +327,18 @@ $tabs-holder-z-index: 250; .diffs .files { .diff-tree-list { position: relative; + // height is fully handled on the javascript side in narrow view + min-height: 0; + height: auto; top: 0; // !important is required to override inline styles of resizable sidebar width: 100% !important; // avoid sticky elements overlap header and other elements z-index: 1; + @include gl-mb-3; } .tree-list-holder { - max-height: calc(50px + 50vh); padding-right: 0; } } diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index ff908d0d4ef..bbcc830353f 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -630,6 +630,11 @@ html { --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; } .with-top-bar { --top-bar-height: 48px; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index d94b5ed89a8..44651a66fcb 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -630,6 +630,11 @@ html { --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; } .with-top-bar { --top-bar-height: 48px; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index f676782de2a..dc26eb98564 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -670,6 +670,11 @@ body.navless { --top-bar-height: 0px; --system-footer-height: 0px; --mr-review-bar-height: 0px; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; } .tab-content { overflow: visible; diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 84e5cc430ef..6b998c3d494 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -13,7 +13,12 @@ class Admin::AbuseReportsController < Admin::ApplicationController def show; end def update - Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute + response = Admin::AbuseReportUpdateService.new(@abuse_report, current_user, permitted_params).execute + if response.success? + render json: { message: response.message } + else + render json: { message: response.message }, status: :unprocessable_entity + end end def destroy diff --git a/app/helpers/resource_events/abuse_report_events_helper.rb b/app/helpers/resource_events/abuse_report_events_helper.rb new file mode 100644 index 00000000000..8adbc891184 --- /dev/null +++ b/app/helpers/resource_events/abuse_report_events_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ResourceEvents + module AbuseReportEventsHelper + def success_message_for_action(action) + case action + when 'ban_user' + s_('AbuseReportEvent|Successfully banned the user') + when 'block_user' + s_('AbuseReportEvent|Successfully blocked the user') + when 'delete_user' + s_('AbuseReportEvent|Successfully scheduled the user for deletion') + when 'close_report' + s_('AbuseReportEvent|Successfully closed the report') + when 'ban_user_and_close_report' + s_('AbuseReportEvent|Successfully banned the user and closed the report') + when 'block_user_and_close_report' + s_('AbuseReportEvent|Successfully blocked the user and closed the report') + when 'delete_user_and_close_report' + s_('AbuseReportEvent|Successfully scheduled the user for deletion and closed the report') + end + end + end +end diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index a51a2a68da0..f609c9318da 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -79,8 +79,7 @@ module Emails options = { from: email_sender, to: @service_desk_setting.custom_email_address_for_verification, - subject: subject, - content_type: "text/plain; charset=UTF-8" + subject: subject } # Outgoing emails from GitLab usually have this set to true. # Service Desk email ingestion ignores auto generated emails. diff --git a/app/models/resource_events/abuse_report_event.rb b/app/models/resource_events/abuse_report_event.rb index 2cddfc393e3..59f88a63998 100644 --- a/app/models/resource_events/abuse_report_event.rb +++ b/app/models/resource_events/abuse_report_event.rb @@ -2,6 +2,8 @@ module ResourceEvents class AbuseReportEvent < ApplicationRecord + include AbuseReportEventsHelper + belongs_to :abuse_report, optional: false belongs_to :user @@ -28,5 +30,9 @@ module ResourceEvents other: 8, unconfirmed: 9 } + + def success_message + success_message_for_action(action) + end end end diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb index c0394eb38c5..f0e84fc44d2 100644 --- a/app/serializers/admin/abuse_report_details_entity.rb +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -71,6 +71,7 @@ module Admin end expose :report do + expose :status expose :message expose :created_at, as: :reported_at expose :category @@ -78,27 +79,9 @@ module Admin expose :reported_content, as: :content expose :reported_from_url, as: :url expose :screenshot_path, as: :screenshot - end - - expose :actions, if: ->(report) { report.user } do - expose :user_blocked do |report| - report.user.blocked? - end - expose :block_user_path do |report| - block_admin_user_path(report.user) - end - expose :remove_report_path do |report| + expose :update_path do |report| admin_abuse_report_path(report) end - expose :remove_user_and_report_path do |report| - admin_abuse_report_path(report, remove_user: true) - end - expose :reported_user do |report| - UserEntity.represent(report.user, only: [:name, :created_at]) - end - expose :redirect_path do |_| - admin_abuse_reports_path - end end end end diff --git a/app/services/admin/abuse_report_update_service.rb b/app/services/admin/abuse_report_update_service.rb index 5b2ad27ede4..12cf8bf14a8 100644 --- a/app/services/admin/abuse_report_update_service.rb +++ b/app/services/admin/abuse_report_update_service.rb @@ -17,8 +17,8 @@ module Admin result = perform_action if result[:status] == :success - close_report_and_record_event - ServiceResponse.success + event = close_report_and_record_event + ServiceResponse.success(message: event.success_message) else ServiceResponse.error(message: result[:message]) end @@ -58,6 +58,8 @@ module Admin end def close_report + return error('Report already closed') if abuse_report.closed? + abuse_report.closed! success end diff --git a/app/services/service_desk/custom_email_verifications/base_service.rb b/app/services/service_desk/custom_email_verifications/base_service.rb new file mode 100644 index 00000000000..fe456e4d3f3 --- /dev/null +++ b/app/services/service_desk/custom_email_verifications/base_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmailVerifications + class BaseService < ::BaseProjectService + attr_reader :settings + + def initialize(project:, current_user: nil, params: {}) + super(project: project, current_user: current_user, params: params) + + @settings = project.service_desk_setting + end + + private + + def notify_project_owners_and_user_with_email(email_method_name: nil, user: nil) + owner_emails = project.owners.map(&:email) + + owner_emails << user.email if user.present? + + owner_emails.uniq(&:downcase).each do |email_address| + Notify.try(email_method_name, settings, email_address).deliver_later + end + end + + def notify_project_owners_and_user_about_result(user: nil) + notify_project_owners_and_user_with_email( + email_method_name: :service_desk_verification_result_email, + user: user + ) + end + + def error_feature_flag_disabled + error_response('Feature flag service_desk_custom_email is not enabled') + end + + def error_response(message) + ServiceResponse.error(message: message) + end + + def error_not_verified(error_identifier) + ServiceResponse.error( + message: _('ServiceDesk|Custom email address could not be verified.'), + reason: error_identifier.to_s + ) + end + end + end +end diff --git a/app/services/service_desk/custom_email_verifications/create_service.rb b/app/services/service_desk/custom_email_verifications/create_service.rb new file mode 100644 index 00000000000..db518bfdf24 --- /dev/null +++ b/app/services/service_desk/custom_email_verifications/create_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmailVerifications + class CreateService < BaseService + attr_reader :ramp_up_error + + def execute + return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) + return error_settings_missing unless settings.present? + return error_user_not_authorized unless can?(current_user, :admin_project, project) + + update_settings + notify_project_owners_and_user_about_verification_start + send_verification_email_and_catch_delivery_errors + + if ramp_up_error + handle_error_case + else + ServiceResponse.success + end + end + + private + + def verification + @verification ||= settings.custom_email_verification || + ServiceDesk::CustomEmailVerification.new(project_id: settings.project_id) + end + + def update_settings + settings.update!(custom_email_enabled: false) if settings.custom_email_enabled? + + verification.mark_as_started!(current_user) + # We use verification association from project, to use it in email, we need to reset it here. + project.reset + end + + def notify_project_owners_and_user_about_verification_start + notify_project_owners_and_user_with_email( + email_method_name: :service_desk_verification_triggered_email, + user: current_user + ) + end + + def send_verification_email_and_catch_delivery_errors + # Send this synchronously as we need to get direct feedback on delivery errors. + Notify.service_desk_custom_email_verification_email(settings).deliver + rescue SocketError, OpenSSL::SSL::SSLError + # e.g. host not found or host certificate issues + @ramp_up_error = :smtp_host_issue + rescue Net::SMTPAuthenticationError + # incorrect username or password + @ramp_up_error = :invalid_credentials + end + + def handle_error_case + notify_project_owners_and_user_about_result(user: current_user) + + verification.mark_as_failed!(ramp_up_error) + + error_not_verified(ramp_up_error) + end + + def error_settings_missing + error_response(_('ServiceDesk|Service Desk setting missing')) + end + + def error_user_not_authorized + error_response(_('ServiceDesk|User cannot manage project.')) + end + end + end +end diff --git a/app/services/service_desk/custom_email_verifications/update_service.rb b/app/services/service_desk/custom_email_verifications/update_service.rb new file mode 100644 index 00000000000..813624cde23 --- /dev/null +++ b/app/services/service_desk/custom_email_verifications/update_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module ServiceDesk + module CustomEmailVerifications + class UpdateService < BaseService + EMAIL_TOKEN_REGEXP = /Verification token: ([A-Za-z0-9_-]{12})/ + + def execute + return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) + return error_parameter_missing if settings.blank? || verification.blank? + return error_already_finished if already_finished_and_no_mail? + return error_already_failed if already_failed_and_no_mail? + + verification_error = verify + + settings.update!(custom_email_enabled: false) if settings.custom_email_enabled? + + notify_project_owners_and_user_about_result(user: verification.triggerer) + + if verification_error.present? + verification.mark_as_failed!(verification_error) + + error_not_verified(verification_error) + else + verification.mark_as_finished! + + ServiceResponse.success + end + end + + private + + def mail + params[:mail] + end + + def verification + @verification ||= settings.custom_email_verification + end + + def already_finished_and_no_mail? + verification.finished? && mail.blank? + end + + def already_failed_and_no_mail? + verification.failed? && mail.blank? + end + + def verify + return :mail_not_received_within_timeframe if mail_not_received_within_timeframe? + return :incorrect_from if incorrect_from? + return :incorrect_token if incorrect_token? + + nil + end + + def mail_not_received_within_timeframe? + # (For completeness) also raise if no email provided + mail.blank? || !verification.in_timeframe? + end + + def incorrect_from? + # Does the email forwarder preserve the FROM header? + mail.from.first != settings.custom_email + end + + def incorrect_token? + message, _stripped_text = Gitlab::Email::ReplyParser.new(mail).execute + + scan_result = message.scan(EMAIL_TOKEN_REGEXP) + + return true if scan_result.empty? + + scan_result.first.first != verification.token + end + + def error_parameter_missing + error_response(_('ServiceDesk|Service Desk setting or verification object missing')) + end + + def error_already_finished + error_response(_('ServiceDesk|Custom email address has already been verified.')) + end + + def error_already_failed + error_response(_('ServiceDesk|Custom email address verification has already been processed and failed.')) + end + end + end +end diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 7654677d8a8..14991ce3824 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -10,6 +10,7 @@ - if ::Gitlab::ServiceDesk.supported? .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project), enabled: "#{@project.service_desk_enabled}", + issue_tracker_enabled: "#{@project.project_feature.issues_enabled?}", incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled), custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled), custom_email_enabled: "#{Gitlab::Email::ServiceDeskEmail.enabled?}", diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 9cbabaee774..e60fa8f019d 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -9,7 +9,7 @@ - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits| %li.js-commit-header.gl-mt-7.gl-pb-2.gl-border-b{ data: { day: day } } - %span.day.font-weight-bold= l(day, format: '%d %b, %Y') + %span.day.font-weight-bold= l(day, format: '%b %d, %Y') %span - %span.commits-count= n_("%d commit", "%d commits", daily_commits.size) % daily_commits.size diff --git a/db/migrate/20230516115259_increase_correlation_id_size_limit_in_abuse_trust_scores.rb b/db/migrate/20230516115259_increase_correlation_id_size_limit_in_abuse_trust_scores.rb new file mode 100644 index 00000000000..03a05ce0d4b --- /dev/null +++ b/db/migrate/20230516115259_increase_correlation_id_size_limit_in_abuse_trust_scores.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class IncreaseCorrelationIdSizeLimitInAbuseTrustScores < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + def up + constraint_correlation_id = check_constraint_name('abuse_trust_scores', 'correlation_id_value', 'max_length') + remove_check_constraint(:abuse_trust_scores, constraint_correlation_id) + add_check_constraint(:abuse_trust_scores, 'char_length(correlation_id_value) <= 255', constraint_correlation_id) + end + + def down + constraint_correlation_id = check_constraint_name('abuse_trust_scores', 'correlation_id_value', 'max_length') + remove_check_constraint(:abuse_trust_scores, constraint_correlation_id) + add_check_constraint(:abuse_trust_scores, 'char_length(correlation_id_value) <= 32', constraint_correlation_id) + end +end diff --git a/db/schema_migrations/20230516115259 b/db/schema_migrations/20230516115259 new file mode 100644 index 00000000000..7e4047fd5ad --- /dev/null +++ b/db/schema_migrations/20230516115259 @@ -0,0 +1 @@ +28a6e5cfda097a1dff9b43a082979bda1dd893c85d24db40148cf989450f0d86
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 7fee4c30bcc..e501a7bc0ad 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10820,7 +10820,7 @@ CREATE TABLE abuse_trust_scores ( updated_at timestamp with time zone NOT NULL, source smallint NOT NULL, correlation_id_value text, - CONSTRAINT check_77ca9551db CHECK ((char_length(correlation_id_value) <= 32)) + CONSTRAINT check_77ca9551db CHECK ((char_length(correlation_id_value) <= 255)) ); CREATE SEQUENCE abuse_trust_scores_id_seq diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index ab5226c1c30..50068982b38 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -739,7 +739,7 @@ attached to the job when it [succeeds, fails, or always](#artifactswhen). The artifacts are sent to GitLab after the job finishes. They are available for download in the GitLab UI if the size is smaller than the -the [maximum artifact size](../../user/gitlab_com/index.md#gitlab-cicd). +[maximum artifact size](../../user/gitlab_com/index.md#gitlab-cicd). By default, jobs in later stages automatically download all the artifacts created by jobs in earlier stages. You can control artifact download behavior in jobs with diff --git a/doc/development/documentation/styleguide/word_list.md b/doc/development/documentation/styleguide/word_list.md index 094de2bd724..8a08fcd0cc8 100644 --- a/doc/development/documentation/styleguide/word_list.md +++ b/doc/development/documentation/styleguide/word_list.md @@ -672,6 +672,12 @@ Do not use Latin abbreviations. Use **that is** instead. ([Vale](../testing.md#v Do not use **in order to**. Use **to** instead. ([Vale](../testing.md#vale) rule: [`Wordy.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/Wordy.yml)) +## indexes, indices + +For the plural of **index**, use **indexes**. + +However, for ElasticSearch, use [**indices**](https://www.elastic.co/blog/what-is-an-elasticsearch-index). + ## Installation from source When referring to the installation method using the self-compiled code, refer to it diff --git a/doc/user/enterprise_user/index.md b/doc/user/enterprise_user/index.md index e64a9de1a12..7bb8705ace0 100644 --- a/doc/user/enterprise_user/index.md +++ b/doc/user/enterprise_user/index.md @@ -71,6 +71,9 @@ The custom domain must match the email domain exactly. For example, if your emai certificate. You can also add the certificate and key later. 1. Select **Add Domain**. +NOTE: +A valid certificate is not required for domain verification. You can ignore error messages regarding the certificate if you are not using GitLab Pages. + #### 2. Get a verification code After you create a new domain, the verification code prompts you. Copy the values from GitLab @@ -104,6 +107,7 @@ from the GitLab project. > - Once your domain has been verified, leave the verification record in place. Your domain is periodically reverified, and may be disabled if the record is removed. +> - A valid certificate is not required for domain verification. ### View domains in group diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md index 276cc3bc7a5..c34de0efeda 100644 --- a/doc/user/project/issues/managing_issues.md +++ b/doc/user/project/issues/managing_issues.md @@ -18,6 +18,8 @@ Prerequisites: To edit an issue: +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. To the right of the title, select **Edit title and description** (**{pencil}**). 1. Edit the available fields. 1. Select **Save changes**. @@ -114,7 +116,8 @@ Prerequisites: To move an issue: -1. Go to the issue. +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. On the right sidebar, select **Move issue**. 1. Search for a project to move the issue to. 1. Select **Move**. @@ -125,7 +128,7 @@ To move an issue: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15991) in GitLab 15.6. -You can move multiple issues at the same time when you’re in a project. +You can move multiple issues at the same time when you're in a project. You can't move tasks or test cases. Prerequisite: @@ -204,10 +207,13 @@ Prerequisites: - You must have at least the Reporter role for the project, be the author of the issue, or be assigned to the issue. -To close an issue, you can do the following: +To close an issue, you can either: -- At the top of the issue, select **Close issue**. - In an [issue board](../issue_board.md), drag an issue card from its list into the **Closed** list. +- From any other page in the GitLab UI: + 1. On the top bar, select **Main menu > Projects** and find your project. + 1. Select **Issues**, then select the title of your issue to view it. + 1. At the top of the issue, select **Close issue**. <!-- Delete when the `moved_mr_sidebar` feature flag is removed --> If you don't see this action at the top of an issue, your project or instance might have @@ -330,6 +336,8 @@ Prerequisites: To change issue type: +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. To the right of the title, select **Edit title and description** (**{pencil}**). 1. Edit the issue and select an issue type from the **Issue type** dropdown list: @@ -348,12 +356,16 @@ Prerequisites: To delete an issue: -1. In an issue, select **Issue actions** (**{ellipsis_v}**). +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. +1. On the top right corner, select **Issue actions** (**{ellipsis_v}**). 1. Select **Delete issue**. Alternatively: -1. In an issue, select **Edit title and description** (**{pencil}**). +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. +1. Select **Edit title and description** (**{pencil}**). 1. Select **Delete issue**. ## Promote an issue to an epic **(PREMIUM)** @@ -366,7 +378,9 @@ You can promote an issue to an [epic](../../group/epics/index.md) in the immedia To promote an issue to an epic: -1. In an issue, select **Issue actions** (**{ellipsis_v}**). +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. +1. On the top right corner, select **Issue actions** (**{ellipsis_v}**). 1. Select **Promote to epic**. Alternatively, you can use the `/promote` [quick action](../quick_actions.md#issues-merge-requests-and-epics). @@ -387,7 +401,8 @@ You can use the `/promote_to_incident` [quick action](../quick_actions.md) to pr To add an issue to an [iteration](../../group/iterations/index.md): -1. Go to the issue. +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. On the right sidebar, in the **Iteration** section, select **Edit**. 1. From the dropdown list, select the iteration to associate this issue with. 1. Select any area outside the dropdown list. @@ -417,6 +432,8 @@ Or: To filter the list of issues: +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**. 1. Above the list of issues, select **Search or filter results...**. 1. In the dropdown list that appears, select the attribute you want to filter by. 1. Select or type the operator to use for filtering the attribute. The following operators are @@ -469,7 +486,8 @@ To refer to an issue elsewhere in GitLab, you can use its full URL or a short re To copy the issue reference to your clipboard: -1. Go to the issue. +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. On the right sidebar, next to **Reference**, select **Copy Reference** (**{copy-to-clipboard}**). You can now paste the reference into another description or comment. @@ -492,7 +510,8 @@ For more information about creating comments by sending an email and the necessa To copy the issue's email address: -1. Go to the issue. +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. On the right sidebar, next to **Issue email**, select **Copy Reference** (**{copy-to-clipboard}**). ## Assignee @@ -508,7 +527,8 @@ themselves or another project member assigns them. To change the assignee on an issue: -1. Go to your issue. +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. On the right sidebar, in the **Assignee** section, select **Edit**. 1. From the dropdown list, select the user to add as an assignee. 1. Select any area outside the dropdown list. @@ -548,7 +568,8 @@ Prerequisites: To edit health status of an issue: -1. Go to the issue. +1. On the top bar, select **Main menu > Projects** and find your project. +1. Select **Issues**, then select the title of your issue to view it. 1. On the right sidebar, in the **Health status** section, select **Edit**. 1. From the dropdown list, select the status to add to this issue: @@ -556,7 +577,7 @@ To edit health status of an issue: - Needs attention (amber) - At risk (red) -You can see the issue’s health status in: +You can see the issue's health status in: - Issues list - Epic tree diff --git a/doc/user/project/merge_requests/approvals/rules.md b/doc/user/project/merge_requests/approvals/rules.md index 23cad67b69c..bc5d4353ffc 100644 --- a/doc/user/project/merge_requests/approvals/rules.md +++ b/doc/user/project/merge_requests/approvals/rules.md @@ -217,6 +217,14 @@ on a merge request, you can either add or remove approvers: Administrators can change the [merge request approvals settings](settings.md#prevent-editing-approval-rules-in-merge-requests) to prevent users from overriding approval rules for merge requests. +## Require multiple approvals for a rule + +To create an approval rule which requires more than one approval: + +- When you [create or edit a rule](#edit-an-approval-rule), set **Approvals required** to `2` or more. +- Use the [Merge requests approvals API](../../../../api/merge_request_approvals.md#update-merge-request-level-rule) + to set the `approvals_required` attribute to `2` or more. + ## Configure optional approval rules Merge request approvals can be optional for projects where approvals are diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md index 05494789b83..6375a562219 100644 --- a/doc/user/project/repository/branches/index.md +++ b/doc/user/project/repository/branches/index.md @@ -62,7 +62,14 @@ GitLab creates a default branch and adds your file to it. ### From an issue +Prerequisites: + +- You must have at least the Developer role in the project. + When viewing an issue, you can create an associated branch directly from that page. +Branches created this way use the +[default pattern for branch names from issues](#configure-default-pattern-for-branch-names-from-issues), +including variables. Prerequisites: diff --git a/doc/user/project/repository/mirror/index.md b/doc/user/project/repository/mirror/index.md index efd143dd205..5b531c85ac9 100644 --- a/doc/user/project/repository/mirror/index.md +++ b/doc/user/project/repository/mirror/index.md @@ -184,6 +184,7 @@ for you to check: - [AWS CodeCommit](https://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) - [Bitbucket](https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/) +- [Codeberg](https://docs.codeberg.org/security/ssh-fingerprint/) - [GitHub](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints) - [GitLab.com](../../../gitlab_com/index.md#ssh-host-keys-fingerprints) - [Launchpad](https://help.launchpad.net/SSHFingerprints) diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb index d340e097700..01430a8aceb 100644 --- a/lib/api/internal/kubernetes.rb +++ b/lib/api/internal/kubernetes.rb @@ -121,6 +121,18 @@ module API default_branch: project.default_branch_or_main } end + + desc 'Verify agent access to a project' do + detail 'Verifies if the agent (owning the token) is authorized to access the given project' + end + route_setting :authentication, cluster_agent_token_allowed: true + get '/verify_project_access', feature_category: :deployment_management, urgency: :low do + project = find_project(params[:id]) + + not_found! unless agent_has_access_to_project?(project) + + status 204 + end end namespace 'kubernetes/agent_configuration' do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b5e6e8e2cde..58b392c8ee8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2114,6 +2114,27 @@ msgstr "" msgid "Abuse reports notification email" msgstr "" +msgid "AbuseReportEvent|Successfully banned the user" +msgstr "" + +msgid "AbuseReportEvent|Successfully banned the user and closed the report" +msgstr "" + +msgid "AbuseReportEvent|Successfully blocked the user" +msgstr "" + +msgid "AbuseReportEvent|Successfully blocked the user and closed the report" +msgstr "" + +msgid "AbuseReportEvent|Successfully closed the report" +msgstr "" + +msgid "AbuseReportEvent|Successfully scheduled the user for deletion" +msgstr "" + +msgid "AbuseReportEvent|Successfully scheduled the user for deletion and closed the report" +msgstr "" + msgid "AbuseReports|%{reportedUser} reported for %{category} by %{reporter}" msgstr "" @@ -2129,21 +2150,69 @@ msgstr "" msgid "AbuseReport|Abuse reports" msgstr "" +msgid "AbuseReport|Abuse unconfirmed" +msgstr "" + +msgid "AbuseReport|Action" +msgstr "" + +msgid "AbuseReport|Actions" +msgstr "" + msgid "AbuseReport|Activity" msgstr "" msgid "AbuseReport|Admin profile" msgstr "" +msgid "AbuseReport|Ban user" +msgstr "" + +msgid "AbuseReport|Block user" +msgstr "" + msgid "AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}" msgstr "" +msgid "AbuseReport|Close report" +msgstr "" + +msgid "AbuseReport|Comment" +msgstr "" + msgid "AbuseReport|Comments" msgstr "" +msgid "AbuseReport|Confirm" +msgstr "" + +msgid "AbuseReport|Confirmed crypto mining" +msgstr "" + +msgid "AbuseReport|Confirmed offensive or abusive behavior" +msgstr "" + +msgid "AbuseReport|Confirmed phishing" +msgstr "" + +msgid "AbuseReport|Confirmed posting of malware" +msgstr "" + +msgid "AbuseReport|Confirmed posting of personal information or credentials" +msgstr "" + +msgid "AbuseReport|Confirmed spam" +msgstr "" + +msgid "AbuseReport|Confirmed violation of a copyright or a trademark" +msgstr "" + msgid "AbuseReport|Credit card" msgstr "" +msgid "AbuseReport|Delete user" +msgstr "" + msgid "AbuseReport|Email" msgstr "" @@ -2171,6 +2240,9 @@ msgstr "" msgid "AbuseReport|Member since" msgstr "" +msgid "AbuseReport|No action" +msgstr "" + msgid "AbuseReport|No user found" msgstr "" @@ -2180,6 +2252,9 @@ msgstr "" msgid "AbuseReport|Phone" msgstr "" +msgid "AbuseReport|Reason" +msgstr "" + msgid "AbuseReport|Registered with name %{name}." msgstr "" @@ -2210,6 +2285,9 @@ msgstr "" msgid "AbuseReport|Snippets" msgstr "" +msgid "AbuseReport|Something else" +msgstr "" + msgid "AbuseReport|Tier" msgstr "" @@ -41725,6 +41803,15 @@ msgstr "" msgid "ServiceAccount|User does not have permission to create a service account." msgstr "" +msgid "ServiceDesk|Custom email address could not be verified." +msgstr "" + +msgid "ServiceDesk|Custom email address has already been verified." +msgstr "" + +msgid "ServiceDesk|Custom email address verification has already been processed and failed." +msgstr "" + msgid "ServiceDesk|Enable Service Desk" msgstr "" @@ -41740,12 +41827,21 @@ msgstr "" msgid "ServiceDesk|Service Desk is not supported" msgstr "" +msgid "ServiceDesk|Service Desk setting missing" +msgstr "" + +msgid "ServiceDesk|Service Desk setting or verification object missing" +msgstr "" + msgid "ServiceDesk|To enable Service Desk on this instance, an instance administrator must first set up incoming email." msgstr "" msgid "ServiceDesk|Use Service Desk to connect with your users and offer customer support through email right inside GitLab" msgstr "" +msgid "ServiceDesk|User cannot manage project." +msgstr "" + msgid "ServiceDesk|Your users can send emails to this address:" msgstr "" @@ -47183,6 +47279,9 @@ msgstr "" msgid "To update Snippets with multiple files, you must use the `files` parameter" msgstr "" +msgid "To use Service Desk in this project, you must %{linkStart}activate the issue tracker%{linkEnd}." +msgstr "" + msgid "To use the additional formats, you must start the required %{container_link_start}companion containers%{container_link_end}." msgstr "" diff --git a/package.json b/package.json index 04aa2c93382..6c979699811 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@gitlab/svgs": "3.47.0", "@gitlab/ui": "62.12.0", "@gitlab/visual-review-tools": "1.7.3", - "@gitlab/web-ide": "0.0.1-dev-20230511143809", + "@gitlab/web-ide": "0.0.1-dev-20230524134151", "@mattiasbuelens/web-streams-adapter": "^0.1.0", "@popperjs/core": "^2.11.2", "@rails/actioncable": "6.1.4-7", diff --git a/spec/factories/service_desk/custom_email_credential.rb b/spec/factories/service_desk/custom_email_credential.rb index da131dd8250..f1da12327a2 100644 --- a/spec/factories/service_desk/custom_email_credential.rb +++ b/spec/factories/service_desk/custom_email_credential.rb @@ -4,7 +4,7 @@ FactoryBot.define do factory :service_desk_custom_email_credential, class: '::ServiceDesk::CustomEmailCredential' do project smtp_address { "smtp.example.com" } - smtp_username { "text@example.com" } + smtp_username { "user@example.com" } smtp_port { 587 } smtp_password { "supersecret" } end diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index a84f469750d..fd09a7f7343 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -187,6 +187,13 @@ RSpec.describe 'Commits', feature_category: :source_code_management do visit project_commits_path(project, branch_name) end + it 'includes a date on which the commits were authored' do + commits = project.repository.commits(branch_name, limit: 40) + commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, _daily_commits| + expect(page).to have_content(day.strftime("%b %d, %Y")) + end + end + it 'includes the committed_date for each commit' do commits = project.repository.commits(branch_name, limit: 40) diff --git a/spec/features/merge_request/user_sees_merge_request_file_tree_sidebar_spec.rb b/spec/features/merge_request/user_sees_merge_request_file_tree_sidebar_spec.rb new file mode 100644 index 00000000000..c385def6762 --- /dev/null +++ b/spec/features/merge_request/user_sees_merge_request_file_tree_sidebar_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Merge request > User sees merge request file tree sidebar', :js, feature_category: :code_review_workflow do + include MergeRequestDiffHelpers + + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + let(:user) { project.creator } + let(:sidebar) { find('.diff-tree-list') } + let(:sidebar_scroller) { sidebar.find('.vue-recycle-scroller') } + + before do + sign_in(user) + visit diffs_project_merge_request_path(project, merge_request) + wait_for_requests + scroll_into_view + end + + it 'sees file tree sidebar' do + expect(page).to have_selector('.file-row[role=button]') + end + + # TODO: fix this test + # For some reason the browser in CI doesn't update the file tree sidebar when review bar is shown + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118378#note_1403906356 + # + # it 'has last entry visible with discussions enabled' do + # add_diff_line_draft_comment('foo', find('.line_holder', match: :first)) + # scroll_into_view + # scroll_to_end + # button = find_all('.file-row[role=button]').last + # expect(button.obscured?).to be_falsy + # end + + shared_examples 'shows last visible file in sidebar' do + it 'shows last file' do + scroll_to_end + button = find_all('.file-row[role=button]').last + title = button.find('[data-testid=file-row-name-container]')[:title] + button.click + expect(page).to have_selector(".file-title-name[title*=\"#{title}\"]") + end + end + + it_behaves_like 'shows last visible file in sidebar' + + context 'when viewing using file-by-file mode' do + let(:user) { create(:user, view_diffs_file_by_file: true) } + + it_behaves_like 'shows last visible file in sidebar' + end + + def scroll_into_view + sidebar.execute_script("this.scrollIntoView({ block: 'end' })") + end + + def scroll_to_end + sidebar_scroller.execute_script('this.scrollBy(0,99999)') + end +end diff --git a/spec/fixtures/emails/service_desk_custom_email_address_verification.eml b/spec/fixtures/emails/service_desk_custom_email_address_verification.eml new file mode 100644 index 00000000000..a5a17589a34 --- /dev/null +++ b/spec/fixtures/emails/service_desk_custom_email_address_verification.eml @@ -0,0 +1,31 @@ +Delivered-To: support+project_slug-project_id-issue-@example.com +Received: by 2002:a05:7022:aa3:b0:5d:66:2e64 with SMTP id dd35csp3394266dlb; Mon, 23 Jan 2023 08:50:49 -0800 (PST) +X-Received: by 2002:a19:a40e:0:b0:4c8:d65:da81 with SMTP id q14-20020a19a40e000000b004c80d65da81mr9022372lfc.60.1674492649184; Mon, 23 Jan 2023 08:50:49 -0800 (PST) +Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41]) by mx.google.com with SMTPS id t20-20020a195f14000000b00499004f4b1asor10121263lfb.188.2023.01.23.08.50.48 for <support+project_slug-project_id-issue-@example.com> (Google Transport Security); Mon, 23 Jan 2023 08:50:49 -0800 (PST) +X-Received: by 2002:a05:6512:224c:b0:4cc:7937:fa04 with SMTP id i12-20020a056512224c00b004cc7937fa04mr1421048lfu.378.1674492648772; Mon, 23 Jan 2023 08:50:48 -0800 (PST) +X-Forwarded-To: support+project_slug-project_id-issue-@example.com +X-Forwarded-For: custom-support-email@example.com support+project_slug-project_id-issue-@example.com +Return-Path: <custom-support-email@example.com> +Received: from gmail.com ([94.31.107.53]) by smtp.gmail.com with ESMTPSA id t13-20020a1c770d000000b003db0ee277b2sm11097876wmi.5.2023.01.23.08.50.47 for <fatjuiceofficial+verify@gmail.com> (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 23 Jan 2023 08:50:47 -0800 (PST) +From: Flight Support <custom-support-email@example.com> +X-Google-Original-From: Flight Support <example@example.com> +Date: Mon, 23 Jan 2023 17:50:46 +0100 +Reply-To: GitLab <noreply@example.com> +To: custom-support-email+verify@example.com +Message-ID: <63d927a0e407c_5f8f3ac0267d@mail.gmail.com> +Subject: Verify custom email address custom-support-email@example.com for Flight +Mime-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit +Auto-Submitted: no +X-Auto-Response-Suppress: All + + + +This email is auto-generated. It verifies the ownership of the entered Service Desk custom email address and +correct functionality of email forwarding. + +Verification token: ZROT4ZZXA-Y6 +-- + +You're receiving this email because of your account on 127.0.0.1. diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js index cabbb5e1591..e519684bbc5 100644 --- a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js +++ b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js @@ -1,14 +1,17 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; import AbuseReportApp from '~/admin/abuse_report/components/abuse_report_app.vue'; import ReportHeader from '~/admin/abuse_report/components/report_header.vue'; import UserDetails from '~/admin/abuse_report/components/user_details.vue'; import ReportedContent from '~/admin/abuse_report/components/reported_content.vue'; import HistoryItems from '~/admin/abuse_report/components/history_items.vue'; +import { SUCCESS_ALERT } from '~/admin/abuse_report/constants'; import { mockAbuseReport } from '../mock_data'; describe('AbuseReportApp', () => { let wrapper; + const findAlert = () => wrapper.findComponent(GlAlert); const findReportHeader = () => wrapper.findComponent(ReportHeader); const findUserDetails = () => wrapper.findComponent(UserDetails); const findReportedContent = () => wrapper.findComponent(ReportedContent); @@ -27,10 +30,44 @@ describe('AbuseReportApp', () => { createComponent(); }); + it('does not show the alert by default', () => { + expect(findAlert().exists()).toBe(false); + }); + + describe('when emitting the showAlert event from the report header', () => { + const message = 'alert message'; + + beforeEach(() => { + findReportHeader().vm.$emit('showAlert', SUCCESS_ALERT, message); + }); + + it('shows the alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('displays the message', () => { + expect(findAlert().text()).toBe(message); + }); + + it('sets the variant property', () => { + expect(findAlert().props('variant')).toBe(SUCCESS_ALERT); + }); + + describe('when dismissing the alert', () => { + beforeEach(() => { + findAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + }); + describe('ReportHeader', () => { it('renders ReportHeader', () => { expect(findReportHeader().props('user')).toBe(mockAbuseReport.user); - expect(findReportHeader().props('actions')).toBe(mockAbuseReport.actions); + expect(findReportHeader().props('report')).toBe(mockAbuseReport.report); }); describe('when no user is present', () => { diff --git a/spec/frontend/admin/abuse_report/components/report_actions_spec.js b/spec/frontend/admin/abuse_report/components/report_actions_spec.js new file mode 100644 index 00000000000..a1a78902b58 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/report_actions_spec.js @@ -0,0 +1,158 @@ +import MockAdapter from 'axios-mock-adapter'; +import { GlDrawer } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import { + HTTP_STATUS_OK, + HTTP_STATUS_UNPROCESSABLE_ENTITY, + HTTP_STATUS_INTERNAL_SERVER_ERROR, +} from '~/lib/utils/http_status'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ReportActions from '~/admin/abuse_report/components/report_actions.vue'; +import { + ACTIONS_I18N, + SUCCESS_ALERT, + FAILED_ALERT, + ERROR_MESSAGE, +} from '~/admin/abuse_report/constants'; +import { mockAbuseReport } from '../mock_data'; + +describe('ReportActions', () => { + let wrapper; + let axiosMock; + + const params = { + user_action: 'ban_user', + close: true, + comment: 'my comment', + reason: 'spam', + }; + + const { report } = mockAbuseReport; + + const clickActionsButton = () => wrapper.findByTestId('actions-button').vm.$emit('click'); + const isDrawerOpen = () => wrapper.findComponent(GlDrawer).props('open'); + const findErrorFor = (id) => wrapper.findByTestId(id).find('.d-block.invalid-feedback'); + const setCloseReport = (close) => wrapper.findByTestId('close').find('input').setChecked(close); + const setSelectOption = (id, value) => + wrapper.findByTestId(`${id}-select`).find(`option[value=${value}]`).setSelected(); + const selectAction = (action) => setSelectOption('action', action); + const selectReason = (reason) => setSelectOption('reason', reason); + const setComment = (comment) => wrapper.findByTestId('comment').find('input').setValue(comment); + const submitForm = () => wrapper.findByTestId('submit-button').vm.$emit('click'); + + const createComponent = (props = {}) => { + wrapper = mountExtended(ReportActions, { + propsData: { + report, + ...props, + }, + }); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + createComponent(); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it('initially hides the drawer', () => { + expect(isDrawerOpen()).toBe(false); + }); + + describe('when clicking the actions button', () => { + beforeEach(() => { + clickActionsButton(); + }); + + it('shows the drawer', () => { + expect(isDrawerOpen()).toBe(true); + }); + + describe.each` + input | errorFor | messageShown + ${null} | ${'action'} | ${true} + ${null} | ${'reason'} | ${true} + ${'close'} | ${'action'} | ${false} + ${'action'} | ${'action'} | ${false} + ${'reason'} | ${'reason'} | ${false} + `('when submitting an invalid form', ({ input, errorFor, messageShown }) => { + describe(`when ${ + input ? `providing a value for the ${input} field` : 'not providing any values' + }`, () => { + beforeEach(() => { + submitForm(); + + if (input === 'close') { + setCloseReport(params.close); + } else if (input === 'action') { + selectAction(params.user_action); + } else if (input === 'reason') { + selectReason(params.reason); + } + }); + + it(`${messageShown ? 'shows' : 'hides'} ${errorFor} error message`, () => { + if (messageShown) { + expect(findErrorFor(errorFor).text()).toBe(ACTIONS_I18N.requiredFieldFeedback); + } else { + expect(findErrorFor(errorFor).exists()).toBe(false); + } + }); + }); + }); + + describe('when submitting a valid form', () => { + describe.each` + response | success | responseStatus | responseData | alertType | alertMessage + ${'successful'} | ${true} | ${HTTP_STATUS_OK} | ${{ message: 'success!' }} | ${SUCCESS_ALERT} | ${'success!'} + ${'custom failure'} | ${false} | ${HTTP_STATUS_UNPROCESSABLE_ENTITY} | ${{ message: 'fail!' }} | ${FAILED_ALERT} | ${'fail!'} + ${'generic failure'} | ${false} | ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${{}} | ${FAILED_ALERT} | ${ERROR_MESSAGE} + `( + 'when the server responds with a $response response', + ({ success, responseStatus, responseData, alertType, alertMessage }) => { + beforeEach(async () => { + jest.spyOn(axios, 'put'); + + axiosMock.onPut(report.updatePath).replyOnce(responseStatus, responseData); + + selectAction(params.user_action); + setCloseReport(params.close); + selectReason(params.reason); + setComment(params.comment); + + await nextTick(); + + submitForm(); + + await waitForPromises(); + }); + + it('does a put call with the right data', () => { + expect(axios.put).toHaveBeenCalledWith(report.updatePath, params); + }); + + it('closes the drawer', () => { + expect(isDrawerOpen()).toBe(false); + }); + + it('emits the showAlert event', () => { + expect(wrapper.emitted('showAlert')).toStrictEqual([[alertType, alertMessage]]); + }); + + it(`${success ? 'does' : 'does not'} emit the closeReport event`, () => { + if (success) { + expect(wrapper.emitted('closeReport')).toBeDefined(); + } else { + expect(wrapper.emitted('closeReport')).toBeUndefined(); + } + }); + }, + ); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/report_header_spec.js b/spec/frontend/admin/abuse_report/components/report_header_spec.js index d584cab05b3..f22f3af091f 100644 --- a/spec/frontend/admin/abuse_report/components/report_header_spec.js +++ b/spec/frontend/admin/abuse_report/components/report_header_spec.js @@ -1,25 +1,27 @@ -import { GlAvatar, GlLink, GlButton } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlAvatar, GlLink, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ReportHeader from '~/admin/abuse_report/components/report_header.vue'; -import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; -import { REPORT_HEADER_I18N } from '~/admin/abuse_report/constants'; +import ReportActions from '~/admin/abuse_report/components/report_actions.vue'; +import { REPORT_HEADER_I18N, STATUS_OPEN, STATUS_CLOSED } from '~/admin/abuse_report/constants'; import { mockAbuseReport } from '../mock_data'; describe('ReportHeader', () => { let wrapper; - const { user, actions } = mockAbuseReport; + const { user, report } = mockAbuseReport; + const findBadge = () => wrapper.findComponent(GlBadge); + const findIcon = () => wrapper.findComponent(GlIcon); const findAvatar = () => wrapper.findComponent(GlAvatar); const findLink = () => wrapper.findComponent(GlLink); const findButton = () => wrapper.findComponent(GlButton); - const findActions = () => wrapper.findComponent(AbuseReportActions); + const findActions = () => wrapper.findComponent(ReportActions); const createComponent = (props = {}) => { wrapper = shallowMount(ReportHeader, { propsData: { user, - actions, + report, ...props, }, }); @@ -51,9 +53,42 @@ describe('ReportHeader', () => { expect(button.text()).toBe(REPORT_HEADER_I18N.adminProfile); }); + describe.each` + status | text | variant | className | badgeIcon + ${STATUS_OPEN} | ${REPORT_HEADER_I18N[STATUS_OPEN]} | ${'success'} | ${'issuable-status-badge-open'} | ${'issues'} + ${STATUS_CLOSED} | ${REPORT_HEADER_I18N[STATUS_CLOSED]} | ${'info'} | ${'issuable-status-badge-closed'} | ${'issue-closed'} + `( + 'rendering the report $status status badge', + ({ status, text, variant, className, badgeIcon }) => { + beforeEach(() => { + createComponent({ report: { ...report, status } }); + }); + + it(`indicates the ${status} status`, () => { + expect(findBadge().text()).toBe(text); + }); + + it(`with the ${variant} variant`, () => { + expect(findBadge().props('variant')).toBe(variant); + }); + + it(`with the text '${text}' as 'aria-label'`, () => { + expect(findBadge().attributes('aria-label')).toBe(text); + }); + + it(`contains the ${className} class`, () => { + expect(findBadge().element.classList).toContain(className); + }); + + it(`has an icon with the ${badgeIcon} name`, () => { + expect(findIcon().props('name')).toBe(badgeIcon); + }); + }, + ); + it('renders the actions', () => { const actionsComponent = findActions(); - expect(actionsComponent.props('report')).toMatchObject(actions); + expect(actionsComponent.props('report')).toMatchObject(report); }); }); diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js index ee0f0967735..8c0ae223c87 100644 --- a/spec/frontend/admin/abuse_report/mock_data.js +++ b/spec/frontend/admin/abuse_report/mock_data.js @@ -40,6 +40,7 @@ export const mockAbuseReport = { path: '/reporter', }, report: { + status: 'open', message: 'This is obvious spam', reportedAt: '2023-03-29T09:39:50.502Z', category: 'spam', @@ -49,13 +50,6 @@ export const mockAbuseReport = { url: 'http://localhost:3000/spamuser417/project/-/merge_requests/1#note_1375', screenshot: '/uploads/-/system/abuse_report/screenshot/27/Screenshot_2023-03-30_at_16.56.37.png', - }, - actions: { - reportedUser: { name: 'Sp4m User', createdAt: '2023-03-29T09:30:23.885Z' }, - userBlocked: false, - blockUserPath: '/admin/users/spamuser417/block', - removeReportPath: '/admin/abuse_reports/27', - removeUserAndReportPath: '/admin/abuse_reports/27?remove_user=true', - redirectPath: '/admin/abuse_reports', + updatePath: '/admin/abuse_reports/27', }, }; diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js deleted file mode 100644 index bc648e52fad..00000000000 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js +++ /dev/null @@ -1,202 +0,0 @@ -import { nextTick } from 'vue'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import { createAlert, VARIANT_SUCCESS } from '~/alert'; -import { sprintf } from '~/locale'; -import { ACTIONS_I18N } from '~/admin/abuse_reports/constants'; -import { mockAbuseReports } from '../mock_data'; - -jest.mock('~/alert'); -jest.mock('~/lib/utils/url_utility'); - -describe('AbuseReportActions', () => { - let wrapper; - - const findRemoveUserAndReportButton = () => wrapper.findByText('Remove user & report'); - const findBlockUserButton = () => wrapper.findByTestId('block-user-button'); - const findRemoveReportButton = () => wrapper.findByText('Remove report'); - const findConfirmationModal = () => wrapper.findComponent(GlModal); - - const report = mockAbuseReports[0]; - - const createComponent = (props = {}) => { - wrapper = mountExtended(AbuseReportActions, { - propsData: { - report, - ...props, - }, - stubs: { - GlDisclosureDropdown, - GlDisclosureDropdownItem, - }, - }); - }; - - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays "Block user", "Remove user & report", and "Remove report" buttons', () => { - expect(findRemoveUserAndReportButton().text()).toBe(ACTIONS_I18N.removeUserAndReport); - - const blockButton = findBlockUserButton(); - expect(blockButton.text()).toBe(ACTIONS_I18N.blockUser); - expect(blockButton.attributes('disabled')).toBeUndefined(); - - expect(findRemoveReportButton().text()).toBe(ACTIONS_I18N.removeReport); - }); - - it('does not show the confirmation modal initially', () => { - expect(findConfirmationModal().props('visible')).toBe(false); - }); - }); - - describe('block button when user is already blocked', () => { - it('is disabled and has the correct text', () => { - createComponent({ report: { ...report, userBlocked: true } }); - - const button = findBlockUserButton(); - expect(button.text()).toBe(ACTIONS_I18N.alreadyBlocked); - expect(button.attributes('disabled')).toBeDefined(); - }); - }); - - describe('actions', () => { - let axiosMock; - - beforeEach(() => { - axiosMock = new MockAdapter(axios); - - createComponent(); - }); - - afterEach(() => { - axiosMock.restore(); - createAlert.mockClear(); - }); - - describe('on remove user and report', () => { - it('shows confirmation modal and reloads the page on success', async () => { - findRemoveUserAndReportButton().trigger('click'); - await nextTick(); - - expect(findConfirmationModal().props()).toMatchObject({ - visible: true, - title: sprintf(ACTIONS_I18N.removeUserAndReportConfirm, { - user: report.reportedUser.name, - }), - }); - - axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK); - - findConfirmationModal().vm.$emit('primary'); - await axios.waitForAll(); - - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - - describe('when a redirect path is present', () => { - beforeEach(() => { - createComponent({ report: { ...report, redirectPath: '/redirect_path' } }); - }); - - it('redirects to the given path', async () => { - findRemoveUserAndReportButton().trigger('click'); - await nextTick(); - - axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK); - - findConfirmationModal().vm.$emit('primary'); - await axios.waitForAll(); - - expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated - }); - }); - }); - - describe('on block user', () => { - beforeEach(async () => { - findBlockUserButton().trigger('click'); - await nextTick(); - }); - - it('shows confirmation modal', () => { - expect(findConfirmationModal().props()).toMatchObject({ - visible: true, - title: ACTIONS_I18N.blockUserConfirm, - }); - }); - - describe.each([ - { - responseData: { notice: 'Notice' }, - createAlertArgs: { message: 'Notice', variant: VARIANT_SUCCESS }, - blockButtonText: ACTIONS_I18N.alreadyBlocked, - blockButtonDisabled: 'disabled', - }, - { - responseData: { error: 'Error' }, - createAlertArgs: { message: 'Error' }, - blockButtonText: ACTIONS_I18N.blockUser, - blockButtonDisabled: undefined, - }, - ])( - 'when response JSON is $responseData', - ({ responseData, createAlertArgs, blockButtonText, blockButtonDisabled }) => { - beforeEach(async () => { - axiosMock.onPut(report.blockUserPath).reply(HTTP_STATUS_OK, responseData); - - findConfirmationModal().vm.$emit('primary'); - await axios.waitForAll(); - }); - - it('updates the block button correctly', () => { - const button = findBlockUserButton(); - expect(button.text()).toBe(blockButtonText); - expect(button.attributes('disabled')).toBe(blockButtonDisabled); - }); - - it('displays the returned message', () => { - expect(createAlert).toHaveBeenCalledWith(createAlertArgs); - }); - }, - ); - }); - - describe('on remove report', () => { - it('reloads the page on success', async () => { - axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK); - - findRemoveReportButton().trigger('click'); - - expect(findConfirmationModal().props('visible')).toBe(false); - - await axios.waitForAll(); - - expect(refreshCurrentPage).toHaveBeenCalled(); - }); - - describe('when a redirect path is present', () => { - beforeEach(() => { - createComponent({ report: { ...report, redirectPath: '/redirect_path' } }); - }); - - it('redirects to the given path', async () => { - axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK); - - findRemoveReportButton().trigger('click'); - - await axios.waitForAll(); - - expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); // eslint-disable-line import/no-deprecated - }); - }); - }); - }); -}); diff --git a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js index 309e5f76b9c..85eafa9e85c 100644 --- a/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/bubble_menus/bubble_menu_spec.js @@ -64,13 +64,12 @@ describe('content_editor/components/bubble_menus/bubble_menu', () => { tippyOptions: expect.objectContaining({ onHidden: expect.any(Function), onShow: expect.any(Function), - appendTo: expect.any(Function), + strategy: 'fixed', maxWidth: 'auto', ...tippyOptions, }), }); - expect(BubbleMenuPlugin.mock.calls[0][0].tippyOptions.appendTo()).toBe(document.body); expect(tiptapEditor.registerPlugin).toHaveBeenCalledWith(pluginInitializationResult); }); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 44dd328025a..59b46e95c45 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -95,7 +95,7 @@ describe('ContentEditor', () => { it('renders footer containing quick actions help text if quick actions docs path is defined', () => { createWrapper({ quickActionsDocsPath: '/foo/bar' }); - expect(findEditorElement().text()).toContain('For quick actions, type /'); + expect(wrapper.text()).toContain('For quick actions, type /'); expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/foo/bar'); }); diff --git a/spec/frontend/content_editor/extensions/description_item_spec.js b/spec/frontend/content_editor/extensions/description_item_spec.js new file mode 100644 index 00000000000..02b80d93886 --- /dev/null +++ b/spec/frontend/content_editor/extensions/description_item_spec.js @@ -0,0 +1,121 @@ +import DescriptionList from '~/content_editor/extensions/description_list'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import { createTestEditor, createDocBuilder, triggerKeyboardInput } from '../test_utils'; + +describe('content_editor/extensions/description_item', () => { + let tiptapEditor; + let doc; + let p; + let descriptionList; + let descriptionItem; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [DescriptionList, DescriptionItem] }); + + ({ + builders: { doc, p, descriptionList, descriptionItem }, + } = createDocBuilder({ + tiptapEditor, + names: { + descriptionList: { nodeType: DescriptionList.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + }, + })); + }); + + describe('shortcut: Enter', () => { + it('splits a description item into two items', () => { + const initialDoc = doc(descriptionList(descriptionItem(p('Description item')))); + const expectedDoc = doc( + descriptionList(descriptionItem(p('Descrip')), descriptionItem(p('tion item'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Enter'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('shortcut: Tab', () => { + it('converts a description term into a description details', () => { + const initialDoc = doc(descriptionList(descriptionItem(p('Description item')))); + const expectedDoc = doc( + descriptionList(descriptionItem({ isTerm: false }, p('Description item'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('has no effect on a description details', () => { + const initialDoc = doc( + descriptionList(descriptionItem({ isTerm: false }, p('Description item'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Tab'); + + expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON()); + }); + }); + + describe('shortcut: Shift-Tab', () => { + it('converts a description details into a description term', () => { + const initialDoc = doc( + descriptionList( + descriptionItem({ isTerm: false }, p('Description item')), + descriptionItem(p('Description item')), + descriptionItem(p('Description item')), + ), + ); + const expectedDoc = doc( + descriptionList( + descriptionItem(p('Description item')), + descriptionItem(p('Description item')), + descriptionItem(p('Description item')), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('lifts a description term', () => { + const initialDoc = doc(descriptionList(descriptionItem(p('Description item')))); + const expectedDoc = doc(p('Description item')); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('capturing keyboard events', () => { + it.each` + key | shiftKey | nodeActive | captured | description + ${'Tab'} | ${false} | ${true} | ${true} | ${'captures Tab key when cursor is inside a description item'} + ${'Tab'} | ${false} | ${false} | ${false} | ${'does not capture Tab key when cursor is not inside a description item'} + ${'Tab'} | ${true} | ${true} | ${true} | ${'captures Shift-Tab key when cursor is inside a description item'} + ${'Tab'} | ${true} | ${false} | ${false} | ${'does not capture Shift-Tab key when cursor is not inside a description item'} + `('$description', ({ key, shiftKey, nodeActive, captured }) => { + const initialDoc = doc(descriptionList(descriptionItem(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(nodeActive); + + expect(triggerKeyboardInput({ tiptapEditor, key, shiftKey })).toBe(captured); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/description_list_spec.js b/spec/frontend/content_editor/extensions/description_list_spec.js new file mode 100644 index 00000000000..e46680956ec --- /dev/null +++ b/spec/frontend/content_editor/extensions/description_list_spec.js @@ -0,0 +1,36 @@ +import DescriptionList from '~/content_editor/extensions/description_list'; +import DescriptionItem from '~/content_editor/extensions/description_item'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; + +describe('content_editor/extensions/description_list', () => { + let tiptapEditor; + let doc; + let p; + let descriptionList; + let descriptionItem; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [DescriptionList, DescriptionItem] }); + + ({ + builders: { doc, p, descriptionList, descriptionItem }, + } = createDocBuilder({ + tiptapEditor, + names: { + descriptionList: { nodeType: DescriptionList.name }, + descriptionItem: { nodeType: DescriptionItem.name }, + }, + })); + }); + + it.each` + inputRuleText | insertedNode | insertedNodeType + ${'<dl>'} | ${() => descriptionList(descriptionItem(p()))} | ${'descriptionList'} + ${'<dl'} | ${() => p()} | ${'paragraph'} + ${'dl>'} | ${() => p()} | ${'paragraph'} + `('with input=$input, it inserts a $insertedNodeType node', ({ inputRuleText, insertedNode }) => { + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + expect(tiptapEditor.getJSON()).toEqual(doc(insertedNode()).toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/details_content_spec.js b/spec/frontend/content_editor/extensions/details_content_spec.js index 575f3bf65e4..02e2b51366a 100644 --- a/spec/frontend/content_editor/extensions/details_content_spec.js +++ b/spec/frontend/content_editor/extensions/details_content_spec.js @@ -1,6 +1,6 @@ import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerKeyboardInput } from '../test_utils'; describe('content_editor/extensions/details_content', () => { let tiptapEditor; @@ -42,7 +42,6 @@ describe('content_editor/extensions/details_content', () => { ); tiptapEditor.commands.setContent(initialDoc.toJSON()); - tiptapEditor.commands.setTextSelection(10); tiptapEditor.commands.keyboardShortcut('Enter'); @@ -66,11 +65,26 @@ describe('content_editor/extensions/details_content', () => { ); tiptapEditor.commands.setContent(initialDoc.toJSON()); - tiptapEditor.commands.setTextSelection(20); tiptapEditor.commands.keyboardShortcut('Shift-Tab'); expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); }); }); + + describe('capturing keyboard events', () => { + it.each` + key | shiftKey | nodeActive | captured | description + ${'Tab'} | ${true} | ${true} | ${true} | ${'captures Shift-Tab key when cursor is inside a details content'} + ${'Tab'} | ${true} | ${false} | ${false} | ${'does not capture Shift-Tab key when cursor is not inside a details content'} + `('$description', ({ key, shiftKey, nodeActive, captured }) => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + jest.spyOn(tiptapEditor, 'isActive').mockReturnValue(nodeActive); + + expect(triggerKeyboardInput({ tiptapEditor, key, shiftKey })).toBe(captured); + }); + }); }); diff --git a/spec/frontend/content_editor/extensions/details_spec.js b/spec/frontend/content_editor/extensions/details_spec.js index cd59943982f..ce97444ec19 100644 --- a/spec/frontend/content_editor/extensions/details_spec.js +++ b/spec/frontend/content_editor/extensions/details_spec.js @@ -1,6 +1,6 @@ import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; -import { createTestEditor, createDocBuilder } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; describe('content_editor/extensions/details', () => { let tiptapEditor; @@ -75,18 +75,13 @@ describe('content_editor/extensions/details', () => { }); it.each` - input | insertedNode - ${'<details>'} | ${(...args) => details(detailsContent(p(...args)))} - ${'<details'} | ${(...args) => p(...args)} - ${'details>'} | ${(...args) => p(...args)} - `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { - const { view } = tiptapEditor; - const { selection } = view.state; - const expectedDoc = doc(insertedNode()); - - // Triggers the event handler that input rules listen to - view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); - - expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + inputRuleText | insertedNode | insertedNodeType + ${'<details>'} | ${() => details(detailsContent(p()))} | ${'details'} + ${'<details'} | ${() => p()} | ${'paragraph'} + ${'details>'} | ${() => p()} | ${'paragraph'} + `('with input=$input, it inserts a $insertedNodeType node', ({ inputRuleText, insertedNode }) => { + triggerNodeInputRule({ tiptapEditor, inputRuleText }); + + expect(tiptapEditor.getJSON()).toEqual(doc(insertedNode()).toJSON()); }); }); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 802ea49631f..9357381c053 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -192,6 +192,15 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => { ); }; +export const triggerKeyboardInput = ({ tiptapEditor, key, shiftKey = false }) => { + let isCaptured = false; + tiptapEditor.view.someProp('handleKeyDown', (f) => { + isCaptured = f(tiptapEditor.view, new KeyboardEvent('keydown', { key, shiftKey })); + return isCaptured; + }); + return isCaptured; +}; + /** * Executes an action that triggers a transaction in the * tiptap Editor. Returns a promise that resolves diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index 87c638d065a..1ec8547d325 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -3,6 +3,7 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import TreeList from '~/diffs/components/tree_list.vue'; import createStore from '~/diffs/store/modules'; +import batchComments from '~/batch_comments/stores/modules/batch_comments'; import DiffFileRow from '~/diffs/components//diff_file_row.vue'; import { stubComponent } from 'helpers/stub_component'; @@ -38,6 +39,7 @@ describe('Diffs tree list component', () => { store = new Vuex.Store({ modules: { diffs: createStore(), + batchComments: batchComments(), }, }); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index cdfe8b02b48..0f70b264326 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -334,14 +334,12 @@ describe('note_app', () => { }); it('should listen hashchange event', () => { - const notesApp = wrapper.findComponent(NotesApp); const hash = 'some dummy hash'; jest.spyOn(urlUtility, 'getLocationHash').mockReturnValue(hash); - const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash'); - + const dispatchMock = jest.spyOn(store, 'dispatch'); window.dispatchEvent(new Event('hashchange'), hash); - expect(setTargetNoteHash).toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledWith('setTargetNoteHash', 'some dummy hash'); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 86e4e88e3cf..7f6ecbac748 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -18,6 +18,7 @@ describe('ServiceDeskRoot', () => { endpoint: '/gitlab-org/gitlab-test/service_desk', initialIncomingEmail: 'servicedeskaddress@example.com', initialIsEnabled: true, + isIssueTrackerEnabled: true, outgoingName: 'GitLab Support Bot', projectKey: 'key', selectedTemplate: 'Bug', @@ -59,6 +60,7 @@ describe('ServiceDeskRoot', () => { initialSelectedTemplate: provideData.selectedTemplate, initialSelectedFileTemplateProjectId: provideData.selectedFileTemplateProjectId, isEnabled: provideData.initialIsEnabled, + isIssueTrackerEnabled: provideData.isIssueTrackerEnabled, isTemplateSaving: false, templates: provideData.templates, }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 84eafc3d0f3..5631927cc2f 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -1,7 +1,8 @@ -import { GlButton, GlDropdown, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlLoadingIcon, GlToggle, GlAlert } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -16,17 +17,44 @@ describe('ServiceDeskSetting', () => { const findTemplateDropdown = () => wrapper.findComponent(GlDropdown); const findToggle = () => wrapper.findComponent(GlToggle); const findSuffixFormGroup = () => wrapper.findByTestId('suffix-form-group'); + const findIssueTrackerInfo = () => wrapper.findComponent(GlAlert); + const findIssueHelpLink = () => wrapper.findByTestId('issue-help-page'); const createComponent = ({ props = {} } = {}) => extendedWrapper( mount(ServiceDeskSetting, { propsData: { isEnabled: true, + isIssueTrackerEnabled: true, ...props, }, }), ); + describe('with issue tracker', () => { + it('does not show the info notice when enabled', () => { + wrapper = createComponent(); + + expect(findIssueTrackerInfo().exists()).toBe(false); + }); + + it('shows info notice when disabled with help page link', () => { + wrapper = createComponent({ + props: { + isIssueTrackerEnabled: false, + }, + }); + + expect(findIssueTrackerInfo().exists()).toBe(true); + expect(findIssueHelpLink().text()).toEqual('activate the issue tracker'); + expect(findIssueHelpLink().attributes('href')).toBe( + helpPagePath('user/project/settings/index.md', { + anchor: 'configure-project-visibility-features-and-permissions', + }), + ); + }); + }); + describe('when isEnabled=true', () => { describe('only isEnabled', () => { describe('as project admin', () => { diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js index 7090db5cad7..1a76e7d1ec6 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_template_dropdown_spec.js @@ -14,6 +14,7 @@ describe('ServiceDeskTemplateDropdown', () => { mount(ServiceDeskTemplateDropdown, { propsData: { isEnabled: true, + isIssueTrackerEnabled: true, ...props, }, }), diff --git a/spec/helpers/admin/abuse_reports_helper_spec.rb b/spec/helpers/admin/abuse_reports_helper_spec.rb index 496b7361b6e..6a7630dc76a 100644 --- a/spec/helpers/admin/abuse_reports_helper_spec.rb +++ b/spec/helpers/admin/abuse_reports_helper_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Admin::AbuseReportsHelper, feature_category: :insider_threat do subject(:data) { helper.abuse_report_data(report)[:abuse_report_data] } it 'has the expected attributes' do - expect(data).to include('user', 'reporter', 'report', 'actions') + expect(data).to include('user', 'reporter', 'report') end end end diff --git a/spec/helpers/resource_events/abuse_report_events_helper_spec.rb b/spec/helpers/resource_events/abuse_report_events_helper_spec.rb new file mode 100644 index 00000000000..f711fb6773c --- /dev/null +++ b/spec/helpers/resource_events/abuse_report_events_helper_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceEvents::AbuseReportEventsHelper, feature_category: :instance_resiliency do + describe '#success_message_for_action' do + using RSpec::Parameterized::TableSyntax + + where(:action, :action_value) do + ResourceEvents::AbuseReportEvent.actions.to_a + end + + with_them do + it { expect(helper.success_message_for_action(action)).not_to be_nil } + end + end +end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index c2c32abbdc4..372808b64d3 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -1571,12 +1571,7 @@ RSpec.describe Notify do end context 'when custom email is enabled' do - let_it_be(:credentials) do - create( - :service_desk_custom_email_credential, - project: project - ) - end + let_it_be(:credentials) { create(:service_desk_custom_email_credential, project: project) } let_it_be(:settings) do create( diff --git a/spec/models/resource_events/abuse_report_event_spec.rb b/spec/models/resource_events/abuse_report_event_spec.rb index 1c709ae4f21..d454632c906 100644 --- a/spec/models/resource_events/abuse_report_event_spec.rb +++ b/spec/models/resource_events/abuse_report_event_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe ResourceEvents::AbuseReportEvent, feature_category: :instance_resiliency, type: :model do + include ResourceEvents::AbuseReportEventsHelper + subject(:event) { build(:abuse_report_event) } describe 'associations' do @@ -14,4 +16,10 @@ RSpec.describe ResourceEvents::AbuseReportEvent, feature_category: :instance_res it { is_expected.to be_valid } it { is_expected.to validate_presence_of(:action) } end + + describe '#success_message' do + it 'returns a success message for the action' do + expect(event.success_message).to eq(success_message_for_action(event.action)) + end + end end diff --git a/spec/requests/admin/abuse_reports_controller_spec.rb b/spec/requests/admin/abuse_reports_controller_spec.rb index 0b5aaabaa61..8d033a2e147 100644 --- a/spec/requests/admin/abuse_reports_controller_spec.rb +++ b/spec/requests/admin/abuse_reports_controller_spec.rb @@ -57,13 +57,46 @@ RSpec.describe Admin::AbuseReportsController, type: :request, feature_category: let(:report) { create(:abuse_report) } let(:params) { { user_action: 'block_user', close: 'true', reason: 'spam', comment: 'obvious spam' } } let(:expected_params) { ActionController::Parameters.new(params).permit! } + let(:message) { 'Service response' } + + subject(:request) { put admin_abuse_report_path(report, params) } it 'invokes the Admin::AbuseReportUpdateService' do expect_next_instance_of(Admin::AbuseReportUpdateService, report, admin, expected_params) do |service| - expect(service).to receive(:execute) + expect(service).to receive(:execute).and_call_original end - put admin_abuse_report_path(report, params) + request + end + + context 'when the service response is a success' do + before do + allow_next_instance_of(Admin::AbuseReportUpdateService, report, admin, expected_params) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.success(message: message)) + end + + request + end + + it 'returns the service response message with a success status' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['message']).to eq(message) + end + end + + context 'when the service response is an error' do + before do + allow_next_instance_of(Admin::AbuseReportUpdateService, report, admin, expected_params) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.error(message: message)) + end + + request + end + + it 'returns the service response message with a failed status' do + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq(message) + end end end diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index c07382a6e04..cd2c21fda29 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -337,6 +337,81 @@ RSpec.describe API::Internal::Kubernetes, feature_category: :deployment_manageme end end + describe 'GET /internal/kubernetes/verify_project_access' do + def send_request(headers: {}, params: {}) + get api("/internal/kubernetes/verify_project_access"), params: params, headers: headers.reverse_merge(jwt_auth_headers) + end + + include_examples 'authorization' + include_examples 'agent authentication' + include_examples 'error handling' + + shared_examples 'access is granted' do + it 'returns success response' do + send_request(params: { id: project_id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + shared_examples 'access is denied' do + it 'returns 404' do + send_request(params: { id: project_id }, headers: { 'Authorization' => "Bearer #{agent_token.token}" }) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'an agent is found' do + let_it_be(:agent_token) { create(:cluster_agent_token) } + let(:project_id) { project.id } + + include_examples 'agent token tracking' + + context 'project is public' do + let(:project) { create(:project, :public) } + + it_behaves_like 'access is granted' + + context 'repository is for project members only' do + let(:project) { create(:project, :public, :repository_private) } + + it_behaves_like 'access is denied' + end + end + + context 'project is private' do + let(:project) { create(:project, :private) } + + it_behaves_like 'access is denied' + + context 'and agent belongs to project' do + let(:agent_token) { create(:cluster_agent_token, agent: create(:cluster_agent, project: project)) } + + it_behaves_like 'access is granted' + end + end + + context 'project is internal' do + let(:project) { create(:project, :internal) } + + it_behaves_like 'access is denied' + + context 'and agent belongs to project' do + let(:agent_token) { create(:cluster_agent_token, agent: create(:cluster_agent, project: project)) } + + it_behaves_like 'access is granted' + end + end + + context 'project does not exist' do + let(:project_id) { non_existing_record_id } + + it_behaves_like 'access is denied' + end + end + end + describe 'POST /internal/kubernetes/authorize_proxy_user', :clean_gitlab_redis_sessions do include SessionHelpers diff --git a/spec/serializers/admin/abuse_report_details_entity_spec.rb b/spec/serializers/admin/abuse_report_details_entity_spec.rb index 0e5e6a62ce1..08bfa57b062 100644 --- a/spec/serializers/admin/abuse_report_details_entity_spec.rb +++ b/spec/serializers/admin/abuse_report_details_entity_spec.rb @@ -21,8 +21,7 @@ RSpec.describe Admin::AbuseReportDetailsEntity, feature_category: :insider_threa expect(entity_hash.keys).to include( :user, :reporter, - :report, - :actions + :report ) end @@ -127,31 +126,15 @@ RSpec.describe Admin::AbuseReportDetailsEntity, feature_category: :insider_threa report_hash = entity_hash[:report] expect(report_hash.keys).to match_array([ + :status, :message, :reported_at, :category, :type, :content, :url, - :screenshot - ]) - end - - it 'correctly exposes `actions`', :aggregate_failures do - actions_hash = entity_hash[:actions] - - expect(actions_hash.keys).to match_array([ - :user_blocked, - :block_user_path, - :remove_user_and_report_path, - :remove_report_path, - :reported_user, - :redirect_path - ]) - - expect(actions_hash[:reported_user].keys).to match_array([ - :name, - :created_at + :screenshot, + :update_path ]) end end diff --git a/spec/serializers/admin/abuse_report_details_serializer_spec.rb b/spec/serializers/admin/abuse_report_details_serializer_spec.rb index f22d92a1763..a42c56c0921 100644 --- a/spec/serializers/admin/abuse_report_details_serializer_spec.rb +++ b/spec/serializers/admin/abuse_report_details_serializer_spec.rb @@ -12,8 +12,7 @@ RSpec.describe Admin::AbuseReportDetailsSerializer, feature_category: :insider_t is_expected.to include( :user, :reporter, - :report, - :actions + :report ) end end diff --git a/spec/services/admin/abuse_report_update_service_spec.rb b/spec/services/admin/abuse_report_update_service_spec.rb index e85b516b87f..7069d8ee5c1 100644 --- a/spec/services/admin/abuse_report_update_service_spec.rb +++ b/spec/services/admin/abuse_report_update_service_spec.rb @@ -52,6 +52,10 @@ RSpec.describe Admin::AbuseReportUpdateService, feature_category: :instance_resi comment: params[:comment] ) end + + it 'returns the event success message' do + expect(subject.message).to eq(abuse_report.events.last.success_message) + end end context 'when invalid parameters are given' do @@ -194,6 +198,15 @@ RSpec.describe Admin::AbuseReportUpdateService, feature_category: :instance_resi it_behaves_like 'closes the report' it_behaves_like 'records an event', action: 'close_report' + + context 'when report is already closed' do + before do + abuse_report.closed! + end + + it_behaves_like 'returns an error response', 'Report already closed' + it_behaves_like 'does not record an event' + end end end end diff --git a/spec/services/service_desk/custom_email_verifications/create_service_spec.rb b/spec/services/service_desk/custom_email_verifications/create_service_spec.rb new file mode 100644 index 00000000000..fceb6fc78b4 --- /dev/null +++ b/spec/services/service_desk/custom_email_verifications/create_service_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ServiceDesk::CustomEmailVerifications::CreateService, feature_category: :service_desk do + describe '#execute' do + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let!(:credential) { create(:service_desk_custom_email_credential, project: project) } + + let(:message_delivery) { instance_double(ActionMailer::MessageDelivery) } + let(:message) { instance_double(Mail::Message) } + + let(:service) { described_class.new(project: project, current_user: user) } + + before do + allow(message_delivery).to receive(:deliver_later) + allow(Notify).to receive(:service_desk_verification_triggered_email).and_return(message_delivery) + + # We send verification email directly + allow(message).to receive(:deliver) + allow(Notify).to receive(:service_desk_custom_email_verification_email).and_return(message) + end + + shared_examples 'a verification process that exits early' do + it 'aborts verification process and exits early', :aggregate_failures do + # Because we exit early it should not send any verification or notification emails + expect(service).to receive(:setup_and_deliver_verification_email).exactly(0).times + expect(Notify).to receive(:service_desk_verification_triggered_email).exactly(0).times + + response = service.execute + + expect(response).to be_error + end + end + + shared_examples 'a verification process with ramp up error' do |error, error_identifier| + it 'aborts verification process', :aggregate_failures do + allow(message).to receive(:deliver).and_raise(error) + + # Creates one verification email + expect(Notify).to receive(:service_desk_custom_email_verification_email).once + + # Correct amount of notification emails were sent + expect(Notify).to receive(:service_desk_verification_triggered_email).exactly(project.owners.size + 1).times + + # Correct amount of result notification emails were sent + expect(Notify).to receive(:service_desk_verification_result_email).exactly(project.owners.size + 1).times + + response = service.execute + + expect(response).to be_error + expect(response.reason).to eq error_identifier + + expect(settings).not_to be_custom_email_enabled + expect(settings.custom_email_verification.triggered_at).not_to be_nil + expect(settings.custom_email_verification).to have_attributes( + token: nil, + triggerer: user, + error: error_identifier, + state: 'failed' + ) + end + end + + it_behaves_like 'a verification process that exits early' + + context 'when feature flag :service_desk_custom_email is disabled' do + before do + stub_feature_flags(service_desk_custom_email: false) + end + + it_behaves_like 'a verification process that exits early' + end + + context 'when service desk setting exists' do + let(:settings) { create(:service_desk_setting, project: project, custom_email: 'user@example.com') } + let(:service) { described_class.new(project: settings.project, current_user: user) } + + it 'aborts verification process and exits early', :aggregate_failures do + # Because we exit early it should not send any verification or notification emails + expect(service).to receive(:setup_and_deliver_verification_email).exactly(0).times + expect(Notify).to receive(:service_desk_verification_triggered_email).exactly(0).times + + response = service.execute + settings.reload + + expect(response).to be_error + + expect(settings.custom_email_enabled).to be false + # Because service should normally add initial verification object + expect(settings.custom_email_verification).to be nil + end + + context 'when user has maintainer role in project' do + before do + project.add_maintainer(user) + end + + it 'initiates verification process successfully', :aggregate_failures do + # Creates one verification email + expect(Notify).to receive(:service_desk_custom_email_verification_email).once + + # Check whether the correct amount of notification emails were sent + expect(Notify).to receive(:service_desk_verification_triggered_email).exactly(project.owners.size + 1).times + + response = service.execute + + settings.reload + verification = settings.custom_email_verification + + expect(response).to be_success + + expect(settings.custom_email_enabled).to be false + + expect(verification).to be_started + expect(verification.token).not_to be_nil + expect(verification.triggered_at).not_to be_nil + expect(verification).to have_attributes( + triggerer: user, + error: nil + ) + end + + context 'when providing invalid SMTP credentials' do + before do + allow(Notify).to receive(:service_desk_verification_result_email).and_return(message_delivery) + end + + it_behaves_like 'a verification process with ramp up error', SocketError, 'smtp_host_issue' + it_behaves_like 'a verification process with ramp up error', OpenSSL::SSL::SSLError, 'smtp_host_issue' + it_behaves_like 'a verification process with ramp up error', + Net::SMTPAuthenticationError.new('Invalid username or password'), 'invalid_credentials' + end + end + end + end +end diff --git a/spec/services/service_desk/custom_email_verifications/update_service_spec.rb b/spec/services/service_desk/custom_email_verifications/update_service_spec.rb new file mode 100644 index 00000000000..f1e683c0185 --- /dev/null +++ b/spec/services/service_desk/custom_email_verifications/update_service_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ServiceDesk::CustomEmailVerifications::UpdateService, feature_category: :service_desk do + describe '#execute' do + let_it_be_with_reload(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let!(:credential) { create(:service_desk_custom_email_credential, project: project) } + let(:settings) { create(:service_desk_setting, project: project, custom_email: 'custom-support-email@example.com') } + + let(:mail_object) { nil } + let(:message_delivery) { instance_double(ActionMailer::MessageDelivery) } + let(:service) { described_class.new(project: settings.project, params: { mail: mail_object }) } + + before do + allow(message_delivery).to receive(:deliver_later) + allow(Notify).to receive(:service_desk_verification_result_email).and_return(message_delivery) + end + + shared_examples 'a failing verification process' do |expected_error_identifier| + it 'refuses to verify and sends result emails' do + expect(Notify).to receive(:service_desk_verification_result_email).twice + + response = described_class.new(project: settings.project, params: { mail: mail_object }).execute + + settings.reset + verification.reset + + expect(response).to be_error + expect(settings).not_to be_custom_email_enabled + expect(verification).to be_failed + + expect(response.reason).to eq expected_error_identifier + expect(verification.error).to eq expected_error_identifier + end + end + + shared_examples 'an early exit from the verification process' do |expected_state| + it 'exits early' do + expect(Notify).to receive(:service_desk_verification_result_email).exactly(0).times + + response = service.execute + + settings.reset + verification.reset + + expect(response).to be_error + expect(settings).not_to be_custom_email_enabled + expect(verification.state).to eq expected_state + end + end + + it 'exits early' do + expect(Notify).to receive(:service_desk_verification_result_email).exactly(0).times + + response = service.execute + + settings.reset + + expect(response).to be_error + expect(settings).not_to be_custom_email_enabled + end + + context 'when feature flag :service_desk_custom_email is disabled' do + before do + stub_feature_flags(service_desk_custom_email: false) + end + + it 'exits early' do + expect(Notify).to receive(:service_desk_verification_result_email).exactly(0).times + + response = service.execute + + expect(response).to be_error + end + end + + context 'when verification exists' do + let!(:verification) { create(:service_desk_custom_email_verification, project: project) } + + context 'when we do not have a verification email' do + # Raise if verification started but no email provided + it_behaves_like 'a failing verification process', 'mail_not_received_within_timeframe' + + context 'when already verified' do + before do + verification.mark_as_finished! + end + + it_behaves_like 'an early exit from the verification process', 'finished' + end + + context 'when we already have an error' do + before do + verification.mark_as_failed!(:smtp_host_issue) + end + + it_behaves_like 'an early exit from the verification process', 'failed' + end + end + + context 'when we have a verification email' do + before do + verification.update!(token: 'ZROT4ZZXA-Y6') # token from email fixture + end + + let(:email_raw) { email_fixture('emails/service_desk_custom_email_address_verification.eml') } + let(:mail_object) { Mail::Message.new(email_raw) } + + it 'verifies and sends result emails' do + expect(Notify).to receive(:service_desk_verification_result_email).twice + + response = service.execute + + settings.reset + verification.reset + + expect(response).to be_success + expect(settings).not_to be_custom_email_enabled + expect(verification).to be_finished + end + + context 'and verification tokens do not match' do + before do + verification.update!(token: 'XXXXXXZXA-XX') + end + + it_behaves_like 'a failing verification process', 'incorrect_token' + end + + context 'and from address does not match with custom email' do + before do + settings.update!(custom_email: 'some-other@example.com') + end + + it_behaves_like 'a failing verification process', 'incorrect_from' + end + + context 'and timeframe for receiving the email is over' do + before do + verification.update!(triggered_at: 40.minutes.ago) + end + + it_behaves_like 'a failing verification process', 'mail_not_received_within_timeframe' + end + end + end + end +end diff --git a/spec/support/helpers/merge_request_diff_helpers.rb b/spec/support/helpers/merge_request_diff_helpers.rb index 7515c789add..bbd9382fcc2 100644 --- a/spec/support/helpers/merge_request_diff_helpers.rb +++ b/spec/support/helpers/merge_request_diff_helpers.rb @@ -3,6 +3,18 @@ module MergeRequestDiffHelpers PageEndReached = Class.new(StandardError) + def add_diff_line_draft_comment(comment, line_holder, diff_side = nil) + click_diff_line(line_holder, diff_side) + page.within('.js-discussion-note-form') do + fill_in('note_note', with: comment) + begin + click_button('Start a review', wait: 0.1) + rescue Capybara::ElementNotFound + click_button('Add to review') + end + end + end + def click_diff_line(line_holder, diff_side = nil) line = get_line_components(line_holder, diff_side) scroll_to_elements_bottom(line_holder) diff --git a/vendor/gems/omniauth_crowd/.gitlab-ci.yml b/vendor/gems/omniauth_crowd/.gitlab-ci.yml index 08a5da1a3d1..ddc5d2fa5c3 100644 --- a/vendor/gems/omniauth_crowd/.gitlab-ci.yml +++ b/vendor/gems/omniauth_crowd/.gitlab-ci.yml @@ -26,3 +26,11 @@ rspec-2.7: rspec-3.0: image: "ruby:3.0" extends: .rspec + +rspec-3.1: + image: "ruby:3.1" + extends: .rspec + +rspec-3.2: + image: "ruby:3.2" + extends: .rspec diff --git a/yarn.lock b/yarn.lock index 703a30a9e61..e2736e2e3a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1134,10 +1134,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/visual-review-tools/-/visual-review-tools-1.7.3.tgz#9ea641146436da388ffbad25d7f2abe0df52c235" integrity sha512-NMV++7Ew1FSBDN1xiZaauU9tfeSfgDHcOLpn+8bGpP+O5orUPm2Eu66R5eC5gkjBPaXosNAxNWtriee+aFk4+g== -"@gitlab/web-ide@0.0.1-dev-20230511143809": - version "0.0.1-dev-20230511143809" - resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20230511143809.tgz#c13dfb4d1edab2e020d4a102d4ec18048917490f" - integrity sha512-caP5WSaTuIhPrPGUWyvPT4np6swkKQHM1Pa9HiBnGhiOhhQ1+3X/+J9EoZXUhnhwiBzS7sp32Uyttam4am/sTA== +"@gitlab/web-ide@0.0.1-dev-20230524134151": + version "0.0.1-dev-20230524134151" + resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20230524134151.tgz#027b859d98a6e062eb7e0632d4d9bc094dc3ac9a" + integrity sha512-uduf0YWp7iotdz+TrYcnbsZVfMo6FytNAPXKxBxHRsK2x6cDqR9HPni/JDkgtvXJDW6cgB9Um0/+NoKcZD0/KQ== "@graphql-eslint/eslint-plugin@3.19.0": version "3.19.0" |