diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-09 18:09:24 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-04-09 18:09:24 +0000 |
commit | b19efd72743e22fd3b340b3c2906ba113e1390de (patch) | |
tree | 6afb0cff9382cf949654608368731ed4883a0678 /spec | |
parent | 9ea69b43c3502c4c63e6d47da40786875197fcf3 (diff) | |
download | gitlab-ce-b19efd72743e22fd3b340b3c2906ba113e1390de.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
22 files changed, 980 insertions, 300 deletions
diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js index b4863d7bc19..e7ff4812ee7 100644 --- a/spec/frontend/captcha/apollo_captcha_link_spec.js +++ b/spec/frontend/captcha/apollo_captcha_link_spec.js @@ -44,7 +44,7 @@ describe('apolloCaptchaLink', () => { }, errors: [ { - message: 'Your Query was detected to be SPAM.', + message: 'Your Query was detected to be spam.', path: ['user'], locations: [{ line: 2, column: 3 }], extensions: { @@ -116,7 +116,7 @@ describe('apolloCaptchaLink', () => { }); }); - it('unresolvable SPAM errors are passed through', (done) => { + it('unresolvable spam errors are passed through', (done) => { setupLink(SPAM_ERROR_RESPONSE); link.request(mockOperation()).subscribe((result) => { expect(result).toEqual(SPAM_ERROR_RESPONSE); @@ -127,8 +127,8 @@ describe('apolloCaptchaLink', () => { }); }); - describe('resolvable SPAM errors', () => { - it('re-submits request with SPAM headers if the captcha modal was solved correctly', (done) => { + describe('resolvable spam errors', () => { + it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => { waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); link.request(mockOperation()).subscribe((result) => { diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 13dbda9cf55..5453c93eac3 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -665,6 +665,41 @@ describe('GfmAutoComplete', () => { expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result); }); }); + + describe('sorter', () => { + const query = 'c'; + + const items = [ + { search: 'DougHackett elayne.krieger' }, + { search: 'BerylHuel cherie.block' }, + { search: 'ErlindaMayert nicolle' }, + { search: 'Administrator root' }, + { search: 'PhoebeSchaden salina' }, + { search: 'CatherinTerry tommy.will' }, + { search: 'AntoineLedner ammie' }, + { search: 'KinaCummings robena' }, + { search: 'CharlsieHarber xzbdulia' }, + ]; + + const expected = [ + // Members whose name/username starts with `c` are grouped first + { search: 'BerylHuel cherie.block' }, + { search: 'CatherinTerry tommy.will' }, + { search: 'CharlsieHarber xzbdulia' }, + // Members whose name/username contains `c` are grouped second + { search: 'DougHackett elayne.krieger' }, + { search: 'ErlindaMayert nicolle' }, + { search: 'PhoebeSchaden salina' }, + { search: 'KinaCummings robena' }, + // Remaining members are grouped last + { search: 'Administrator root' }, + { search: 'AntoineLedner ammie' }, + ]; + + it('sorts by match with start of name/username, then match with any part of name/username, and maintains sort order', () => { + expect(GfmAutoComplete.Members.sort(query, items)).toMatchObject(expected); + }); + }); }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 67ae644911a..476804bda12 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -1,5 +1,5 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -28,13 +28,24 @@ describe('IssuesListApp component', () => { let axiosMock; let wrapper; - const calendarPath = 'calendar/path'; - const endpoint = 'api/endpoint'; - const exportCsvPath = 'export/csv/path'; - const fullPath = 'path/to/project'; - const issuesPath = `${fullPath}/-/issues`; - const newIssuePath = `new/issue/path`; - const rssPath = 'rss/path'; + const defaultProvide = { + calendarPath: 'calendar/path', + canBulkUpdate: false, + emptyStateSvgPath: 'empty-state.svg', + endpoint: 'api/endpoint', + exportCsvPath: 'export/csv/path', + fullPath: 'path/to/project', + hasIssues: true, + isSignedIn: false, + issuesPath: 'path/to/issues', + jiraIntegrationPath: 'jira/integration/path', + newIssuePath: 'new/issue/path', + rssPath: 'rss/path', + showImportButton: true, + showNewIssueLink: true, + signInPath: 'sign/in/path', + }; + const state = 'opened'; const xPage = 1; const xTotal = 25; @@ -51,27 +62,27 @@ describe('IssuesListApp component', () => { }, }; + const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); + const findGlButton = () => wrapper.findComponent(GlButton); const findGlButtons = () => wrapper.findAllComponents(GlButton); const findGlButtonAt = (index) => findGlButtons().at(index); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlLink = () => wrapper.findComponent(GlLink); const findIssuableList = () => wrapper.findComponent(IssuableList); - const mountComponent = ({ provide = {} } = {}) => - shallowMount(IssuesListApp, { + const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) => + mountFn(IssuesListApp, { provide: { - calendarPath, - endpoint, - exportCsvPath, - fullPath, - issuesPath, - newIssuePath, - rssPath, + ...defaultProvide, ...provide, }, }); beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); - axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); + axiosMock + .onGet(defaultProvide.endpoint) + .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers); }); afterEach(() => { @@ -88,7 +99,7 @@ describe('IssuesListApp component', () => { it('renders', () => { expect(findIssuableList().props()).toMatchObject({ - namespace: fullPath, + namespace: defaultProvide.fullPath, recentSearchesStorageKey: 'issues', searchInputPlaceholder: 'Search or filter results…', sortOptions, @@ -96,7 +107,7 @@ describe('IssuesListApp component', () => { tabs: IssuableListTabs, currentTab: IssuableStates.Opened, tabCounts, - showPaginationControls: true, + showPaginationControls: false, issuables: [], totalItems: xTotal, currentPage: xPage, @@ -112,7 +123,7 @@ describe('IssuesListApp component', () => { wrapper = mountComponent(); expect(findGlButtonAt(0).attributes()).toMatchObject({ - href: rssPath, + href: defaultProvide.rssPath, icon: 'rss', 'aria-label': IssuesListApp.i18n.rssLabel, }); @@ -122,7 +133,7 @@ describe('IssuesListApp component', () => { wrapper = mountComponent(); expect(findGlButtonAt(1).attributes()).toMatchObject({ - href: calendarPath, + href: defaultProvide.calendarPath, icon: 'calendar', 'aria-label': IssuesListApp.i18n.calendarLabel, }); @@ -140,8 +151,8 @@ describe('IssuesListApp component', () => { await waitForPromises(); - expect(wrapper.findComponent(CsvImportExportButtons).props()).toMatchObject({ - exportCsvPath: `${exportCsvPath}${search}`, + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: `${defaultProvide.exportCsvPath}${search}`, issuableCount: xTotal, }); }); @@ -153,7 +164,7 @@ describe('IssuesListApp component', () => { expect(findGlButtonAt(2).text()).toBe('Edit issues'); }); - it('does not render when user has permissions', () => { + it('does not render when user does not have permissions', () => { wrapper = mountComponent({ provide: { canBulkUpdate: false } }); expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); @@ -175,7 +186,7 @@ describe('IssuesListApp component', () => { wrapper = mountComponent({ provide: { showNewIssueLink: true } }); expect(findGlButtonAt(2).text()).toBe('New issue'); - expect(findGlButtonAt(2).attributes('href')).toBe(newIssuePath); + expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath); }); it('does not render when user does not have permissions', () => { @@ -186,20 +197,50 @@ describe('IssuesListApp component', () => { }); }); - describe('initial sort', () => { - it.each(Object.keys(sortParams))('is set as %s when the url query matches', (sortKey) => { - Object.defineProperty(window, 'location', { - writable: true, - value: { - href: setUrlParams(sortParams[sortKey], TEST_HOST), - }, + describe('initial url params', () => { + describe('page', () => { + it('is set from the url params', () => { + const page = 5; + + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ page }, TEST_HOST) }, + }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('currentPage')).toBe(page); }); + }); - wrapper = mountComponent(); + describe('sort', () => { + it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) }, + }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: sortKey, - urlParams: sortParams[sortKey], + wrapper = mountComponent(); + + expect(findIssuableList().props()).toMatchObject({ + initialSortBy: sortKey, + urlParams: sortParams[sortKey], + }); + }); + }); + + describe('state', () => { + it('is set from the url params', () => { + const initialState = IssuableStates.All; + + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ state: initialState }, TEST_HOST) }, + }); + + wrapper = mountComponent(); + + expect(findIssuableList().props('currentTab')).toBe(initialState); }); }); }); @@ -221,148 +262,285 @@ describe('IssuesListApp component', () => { ); }); - describe('when "click-tab" event is emitted by IssuableList', () => { - beforeEach(() => { - axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, { - 'x-page': 2, - 'x-total': xTotal, + describe('empty states', () => { + describe('when there are issues', () => { + describe('when search returns no results', () => { + beforeEach(async () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) }, + }); + + wrapper = mountComponent({ provide: { hasIssues: true } }); + + await waitForPromises(); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noSearchResultsDescription, + title: IssuesListApp.i18n.noSearchResultsTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Open" tab has no issues', () => { + beforeEach(() => { + wrapper = mountComponent({ provide: { hasIssues: true } }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noOpenIssuesDescription, + title: IssuesListApp.i18n.noOpenIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); }); - wrapper = mountComponent(); + describe('when "Closed" tab has no issues', () => { + beforeEach(async () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) }, + }); - findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); + wrapper = mountComponent({ provide: { hasIssues: true } }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: IssuesListApp.i18n.noClosedIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); }); - it('makes API call to filter the list by the new state and resets the page to 1', () => { - expect(axiosMock.history.get[1].params).toMatchObject({ - page: 1, - state: IssuableStates.Closed, + describe('when there are no issues', () => { + describe('when user is logged in', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { hasIssues: false, isSignedIn: true }, + mountFn: mount, + }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noIssuesSignedInDescription, + title: IssuesListApp.i18n.noIssuesSignedInTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + + it('shows "New issue" and import/export buttons', () => { + expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel); + expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath); + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: defaultProvide.exportCsvPath, + issuableCount: 0, + }); + }); + + it('shows Jira integration information', () => { + const paragraphs = wrapper.findAll('p'); + expect(paragraphs.at(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); + expect(paragraphs.at(3).text()).toContain( + 'Enable the Jira integration to view your Jira issues in GitLab.', + ); + expect(paragraphs.at(4).text()).toContain( + IssuesListApp.i18n.jiraIntegrationSecondaryMessage, + ); + expect(findGlLink().text()).toBe('Enable the Jira integration'); + expect(findGlLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath); + }); + }); + + describe('when user is logged out', () => { + beforeEach(() => { + wrapper = mountComponent({ + provide: { hasIssues: false, isSignedIn: false }, + }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noIssuesSignedOutDescription, + title: IssuesListApp.i18n.noIssuesSignedOutTitle, + svgPath: defaultProvide.emptyStateSvgPath, + primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText, + primaryButtonLink: defaultProvide.signInPath, + }); + }); }); }); }); - describe('when "page-change" event is emitted by IssuableList', () => { - const data = [{ id: 10, title: 'title', state }]; - const page = 2; - const totalItems = 21; + describe('events', () => { + describe('when "click-tab" event is emitted by IssuableList', () => { + beforeEach(() => { + axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, { + 'x-page': 2, + 'x-total': xTotal, + }); - beforeEach(async () => { - axiosMock.onGet(endpoint).reply(200, data, { - 'x-page': page, - 'x-total': totalItems, + wrapper = mountComponent(); + + findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); }); - wrapper = mountComponent(); + it('makes API call to filter the list by the new state and resets the page to 1', () => { + expect(axiosMock.history.get[1].params).toMatchObject({ + page: 1, + state: IssuableStates.Closed, + }); + }); + }); - findIssuableList().vm.$emit('page-change', page); + describe('when "page-change" event is emitted by IssuableList', () => { + const data = [{ id: 10, title: 'title', state }]; + const page = 2; + const totalItems = 21; - await waitForPromises(); - }); + beforeEach(async () => { + axiosMock.onGet(defaultProvide.endpoint).reply(200, data, { + 'x-page': page, + 'x-total': totalItems, + }); + + wrapper = mountComponent(); - it('fetches issues with expected params', () => { - expect(axiosMock.history.get[1].params).toEqual({ - page, - per_page: PAGE_SIZE, - state, - with_labels_details: true, + findIssuableList().vm.$emit('page-change', page); + + await waitForPromises(); }); - }); - it('updates IssuableList with response data', () => { - expect(findIssuableList().props()).toMatchObject({ - issuables: data, - totalItems, - currentPage: page, - previousPage: page - 1, - nextPage: page + 1, - urlParams: { page, state }, + it('fetches issues with expected params', () => { + expect(axiosMock.history.get[1].params).toEqual({ + page, + per_page: PAGE_SIZE, + state, + with_labels_details: true, + }); + }); + + it('updates IssuableList with response data', () => { + expect(findIssuableList().props()).toMatchObject({ + issuables: data, + totalItems, + currentPage: page, + previousPage: page - 1, + nextPage: page + 1, + urlParams: { page, state }, + }); }); }); - }); - describe('when "reorder" event is emitted by IssuableList', () => { - const issueOne = { id: 1, iid: 101, title: 'Issue one' }; - const issueTwo = { id: 2, iid: 102, title: 'Issue two' }; - const issueThree = { id: 3, iid: 103, title: 'Issue three' }; - const issueFour = { id: 4, iid: 104, title: 'Issue four' }; - const issues = [issueOne, issueTwo, issueThree, issueFour]; + describe('when "reorder" event is emitted by IssuableList', () => { + const issueOne = { id: 1, iid: 101, title: 'Issue one' }; + const issueTwo = { id: 2, iid: 102, title: 'Issue two' }; + const issueThree = { id: 3, iid: 103, title: 'Issue three' }; + const issueFour = { id: 4, iid: 104, title: 'Issue four' }; + const issues = [issueOne, issueTwo, issueThree, issueFour]; - beforeEach(async () => { - axiosMock.onGet(endpoint).reply(200, issues, fetchIssuesResponse.headers); - wrapper = mountComponent(); - await waitForPromises(); - }); + beforeEach(async () => { + axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers); + wrapper = mountComponent(); + await waitForPromises(); + }); - describe('when successful', () => { - describe.each` - description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId - ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} - ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} - ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} - ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} - `( - 'when moving issue $description', - ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - it('makes API call to reorder the issue', async () => { - findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - - await waitForPromises(); - - expect(axiosMock.history.put[0]).toMatchObject({ - url: `${issuesPath}/${issueToMove.iid}/reorder`, - data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }), + describe('when successful', () => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`, + data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }), + }); }); + }, + ); + }); + + describe('when unsuccessful', () => { + it('displays an error message', async () => { + axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500); + + findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError }); + }); + }); + }); + + describe('when "sort" event is emitted by IssuableList', () => { + it.each(Object.keys(sortParams))( + 'fetches issues with correct params with payload `%s`', + async (sortKey) => { + wrapper = mountComponent(); + + findIssuableList().vm.$emit('sort', sortKey); + + await waitForPromises(); + + expect(axiosMock.history.get[1].params).toEqual({ + page: xPage, + per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, + state, + with_labels_details: true, + ...sortParams[sortKey], }); }, ); }); - describe('when unsuccessful', () => { - it('displays an error message', async () => { - axiosMock.onPut(`${issuesPath}/${issueOne.iid}/reorder`).reply(500); + describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => { + beforeEach(() => { + wrapper = mountComponent(); + jest.spyOn(eventHub, '$emit'); + }); - findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); + it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => { + findIssuableList().vm.$emit('update-legacy-bulk-edit'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError }); + expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); }); }); - }); - describe('when "sort" event is emitted by IssuableList', () => { - it.each(Object.keys(sortParams))( - 'fetches issues with correct params for "sort" payload `%s`', - async (sortKey) => { + describe('when "filter" event is emitted by IssuableList', () => { + beforeEach(async () => { wrapper = mountComponent(); - findIssuableList().vm.$emit('sort', sortKey); + const payload = [ + { type: 'filtered-search-term', value: { data: 'no' } }, + { type: 'filtered-search-term', value: { data: 'issues' } }, + ]; - await waitForPromises(); + findIssuableList().vm.$emit('filter', payload); - expect(axiosMock.history.get[1].params).toEqual({ - page: xPage, - per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE, - state, - with_labels_details: true, - ...sortParams[sortKey], - }); - }, - ); - }); - - describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => { - beforeEach(() => { - wrapper = mountComponent(); - jest.spyOn(eventHub, '$emit'); - }); - - it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => { - findIssuableList().vm.$emit('update-legacy-bulk-edit'); - - await waitForPromises(); + await waitForPromises(); + }); - expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); + it('makes an API call to search for issues with the search term', () => { + expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' }); + }); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js new file mode 100644 index 00000000000..fa937100982 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -0,0 +1,123 @@ +import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; +import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; +import { mockDefaultBranch, mockProjectBranches, mockProjectFullPath } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Pipeline editor branch switcher', () => { + let wrapper; + let mockApollo; + let mockAvailableBranchQuery; + + const createComponentWithApollo = () => { + const resolvers = { + Query: { + project: mockAvailableBranchQuery, + }, + }; + + mockApollo = createMockApollo([], resolvers); + wrapper = shallowMount(BranchSwitcher, { + localVue, + apolloProvider: mockApollo, + provide: { + projectFullPath: mockProjectFullPath, + }, + data() { + return { + currentBranch: mockDefaultBranch, + }; + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + + beforeEach(() => { + mockAvailableBranchQuery = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('while querying', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('does not render dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + }); + + describe('after querying', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('query is called with correct variables', async () => { + expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1); + expect(mockAvailableBranchQuery).toHaveBeenCalledWith( + expect.anything(), + { + fullPath: mockProjectFullPath, + }, + expect.anything(), + expect.anything(), + ); + }); + + it('renders list of branches', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownItems()).toHaveLength(mockProjectBranches.repository.branches.length); + }); + + it('renders current branch at the top of the list with a check mark', () => { + const firstDropdownItem = findDropdownItems().at(0); + const icon = firstDropdownItem.findComponent(GlIcon); + + expect(firstDropdownItem.text()).toBe(mockDefaultBranch); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe('check'); + }); + + it('does not render check mark for other branches', () => { + const secondDropdownItem = findDropdownItems().at(1); + const icon = secondDropdownItem.findComponent(GlIcon); + + expect(icon.classes()).toContain('gl-visibility-hidden'); + }); + }); + + describe('on fetch error', () => { + beforeEach(async () => { + mockAvailableBranchQuery.mockResolvedValue(new Error()); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('does not render dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + + it('shows an error message', () => { + expect(wrapper.emitted('showError')).toBeDefined(); + expect(wrapper.emitted('showError')[0]).toEqual([ + { + reasons: [wrapper.vm.$options.i18n.fetchError], + type: DEFAULT_FAILURE, + }, + ]); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js new file mode 100644 index 00000000000..94a0a7d14ee --- /dev/null +++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; +import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; + +describe('Pipeline editor file nav', () => { + let wrapper; + const mockProvide = { + glFeatures: { + pipelineEditorBranchSwitcher: true, + }, + }; + + const createComponent = ({ provide = {} } = {}) => { + wrapper = shallowMount(PipelineEditorFileNav, { + provide: { + ...mockProvide, + ...provide, + }, + }); + }; + + const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the branch switcher', () => { + expect(findBranchSwitcher().exists()).toBe(true); + }); + }); + + describe('with branch switcher feature flag OFF', () => { + it('does not render the branch switcher', () => { + createComponent({ + provide: { + glFeatures: { pipelineEditorBranchSwitcher: false }, + }, + }); + + expect(findBranchSwitcher().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index d39c0d80296..f0932fc55d3 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -9,6 +9,7 @@ import { mockDefaultBranch, mockLintResponse, mockProjectFullPath, + mockProjectBranches, } from '../mock_data'; jest.mock('~/api', () => { @@ -46,6 +47,23 @@ describe('~/pipeline_editor/graphql/resolvers', () => { await expect(result.rawData).resolves.toBe(mockCiYml); }); }); + + describe('project', () => { + it('resolves project data with type names', async () => { + const result = await resolvers.Query.project(); + + // eslint-disable-next-line no-underscore-dangle + expect(result.__typename).toBe('Project'); + }); + + it('resolves project with available list of branches', async () => { + const result = await resolvers.Query.project(); + + expect(result.repository.branches).toHaveLength( + mockProjectBranches.repository.branches.length, + ); + }); + }); }); describe('Mutation', () => { diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index 16d5ba0e714..7f651a42231 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -138,6 +138,20 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; +export const mockProjectBranches = { + __typename: 'Project', + repository: { + __typename: 'Repository', + branches: [ + { __typename: 'Branch', name: 'master' }, + { __typename: 'Branch', name: 'main' }, + { __typename: 'Branch', name: 'develop' }, + { __typename: 'Branch', name: 'production' }, + { __typename: 'Branch', name: 'test' }, + ], + }, +}; + export const mockProjectPipeline = { pipeline: { commitPath: '/-/commit/aabbccdd', diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 43e22db1d44..a1e3d24acfa 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; +import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; import { MERGED_TAB, VISUALIZE_TAB } from '~/pipeline_editor/constants'; @@ -27,6 +28,7 @@ describe('Pipeline editor home wrapper', () => { const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader); const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs); const findCommitSection = () => wrapper.findComponent(CommitSection); + const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav); afterEach(() => { wrapper.destroy(); @@ -38,6 +40,10 @@ describe('Pipeline editor home wrapper', () => { createComponent(); }); + it('shows the file nav', () => { + expect(findFileNav().exists()).toBe(true); + }); + it('shows the pipeline editor header', () => { expect(findPipelineEditorHeader().exists()).toBe(true); }); diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/pipelines_ci_templates_spec.js index 89f519adf7e..db2ade6f446 100644 --- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/pipelines_ci_templates_spec.js @@ -7,16 +7,23 @@ const addCiYmlPath = "/-/new/master?commit_message='Add%20.gitlab-ci.yml'"; describe('Pipelines CI Templates', () => { let wrapper; + const GlEmoji = { template: '<img/>' }; + const createWrapper = () => { return shallowMount(PipelinesCiTemplate, { provide: { addCiYmlPath, }, + stubs: { + GlEmoji, + }, }); }; + const findTestTemplateLinks = () => wrapper.findAll('[data-testid="test-template-link"]'); const findTemplateDescriptions = () => wrapper.findAll('[data-testid="template-description"]'); const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]'); + const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]'); const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]'); afterEach(() => { @@ -24,7 +31,19 @@ describe('Pipelines CI Templates', () => { wrapper = null; }); - describe('renders templates', () => { + describe('renders test template', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('links to the hello world template', () => { + expect(findTestTemplateLinks().at(0).attributes('href')).toBe( + addCiYmlPath.concat('&template=Hello-World'), + ); + }); + }); + + describe('renders template list', () => { beforeEach(() => { wrapper = createWrapper(); }); @@ -37,20 +56,24 @@ describe('Pipelines CI Templates', () => { expect(content).toContain(...keys); }); + it('has the correct template name', () => { + expect(findTemplateNames().at(0).text()).toBe('Android'); + }); + it('links to the correct template', () => { - expect(findTemplateLinks().at(0).attributes('href')).toEqual( + expect(findTemplateLinks().at(0).attributes('href')).toBe( addCiYmlPath.concat('&template=Android'), ); }); it('has the description of the template', () => { - expect(findTemplateDescriptions().at(0).text()).toEqual( - 'Continuous deployment template to test and deploy your Android project.', + expect(findTemplateDescriptions().at(0).text()).toBe( + 'CI/CD template to test and deploy your Android project.', ); }); it('has the right logo of the template', () => { - expect(findTemplateLogos().at(0).attributes('src')).toEqual( + expect(findTemplateLogos().at(0).attributes('src')).toBe( '/assets/illustrations/logos/android.svg', ); }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 2b6d3ca8c2a..efdb52cfcd9 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -5,10 +5,9 @@ import { nextTick } from 'vue'; import VueApollo, { ApolloMutation } from 'vue-apollo'; import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; -import CaptchaModal from '~/captcha/captcha_modal.vue'; +import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; import { deprecatedCreateFlash as Flash } from '~/flash'; import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; @@ -30,9 +29,8 @@ jest.mock('~/flash'); const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js']; const TEST_API_ERROR = new Error('TEST_API_ERROR'); +const TEST_CAPTCHA_ERROR = new UnsolvedCaptchaError(); const TEST_MUTATION_ERROR = 'Test mutation error'; -const TEST_CAPTCHA_RESPONSE = 'i-got-a-captcha'; -const TEST_CAPTCHA_SITE_KEY = 'abc123'; const TEST_ACTIONS = { NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }), NO_PATH: merge({}, testEntries.created.diff, { filePath: '' }), @@ -59,9 +57,6 @@ const createMutationResponse = (key, obj = {}) => ({ __typename: 'Snippet', webUrl: TEST_WEB_URL, }, - spamLogId: null, - needsCaptchaResponse: false, - captchaSiteKey: null, }, obj, ), @@ -71,13 +66,6 @@ const createMutationResponse = (key, obj = {}) => ({ const createMutationResponseWithErrors = (key) => createMutationResponse(key, { errors: [TEST_MUTATION_ERROR] }); -const createMutationResponseWithRecaptcha = (key) => - createMutationResponse(key, { - errors: ['ignored captcha error message'], - needsCaptchaResponse: true, - captchaSiteKey: TEST_CAPTCHA_SITE_KEY, - }); - const getApiData = ({ id, title = '', @@ -126,7 +114,6 @@ describe('Snippet Edit app', () => { }); const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); - const findCaptchaModal = () => wrapper.find(CaptchaModal); const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); @@ -159,7 +146,6 @@ describe('Snippet Edit app', () => { stubs: { ApolloMutation, FormFooterActions, - CaptchaModal: stubComponent(CaptchaModal), }, provide: { selectedLevel, @@ -209,7 +195,6 @@ describe('Snippet Edit app', () => { }); it('should render components', () => { - expect(wrapper.find(CaptchaModal).exists()).toBe(true); expect(wrapper.find(TitleField).exists()).toBe(true); expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true); expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true); @@ -338,10 +323,10 @@ describe('Snippet Edit app', () => { }, ); - describe('with apollo network error', () => { + describe.each([TEST_API_ERROR, TEST_CAPTCHA_ERROR])('with apollo network error', (error) => { beforeEach(async () => { jest.spyOn(console, 'error').mockImplementation(); - mutateSpy.mockRejectedValue(TEST_API_ERROR); + mutateSpy.mockRejectedValue(error); await createComponentAndSubmit(); }); @@ -353,7 +338,7 @@ describe('Snippet Edit app', () => { it('should flash', () => { // Apollo automatically wraps the resolver's error in a NetworkError expect(Flash).toHaveBeenCalledWith( - `Can't update snippet: Network error: ${TEST_API_ERROR.message}`, + `Can't update snippet: Network error: ${error.message}`, ); }); @@ -363,54 +348,10 @@ describe('Snippet Edit app', () => { // eslint-disable-next-line no-console expect(console.error).toHaveBeenCalledWith( '[gitlab] unexpected error while updating snippet', - expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }), + expect.objectContaining({ message: `Network error: ${error.message}` }), ); }); }); - - describe('when needsCaptchaResponse is true', () => { - let modal; - - beforeEach(async () => { - mutateSpy - .mockResolvedValueOnce(createMutationResponseWithRecaptcha('updateSnippet')) - .mockResolvedValueOnce(createMutationResponseWithErrors('updateSnippet')); - - await createComponentAndSubmit(); - - modal = findCaptchaModal(); - - mutateSpy.mockClear(); - }); - - it('should display captcha modal', () => { - expect(urlUtils.redirectTo).not.toHaveBeenCalled(); - expect(modal.props()).toEqual({ - needsCaptchaResponse: true, - captchaSiteKey: TEST_CAPTCHA_SITE_KEY, - }); - }); - - describe.each` - response | expectedCalls - ${null} | ${[]} - ${TEST_CAPTCHA_RESPONSE} | ${[['updateSnippet', { input: { ...getApiData(createSnippet()), captchaResponse: TEST_CAPTCHA_RESPONSE } }]]} - `('when captcha response is $response', ({ response, expectedCalls }) => { - beforeEach(async () => { - modal.vm.$emit('receivedCaptchaResponse', response); - - await nextTick(); - }); - - it('sets needsCaptchaResponse to false', () => { - expect(modal.props('needsCaptchaResponse')).toEqual(false); - }); - - it(`expected to call times = ${expectedCalls.length}`, () => { - expect(mutateSpy.mock.calls).toEqual(expectedCalls); - }); - }); - }); }); }); diff --git a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb deleted file mode 100644 index 8d1fce406fa..00000000000 --- a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Mutations::CanMutateSpammable do - let(:mutation_class) do - Class.new(Mutations::BaseMutation) do - include Mutations::CanMutateSpammable - end - end - - let(:request) { double(:request) } - let(:query) { double(:query, schema: GitlabSchema) } - let(:context) { GraphQL::Query::Context.new(query: query, object: nil, values: { request: request }) } - - subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) } - - describe '#additional_spam_params' do - it 'returns additional spam-related params' do - expect(subject.send(:additional_spam_params)).to eq({ api: true, request: request }) - end - end - - describe '#with_spam_action_fields' do - let(:spam_log) { double(:spam_log, id: 1) } - let(:spammable) { double(:spammable, spam?: true, render_recaptcha?: true, spam_log: spam_log) } - - before do - allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { 'abc123' } - end - - it 'merges in spam action fields from spammable' do - result = subject.send(:with_spam_action_response_fields, spammable) do - { other_field: true } - end - expect(result) - .to eq({ - spam: true, - needs_captcha_response: true, - spam_log_id: 1, - captcha_site_key: 'abc123', - other_field: true - }) - end - end -end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index b5b2ca1dcd3..21a01f349b5 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -288,24 +288,31 @@ RSpec.describe IssuesHelper do allow(helper).to receive(:current_user).and_return(current_user) allow(helper).to receive(:finder).and_return(finder) allow(helper).to receive(:can?).and_return(true) - allow(helper).to receive(:url_for).and_return('#') + allow(helper).to receive(:image_path).and_return('#') allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#') + allow(helper).to receive(:url_for).and_return('#') expected = { calendar_path: '#', can_bulk_update: 'true', can_edit: 'true', + can_import_issues: 'true', email: current_user&.notification_email, + empty_state_svg_path: '#', endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), export_csv_path: export_csv_project_issues_path(project), full_path: project.full_path, + has_issues: project_issues(project).exists?.to_s, import_csv_issues_path: '#', + is_signed_in: current_user.present?.to_s, issues_path: project_issues_path(project), + jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }), project_import_jira_path: project_import_jira_path(project), rss_path: '#', - show_new_issue_link: 'true' + show_new_issue_link: 'true', + sign_in_path: new_user_session_path } expect(helper.issues_list_data(project, current_user, finder)).to include(expected) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 76e71264ade..a736d001138 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -585,6 +585,68 @@ RSpec.describe Ci::Build do is_expected.to be_falsey end end + + context 'with runners_cached_states feature flag enabled' do + before do + stub_feature_flags(runners_cached_states: true) + end + + it 'caches the result in Redis' do + expect(Rails.cache).to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute) + + build.any_runners_online? + end + end + + context 'with runners_cached_states feature flag disabled' do + before do + stub_feature_flags(runners_cached_states: false) + end + + it 'does not cache' do + expect(Rails.cache).not_to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute) + + build.any_runners_online? + end + end + end + + describe '#any_runners_available?' do + subject { build.any_runners_available? } + + context 'when no runners' do + it { is_expected.to be_falsey } + end + + context 'when there are runners' do + let!(:runner) { create(:ci_runner, :project, projects: [build.project]) } + + it { is_expected.to be_truthy } + end + + context 'with runners_cached_states feature flag enabled' do + before do + stub_feature_flags(runners_cached_states: true) + end + + it 'caches the result in Redis' do + expect(Rails.cache).to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute) + + build.any_runners_available? + end + end + + context 'with runners_cached_states feature flag disabled' do + before do + stub_feature_flags(runners_cached_states: false) + end + + it 'does not cache' do + expect(Rails.cache).not_to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute) + + build.any_runners_available? + end + end end describe '#artifacts?' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 01132e18745..b964a18e148 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1629,6 +1629,8 @@ RSpec.describe Project, factory_default: :keep do end describe '#any_active_runners?' do + subject { project.any_active_runners? } + context 'shared runners' do let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) } let(:specific_runner) { create(:ci_runner, :project, projects: [project]) } @@ -1638,19 +1640,19 @@ RSpec.describe Project, factory_default: :keep do let(:shared_runners_enabled) { false } it 'has no runners available' do - expect(project.any_active_runners?).to be_falsey + is_expected.to be_falsey end it 'has a specific runner' do specific_runner - expect(project.any_active_runners?).to be_truthy + is_expected.to be_truthy end it 'has a shared runner, but they are prohibited to use' do shared_runner - expect(project.any_active_runners?).to be_falsey + is_expected.to be_falsey end it 'checks the presence of specific runner' do @@ -1672,7 +1674,7 @@ RSpec.describe Project, factory_default: :keep do it 'has a shared runner' do shared_runner - expect(project.any_active_runners?).to be_truthy + is_expected.to be_truthy end it 'checks the presence of shared runner' do @@ -1698,13 +1700,13 @@ RSpec.describe Project, factory_default: :keep do let(:group_runners_enabled) { false } it 'has no runners available' do - expect(project.any_active_runners?).to be_falsey + is_expected.to be_falsey end it 'has a group runner, but they are prohibited to use' do group_runner - expect(project.any_active_runners?).to be_falsey + is_expected.to be_falsey end end @@ -1714,7 +1716,7 @@ RSpec.describe Project, factory_default: :keep do it 'has a group runner' do group_runner - expect(project.any_active_runners?).to be_truthy + is_expected.to be_truthy end it 'checks the presence of group runner' do @@ -1732,6 +1734,126 @@ RSpec.describe Project, factory_default: :keep do end end + describe '#any_online_runners?' do + subject { project.any_online_runners? } + + context 'shared runners' do + let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) } + let(:specific_runner) { create(:ci_runner, :project, :online, projects: [project]) } + let(:shared_runner) { create(:ci_runner, :instance, :online) } + let(:offline_runner) { create(:ci_runner, :instance) } + + context 'for shared runners disabled' do + let(:shared_runners_enabled) { false } + + it 'has no runners available' do + is_expected.to be_falsey + end + + it 'has a specific runner' do + specific_runner + + is_expected.to be_truthy + end + + it 'has a shared runner, but they are prohibited to use' do + shared_runner + + is_expected.to be_falsey + end + + it 'checks the presence of specific runner' do + specific_runner + + expect(project.any_online_runners? { |runner| runner == specific_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + specific_runner + + expect(project.any_online_runners? { false }).to be_falsey + end + + it 'returns false if runner is offline' do + offline_runner + + is_expected.to be_falsey + end + end + + context 'for shared runners enabled' do + let(:shared_runners_enabled) { true } + + it 'has a shared runner' do + shared_runner + + is_expected.to be_truthy + end + + it 'checks the presence of shared runner' do + shared_runner + + expect(project.any_online_runners? { |runner| runner == shared_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + shared_runner + + expect(project.any_online_runners? { false }).to be_falsey + end + end + end + + context 'group runners' do + let(:project) { create(:project, group_runners_enabled: group_runners_enabled) } + let(:group) { create(:group, projects: [project]) } + let(:group_runner) { create(:ci_runner, :group, :online, groups: [group]) } + let(:offline_runner) { create(:ci_runner, :group, groups: [group]) } + + context 'for group runners disabled' do + let(:group_runners_enabled) { false } + + it 'has no runners available' do + is_expected.to be_falsey + end + + it 'has a group runner, but they are prohibited to use' do + group_runner + + is_expected.to be_falsey + end + end + + context 'for group runners enabled' do + let(:group_runners_enabled) { true } + + it 'has a group runner' do + group_runner + + is_expected.to be_truthy + end + + it 'has an offline group runner' do + offline_runner + + is_expected.to be_falsey + end + + it 'checks the presence of group runner' do + group_runner + + expect(project.any_online_runners? { |runner| runner == group_runner }).to be_truthy + end + + it 'returns false if match cannot be found' do + group_runner + + expect(project.any_online_runners? { false }).to be_falsey + end + end + end + end + describe '#shared_runners' do let!(:runner) { create(:ci_runner, :instance) } diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb index 1c2260070ec..d944c9e9e57 100644 --- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb @@ -211,5 +211,9 @@ RSpec.describe 'Creating a Snippet' do end end end + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Snippets::Create } + end end end diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb index 43dc8d8bc44..28ab593526a 100644 --- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb @@ -157,6 +157,9 @@ RSpec.describe 'Updating a Snippet' do it_behaves_like 'graphql update actions' it_behaves_like 'when the snippet is not found' it_behaves_like 'snippet edit usage data counters' + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Snippets::Update } + end end describe 'ProjectSnippet' do @@ -201,6 +204,10 @@ RSpec.describe 'Updating a Snippet' do end it_behaves_like 'snippet edit usage data counters' + + it_behaves_like 'has spam protection' do + let(:mutation_class) { ::Mutations::Snippets::Update } + end end it_behaves_like 'when the snippet is not found' diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index a1ca5ad3c2f..d362f4efb7c 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -461,7 +461,7 @@ RSpec.describe Issues::CreateService do end context 'checking spam' do - let(:request) { double(:request) } + let(:request) { double(:request, headers: nil) } let(:api) { true } let(:captcha_response) { 'abc123' } let(:spam_log_id) { 1 } diff --git a/spec/services/spam/spam_action_service_spec.rb b/spec/services/spam/spam_action_service_spec.rb index 371923f1518..e8ac826df1c 100644 --- a/spec/services/spam/spam_action_service_spec.rb +++ b/spec/services/spam/spam_action_service_spec.rb @@ -5,6 +5,8 @@ require 'spec_helper' RSpec.describe Spam::SpamActionService do include_context 'includes Spam constants' + let(:request) { double(:request, env: env, headers: {}) } + let(:issue) { create(:issue, project: project, author: user) } let(:fake_ip) { '1.2.3.4' } let(:fake_user_agent) { 'fake-user-agent' } let(:fake_referrer) { 'fake-http-referrer' } @@ -14,11 +16,8 @@ RSpec.describe Spam::SpamActionService do 'HTTP_REFERRER' => fake_referrer } end - let(:request) { double(:request, env: env) } - let_it_be(:project) { create(:project, :public) } let_it_be(:user) { create(:user) } - let(:issue) { create(:issue, project: project, author: user) } before do issue.spam = false @@ -48,7 +47,7 @@ RSpec.describe Spam::SpamActionService do shared_examples 'creates a spam log' do it do - expect { subject }.to change { SpamLog.count }.by(1) + expect { subject }.to change(SpamLog, :count).by(1) new_spam_log = SpamLog.last expect(new_spam_log.user_id).to eq(user.id) @@ -62,7 +61,7 @@ RSpec.describe Spam::SpamActionService do end describe '#execute' do - let(:request) { double(:request, env: env) } + let(:request) { double(:request, env: env, headers: nil) } let(:fake_captcha_verification_service) { double(:captcha_verification_service) } let(:fake_verdict_service) { double(:spam_verdict_service) } let(:allowlisted) { false } @@ -70,7 +69,7 @@ RSpec.describe Spam::SpamActionService do let(:captcha_response) { 'abc123' } let(:spam_log_id) { existing_spam_log.id } let(:spam_params) do - Spam::SpamActionService.filter_spam_params!( + ::Spam::SpamParams.new( api: api, captcha_response: captcha_response, spam_log_id: spam_log_id @@ -111,10 +110,30 @@ RSpec.describe Spam::SpamActionService do allow(Spam::SpamVerdictService).to receive(:new).with(verdict_service_args).and_return(fake_verdict_service) end + context 'when the captcha params are passed in the headers' do + let(:request) { double(:request, env: env, headers: headers) } + let(:spam_params) { Spam::SpamActionService.filter_spam_params!({ api: api }, request) } + let(:headers) do + { + 'X-GitLab-Captcha-Response' => captcha_response, + 'X-GitLab-Spam-Log-Id' => spam_log_id + } + end + + it 'extracts the headers correctly' do + expect(fake_captcha_verification_service) + .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(true) + expect(SpamLog) + .to receive(:verify_recaptcha!).with(user_id: user.id, id: spam_log_id) + + subject + end + end + context 'when captcha response verification returns true' do before do - expect(fake_captcha_verification_service) - .to receive(:execute).with(captcha_response: captcha_response, request: request) { true } + allow(fake_captcha_verification_service) + .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(true) end it "doesn't check with the SpamVerdictService" do @@ -136,8 +155,8 @@ RSpec.describe Spam::SpamActionService do context 'when captcha response verification returns false' do before do - expect(fake_captcha_verification_service) - .to receive(:execute).with(captcha_response: captcha_response, request: request) { false } + allow(fake_captcha_verification_service) + .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(false) end context 'when spammable attributes have not changed' do @@ -146,21 +165,20 @@ RSpec.describe Spam::SpamActionService do end it 'does not create a spam log' do - expect { subject } - .not_to change { SpamLog.count } + expect { subject }.not_to change(SpamLog, :count) end end context 'when spammable attributes have changed' do let(:expected_service_check_response_message) do - /check Issue spammable model for any errors or captcha requirement/ + /Check Issue spammable model for any errors or CAPTCHA requirement/ end before do - issue.description = 'SPAM!' + issue.description = 'Lovely Spam! Wonderful Spam!' end - context 'if allowlisted' do + context 'when allowlisted' do let(:allowlisted) { true } it 'does not perform spam check' do @@ -229,7 +247,7 @@ RSpec.describe Spam::SpamActionService do response = subject expect(response.message).to match(expected_service_check_response_message) - expect(issue.needs_recaptcha?).to be_truthy + expect(issue).to be_needs_recaptcha end end @@ -253,8 +271,7 @@ RSpec.describe Spam::SpamActionService do end it 'does not create a spam log' do - expect { subject } - .not_to change { SpamLog.count } + expect { subject }.not_to change(SpamLog, :count) end it 'clears spam flags' do @@ -264,9 +281,9 @@ RSpec.describe Spam::SpamActionService do end end - context 'spam verdict service options' do + context 'with spam verdict service options' do before do - allow(fake_verdict_service).to receive(:execute) { ALLOW } + allow(fake_verdict_service).to receive(:execute).and_return(ALLOW) end context 'when the request is nil' do diff --git a/spec/spam/concerns/has_spam_action_response_fields_spec.rb b/spec/spam/concerns/has_spam_action_response_fields_spec.rb index 4d5f8d9d431..9752f6a0b69 100644 --- a/spec/spam/concerns/has_spam_action_response_fields_spec.rb +++ b/spec/spam/concerns/has_spam_action_response_fields_spec.rb @@ -19,16 +19,12 @@ RSpec.describe Spam::Concerns::HasSpamActionResponseFields do end it 'merges in spam action fields from spammable' do - result = subject.send(:with_spam_action_response_fields, spammable) do - { other_field: true } - end - expect(result) + expect(subject.spam_action_response_fields(spammable)) .to eq({ spam: true, needs_captcha_response: true, spam_log_id: 1, - captcha_site_key: recaptcha_site_key, - other_field: true + captcha_site_key: recaptcha_site_key }) end end diff --git a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb index bb4270d7db6..fc795012ce7 100644 --- a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb @@ -21,13 +21,13 @@ RSpec.shared_examples 'a mutation which can mutate a spammable' do end end - describe "#with_spam_action_response_fields" do + describe "#spam_action_response_fields" do it 'resolves with spam action fields' do subject # NOTE: We do not need to assert on the specific values of spam action fields here, we only need - # to verify that #with_spam_action_response_fields was invoked and that the fields are present in the - # response. The specific behavior of #with_spam_action_response_fields is covered in the + # to verify that #spam_action_response_fields was invoked and that the fields are present in the + # response. The specific behavior of #spam_action_response_fields is covered in the # HasSpamActionResponseFields unit tests. expect(mutation_response.keys) .to include('spam', 'spamLogId', 'needsCaptchaResponse', 'captchaSiteKey') diff --git a/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb b/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb new file mode 100644 index 00000000000..8fb89a4f80e --- /dev/null +++ b/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'has spam protection' do + include AfterNextHelpers + + describe '#check_spam_action_response!' do + let(:variables) { nil } + let(:headers) { {} } + let(:spam_log_id) { 123 } + let(:captcha_site_key) { 'abc123' } + + def send_request + post_graphql_mutation(mutation, current_user: current_user) + end + + before do + allow_next(mutation_class).to receive(:spam_action_response_fields).and_return( + spam: spam, + needs_captcha_response: render_captcha, + spam_log_id: spam_log_id, + captcha_site_key: captcha_site_key + ) + end + + context 'when the object is spam (DISALLOW)' do + shared_examples 'disallow response' do + it 'informs the client that the request was denied as spam' do + send_request + + expect(graphql_errors) + .to contain_exactly a_hash_including('message' => ::Mutations::SpamProtection::SPAM_DISALLOWED_MESSAGE) + expect(graphql_errors) + .to contain_exactly a_hash_including('extensions' => { "spam" => true }) + end + end + + let(:spam) { true } + + context 'and no CAPTCHA is available' do + let(:render_captcha) { false } + + it_behaves_like 'disallow response' + end + + context 'and a CAPTCHA is required' do + let(:render_captcha) { true } + + it_behaves_like 'disallow response' + end + end + + context 'when the object is not spam (CONDITIONAL ALLOW)' do + let(:spam) { false } + + context 'and no CAPTCHA is required' do + let(:render_captcha) { false } + + it 'does not return a to-level error' do + send_request + + expect(graphql_errors).to be_blank + end + end + + context 'and a CAPTCHA is required' do + let(:render_captcha) { true } + + it 'informs the client that the request may be retried after solving the CAPTCHA' do + send_request + + expect(graphql_errors) + .to contain_exactly a_hash_including('message' => ::Mutations::SpamProtection::NEEDS_CAPTCHA_RESPONSE_MESSAGE) + expect(graphql_errors) + .to contain_exactly a_hash_including('extensions' => { + "captcha_site_key" => captcha_site_key, + "needs_captcha_response" => true, + "spam_log_id" => spam_log_id + }) + end + end + end + end +end diff --git a/spec/support/shared_examples/services/snippets_shared_examples.rb b/spec/support/shared_examples/services/snippets_shared_examples.rb index 10add3a7299..0c4db7ded69 100644 --- a/spec/support/shared_examples/services/snippets_shared_examples.rb +++ b/spec/support/shared_examples/services/snippets_shared_examples.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true RSpec.shared_examples 'checking spam' do - let(:request) { double(:request) } + let(:request) { double(:request, headers: headers) } + let(:headers) { nil } let(:api) { true } let(:captcha_response) { 'abc123' } let(:spam_log_id) { 1 } @@ -44,6 +45,44 @@ RSpec.shared_examples 'checking spam' do subject end + context 'when CAPTCHA arguments are passed in the headers' do + let(:headers) do + { + 'X-GitLab-Spam-Log-Id' => spam_log_id, + 'X-GitLab-Captcha-Response' => captcha_response + } + end + + let(:extra_opts) do + { + request: request, + api: api, + disable_spam_action_service: disable_spam_action_service + } + end + + it 'executes the SpamActionService correctly' do + spam_params = Spam::SpamParams.new( + api: api, + captcha_response: captcha_response, + spam_log_id: spam_log_id + ) + expect_next_instance_of( + Spam::SpamActionService, + { + spammable: kind_of(Snippet), + request: request, + user: an_instance_of(User), + action: action + } + ) do |instance| + expect(instance).to receive(:execute).with(spam_params: spam_params) + end + + subject + end + end + context 'when spam action service is disabled' do let(:disable_spam_action_service) { true } |