diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-12 21:11:09 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-12 21:11:09 +0300 |
commit | 60eaf3d90650086dedb6fd94d6169dc5ab1f8d1e (patch) | |
tree | ff943955f1c424787ba65ff7c1607c522c103115 /spec | |
parent | 1c8734ca5c2981e62b9c1162851ed136de86bbbf (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
17 files changed, 867 insertions, 41 deletions
diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 9a512fe8fd2..8819f085a5f 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -84,8 +84,10 @@ RSpec.describe 'Issue Sidebar' do click_link user2.name end - find('.js-right-sidebar').click - find('.block.assignee .edit-link').click + within '.js-right-sidebar' do + find('.block.assignee').click(x: 0, y: 0) + find('.block.assignee .edit-link').click + end expect(page.all('.dropdown-menu-user li').length).to eq(1) expect(find('.dropdown-input-field').value).to eq(user2.name) diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index dac600c98bf..4eecb63c47e 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -155,7 +155,7 @@ RSpec.describe "Issues > User edits issue", :js do page.within '.block.labels' do # Remove `verisimilitude` label - within '.gl-label' do + within '.gl-label', text: 'verisimilitude' do click_button 'Remove label' end diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js index 137a0a7326d..a1f1d47ab90 100644 --- a/spec/frontend/work_items/components/work_item_actions_spec.js +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -1,5 +1,5 @@ -import { GlDropdownItem, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; describe('WorkItemActions component', () => { @@ -7,12 +7,19 @@ describe('WorkItemActions component', () => { let glModalDirective; const findModal = () => wrapper.findComponent(GlModal); - const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findConfidentialityToggleButton = () => + wrapper.findByTestId('confidentiality-toggle-action'); + const findDeleteButton = () => wrapper.findByTestId('delete-action'); - const createComponent = ({ canDelete = true } = {}) => { + const createComponent = ({ + canUpdate = true, + canDelete = true, + isConfidential = false, + isParentConfidential = false, + } = {}) => { glModalDirective = jest.fn(); - wrapper = shallowMount(WorkItemActions, { - propsData: { workItemId: '123', canDelete }, + wrapper = shallowMountExtended(WorkItemActions, { + propsData: { workItemId: '123', canUpdate, canDelete, isConfidential, isParentConfidential }, directives: { glModal: { bind(_, { value }) { @@ -34,27 +41,69 @@ describe('WorkItemActions component', () => { expect(findModal().props('visible')).toBe(false); }); - it('shows confirm modal when clicking Delete work item', () => { + it('renders dropdown actions', () => { createComponent(); - findDeleteButton().vm.$emit('click'); - - expect(glModalDirective).toHaveBeenCalled(); + expect(findConfidentialityToggleButton().exists()).toBe(true); + expect(findDeleteButton().exists()).toBe(true); }); - it('emits event when clicking OK button', () => { - createComponent(); + describe('toggle confidentiality action', () => { + it.each` + isConfidential | buttonText + ${true} | ${'Turn off confidentiality'} + ${false} | ${'Turn on confidentiality'} + `( + 'renders confidentiality toggle button with text "$buttonText"', + ({ isConfidential, buttonText }) => { + createComponent({ isConfidential }); + + expect(findConfidentialityToggleButton().text()).toBe(buttonText); + }, + ); + + it('emits `toggleWorkItemConfidentiality` event when clicked', () => { + createComponent(); - findModal().vm.$emit('ok'); + findConfidentialityToggleButton().vm.$emit('click'); - expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]); + expect(wrapper.emitted('toggleWorkItemConfidentiality')[0]).toEqual([true]); + }); + + it.each` + props | propName | value + ${{ isParentConfidential: true }} | ${'isParentConfidential'} | ${true} + ${{ canUpdate: false }} | ${'canUpdate'} | ${false} + `('does not render when $propName is $value', ({ props }) => { + createComponent(props); + + expect(findConfidentialityToggleButton().exists()).toBe(false); + }); }); - it('does not render when canDelete is false', () => { - createComponent({ - canDelete: false, + describe('delete action', () => { + it('shows confirm modal when clicked', () => { + createComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + + it('emits event when clicking OK button', () => { + createComponent(); + + findModal().vm.$emit('ok'); + + expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]); }); - expect(wrapper.html()).toBe(''); + it('does not render when canDelete is false', () => { + createComponent({ + canDelete: false, + }); + + expect(wrapper.findByTestId('delete-action').exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 0f6e31316bd..2b56cc3ca46 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -132,6 +132,14 @@ describe('WorkItemLinks', () => { expect(findFirstLinksMenu().exists()).toBe(true); }); + it('renders confidentiality icon when child item is confidential', () => { + const children = wrapper.findAll('[data-testid="links-child"]'); + const confidentialIcon = children.at(0).find('[data-testid="confidential-icon"]'); + + expect(confidentialIcon.exists()).toBe(true); + expect(confidentialIcon.props('name')).toBe('eye-slash'); + }); + describe('when no permission to update', () => { beforeEach(async () => { await createComponent({ response: workItemHierarchyNoUpdatePermissionResponse }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 5e142a9e47e..0949e838b6b 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -25,6 +25,7 @@ export const workItemQueryResponse = { title: 'Test', state: 'OPEN', description: 'description', + confidential: false, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -57,6 +58,7 @@ export const workItemQueryResponse = { id: 'gid://gitlab/Issue/1', iid: '5', title: 'Parent title', + confidential: false, }, children: { nodes: [ @@ -82,6 +84,7 @@ export const updateWorkItemMutationResponse = { title: 'Updated title', state: 'OPEN', description: 'description', + confidential: false, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -115,12 +118,23 @@ export const updateWorkItemMutationResponse = { }, }; +export const mockParent = { + parent: { + id: 'gid://gitlab/Issue/1', + iid: '5', + title: 'Parent title', + confidential: false, + }, +}; + export const workItemResponseFactory = ({ canUpdate = false, + canDelete = false, allowsMultipleAssignees = true, assigneesWidgetPresent = true, weightWidgetPresent = true, - parent = null, + confidential = false, + parent = mockParent.parent, } = {}) => ({ data: { workItem: { @@ -129,13 +143,14 @@ export const workItemResponseFactory = ({ title: 'Updated title', state: 'OPEN', description: 'description', + confidential, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', name: 'Task', }, userPermissions: { - deleteWorkItem: false, + deleteWorkItem: canDelete, updateWorkItem: canUpdate, }, widgets: [ @@ -216,6 +231,7 @@ export const createWorkItemMutationResponse = { title: 'Updated title', state: 'OPEN', description: 'description', + confidential: false, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -243,6 +259,7 @@ export const createWorkItemFromTaskMutationResponse = { id: 'gid://gitlab/WorkItem/1', title: 'Updated title', state: 'OPEN', + confidential: false, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -267,6 +284,7 @@ export const createWorkItemFromTaskMutationResponse = { title: 'Updated title', state: 'OPEN', description: '', + confidential: false, workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -399,6 +417,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = { }, title: 'xyz', state: 'OPEN', + confidential: false, __typename: 'WorkItem', }, ], @@ -444,6 +463,7 @@ export const workItemHierarchyResponse = { }, title: 'xyz', state: 'OPEN', + confidential: true, __typename: 'WorkItem', }, { @@ -454,6 +474,7 @@ export const workItemHierarchyResponse = { }, title: 'abc', state: 'CLOSED', + confidential: false, __typename: 'WorkItem', }, { @@ -464,6 +485,7 @@ export const workItemHierarchyResponse = { }, title: 'bar', state: 'OPEN', + confidential: false, __typename: 'WorkItem', }, { @@ -474,6 +496,7 @@ export const workItemHierarchyResponse = { }, title: 'foobar', state: 'OPEN', + confidential: false, __typename: 'WorkItem', }, ], @@ -505,6 +528,7 @@ export const changeWorkItemParentMutationResponse = { id: 'gid://gitlab/WorkItem/2', state: 'OPEN', title: 'Foo', + confidential: false, widgets: [ { __typename: 'WorkItemWidgetHierarchy', @@ -662,11 +686,3 @@ export const projectLabelsResponse = { }, }, }; - -export const mockParent = { - parent: { - id: 'gid://gitlab/Issue/1', - iid: '5', - title: 'Parent title', - }, -}; diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js index b85fc469c23..6d2d478eb8e 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -1,11 +1,12 @@ -import { GlAlert, GlSkeletonLoader, GlButton } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; @@ -16,6 +17,8 @@ import WorkItemInformation from '~/work_items/components/work_item_information.v import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; import { temporaryConfig } from '~/work_items/graphql/provider'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { @@ -30,12 +33,19 @@ describe('WorkItemDetail component', () => { Vue.use(VueApollo); - const workItemQueryResponse = workItemResponseFactory(); + const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); + const workItemQueryResponseWithoutParent = workItemResponseFactory({ + parent: null, + canUpdate: true, + canDelete: true, + }); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); const findAlert = () => wrapper.findComponent(GlAlert); const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); @@ -51,17 +61,21 @@ describe('WorkItemDetail component', () => { const createComponent = ({ isModal = false, + updateInProgress = false, workItemId = workItemQueryResponse.data.workItem.id, handler = successHandler, subscriptionHandler = initialSubscriptionHandler, + confidentialityMock = [updateWorkItemMutation, jest.fn()], workItemsMvc2Enabled = false, includeWidgets = false, + error = undefined, } = {}) => { wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo( [ [workItemQuery, handler], [workItemTitleSubscription, subscriptionHandler], + confidentialityMock, ], {}, { @@ -69,6 +83,12 @@ describe('WorkItemDetail component', () => { }, ), propsData: { isModal, workItemId }, + data() { + return { + updateInProgress, + error, + }; + }, provide: { glFeatures: { workItemsMvc2: workItemsMvc2Enabled, @@ -146,6 +166,145 @@ describe('WorkItemDetail component', () => { }); }); + describe('confidentiality', () => { + const errorMessage = 'Mutation failed'; + const confidentialWorkItem = workItemResponseFactory({ + confidential: true, + }); + + // Mocks for work item without parent + const withoutParentExpectedInputVars = { + id: workItemQueryResponse.data.workItem.id, + confidential: true, + }; + const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: confidentialWorkItem.data.workItem, + errors: [], + }, + }, + }); + const withoutParentHandlerMock = jest + .fn() + .mockResolvedValue(workItemQueryResponseWithoutParent); + const confidentialityWithoutParentMock = [ + updateWorkItemMutation, + toggleConfidentialityWithoutParentHandler, + ]; + const confidentialityWithoutParentFailureMock = [ + updateWorkItemMutation, + jest.fn().mockRejectedValue(new Error(errorMessage)), + ]; + + // Mocks for work item with parent + const withParentExpectedInputVars = { + id: mockParent.parent.id, + taskData: { id: workItemQueryResponse.data.workItem.id, confidential: true }, + }; + const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({ + data: { + workItemUpdate: { + workItem: { + id: confidentialWorkItem.data.workItem.id, + descriptionHtml: confidentialWorkItem.data.workItem.description, + }, + task: { + workItem: confidentialWorkItem.data.workItem, + confidential: true, + }, + errors: [], + }, + }, + }); + const confidentialityWithParentMock = [ + updateWorkItemTaskMutation, + toggleConfidentialityWithParentHandler, + ]; + const confidentialityWithParentFailureMock = [ + updateWorkItemTaskMutation, + jest.fn().mockRejectedValue(new Error(errorMessage)), + ]; + + describe.each` + context | handlerMock | confidentialityMock | confidentialityFailureMock | inputVariables + ${'no parent'} | ${withoutParentHandlerMock} | ${confidentialityWithoutParentMock} | ${confidentialityWithoutParentFailureMock} | ${withoutParentExpectedInputVars} + ${'parent'} | ${successHandler} | ${confidentialityWithParentMock} | ${confidentialityWithParentFailureMock} | ${withParentExpectedInputVars} + `( + 'when work item has $context', + ({ handlerMock, confidentialityMock, confidentialityFailureMock, inputVariables }) => { + it('renders confidential badge when work item is confidential', async () => { + createComponent({ + handler: jest.fn().mockResolvedValue(confidentialWorkItem), + confidentialityMock, + }); + + await waitForPromises(); + + const confidentialBadge = wrapper.findComponent(GlBadge); + expect(confidentialBadge.exists()).toBe(true); + expect(confidentialBadge.props()).toMatchObject({ + variant: 'warning', + icon: 'eye-slash', + }); + expect(confidentialBadge.text()).toBe('Confidential'); + }); + + it('renders gl-loading-icon while update mutation is in progress', async () => { + createComponent({ + handler: handlerMock, + confidentialityMock, + }); + + await waitForPromises(); + + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('emits workItemUpdated and shows confidentiality badge when mutation is successful', async () => { + createComponent({ + handler: handlerMock, + confidentialityMock, + }); + + await waitForPromises(); + + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await waitForPromises(); + + expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]); + expect(confidentialityMock[1]).toHaveBeenCalledWith({ + input: inputVariables, + }); + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows alert message when mutation fails', async () => { + createComponent({ + handler: handlerMock, + confidentialityMock: confidentialityFailureMock, + }); + + await waitForPromises(); + findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true); + await waitForPromises(); + + expect(wrapper.emitted('workItemUpdated')).toBeFalsy(); + + await nextTick(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errorMessage); + expect(findLoadingIcon().exists()).toBe(false); + }); + }, + ); + }); + describe('description', () => { it('does not show description widget if loading description fails', () => { createComponent(); @@ -169,7 +328,7 @@ describe('WorkItemDetail component', () => { }); it('does not show secondary breadcrumbs if there is not a parent', async () => { - createComponent(); + createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); await waitForPromises(); @@ -177,7 +336,7 @@ describe('WorkItemDetail component', () => { }); it('shows work item type if there is not a parent', async () => { - createComponent(); + createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) }); await waitForPromises(); expect(findWorkItemType().exists()).toBe(true); diff --git a/spec/graphql/types/ci/variable_input_type_spec.rb b/spec/graphql/types/ci/variable_input_type_spec.rb new file mode 100644 index 00000000000..a56b6287dee --- /dev/null +++ b/spec/graphql/types/ci/variable_input_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiVariableInput'] do + include GraphqlHelpers + + it 'has the correct arguments' do + expect(described_class.arguments.keys).to match_array(%w[key value]) + end +end diff --git a/spec/lib/container_registry/gitlab_api_client_spec.rb b/spec/lib/container_registry/gitlab_api_client_spec.rb index f2c627734a3..7836d8706f6 100644 --- a/spec/lib/container_registry/gitlab_api_client_spec.rb +++ b/spec/lib/container_registry/gitlab_api_client_spec.rb @@ -212,6 +212,105 @@ RSpec.describe ContainerRegistry::GitlabApiClient do end end + describe '#tags' do + let(:path) { 'namespace/path/to/repository' } + let(:page_size) { 100 } + let(:last) { nil } + let(:response) do + [ + { + name: '0.1.0', + digest: 'sha256:1234567890', + media_type: 'application/vnd.oci.image.manifest.v1+json', + size_bytes: 1234567890, + created_at: 5.minutes.ago + }, + { + name: 'latest', + digest: 'sha256:1234567892', + media_type: 'application/vnd.oci.image.manifest.v1+json', + size_bytes: 1234567892, + created_at: 10.minutes.ago + } + ] + end + + subject { client.tags(path, page_size: page_size, last: last) } + + context 'with valid parameters' do + let(:expected) do + { + pagination: {}, + response_body: ::Gitlab::Json.parse(response.to_json) + } + end + + before do + stub_tags(path, page_size: page_size, respond_with: response) + end + + it { is_expected.to eq(expected) } + end + + context 'with a response with a link header' do + let(:next_page_url) { 'http://sandbox.org/test?last=b' } + let(:expected) do + { + pagination: { next: { uri: URI(next_page_url) } }, + response_body: ::Gitlab::Json.parse(response.to_json) + } + end + + before do + stub_tags(path, page_size: page_size, next_page_url: next_page_url, respond_with: response) + end + + it { is_expected.to eq(expected) } + end + + context 'with a large page size set' do + let(:page_size) { described_class::MAX_TAGS_PAGE_SIZE + 1000 } + + let(:expected) do + { + pagination: {}, + response_body: ::Gitlab::Json.parse(response.to_json) + } + end + + before do + stub_tags(path, page_size: described_class::MAX_TAGS_PAGE_SIZE, respond_with: response) + end + + it { is_expected.to eq(expected) } + end + + context 'with a last parameter set' do + let(:last) { 'test' } + + let(:expected) do + { + pagination: {}, + response_body: ::Gitlab::Json.parse(response.to_json) + } + end + + before do + stub_tags(path, page_size: page_size, last: last, respond_with: response) + end + + it { is_expected.to eq(expected) } + end + + context 'with non successful response' do + before do + stub_tags(path, page_size: page_size, status_code: 404) + end + + it { is_expected.to eq({}) } + end + end + describe '.supports_gitlab_api?' do subject { described_class.supports_gitlab_api? } @@ -389,4 +488,30 @@ RSpec.describe ContainerRegistry::GitlabApiClient do .with(headers: headers) .to_return(status: status_code, body: respond_with.to_json, headers: { 'Content-Type' => described_class::JSON_TYPE }) end + + def stub_tags(path, page_size: nil, last: nil, next_page_url: nil, status_code: 200, respond_with: {}) + params = { n: page_size, last: last }.compact + + url = "#{registry_api_url}/gitlab/v1/repositories/#{path}/tags/list/" + + if params.present? + url += "?#{params.map { |param, val| "#{param}=#{val}" }.join('&')}" + end + + request_headers = { 'Accept' => described_class::JSON_TYPE } + request_headers['Authorization'] = "bearer #{token}" if token + + response_headers = { 'Content-Type' => described_class::JSON_TYPE } + if next_page_url + response_headers['Link'] = "<#{next_page_url}>; rel=\"next\"" + end + + stub_request(:get, url) + .with(headers: request_headers) + .to_return( + status: status_code, + body: respond_with.to_json, + headers: response_headers + ) + end end diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index 9b931ab6dbc..190ddef0cd5 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -205,6 +205,41 @@ RSpec.describe ContainerRegistry::Tag do it_behaves_like 'a processable' end + + describe '#force_created_at_from_iso8601' do + subject { tag.force_created_at_from_iso8601(input) } + + shared_examples 'setting and caching the created_at value' do + it 'sets and caches the created_at value' do + expect(tag).not_to receive(:config) + + subject + + expect(tag.created_at).to eq(expected_value) + end + end + + context 'with a valid input' do + let(:input) { 2.days.ago.iso8601 } + let(:expected_value) { DateTime.iso8601(input) } + + it_behaves_like 'setting and caching the created_at value' + end + + context 'with a nil input' do + let(:input) { nil } + let(:expected_value) { nil } + + it_behaves_like 'setting and caching the created_at value' + end + + context 'with an invalid input' do + let(:input) { 'not a timestamp' } + let(:expected_value) { nil } + + it_behaves_like 'setting and caching the created_at value' + end + end end end end diff --git a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb index cb6d8b62f94..431fe9f3591 100644 --- a/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb +++ b/spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Gitlab::Ci::Parsers::Sbom::Cyclonedx do } end - subject(:parse!) { described_class.new(raw_report_data, report).parse! } + subject(:parse!) { described_class.new.parse!(raw_report_data, report) } before do allow_next_instance_of(Gitlab::Ci::Parsers::Sbom::Validators::CyclonedxSchemaValidator) do |validator| diff --git a/spec/lib/gitlab/ci/reports/sbom/reports_spec.rb b/spec/lib/gitlab/ci/reports/sbom/reports_spec.rb new file mode 100644 index 00000000000..97d8d7abb33 --- /dev/null +++ b/spec/lib/gitlab/ci/reports/sbom/reports_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Reports::Sbom::Reports do + subject(:reports_list) { described_class.new } + + describe '#add_report' do + let(:rep1) { Gitlab::Ci::Reports::Sbom::Report.new } + let(:rep2) { Gitlab::Ci::Reports::Sbom::Report.new } + + it 'appends the report to the report list' do + reports_list.add_report(rep1) + reports_list.add_report(rep2) + + expect(reports_list.reports.length).to eq(2) + expect(reports_list.reports.first).to eq(rep1) + expect(reports_list.reports.last).to eq(rep2) + end + end +end diff --git a/spec/lib/gitlab/utils/link_header_parser_spec.rb b/spec/lib/gitlab/utils/link_header_parser_spec.rb new file mode 100644 index 00000000000..e15ef930271 --- /dev/null +++ b/spec/lib/gitlab/utils/link_header_parser_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Utils::LinkHeaderParser do + let(:parser) { described_class.new(header) } + + describe '#parse' do + subject { parser.parse } + + context 'with a valid header' do + let(:header) { generate_header(next: 'http://sandbox.org/next') } + let(:expected) { { next: { uri: URI('http://sandbox.org/next') } } } + + it { is_expected.to eq(expected) } + + context 'with multiple links' do + let(:header) { generate_header(next: 'http://sandbox.org/next', previous: 'http://sandbox.org/previous') } + let(:expected) do + { + next: { uri: URI('http://sandbox.org/next') }, + previous: { uri: URI('http://sandbox.org/previous') } + } + end + + it { is_expected.to eq(expected) } + end + + context 'with an incomplete uri' do + let(:header) { '<http://sandbox.org/next; rel="next"' } + + it { is_expected.to eq({}) } + end + + context 'with no rel' do + let(:header) { '<http://sandbox.org/next>; direction="next"' } + + it { is_expected.to eq({}) } + end + + context 'with multiple rel elements' do + # check https://datatracker.ietf.org/doc/html/rfc5988#section-5.3: + # occurrences after the first MUST be ignored by parsers + let(:header) { '<http://sandbox.org/next>; rel="next"; rel="dummy"' } + + it { is_expected.to eq(expected) } + end + + context 'when the url is too long' do + let(:header) { "<http://sandbox.org/#{'a' * 500}>; rel=\"next\"" } + + it { is_expected.to eq({}) } + end + end + + context 'with nil header' do + let(:header) { nil } + + it { is_expected.to eq({}) } + end + + context 'with empty header' do + let(:header) { '' } + + it { is_expected.to eq({}) } + end + + def generate_header(links) + stringified_links = links.map do |rel, url| + "<#{url}>; rel=\"#{rel}\"" + end + stringified_links.join(', ') + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 3c9e831f7c7..185bf3e8164 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -5609,4 +5609,58 @@ RSpec.describe Ci::Build do let!(:model) { create(:ci_build, user: create(:user)) } let!(:parent) { model.user } end + + describe '#clone' do + let_it_be(:user) { FactoryBot.build(:user) } + + context 'when given new job variables' do + context 'when the cloned build has an action' do + it 'applies the new job variables' do + build = create(:ci_build, :actionable) + create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value') + create(:ci_job_variable, job: build, key: 'OLD_KEY', value: 'i will not live for long') + + new_build = build.clone(current_user: user, new_job_variables_attributes: [ + { key: 'TEST_KEY', value: 'new value' }, + { key: 'NEW_KEY', value: 'exciting new value' } + ]) + new_build.save! + + expect(new_build.job_variables.count).to be(2) + expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY', 'NEW_KEY') + expect(new_build.job_variables.map(&:value)).to contain_exactly('new value', 'exciting new value') + end + end + + context 'when the cloned build does not have an action' do + it 'applies the old job variables' do + build = create(:ci_build) + create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value') + + new_build = build.clone(current_user: user, new_job_variables_attributes: [ + { key: 'TEST_KEY', value: 'new value' } + ]) + new_build.save! + + expect(new_build.job_variables.count).to be(1) + expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY') + expect(new_build.job_variables.map(&:value)).to contain_exactly('old value') + end + end + end + + context 'when not given new job variables' do + it 'applies the old job variables' do + build = create(:ci_build) + create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value') + + new_build = build.clone(current_user: user) + new_build.save! + + expect(new_build.job_variables.count).to be(1) + expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY') + expect(new_build.job_variables.map(&:value)).to contain_exactly('old value') + end + end + end end diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index d85edf5595b..a4329993e91 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -525,6 +525,162 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end + describe '#each_tags_page' do + let(:page_size) { 100 } + + shared_examples 'iterating through a page' do |expected_tags: true| + it 'iterates through one page' do + expect(repository.gitlab_api_client).to receive(:tags) + .with(repository.path, page_size: page_size, last: nil) + .and_return(client_response) + expect { |b| repository.each_tags_page(page_size: page_size, &b) } + .to yield_with_args(expected_tags ? expected_tags_from(client_response_tags) : []) + end + end + + context 'with an empty page' do + let(:client_response) { { pagination: {}, response_body: [] } } + + it_behaves_like 'iterating through a page', expected_tags: false + end + + context 'with one page' do + let(:client_response) { { pagination: {}, response_body: client_response_tags } } + let(:client_response_tags) do + [ + { + 'name' => '0.1.0', + 'created_at' => '2022-06-07T12:10:12.412+00:00' + }, + { + 'name' => 'latest', + 'created_at' => '2022-06-07T12:11:13.633+00:00' + } + ] + end + + context 'with a nil created_at' do + let(:client_response_tags) { ['name' => '0.1.0', 'created_at' => nil] } + + it_behaves_like 'iterating through a page' + end + + context 'with an invalid created_at' do + let(:client_response_tags) { ['name' => '0.1.0', 'created_at' => 'not_a_timestamp'] } + + it_behaves_like 'iterating through a page' + end + end + + context 'with two pages' do + let(:client_response1) { { pagination: { next: { uri: URI('http://localhost/next?last=latest') } }, response_body: client_response_tags1 } } + let(:client_response_tags1) do + [ + { + 'name' => '0.1.0', + 'created_at' => '2022-06-07T12:10:12.412+00:00' + }, + { + 'name' => 'latest', + 'created_at' => '2022-06-07T12:11:13.633+00:00' + } + ] + end + + let(:client_response2) { { pagination: {}, response_body: client_response_tags2 } } + let(:client_response_tags2) do + [ + { + 'name' => '1.2.3', + 'created_at' => '2022-06-10T12:10:15.412+00:00' + }, + { + 'name' => '2.3.4', + 'created_at' => '2022-06-11T12:11:17.633+00:00' + } + ] + end + + it 'iterates through two pages' do + expect(repository.gitlab_api_client).to receive(:tags) + .with(repository.path, page_size: page_size, last: nil) + .and_return(client_response1) + expect(repository.gitlab_api_client).to receive(:tags) + .with(repository.path, page_size: page_size, last: 'latest') + .and_return(client_response2) + expect { |b| repository.each_tags_page(page_size: page_size, &b) } + .to yield_successive_args(expected_tags_from(client_response_tags1), expected_tags_from(client_response_tags2)) + end + end + + context 'when max pages is reached' do + before do + stub_const('ContainerRepository::MAX_TAGS_PAGES', 0) + end + + it 'raises an error' do + expect { repository.each_tags_page(page_size: page_size) {} } + .to raise_error(StandardError, 'too many pages requested') + end + end + + context 'without a block set' do + it 'raises an Argument error' do + expect { repository.each_tags_page(page_size: page_size) }.to raise_error(ArgumentError, 'block not given') + end + end + + context 'without a page size set' do + let(:client_response) { { pagination: {}, response_body: [] } } + + it 'uses a default size' do + expect(repository.gitlab_api_client).to receive(:tags) + .with(repository.path, page_size: 100, last: nil) + .and_return(client_response) + expect { |b| repository.each_tags_page(&b) }.to yield_with_args([]) + end + end + + context 'with an empty client response' do + let(:client_response) { {} } + + it 'breaks the loop' do + expect(repository.gitlab_api_client).to receive(:tags) + .with(repository.path, page_size: page_size, last: nil) + .and_return(client_response) + expect { |b| repository.each_tags_page(page_size: page_size, &b) }.not_to yield_control + end + end + + context 'with a nil page' do + let(:client_response) { { pagination: {}, response_body: nil } } + + it_behaves_like 'iterating through a page', expected_tags: false + end + + context 'calling on a non migrated repository' do + before do + repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days) + end + + it 'raises an Argument error' do + expect { repository.each_tags_page }.to raise_error(ArgumentError, 'not a migrated repository') + end + end + + def expected_tags_from(client_tags) + client_tags.map do |tag| + created_at = + begin + DateTime.iso8601(tag['created_at']) + rescue ArgumentError + nil + end + an_object_having_attributes(name: tag['name'], created_at: created_at) + end + end + end + describe '#tags_count' do it 'returns the count of tags' do expect(repository.tags_count).to eq(1) @@ -1348,6 +1504,28 @@ RSpec.describe ContainerRepository, :aggregate_failures do end end + describe '#migrated?' do + subject { repository.migrated? } + + it { is_expected.to eq(true) } + + context 'with a created_at older than phase 1 ends' do + before do + repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days) + end + + it { is_expected.to eq(false) } + + context 'with migration state set to import_done' do + before do + repository.update!(migration_state: 'import_done') + end + + it { is_expected.to eq(true) } + end + end + end + context 'with repositories' do let_it_be_with_reload(:repository) { create(:container_repository, :cleanup_unscheduled) } let_it_be(:other_repository) { create(:container_repository, :cleanup_unscheduled) } diff --git a/spec/requests/api/graphql/ci/manual_variables_spec.rb b/spec/requests/api/graphql/ci/manual_variables_spec.rb index b7aa76511a3..a15bac2b8bd 100644 --- a/spec/requests/api/graphql/ci/manual_variables_spec.rb +++ b/spec/requests/api/graphql/ci/manual_variables_spec.rb @@ -35,8 +35,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables' do project.add_maintainer(user) end - it 'returns the manual variables for the jobs' do - job = create(:ci_build, :manual, pipeline: pipeline) + it 'returns the manual variables for actionable jobs' do + job = create(:ci_build, :actionable, pipeline: pipeline) create(:ci_job_variable, key: 'MANUAL_TEST_VAR', job: job) post_graphql(query, current_user: user) @@ -46,8 +46,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables' do expect(variables_data.map { |var| var['key'] }).to match_array(['MANUAL_TEST_VAR']) end - it 'does not fetch job variables for jobs that are not manual' do - job = create(:ci_build, pipeline: pipeline) + it 'does not fetch job variables for jobs that are not actionable' do + job = create(:ci_build, pipeline: pipeline, status: :manual) create(:ci_job_variable, key: 'THIS_VAR_WOULD_SHOULD_NEVER_EXIST', job: job) post_graphql(query, current_user: user) diff --git a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb index ef640183bd8..8cf559a372a 100644 --- a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb @@ -47,6 +47,38 @@ RSpec.describe 'JobRetry' do expect(new_job).not_to be_retried end + context 'when given CI variables' do + let(:job) { create(:ci_build, :success, :actionable, pipeline: pipeline, name: 'build') } + + let(:mutation) do + variables = { + id: job.to_global_id.to_s, + variables: { key: 'MANUAL_VAR', value: 'test manual var' } + } + + graphql_mutation(:job_retry, variables, + <<-QL + errors + job { + id + } + QL + ) + end + + it 'applies them to a retried manual job' do + post_graphql_mutation(mutation, current_user: user) + + expect(response).to have_gitlab_http_status(:success) + + new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id + new_job = ::Ci::Build.find(new_job_id) + expect(new_job.job_variables.count).to be(1) + expect(new_job.job_variables.first.key).to eq('MANUAL_VAR') + expect(new_job.job_variables.first.value).to eq('test manual var') + end + end + context 'when the job is not retryable' do let(:job) { create(:ci_build, :retried, pipeline: pipeline) } diff --git a/spec/services/ci/retry_job_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb index f042471bd1f..b14e4187c7a 100644 --- a/spec/services/ci/retry_job_service_spec.rb +++ b/spec/services/ci/retry_job_service_spec.rb @@ -17,6 +17,7 @@ RSpec.describe Ci::RetryJobService do name: 'test') end + let(:job_variables_attributes) { [{ key: 'MANUAL_VAR', value: 'manual test var' }] } let(:user) { developer } let(:service) { described_class.new(project, user) } @@ -206,6 +207,14 @@ RSpec.describe Ci::RetryJobService do include_context 'retryable bridge' it_behaves_like 'clones the job' + + context 'when given variables' do + let(:new_job) { service.clone!(job, variables: job_variables_attributes) } + + it 'does not give variables to the new bridge' do + expect { new_job }.not_to raise_error + end + end end context 'when the job to be cloned is a build' do @@ -250,6 +259,28 @@ RSpec.describe Ci::RetryJobService do expect { new_job }.not_to change { Environment.count } end end + + context 'when given variables' do + let(:new_job) { service.clone!(job, variables: job_variables_attributes) } + + context 'when the build is actionable' do + let_it_be_with_refind(:job) { create(:ci_build, :actionable, pipeline: pipeline) } + + it 'gives variables to the new build' do + expect(new_job.job_variables.count).to be(1) + expect(new_job.job_variables.first.key).to eq('MANUAL_VAR') + expect(new_job.job_variables.first.value).to eq('manual test var') + end + end + + context 'when the build is not actionable' do + let_it_be_with_refind(:job) { create(:ci_build, pipeline: pipeline) } + + it 'does not give variables to the new build' do + expect(new_job.job_variables.count).to be_zero + end + end + end end end @@ -260,6 +291,14 @@ RSpec.describe Ci::RetryJobService do include_context 'retryable bridge' it_behaves_like 'retries the job' + + context 'when given variables' do + let(:new_job) { service.clone!(job, variables: job_variables_attributes) } + + it 'does not give variables to the new bridge' do + expect { new_job }.not_to raise_error + end + end end context 'when the job to be retried is a build' do @@ -288,6 +327,28 @@ RSpec.describe Ci::RetryJobService do expect { service.execute(job) }.not_to exceed_all_query_limit(control_count) end end + + context 'when given variables' do + let(:new_job) { service.clone!(job, variables: job_variables_attributes) } + + context 'when the build is actionable' do + let_it_be_with_refind(:job) { create(:ci_build, :actionable, pipeline: pipeline) } + + it 'gives variables to the new build' do + expect(new_job.job_variables.count).to be(1) + expect(new_job.job_variables.first.key).to eq('MANUAL_VAR') + expect(new_job.job_variables.first.value).to eq('manual test var') + end + end + + context 'when the build is not actionable' do + let_it_be_with_refind(:job) { create(:ci_build, pipeline: pipeline) } + + it 'does not give variables to the new build' do + expect(new_job.job_variables.count).to be_zero + end + end + end end end end |