diff options
Diffstat (limited to 'spec')
39 files changed, 820 insertions, 128 deletions
diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index 25d32436d58..c6fd184ede0 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -305,11 +305,37 @@ RSpec.describe Groups::GroupMembersController do group.add_owner(user) end - it 'cannot removes himself from the group' do + it 'cannot remove user from the group' do delete :leave, params: { group_id: group } expect(response).to have_gitlab_http_status(:forbidden) end + + context 'and there is a group project bot owner' do + before do + create(:group_member, :owner, source: group, user: create(:user, :project_bot)) + end + + it 'cannot remove user from the group' do + delete :leave, params: { group_id: group } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'and there is another owner' do + before do + create(:group_member, :owner, source: group) + end + + it 'removes user from members', :aggregate_failures do + delete :leave, params: { group_id: group } + + expect(controller).to set_flash.to "You left the \"#{group.name}\" group." + expect(response).to redirect_to(dashboard_groups_path) + expect(group.users).not_to include user + end + end end context 'and is a requester' do diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb index ef66124bff1..56e55c45e66 100644 --- a/spec/controllers/import/github_controller_spec.rb +++ b/spec/controllers/import/github_controller_spec.rb @@ -96,19 +96,6 @@ RSpec.describe Import::GithubController do describe "POST personal_access_token" do it_behaves_like 'a GitHub-ish import controller: POST personal_access_token' - - it 'passes namespace_id param as query param if it was present' do - namespace_id = 5 - status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id }) - - allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client| - allow(client).to receive(:user).and_return(true) - end - - post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 } - - expect(controller).to redirect_to(status_import_url) - end end describe "GET status" do diff --git a/spec/frontend/__helpers__/init_vue_mr_page_helper.js b/spec/frontend/__helpers__/init_vue_mr_page_helper.js index ee01e9e6268..6b719a32480 100644 --- a/spec/frontend/__helpers__/init_vue_mr_page_helper.js +++ b/spec/frontend/__helpers__/init_vue_mr_page_helper.js @@ -13,16 +13,16 @@ export default function initVueMRPage() { const diffsAppProjectPath = 'testproject'; const mrEl = document.createElement('div'); mrEl.className = 'merge-request fixture-mr'; - mrEl.setAttribute('data-mr-action', 'diffs'); + mrEl.dataset.mrAction = 'diffs'; mrTestEl.appendChild(mrEl); const mrDiscussionsEl = document.createElement('div'); mrDiscussionsEl.id = 'js-vue-mr-discussions'; - mrDiscussionsEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); - mrDiscussionsEl.setAttribute('data-noteable-data', JSON.stringify(noteableDataMock)); - mrDiscussionsEl.setAttribute('data-notes-data', JSON.stringify(notesDataMock)); - mrDiscussionsEl.setAttribute('data-noteable-type', 'merge-request'); - mrDiscussionsEl.setAttribute('data-is-locked', 'false'); + mrDiscussionsEl.dataset.currentUserData = JSON.stringify(userDataMock); + mrDiscussionsEl.dataset.noteableData = JSON.stringify(noteableDataMock); + mrDiscussionsEl.dataset.notesData = JSON.stringify(notesDataMock); + mrDiscussionsEl.dataset.noteableType = 'merge-request'; + mrDiscussionsEl.dataset.isLocked = 'false'; mrTestEl.appendChild(mrDiscussionsEl); const discussionCounterEl = document.createElement('div'); @@ -31,9 +31,9 @@ export default function initVueMRPage() { const diffsAppEl = document.createElement('div'); diffsAppEl.id = 'js-diffs-app'; - diffsAppEl.setAttribute('data-endpoint', diffsAppEndpoint); - diffsAppEl.setAttribute('data-project-path', diffsAppProjectPath); - diffsAppEl.setAttribute('data-current-user-data', JSON.stringify(userDataMock)); + diffsAppEl.dataset.endpoint = diffsAppEndpoint; + diffsAppEl.dataset.projectPath = diffsAppProjectPath; + diffsAppEl.dataset.currentUserData = JSON.stringify(userDataMock); mrTestEl.appendChild(diffsAppEl); const mock = new MockAdapter(axios); diff --git a/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js index bce9d93bea8..45b9c31c4db 100644 --- a/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js +++ b/spec/frontend/__helpers__/matchers/to_have_sprite_icon.js @@ -9,7 +9,7 @@ export const toHaveSpriteIcon = (element, iconName) => { const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); const matchingIcon = iconReferences.find( - (reference) => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, + (reference) => reference.parentNode.dataset.testid === `${iconName}-icon`, ); const pass = Boolean(matchingIcon); diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js index 06dbadd6d3d..961fa96acdd 100644 --- a/spec/frontend/admin/users/index_spec.js +++ b/spec/frontend/admin/users/index_spec.js @@ -12,8 +12,8 @@ describe('initAdminUsersApp', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-users', JSON.stringify(users)); - el.setAttribute('data-paths', JSON.stringify(paths)); + el.dataset.users = JSON.stringify(users); + el.dataset.paths = JSON.stringify(paths); wrapper = createWrapper(initAdminUsersApp(el)); }); @@ -40,8 +40,8 @@ describe('initAdminUserActions', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-user', JSON.stringify(user)); - el.setAttribute('data-paths', JSON.stringify(paths)); + el.dataset.user = JSON.stringify(user); + el.dataset.paths = JSON.stringify(paths); wrapper = createWrapper(initAdminUserActions(el)); }); diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js index 0ff9d60f409..f9a6b2df662 100644 --- a/spec/frontend/authentication/two_factor_auth/index_spec.js +++ b/spec/frontend/authentication/two_factor_auth/index_spec.js @@ -15,8 +15,8 @@ describe('initRecoveryCodes', () => { beforeEach(() => { el = document.createElement('div'); el.setAttribute('class', 'js-2fa-recovery-codes'); - el.setAttribute('data-codes', codesJsonString); - el.setAttribute('data-profile-account-path', profileAccountPath); + el.dataset.codes = codesJsonString; + el.dataset.profileAccountPath = profileAccountPath; document.body.appendChild(el); wrapper = createWrapper(initRecoveryCodes()); diff --git a/spec/frontend/blob/components/table_contents_spec.js b/spec/frontend/blob/components/table_contents_spec.js index 358ac31819c..2cbac809a0d 100644 --- a/spec/frontend/blob/components/table_contents_spec.js +++ b/spec/frontend/blob/components/table_contents_spec.js @@ -11,7 +11,7 @@ function createComponent() { } async function setLoaded(loaded) { - document.querySelector('.blob-viewer').setAttribute('data-loaded', loaded); + document.querySelector('.blob-viewer').dataset.loaded = loaded; await nextTick(); } @@ -53,7 +53,7 @@ describe('Markdown table of contents component', () => { it('does not show dropdown when viewing non-rich content', async () => { createComponent(); - document.querySelector('.blob-viewer').setAttribute('data-type', 'simple'); + document.querySelector('.blob-viewer').dataset.type = 'simple'; await setLoaded(true); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 5f6baf3f63d..b2559af182b 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -80,9 +80,9 @@ describe('Blob viewer', () => { return asyncClick() .then(() => asyncClick()) .then(() => { - expect( - document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'), - ).toBe('true'); + expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe( + 'true', + ); }); }); diff --git a/spec/frontend/cascading_settings/components/lock_popovers_spec.js b/spec/frontend/cascading_settings/components/lock_popovers_spec.js index 585e6ac505b..182e3c1c8ff 100644 --- a/spec/frontend/cascading_settings/components/lock_popovers_spec.js +++ b/spec/frontend/cascading_settings/components/lock_popovers_spec.js @@ -21,12 +21,12 @@ describe('LockPopovers', () => { }; if (lockedByApplicationSetting) { - popoverMountEl.setAttribute('data-popover-data', JSON.stringify(popoverData)); + popoverMountEl.dataset.popoverData = JSON.stringify(popoverData); } else if (lockedByAncestor) { - popoverMountEl.setAttribute( - 'data-popover-data', - JSON.stringify({ ...popoverData, ancestor_namespace: mockNamespace }), - ); + popoverMountEl.dataset.popoverData = JSON.stringify({ + ...popoverData, + ancestor_namespace: mockNamespace, + }); } document.body.appendChild(popoverMountEl); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index c47a9e697b6..8eee61d1342 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -195,8 +195,8 @@ describe('Code navigation actions', () => { it('commits SET_CURRENT_DEFINITION with LSIF data', () => { target.classList.add('js-code-navigation'); - target.setAttribute('data-line-index', '0'); - target.setAttribute('data-char-index', '0'); + target.dataset.lineIndex = '0'; + target.dataset.charIndex = '0'; return testAction( actions.showDefinition, @@ -218,8 +218,8 @@ describe('Code navigation actions', () => { it('adds hll class to target element', () => { target.classList.add('js-code-navigation'); - target.setAttribute('data-line-index', '0'); - target.setAttribute('data-char-index', '0'); + target.dataset.lineIndex = '0'; + target.dataset.charIndex = '0'; return testAction( actions.showDefinition, @@ -243,8 +243,8 @@ describe('Code navigation actions', () => { it('caches current target element', () => { target.classList.add('js-code-navigation'); - target.setAttribute('data-line-index', '0'); - target.setAttribute('data-char-index', '0'); + target.dataset.lineIndex = '0'; + target.dataset.charIndex = '0'; return testAction( actions.showDefinition, diff --git a/spec/frontend/confirm_modal_spec.js b/spec/frontend/confirm_modal_spec.js index 53991349ee5..4224fb6be2a 100644 --- a/spec/frontend/confirm_modal_spec.js +++ b/spec/frontend/confirm_modal_spec.js @@ -31,9 +31,9 @@ describe('ConfirmModal', () => { buttons.forEach((x) => { const button = document.createElement('button'); button.setAttribute('class', 'js-confirm-modal-button'); - button.setAttribute('data-path', x.path); - button.setAttribute('data-method', x.method); - button.setAttribute('data-modal-attributes', JSON.stringify(x.modalAttributes)); + button.dataset.path = x.path; + button.dataset.method = x.method; + button.dataset.modalAttributes = JSON.stringify(x.modalAttributes); button.innerHTML = 'Action'; buttonContainer.appendChild(button); }); diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js index 2236b5aa261..05161437c22 100644 --- a/spec/frontend/helpers/startup_css_helper_spec.js +++ b/spec/frontend/helpers/startup_css_helper_spec.js @@ -59,9 +59,10 @@ describe('waitForCSSLoaded', () => { <link href="two.css" data-startupcss="loading"> `); const events = waitForCSSLoaded(mockedCallback); - document - .querySelectorAll('[data-startupcss="loading"]') - .forEach((elem) => elem.setAttribute('data-startupcss', 'loaded')); + document.querySelectorAll('[data-startupcss="loading"]').forEach((elem) => { + // eslint-disable-next-line no-param-reassign + elem.dataset.startupcss = 'loaded'; + }); document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded')); await events; diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js index 20b26f5abba..cb7173c56a8 100644 --- a/spec/frontend/issues/create_merge_request_dropdown_spec.js +++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js @@ -84,7 +84,7 @@ describe('CreateMergeRequestDropdown', () => { }); it('enables when can create confidential issue', () => { - document.querySelector('.js-create-mr').setAttribute('data-is-confidential', 'true'); + document.querySelector('.js-create-mr').dataset.isConfidential = 'true'; confidentialState.selectedProject = { name: 'test' }; dropdown.enable(); @@ -93,7 +93,7 @@ describe('CreateMergeRequestDropdown', () => { }); it('does not enable when can not create confidential issue', () => { - document.querySelector('.js-create-mr').setAttribute('data-is-confidential', 'true'); + document.querySelector('.js-create-mr').dataset.isConfidential = 'true'; dropdown.enable(); diff --git a/spec/frontend/labels/delete_label_modal_spec.js b/spec/frontend/labels/delete_label_modal_spec.js index 98049538948..67220821fe0 100644 --- a/spec/frontend/labels/delete_label_modal_spec.js +++ b/spec/frontend/labels/delete_label_modal_spec.js @@ -25,11 +25,11 @@ describe('DeleteLabelModal', () => { buttons.forEach((x) => { const button = document.createElement('button'); button.setAttribute('class', 'js-delete-label-modal-button'); - button.setAttribute('data-label-name', x.labelName); - button.setAttribute('data-destroy-path', x.destroyPath); + button.dataset.labelName = x.labelName; + button.dataset.destroyPath = x.destroyPath; if (x.subjectName) { - button.setAttribute('data-subject-name', x.subjectName); + button.dataset.subjectName = x.subjectName; } button.innerHTML = 'Action'; diff --git a/spec/frontend/lazy_loader_spec.js b/spec/frontend/lazy_loader_spec.js index 3d8b0d9c307..e0b6c7119f9 100644 --- a/spec/frontend/lazy_loader_spec.js +++ b/spec/frontend/lazy_loader_spec.js @@ -27,7 +27,7 @@ describe('LazyLoader', () => { const createLazyLoadImage = () => { const newImg = document.createElement('img'); newImg.className = 'lazy'; - newImg.setAttribute('data-src', TEST_PATH); + newImg.dataset.src = TEST_PATH; document.body.appendChild(newImg); triggerChildMutation(); @@ -108,7 +108,7 @@ describe('LazyLoader', () => { expect(LazyLoader.loadImage).toHaveBeenCalledWith(img); expect(img.getAttribute('src')).toBe(TEST_PATH); - expect(img.getAttribute('data-src')).toBe(null); + expect(img.dataset.src).toBeUndefined(); expect(img).toHaveClass('js-lazy-loaded'); }); diff --git a/spec/frontend/members/index_spec.js b/spec/frontend/members/index_spec.js index efabe54f238..251a8b0b774 100644 --- a/spec/frontend/members/index_spec.js +++ b/spec/frontend/members/index_spec.js @@ -24,7 +24,7 @@ describe('initMembersApp', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-members-data', dataAttribute); + el.dataset.membersData = dataAttribute; window.gon = { current_user_id: 123 }; }); diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index a157cfa1c1d..b0c9459ff4f 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -256,7 +256,7 @@ describe('Members Utils', () => { beforeEach(() => { el = document.createElement('div'); - el.setAttribute('data-members-data', dataAttribute); + el.dataset.membersData = dataAttribute; }); afterEach(() => { diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index 7dc6f90d202..de415b5bfe0 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -78,8 +78,8 @@ describe('Markdown component', () => { }); await nextTick(); - expect(findLink().getAttribute('data-remote')).toBe(null); - expect(findLink().getAttribute('data-type')).toBe(null); + expect(findLink().dataset.remote).toBeUndefined(); + expect(findLink().dataset.type).toBeUndefined(); }); describe('When parsing images', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 4ecfbc5de1f..38f29ac2559 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -404,13 +404,13 @@ describe('Actions Notes Store', () => { beforeEach(() => { axiosMock.onDelete(endpoint).replyOnce(200, {}); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); afterEach(() => { axiosMock.restore(); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', () => { @@ -440,7 +440,7 @@ describe('Actions Notes Store', () => { it('dispatches removeDiscussionsFromDiff on merge request page', () => { const note = { path: endpoint, id: 1 }; - document.body.setAttribute('data-page', 'projects:merge_requests:show'); + document.body.dataset.page = 'projects:merge_requests:show'; return testAction( actions.removeNote, @@ -473,13 +473,13 @@ describe('Actions Notes Store', () => { beforeEach(() => { axiosMock.onDelete(endpoint).replyOnce(200, {}); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); afterEach(() => { axiosMock.restore(); - document.body.setAttribute('data-page', ''); + document.body.dataset.page = ''; }); it('dispatches removeNote', () => { diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 008961bf709..2da176dbfe4 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -17,11 +17,11 @@ describe('performance bar wrapper', () => { performance.getEntriesByType = jest.fn().mockReturnValue([]); peekWrapper.setAttribute('id', 'js-peek'); - peekWrapper.setAttribute('data-env', 'development'); - peekWrapper.setAttribute('data-request-id', '123'); - peekWrapper.setAttribute('data-peek-url', '/-/peek/results'); - peekWrapper.setAttribute('data-stats-url', 'https://log.gprd.gitlab.net/app/dashboards#/view/'); - peekWrapper.setAttribute('data-profile-url', '?lineprofiler=true'); + peekWrapper.dataset.env = 'development'; + peekWrapper.dataset.requestId = '123'; + peekWrapper.dataset.peekUrl = '/-/peek/results'; + peekWrapper.dataset.statsUrl = 'https://log.gprd.gitlab.net/app/dashboards#/view/'; + peekWrapper.dataset.profileUrl = '?lineprofiler=true'; mock = new MockAdapter(axios); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index 4639552b4d3..266f047e9dc 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -53,7 +53,7 @@ describe('Search autocomplete dropdown', () => { }; const disableProjectIssues = () => { - document.querySelector('.js-search-project-options').setAttribute('data-issues-disabled', true); + document.querySelector('.js-search-project-options').dataset.issuesDisabled = true; }; // Mock `gl` object in window for dashboard specific page. App code will need it. diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js index 2c3db36d7e6..1544fed5240 100644 --- a/spec/frontend/user_popovers_spec.js +++ b/spec/frontend/user_popovers_spec.js @@ -22,7 +22,7 @@ describe('User Popovers', () => { const link = document.createElement('a'); link.classList.add('js-user-link'); - link.setAttribute('data-user', '1'); + link.dataset.user = '1'; return link; }; diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js index 59edde48eab..9231e38ea90 100644 --- a/spec/frontend/users_select/test_helper.js +++ b/spec/frontend/users_select/test_helper.js @@ -95,10 +95,10 @@ export const setAssignees = (...users) => { const input = document.createElement('input'); input.name = 'merge_request[assignee_ids][]'; input.value = user.id.toString(); - input.setAttribute('data-avatar-url', user.avatar_url); - input.setAttribute('data-name', user.name); - input.setAttribute('data-username', user.username); - input.setAttribute('data-can-merge', user.can_merge); + input.dataset.avatarUrl = user.avatar_url; + input.dataset.name = user.name; + input.dataset.username = user.username; + input.dataset.canMerge = user.can_merge; return input; }), ); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 8efc4d84624..29ee7e0010f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -193,9 +193,7 @@ describe('MRWidgetMerged', () => { it('shows button to copy commit SHA to clipboard', () => { expect(selectors.copyMergeShaButton).not.toBe(null); - expect(selectors.copyMergeShaButton.getAttribute('data-clipboard-text')).toBe( - vm.mr.mergeCommitSha, - ); + expect(selectors.copyMergeShaButton.dataset.clipboardText).toBe(vm.mr.mergeCommitSha); }); it('hides button to copy commit SHA if SHA does not exist', async () => { diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index a8c55c2c735..d134877e584 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -424,7 +424,7 @@ describe('MrWidgetOptions', () => { beforeEach(() => { const favicon = document.createElement('link'); favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('data-original-href', faviconDataUrl); + favicon.dataset.originalHref = faviconDataUrl; document.body.appendChild(favicon); faviconElement = document.getElementById('favicon'); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js new file mode 100644 index 00000000000..fe614f03119 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_item_spec.js @@ -0,0 +1,35 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { hexToRgb } from '~/lib/utils/color_utils'; +import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue'; +import { color } from './mock_data'; + +describe('ColorItem', () => { + let wrapper; + + const propsData = color; + + const createComponent = () => { + wrapper = shallowMountExtended(ColorItem, { + propsData, + }); + }; + + const findColorItem = () => wrapper.findByTestId('color-item'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toBe(propsData.title); + }); + + it('renders the correct background color for the color item', () => { + const convertedColor = hexToRgb(propsData.color).join(', '); + expect(findColorItem().attributes('style')).toBe(`background-color: rgb(${convertedColor});`); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js new file mode 100644 index 00000000000..93b59800c27 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -0,0 +1,192 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; +import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue'; +import epicColorQuery from '~/vue_shared/components/color_select_dropdown/graphql/epic_color.query.graphql'; +import updateEpicColorMutation from '~/vue_shared/components/color_select_dropdown/graphql/epic_update_color.mutation.graphql'; +import ColorSelectRoot from '~/vue_shared/components/color_select_dropdown/color_select_root.vue'; +import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; +import { colorQueryResponse, updateColorMutationResponse, color } from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +const successfulQueryHandler = jest.fn().mockResolvedValue(colorQueryResponse); +const successfulMutationHandler = jest.fn().mockResolvedValue(updateColorMutationResponse); +const errorQueryHandler = jest.fn().mockRejectedValue('Error fetching epic color.'); +const errorMutationHandler = jest.fn().mockRejectedValue('An error occurred while updating color.'); + +const defaultProps = { + allowEdit: true, + iid: '1', + fullPath: 'workspace-1', +}; + +describe('LabelsSelectRoot', () => { + let wrapper; + + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findDropdownContents = () => wrapper.findComponent(DropdownContents); + + const createComponent = ({ + queryHandler = successfulQueryHandler, + mutationHandler = successfulMutationHandler, + propsData, + } = {}) => { + const mockApollo = createMockApollo([ + [epicColorQuery, queryHandler], + [updateEpicColorMutation, mutationHandler], + ]); + + wrapper = shallowMount(ColorSelectRoot, { + apolloProvider: mockApollo, + propsData: { + ...defaultProps, + ...propsData, + }, + provide: { + canUpdate: true, + }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + const defaultClasses = ['labels-select-wrapper', 'gl-relative']; + + it.each` + variant | cssClass + ${'sidebar'} | ${defaultClasses} + ${'embedded'} | ${[...defaultClasses, 'is-embedded']} + `( + 'renders component root element with CSS class `$cssClass` when variant is "$variant"', + async ({ variant, cssClass }) => { + createComponent({ + propsData: { variant }, + }); + + expect(wrapper.classes()).toEqual(cssClass); + }, + ); + }); + + describe('if the variant is `sidebar`', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders SidebarEditableItem component', () => { + expect(findSidebarEditableItem().exists()).toBe(true); + }); + + it('renders correct props for the SidebarEditableItem component', () => { + expect(findSidebarEditableItem().props()).toMatchObject({ + title: wrapper.vm.$options.i18n.widgetTitle, + canEdit: defaultProps.allowEdit, + loading: true, + }); + }); + + describe('when colors are loaded', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes false `loading` prop to sidebar editable item', () => { + expect(findSidebarEditableItem().props('loading')).toBe(false); + }); + + it('renders dropdown value component when query colors is resolved', () => { + expect(findDropdownValue().props('selectedColor')).toMatchObject(color); + }); + }); + }); + + describe('if the variant is `embedded`', () => { + beforeEach(() => { + createComponent({ propsData: { iid: undefined, variant: DROPDOWN_VARIANT.Embedded } }); + }); + + it('renders DropdownContents component', () => { + expect(findDropdownContents().exists()).toBe(true); + }); + + it('renders correct props for the DropdownContents component', () => { + expect(findDropdownContents().props()).toMatchObject({ + variant: DROPDOWN_VARIANT.Embedded, + dropdownTitle: wrapper.vm.$options.i18n.assignColor, + dropdownButtonText: wrapper.vm.$options.i18n.dropdownButtonText, + }); + }); + + it('handles DropdownContents setColor', () => { + findDropdownContents().vm.$emit('setColor', color); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + }); + }); + + describe('when epicColorQuery errored', () => { + beforeEach(async () => { + createComponent({ queryHandler: errorQueryHandler }); + await waitForPromises(); + }); + + it('creates flash with error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + captureError: true, + message: 'Error fetching epic color.', + }); + }); + }); + + it('emits `updateSelectedColor` event on dropdown contents `setColor` event if iid is not set', () => { + createComponent({ propsData: { iid: undefined } }); + + findDropdownContents().vm.$emit('setColor', color); + expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]); + }); + + describe('when updating color for epic', () => { + beforeEach(() => { + createComponent(); + findDropdownContents().vm.$emit('setColor', color); + }); + + it('sets the loading state', () => { + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); + + it('updates color correctly after successful mutation', async () => { + await waitForPromises(); + expect(findDropdownValue().props('selectedColor').color).toEqual( + updateColorMutationResponse.data.updateIssuableColor.issuable.color, + ); + }); + + it('displays an error if mutation was rejected', async () => { + createComponent({ mutationHandler: errorMutationHandler }); + findDropdownContents().vm.$emit('setColor', color); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + captureError: true, + error: expect.anything(), + message: 'An error occurred while updating color.', + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js new file mode 100644 index 00000000000..303824c77b3 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_color_view_spec.js @@ -0,0 +1,43 @@ +import { GlDropdownForm } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; +import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue'; +import { ISSUABLE_COLORS } from '~/vue_shared/components/color_select_dropdown/constants'; +import { color as defaultColor } from './mock_data'; + +const propsData = { + selectedColor: defaultColor, +}; + +describe('DropdownContentsColorView', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(DropdownContentsColorView, { + propsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findColors = () => wrapper.findAllComponents(ColorItem); + const findColorList = () => wrapper.findComponent(GlDropdownForm); + + it('renders color list', async () => { + expect(findColorList().exists()).toBe(true); + expect(findColors()).toHaveLength(ISSUABLE_COLORS.length); + }); + + it.each(ISSUABLE_COLORS)('emits an `input` event with %o on click on the option %#', (color) => { + const colorIndex = ISSUABLE_COLORS.indexOf(color); + findColors().at(colorIndex).trigger('click'); + + expect(wrapper.emitted('input')[0][0]).toMatchObject(color); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js new file mode 100644 index 00000000000..74f50b878e2 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js @@ -0,0 +1,113 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants'; +import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; +import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue'; + +import { color } from './mock_data'; + +const showDropdown = jest.fn(); +const focusInput = jest.fn(); + +const defaultProps = { + dropdownTitle: '', + selectedColor: color, + dropdownButtonText: '', + variant: '', + isVisible: false, +}; + +const GlDropdownStub = { + template: ` + <div> + <slot name="header"></slot> + <slot></slot> + </div> + `, + methods: { + show: showDropdown, + hide: jest.fn(), + }, +}; + +const DropdownHeaderStub = { + template: ` + <div>Hello, I am a header</div> + `, + methods: { + focusInput, + }, +}; + +describe('DropdownContent', () => { + let wrapper; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(DropdownContents, { + propsData: { + ...defaultProps, + ...propsData, + }, + stubs: { + GlDropdown: GlDropdownStub, + DropdownHeader: DropdownHeaderStub, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findColorView = () => wrapper.findComponent(DropdownContentsColorView); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub); + const findDropdown = () => wrapper.findComponent(GlDropdownStub); + + it('calls dropdown `show` method on `isVisible` prop change', async () => { + createComponent(); + await wrapper.setProps({ + isVisible: true, + }); + + expect(showDropdown).toHaveBeenCalledTimes(1); + }); + + it('does not emit `setColor` event on dropdown hide if color did not change', () => { + createComponent(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setColor')).toBeUndefined(); + }); + + it('emits `setColor` event on dropdown hide if color changed on non-sidebar widget', async () => { + createComponent({ propsData: { variant: DROPDOWN_VARIANT.Embedded } }); + const updatedColor = { + title: 'Blue-gray', + color: '#6699cc', + }; + findColorView().vm.$emit('input', updatedColor); + await nextTick(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setColor')).toEqual([[updatedColor]]); + }); + + it('emits `setColor` event on visibility change if color changed on sidebar widget', async () => { + createComponent({ propsData: { variant: DROPDOWN_VARIANT.Sidebar, isVisible: true } }); + const updatedColor = { + title: 'Blue-gray', + color: '#6699cc', + }; + findColorView().vm.$emit('input', updatedColor); + wrapper.setProps({ isVisible: false }); + await nextTick(); + + expect(wrapper.emitted('setColor')).toEqual([[updatedColor]]); + }); + + it('renders header', () => { + createComponent(); + + expect(findDropdownHeader().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js new file mode 100644 index 00000000000..d203d78477f --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_header_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue'; + +const propsData = { + dropdownTitle: 'Epic color', +}; + +describe('DropdownHeader', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(DropdownHeader, { propsData }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + createComponent(); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toBe(propsData.dropdownTitle); + }); + + it('renders a close button', () => { + expect(findButton().attributes('aria-label')).toBe('Close'); + }); + + it('emits `closeDropdown` event on button click', () => { + expect(wrapper.emitted('closeDropdown')).toBeUndefined(); + findButton().vm.$emit('click'); + + expect(wrapper.emitted('closeDropdown')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js new file mode 100644 index 00000000000..f22592dd604 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js @@ -0,0 +1,46 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ColorItem from '~/vue_shared/components/color_select_dropdown/color_item.vue'; +import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue'; + +import { color } from './mock_data'; + +const propsData = { + selectedColor: color, +}; + +describe('DropdownValue', () => { + let wrapper; + + const findColorItems = () => wrapper.findAllComponents(ColorItem); + + const createComponent = () => { + wrapper = shallowMountExtended(DropdownValue, { propsData }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there is a color set', () => { + it('renders the color', () => { + expect(findColorItems()).toHaveLength(2); + }); + + it.each` + index | cssClass + ${0} | ${['gl-font-base', 'gl-line-height-24']} + ${1} | ${['hide-collapsed']} + `( + 'passes correct props to the ColorItem with CSS class `$cssClass`', + async ({ index, cssClass }) => { + expect(findColorItems().at(index).props()).toMatchObject(propsData.selectedColor); + expect(findColorItems().at(index).classes()).toEqual(cssClass); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/mock_data.js b/spec/frontend/vue_shared/components/color_select_dropdown/mock_data.js new file mode 100644 index 00000000000..097f47cc731 --- /dev/null +++ b/spec/frontend/vue_shared/components/color_select_dropdown/mock_data.js @@ -0,0 +1,30 @@ +export const color = { + color: '#217645', + title: 'Green', +}; + +export const colorQueryResponse = { + data: { + workspace: { + id: 'gid://gitlab/Workspace/1', + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/1', + color: '#217645', + }, + }, + }, +}; + +export const updateColorMutationResponse = { + data: { + updateIssuableColor: { + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/1', + color: '#217645', + }, + errors: [], + }, + }, +}; diff --git a/spec/frontend_integration/ide/helpers/ide_helper.js b/spec/frontend_integration/ide/helpers/ide_helper.js index 8c5ff816c74..0c3786929d8 100644 --- a/spec/frontend_integration/ide/helpers/ide_helper.js +++ b/spec/frontend_integration/ide/helpers/ide_helper.js @@ -46,14 +46,14 @@ export const findMonacoDiffEditor = () => export const findAndSetEditorValue = async (value) => { const editor = await findMonacoEditor(); - const uri = editor.getAttribute('data-uri'); + const { uri } = editor.dataset; monacoEditor.getModel(uri).setValue(value); }; export const getEditorValue = async () => { const editor = await findMonacoEditor(); - const uri = editor.getAttribute('data-uri'); + const { uri } = editor.dataset; return monacoEditor.getModel(uri).getValue(); }; diff --git a/spec/lib/container_registry/migration_spec.rb b/spec/lib/container_registry/migration_spec.rb index 8236ab0a70c..fea66d3c8f4 100644 --- a/spec/lib/container_registry/migration_spec.rb +++ b/spec/lib/container_registry/migration_spec.rb @@ -229,4 +229,31 @@ RSpec.describe ContainerRegistry::Migration do it { is_expected.to eq(false) } end end + + describe '.delete_container_repository_worker_support?' do + subject { described_class.delete_container_repository_worker_support? } + + it { is_expected.to eq(true) } + + context 'feature flag disabled' do + before do + stub_feature_flags(container_registry_migration_phase2_delete_container_repository_worker_support: false) + end + + it { is_expected.to eq(false) } + end + end + + describe '.dynamic_pre_import_timeout_for' do + let(:container_repository) { build(:container_repository) } + + subject { described_class.dynamic_pre_import_timeout_for(container_repository) } + + it 'returns the expected seconds' do + stub_application_setting(container_registry_pre_import_tags_rate: 0.6) + expect(container_repository).to receive(:tags_count).and_return(50) + + expect(subject).to eq((0.6 * 50).seconds) + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index ab92606e6fc..d47f43a630d 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -991,6 +991,14 @@ RSpec.describe Group do it { expect(group.last_owner?(@members[:owner])).to be_truthy } + context 'there is also a project_bot owner' do + before do + group.add_user(create(:user, :project_bot), GroupMember::OWNER) + end + + it { expect(group.last_owner?(@members[:owner])).to be_truthy } + end + context 'with two owners' do before do create(:group_member, :owner, group: group) @@ -1116,35 +1124,58 @@ RSpec.describe Group do end end - describe '#single_owner?' do + describe '#all_owners_excluding_project_bots' do let_it_be(:user) { create(:user) } context 'when there is only one owner' do - before do + let!(:owner) do group.add_user(user, GroupMember::OWNER) end - it 'returns true' do - expect(group.single_owner?).to eq(true) + it 'returns the owner' do + expect(group.all_owners_excluding_project_bots).to contain_exactly(owner) + end + + context 'and there is also a project_bot owner' do + before do + group.add_user(create(:user, :project_bot), GroupMember::OWNER) + end + + it 'returns only the human owner' do + expect(group.all_owners_excluding_project_bots).to contain_exactly(owner) + end end end context 'when there are multiple owners' do let_it_be(:user_2) { create(:user) } - before do + let!(:owner) do group.add_user(user, GroupMember::OWNER) + end + + let!(:owner2) do group.add_user(user_2, GroupMember::OWNER) end - it 'returns true' do - expect(group.single_owner?).to eq(false) + it 'returns both owners' do + expect(group.all_owners_excluding_project_bots).to contain_exactly(owner, owner2) + end + + context 'and there is also a project_bot owner' do + before do + group.add_user(create(:user, :project_bot), GroupMember::OWNER) + end + + it 'returns only the human owners' do + expect(group.all_owners_excluding_project_bots).to contain_exactly(owner, owner2) + end end end context 'when there are no owners' do it 'returns false' do - expect(group.single_owner?).to eq(false) + expect(group.all_owners_excluding_project_bots).to be_empty end end end diff --git a/spec/models/members/last_group_owner_assigner_spec.rb b/spec/models/members/last_group_owner_assigner_spec.rb index bb0f751e7d5..429cf4190cf 100644 --- a/spec/models/members/last_group_owner_assigner_spec.rb +++ b/spec/models/members/last_group_owner_assigner_spec.rb @@ -94,5 +94,18 @@ RSpec.describe LastGroupOwnerAssigner do end end end + + context 'when there are bot members' do + context 'with a bot owner' do + specify do + create(:group_member, :owner, source: group, user: create(:user, :project_bot)) + + expect { assigner.execute }.to change(group_member, :last_owner) + .from(nil).to(true) + .and change(group_member, :last_blocked_owner) + .from(nil).to(false) + end + end + end end end diff --git a/spec/requests/api/terraform/modules/v1/packages_spec.rb b/spec/requests/api/terraform/modules/v1/packages_spec.rb index 7d86244cb1b..f7736130245 100644 --- a/spec/requests/api/terraform/modules/v1/packages_spec.rb +++ b/spec/requests/api/terraform/modules/v1/packages_spec.rb @@ -106,14 +106,14 @@ RSpec.describe API::Terraform::Modules::V1::Packages do context 'with valid namespace' do where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do :public | :developer | true | :personal_access_token | true | 'grants terraform module download' | :success - :public | :guest | true | :personal_access_token | true | 'rejects terraform module packages access' | :not_found + :public | :guest | true | :personal_access_token | true | 'grants terraform module download' | :success :public | :developer | true | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized :public | :guest | true | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized - :public | :developer | false | :personal_access_token | true | 'rejects terraform module packages access' | :not_found - :public | :guest | false | :personal_access_token | true | 'rejects terraform module packages access' | :not_found + :public | :developer | false | :personal_access_token | true | 'grants terraform module download' | :success + :public | :guest | false | :personal_access_token | true | 'grants terraform module download' | :success :public | :developer | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized :public | :guest | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized - :public | :anonymous | false | :personal_access_token | true | 'rejects terraform module packages access' | :not_found + :public | :anonymous | false | :anonymous | true | 'grants terraform module download' | :success :private | :developer | true | :personal_access_token | true | 'grants terraform module download' | :success :private | :guest | true | :personal_access_token | true | 'rejects terraform module packages access' | :forbidden :private | :developer | true | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized @@ -122,12 +122,12 @@ RSpec.describe API::Terraform::Modules::V1::Packages do :private | :guest | false | :personal_access_token | true | 'rejects terraform module packages access' | :forbidden :private | :developer | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized :private | :guest | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized - :private | :anonymous | false | :personal_access_token | true | 'rejects terraform module packages access' | :unauthorized + :private | :anonymous | false | :anonymous | true | 'rejects terraform module packages access' | :unauthorized :public | :developer | true | :job_token | true | 'grants terraform module download' | :success - :public | :guest | true | :job_token | true | 'rejects terraform module packages access' | :not_found + :public | :guest | true | :job_token | true | 'grants terraform module download' | :success :public | :guest | true | :job_token | false | 'rejects terraform module packages access' | :unauthorized - :public | :developer | false | :job_token | true | 'rejects terraform module packages access' | :not_found - :public | :guest | false | :job_token | true | 'rejects terraform module packages access' | :not_found + :public | :developer | false | :job_token | true | 'grants terraform module download' | :success + :public | :guest | false | :job_token | true | 'grants terraform module download' | :success :public | :developer | false | :job_token | false | 'rejects terraform module packages access' | :unauthorized :public | :guest | false | :job_token | false | 'rejects terraform module packages access' | :unauthorized :private | :developer | true | :job_token | true | 'grants terraform module download' | :success @@ -146,6 +146,7 @@ RSpec.describe API::Terraform::Modules::V1::Packages do before do group.update!(visibility: visibility.to_s) + project.update!(visibility: visibility.to_s) end it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] @@ -158,7 +159,8 @@ RSpec.describe API::Terraform::Modules::V1::Packages do let(:tokens) do { personal_access_token: ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = personal_access_token.id }.encoded, - job_token: ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = job.token }.encoded + job_token: ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = job.token }.encoded, + anonymous: "" } end @@ -167,14 +169,14 @@ RSpec.describe API::Terraform::Modules::V1::Packages do context 'with valid namespace' do where(:visibility, :user_role, :member, :token_type, :valid_token, :shared_examples_name, :expected_status) do :public | :developer | true | :personal_access_token | true | 'grants terraform module package file access' | :success - :public | :guest | true | :personal_access_token | true | 'rejects terraform module packages access' | :not_found + :public | :guest | true | :personal_access_token | true | 'grants terraform module package file access' | :success :public | :developer | true | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized :public | :guest | true | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized - :public | :developer | false | :personal_access_token | true | 'rejects terraform module packages access' | :not_found - :public | :guest | false | :personal_access_token | true | 'rejects terraform module packages access' | :not_found + :public | :developer | false | :personal_access_token | true | 'grants terraform module package file access' | :success + :public | :guest | false | :personal_access_token | true | 'grants terraform module package file access' | :success :public | :developer | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized :public | :guest | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized - :public | :anonymous | false | :personal_access_token | true | 'rejects terraform module packages access' | :not_found + :public | :anonymous | false | :anonymous | true | 'grants terraform module package file access' | :success :private | :developer | true | :personal_access_token | true | 'grants terraform module package file access' | :success :private | :guest | true | :personal_access_token | true | 'rejects terraform module packages access' | :forbidden :private | :developer | true | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized @@ -183,12 +185,12 @@ RSpec.describe API::Terraform::Modules::V1::Packages do :private | :guest | false | :personal_access_token | true | 'rejects terraform module packages access' | :forbidden :private | :developer | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized :private | :guest | false | :personal_access_token | false | 'rejects terraform module packages access' | :unauthorized - :private | :anonymous | false | :personal_access_token | true | 'rejects terraform module packages access' | :forbidden + :private | :anonymous | false | :anonymous | true | 'rejects terraform module packages access' | :unauthorized :public | :developer | true | :job_token | true | 'grants terraform module package file access' | :success - :public | :guest | true | :job_token | true | 'rejects terraform module packages access' | :not_found - :public | :guest | true | :job_token | false | 'rejects terraform module packages access' | :unauthorized - :public | :developer | false | :job_token | true | 'rejects terraform module packages access' | :not_found - :public | :guest | false | :job_token | true | 'rejects terraform module packages access' | :not_found + :public | :guest | true | :job_token | true | 'grants terraform module package file access' | :success + :public | :guest | true | :job_token | false | 'rejects terraform module packages access' | :unauthorized + :public | :developer | false | :job_token | true | 'grants terraform module package file access' | :success + :public | :guest | false | :job_token | true | 'grants terraform module package file access' | :success :public | :developer | false | :job_token | false | 'rejects terraform module packages access' | :unauthorized :public | :guest | false | :job_token | false | 'rejects terraform module packages access' | :unauthorized :private | :developer | true | :job_token | true | 'grants terraform module package file access' | :success @@ -203,10 +205,17 @@ RSpec.describe API::Terraform::Modules::V1::Packages do with_them do let(:token) { valid_token ? tokens[token_type] : 'invalid-token123' } - let(:snowplow_gitlab_standard_context) { { project: project, user: user, namespace: project.namespace } } + let(:snowplow_gitlab_standard_context) do + { + project: project, + user: user_role == :anonymous ? nil : user, + namespace: project.namespace + } + end before do group.update!(visibility: visibility.to_s) + project.update!(visibility: visibility.to_s) end it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb index 2ea98002de1..5faf462c23c 100644 --- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb @@ -36,6 +36,19 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST personal_access_toke expect(session[:"#{provider}_access_token"]).to eq(token) expect(controller).to redirect_to(status_import_url) end + + it 'passes namespace_id param as query param if it was present' do + namespace_id = 5 + status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id }) + + allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client| + allow(client).to receive(:user).and_return(true) + end + + post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 } + + expect(controller).to redirect_to(status_import_url) + end end RSpec.shared_examples 'a GitHub-ish import controller: GET new' do diff --git a/spec/workers/delete_container_repository_worker_spec.rb b/spec/workers/delete_container_repository_worker_spec.rb index ec040eab2d4..a011457444a 100644 --- a/spec/workers/delete_container_repository_worker_spec.rb +++ b/spec/workers/delete_container_repository_worker_spec.rb @@ -3,31 +3,119 @@ require 'spec_helper' RSpec.describe DeleteContainerRepositoryWorker do - let(:registry) { create(:container_repository) } - let(:project) { registry.project } - let(:user) { project.first_owner } + let_it_be(:repository) { create(:container_repository) } - subject { described_class.new } + let(:project) { repository.project } + let(:user) { project.first_owner } + let(:worker) { described_class.new } describe '#perform' do + let(:user_id) { user.id } + let(:repository_id) { repository.id } + + subject(:perform) { worker.perform(user_id, repository_id) } + it 'executes the destroy service' do - service = instance_double(Projects::ContainerRepository::DestroyService) - expect(service).to receive(:execute) - expect(Projects::ContainerRepository::DestroyService).to receive(:new).with(project, user).and_return(service) + expect_destroy_service_execution + + perform + end + + context 'with an invalid user id' do + let(:user_id) { -1 } + + it { expect { perform }.not_to raise_error } + end - subject.perform(user.id, registry.id) + context 'with an invalid repository id' do + let(:repository_id) { -1 } + + it { expect { perform }.not_to raise_error } end - it 'does not raise error when user could not be found' do - expect do - subject.perform(-1, registry.id) - end.not_to raise_error + context 'with a repository being migrated', :freeze_time do + before do + stub_application_setting( + container_registry_pre_import_tags_rate: 0.5, + container_registry_import_timeout: 10.minutes.to_i + ) + end + + shared_examples 'destroying the repository' do + it 'does destroy the repository' do + expect_next_found_instance_of(ContainerRepository) do |container_repository| + expect(container_repository).not_to receive(:tags_count) + end + expect(described_class).not_to receive(:perform_in) + expect_destroy_service_execution + + perform + end + end + + shared_examples 'not re enqueuing job if feature flag is disabled' do + before do + stub_feature_flags(container_registry_migration_phase2_delete_container_repository_worker_support: false) + end + + it_behaves_like 'destroying the repository' + end + + context 'with migration state set to pre importing' do + let_it_be(:repository) { create(:container_repository, :pre_importing) } + + let(:tags_count) { 60 } + let(:delay) { (tags_count * 0.5).seconds + 10.minutes + described_class::FIXED_DELAY } + + it 'does not destroy the repository and re enqueue the job' do + expect_next_found_instance_of(ContainerRepository) do |container_repository| + expect(container_repository).to receive(:tags_count).and_return(tags_count) + end + expect(described_class).to receive(:perform_in).with(delay.from_now) + expect(worker).to receive(:log_extra_metadata_on_done).with(:delete_postponed, delay) + expect(::Projects::ContainerRepository::DestroyService).not_to receive(:new) + + perform + end + + it_behaves_like 'not re enqueuing job if feature flag is disabled' + end + + %i[pre_import_done importing import_aborted].each do |migration_state| + context "with migration state set to #{migration_state}" do + let_it_be(:repository) { create(:container_repository, migration_state) } + + let(:delay) { 10.minutes + described_class::FIXED_DELAY } + + it 'does not destroy the repository and re enqueue the job' do + expect_next_found_instance_of(ContainerRepository) do |container_repository| + expect(container_repository).not_to receive(:tags_count) + end + expect(described_class).to receive(:perform_in).with(delay.from_now) + expect(worker).to receive(:log_extra_metadata_on_done).with(:delete_postponed, delay) + expect(::Projects::ContainerRepository::DestroyService).not_to receive(:new) + + perform + end + + it_behaves_like 'not re enqueuing job if feature flag is disabled' + end + end + + %i[default import_done import_skipped].each do |migration_state| + context "with migration state set to #{migration_state}" do + let_it_be(:repository) { create(:container_repository, migration_state) } + + it_behaves_like 'destroying the repository' + it_behaves_like 'not re enqueuing job if feature flag is disabled' + end + end end - it 'does not raise error when registry could not be found' do - expect do - subject.perform(user.id, -1) - end.not_to raise_error + def expect_destroy_service_execution + service = instance_double(Projects::ContainerRepository::DestroyService) + expect(service).to receive(:execute) + expect(Projects::ContainerRepository::DestroyService).to receive(:new).with(project, user).and_return(service) end end end |