Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-08-12 21:11:09 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-08-12 21:11:09 +0300
commit60eaf3d90650086dedb6fd94d6169dc5ab1f8d1e (patch)
treeff943955f1c424787ba65ff7c1607c522c103115 /spec
parent1c8734ca5c2981e62b9c1162851ed136de86bbbf (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/features/issues/issue_sidebar_spec.rb6
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb2
-rw-r--r--spec/frontend/work_items/components/work_item_actions_spec.js85
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js8
-rw-r--r--spec/frontend/work_items/mock_data.js36
-rw-r--r--spec/frontend/work_items/pages/work_item_detail_spec.js169
-rw-r--r--spec/graphql/types/ci/variable_input_type_spec.rb11
-rw-r--r--spec/lib/container_registry/gitlab_api_client_spec.rb125
-rw-r--r--spec/lib/container_registry/tag_spec.rb35
-rw-r--r--spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/reports/sbom/reports_spec.rb21
-rw-r--r--spec/lib/gitlab/utils/link_header_parser_spec.rb75
-rw-r--r--spec/models/ci/build_spec.rb54
-rw-r--r--spec/models/container_repository_spec.rb178
-rw-r--r--spec/requests/api/graphql/ci/manual_variables_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_retry_spec.rb32
-rw-r--r--spec/services/ci/retry_job_service_spec.rb61
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