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 /spec | |
parent | 61ebd5753018a1f4b6032122f6ea625dc4e4fc8e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
33 files changed, 1028 insertions, 276 deletions
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) |