diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-19 21:09:33 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2024-01-19 21:09:33 +0300 |
commit | d1be3e6f776e1c77976537548c1daa9af2fb2650 (patch) | |
tree | 387d3c8f06e18bbfa24a4b0b015a7245e166927c /spec | |
parent | 8f3a9dbb94b5a9ae4570a22bbc2a75e7572407c8 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
31 files changed, 937 insertions, 193 deletions
diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb index e21010b76f7..73a9820bdaf 100644 --- a/spec/controllers/groups/settings/integrations_controller_spec.rb +++ b/spec/controllers/groups/settings/integrations_controller_spec.rb @@ -55,7 +55,9 @@ RSpec.describe Groups::Settings::IntegrationsController, feature_category: :inte get :edit, params: { group_id: group, - id: Integration.available_integration_names(include_project_specific: false).sample + id: Integration.available_integration_names( + include_project_specific: false, include_instance_specific: false + ).sample } expect(response).to have_gitlab_http_status(:not_found) @@ -67,7 +69,9 @@ RSpec.describe Groups::Settings::IntegrationsController, feature_category: :inte group.add_owner(user) end - Integration.available_integration_names(include_project_specific: false).each do |integration_name| + Integration.available_integration_names( + include_project_specific: false, include_instance_specific: false + ).each do |integration_name| context integration_name do it 'successfully displays the template' do get :edit, params: { group_id: group, id: integration_name } diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 1d698e1b4d8..7c52907cefd 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -292,6 +292,13 @@ FactoryBot.define do active { true } end + factory :beyond_identity_integration, class: 'Integrations::BeyondIdentity' do + type { 'Integrations::BeyondIdentity' } + active { true } + instance { true } + token { 'api-token' } + end + factory :assembla_integration, class: 'Integrations::Assembla' do project token { 'secrettoken' } diff --git a/spec/features/admin/integrations/instance_integrations_spec.rb b/spec/features/admin/integrations/instance_integrations_spec.rb index d963aa700eb..5f2ce1d0411 100644 --- a/spec/features/admin/integrations/instance_integrations_spec.rb +++ b/spec/features/admin/integrations/instance_integrations_spec.rb @@ -10,7 +10,11 @@ RSpec.describe 'Instance integrations', :js, feature_category: :integrations do end it_behaves_like 'integration settings form' do - let(:integrations) { Integration.find_or_initialize_all_non_project_specific(Integration.for_instance) } + let(:integrations) do + Integration.find_or_initialize_all_non_project_specific( + Integration.for_instance, include_instance_specific: true + ) + end def navigate_to_integration(integration) visit_instance_integration(integration.title) diff --git a/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js b/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js index ea216300017..fe59baf0750 100644 --- a/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js +++ b/spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js @@ -17,7 +17,7 @@ describe('Catalog Tabs', () => { }; const findAllTab = () => wrapper.findByTestId('resources-all-tab'); - const findYourResourcesTab = () => wrapper.findByTestId('resources-your-tab'); + const findGroupResourcesTab = () => wrapper.findByTestId('resources-group-tab'); const findLoadingIcons = () => wrapper.findAllComponents(GlLoadingIcon); const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click'); @@ -52,9 +52,9 @@ describe('Catalog Tabs', () => { expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.resourceCounts.all}`); }); - it('renders your resources tab with count', () => { - expect(trimText(findYourResourcesTab().text())).toBe( - `Your resources ${defaultProps.resourceCounts.namespaces}`, + it('renders group resources tab with count', () => { + expect(trimText(findGroupResourcesTab().text())).toBe( + `Your groups ${defaultProps.resourceCounts.namespaces}`, ); }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 28b264cede9..7ff488f7fcb 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -147,6 +147,31 @@ describe('Design note component', () => { it('should not display a dropdown if user does not have a permission to delete note', () => { expect(findDropdown().exists()).toBe(false); }); + + it('should not have a `Deleted user` header', () => { + expect(wrapper.text()).not.toContain('A deleted user'); + }); + }); + + describe('when note has no author', () => { + beforeEach(() => { + createComponent({ + props: { + note: { + ...note, + author: null, + }, + }, + }); + }); + + it('should not render author details', () => { + expect(findUserLink().exists()).toBe(false); + }); + + it('should render a `Deleted user` header', () => { + expect(wrapper.text()).toContain('A deleted user'); + }); }); describe('when user has a permission to edit note', () => { @@ -280,6 +305,7 @@ describe('Design note component', () => { ...note, userPermissions: { adminNote: true, + awardEmoji: false, }, }, }, @@ -297,6 +323,7 @@ describe('Design note component', () => { ...note, userPermissions: { adminNote: true, + awardEmoji: false, }, }, }, diff --git a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js index 07d8b4b8b3d..12677470127 100644 --- a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js +++ b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js @@ -1,55 +1,80 @@ -import { GlBadge, GlButton } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GlExperimentBadge } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { IndexMlModels } from '~/ml/model_registry/apps'; import ModelRow from '~/ml/model_registry/components/model_row.vue'; -import Pagination from '~/vue_shared/components/incubation/pagination.vue'; -import SearchBar from '~/ml/model_registry/components/search_bar.vue'; -import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '~/ml/model_registry/constants'; +import { MODEL_ENTITIES } from '~/ml/model_registry/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import EmptyState from '~/ml/model_registry/components/empty_state.vue'; import ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue'; -import { mockModels, startCursor, defaultPageInfo } from '../mock_data'; - -let wrapper; - -const createWrapper = (propsData = {}) => { - wrapper = shallowMountExtended(IndexMlModels, { - propsData: { - models: mockModels, - pageInfo: defaultPageInfo, - modelCount: 2, - createModelPath: 'path/to/create', - canWriteModelRegistry: false, - ...propsData, - }, - }); +import SearchableList from '~/ml/model_registry/components/searchable_list.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getModelsQuery from '~/ml/model_registry/graphql/queries/get_models.query.graphql'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { modelsQuery, modelWithOneVersion, modelWithoutVersion } from '../graphql_mock_data'; + +Vue.use(VueApollo); + +const defaultProps = { + projectPath: 'path/to/project', + createModelPath: 'path/to/create', + canWriteModelRegistry: false, }; -const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index); -const findPagination = () => wrapper.findComponent(Pagination); -const findEmptyState = () => wrapper.findComponent(EmptyState); -const findSearchBar = () => wrapper.findComponent(SearchBar); -const findTitleArea = () => wrapper.findComponent(TitleArea); -const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem); -const findBadge = () => wrapper.findComponent(GlBadge); -const findCreateButton = () => findTitleArea().findComponent(GlButton); -const findActionsDropdown = () => wrapper.findComponent(ActionsDropdown); - describe('ml/model_registry/apps/index_ml_models', () => { - describe('empty state', () => { - beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo })); + let wrapper; + let apolloProvider; + + const createWrapper = ({ + props = {}, + resolver = jest.fn().mockResolvedValue(modelsQuery()), + } = {}) => { + const requestHandlers = [[getModelsQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + + const propsData = { + ...defaultProps, + ...props, + }; + + wrapper = mountExtended(IndexMlModels, { + apolloProvider, + propsData, + }); + }; - it('shows empty state', () => { - expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.model); + beforeEach(() => { + jest.spyOn(Sentry, 'captureException').mockImplementation(); + }); + + const emptyQueryResolver = () => jest.fn().mockResolvedValue(modelsQuery([])); + + const findAllRows = () => wrapper.findAllComponents(ModelRow); + const findRow = (index) => findAllRows().at(index); + const findEmptyState = () => wrapper.findComponent(EmptyState); + const findTitleArea = () => wrapper.findComponent(TitleArea); + const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem); + const findBadge = () => wrapper.findComponent(GlExperimentBadge); + const findCreateButton = () => wrapper.findByTestId('create-model-button'); + const findActionsDropdown = () => wrapper.findComponent(ActionsDropdown); + const findSearchableList = () => wrapper.findComponent(SearchableList); + + describe('header', () => { + beforeEach(() => { + createWrapper(); }); - it('does not show pagination', () => { - expect(findPagination().exists()).toBe(false); + it('displays the title', () => { + expect(findTitleArea().text()).toContain('Model registry'); }); - it('does not show search bar', () => { - expect(findSearchBar().exists()).toBe(false); + it('displays the experiment badge', () => { + expect(findBadge().props('helpPageUrl')).toBe( + '/help/user/project/ml/model_registry/index.md', + ); }); it('renders the extra actions button', () => { @@ -57,65 +82,155 @@ describe('ml/model_registry/apps/index_ml_models', () => { }); }); + describe('empty state', () => { + it('shows empty state', async () => { + createWrapper({ resolver: emptyQueryResolver() }); + + await waitForPromises(); + + expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.model); + }); + }); + describe('create button', () => { describe('when user has no permission to write model registry', () => { - it('does not display create button', () => { - createWrapper(); + it('does not display create button', async () => { + createWrapper({ resolver: emptyQueryResolver() }); + + await waitForPromises(); expect(findCreateButton().exists()).toBe(false); }); }); describe('when user has permission to write model registry', () => { - it('displays create button', () => { - createWrapper({ canWriteModelRegistry: true }); + it('displays create button', async () => { + createWrapper({ + props: { canWriteModelRegistry: true }, + resolver: emptyQueryResolver(), + }); + + await waitForPromises(); expect(findCreateButton().attributes().href).toBe('path/to/create'); }); }); }); + describe('when loading data fails', () => { + beforeEach(async () => { + const error = new Error('Failure!'); + + createWrapper({ resolver: jest.fn().mockRejectedValue(error) }); + + await waitForPromises(); + }); + + it('error message is displayed', () => { + expect(findSearchableList().props('errorMessage')).toBe( + 'Failed to load model with error: Failure!', + ); + }); + + it('error is logged in sentry', () => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + describe('with data', () => { - beforeEach(() => { + it('does not show empty state', async () => { createWrapper(); - }); + await waitForPromises(); - it('does not show empty state', () => { expect(findEmptyState().exists()).toBe(false); }); describe('header', () => { - it('displays the title', () => { - expect(findTitleArea().text()).toContain('Model registry'); + it('sets model metadata item to model count', async () => { + createWrapper(); + await waitForPromises(); + + expect(findModelCountMetadataItem().props('text')).toBe('2 models'); }); + }); - it('displays the experiment badge', () => { - expect(findBadge().attributes().href).toBe('/help/user/project/ml/model_registry/index.md'); + describe('shows models', () => { + beforeEach(async () => { + createWrapper(); + await waitForPromises(); }); - it('sets model metadata item to model count', () => { - expect(findModelCountMetadataItem().props('text')).toBe(`2 models`); + it('passes items to list', () => { + expect(findSearchableList().props('items')).toEqual([ + modelWithOneVersion, + modelWithoutVersion, + ]); }); - }); - it('adds a search bar', () => { - expect(findSearchBar().props()).toMatchObject({ sortableFields: BASE_SORT_FIELDS }); - }); + it('displays package version rows', () => { + expect(findAllRows()).toHaveLength(2); + }); + + it('binds the correct props', () => { + expect(findRow(0).props()).toMatchObject({ + model: expect.objectContaining(modelWithOneVersion), + }); - describe('model list', () => { - it('displays the models', () => { - expect(findModelRow(0).props('model')).toMatchObject(mockModels[0]); - expect(findModelRow(1).props('model')).toMatchObject(mockModels[1]); + expect(findRow(1).props()).toMatchObject({ + model: expect.objectContaining(modelWithoutVersion), + }); }); }); - describe('pagination', () => { - it('should show', () => { - expect(findPagination().exists()).toBe(true); + describe('when query is updated', () => { + let resolver; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue(modelsQuery()); + createWrapper({ resolver }); + }); + + it('when orderBy or sort are not present, use default value', async () => { + findSearchableList().vm.$emit('fetch-page', { + after: 'eyJpZCI6IjIifQ', + first: 30, + }); + + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + fullPath: 'path/to/project', + first: 30, + name: undefined, + orderBy: 'CREATED_AT', + sort: 'DESC', + after: 'eyJpZCI6IjIifQ', + }), + ); }); - it('passes pagination to pagination component', () => { - expect(findPagination().props('startCursor')).toBe(startCursor); + it('when orderBy or sort present, updates filters', async () => { + findSearchableList().vm.$emit('fetch-page', { + after: 'eyJpZCI6IjIifQ', + first: 30, + orderBy: 'name', + sort: 'asc', + name: 'something', + }); + + await waitForPromises(); + + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ + fullPath: 'path/to/project', + first: 30, + name: 'something', + orderBy: 'NAME', + sort: 'ASC', + after: 'eyJpZCI6IjIifQ', + }), + ); }); }); }); diff --git a/spec/frontend/ml/model_registry/components/model_row_spec.js b/spec/frontend/ml/model_registry/components/model_row_spec.js index 09729292355..02359949f5a 100644 --- a/spec/frontend/ml/model_registry/components/model_row_spec.js +++ b/spec/frontend/ml/model_registry/components/model_row_spec.js @@ -1,38 +1,45 @@ -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlTruncate } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; import ModelRow from '~/ml/model_registry/components/model_row.vue'; -import { mockModels, modelWithoutVersion } from '../mock_data'; +import { modelWithOneVersion, modelWithVersions, modelWithoutVersion } from '../graphql_mock_data'; let wrapper; -const createWrapper = (model = mockModels[0]) => { +const createWrapper = (model = modelWithVersions) => { wrapper = shallowMountExtended(ModelRow, { propsData: { model } }); }; -const findTitleLink = () => wrapper.findAllComponents(GlLink).at(0); -const findVersionLink = () => wrapper.findAllComponents(GlLink).at(1); +const findListItem = () => wrapper.findComponent(ListItem); +const findTitleLink = () => findListItem().findAllComponents(GlLink).at(0); +const findTruncated = () => findTitleLink().findComponent(GlTruncate); +const findVersionLink = () => findListItem().findAllComponents(GlLink).at(1); const findMessage = (message) => wrapper.findByText(message); describe('ModelRow', () => { it('Has a link to the model', () => { createWrapper(); - expect(findTitleLink().text()).toBe(mockModels[0].name); - expect(findTitleLink().attributes('href')).toBe(mockModels[0].path); + expect(findTruncated().props('text')).toBe(modelWithVersions.name); + expect(findTitleLink().attributes('href')).toBe(modelWithVersions._links.showPath); }); it('Shows the latest version and the version count', () => { createWrapper(); - expect(findVersionLink().text()).toBe(mockModels[0].version); - expect(findVersionLink().attributes('href')).toBe(mockModels[0].versionPath); - expect(findMessage('· 3 versions').exists()).toBe(true); + expect(findVersionLink().text()).toBe(modelWithVersions.latestVersion.version); + expect(findVersionLink().attributes('href')).toBe( + modelWithVersions.latestVersion._links.showPath, + ); + expect(findMessage('· 2 versions').exists()).toBe(true); }); it('Shows the latest version and no version count if it has only 1 version', () => { - createWrapper(mockModels[1]); + createWrapper(modelWithOneVersion); - expect(findVersionLink().text()).toBe(mockModels[1].version); - expect(findVersionLink().attributes('href')).toBe(mockModels[1].versionPath); + expect(findVersionLink().text()).toBe(modelWithOneVersion.latestVersion.version); + expect(findVersionLink().attributes('href')).toBe( + modelWithOneVersion.latestVersion._links.showPath, + ); expect(findMessage('· 1 version').exists()).toBe(true); }); diff --git a/spec/frontend/ml/model_registry/components/searchable_list_spec.js b/spec/frontend/ml/model_registry/components/searchable_list_spec.js index ea58a9a830a..67c61a0d583 100644 --- a/spec/frontend/ml/model_registry/components/searchable_list_spec.js +++ b/spec/frontend/ml/model_registry/components/searchable_list_spec.js @@ -3,6 +3,9 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SearchableList from '~/ml/model_registry/components/searchable_list.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import { BASE_SORT_FIELDS } from '~/ml/model_registry/constants'; +import * as urlHelpers from '~/lib/utils/url_utility'; import { defaultPageInfo } from '../mock_data'; describe('ml/model_registry/components/searchable_list.vue', () => { @@ -14,12 +17,23 @@ describe('ml/model_registry/components/searchable_list.vue', () => { const findEmptyState = () => wrapper.findByTestId('empty-state-slot'); const findFirstRow = () => wrapper.findByTestId('element'); const findRows = () => wrapper.findAllByTestId('element'); + const findSearch = () => wrapper.findComponent(RegistrySearch); + + const expectedFirstPage = { + after: 'eyJpZCI6IjIifQ', + first: 30, + last: null, + orderBy: 'created_at', + sort: 'desc', + }; const defaultProps = { items: ['a', 'b', 'c'], pageInfo: defaultPageInfo, isLoading: false, errorMessage: '', + showSearch: false, + sortableFields: [], }; const mountComponent = (props = {}) => { @@ -143,6 +157,12 @@ describe('ml/model_registry/components/searchable_list.vue', () => { describe('when user interacts with pagination', () => { beforeEach(() => mountComponent()); + it('when it is created emits fetch-page to get first page', () => { + mountComponent({ showSearch: true, sortableFields: BASE_SORT_FIELDS }); + + expect(wrapper.emitted('fetch-page')).toEqual([[expectedFirstPage]]); + }); + it('when list emits next-page emits fetchPage with correct pageInfo', () => { findRegistryList().vm.$emit('next-page'); @@ -150,9 +170,11 @@ describe('ml/model_registry/components/searchable_list.vue', () => { after: 'eyJpZCI6IjIifQ', first: 30, last: null, + orderBy: 'created_at', + sort: 'desc', }; - expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]); + expect(wrapper.emitted('fetch-page')).toEqual([[expectedFirstPage], [expectedNewPageInfo]]); }); it('when list emits prev-page emits fetchPage with correct pageInfo', () => { @@ -162,9 +184,77 @@ describe('ml/model_registry/components/searchable_list.vue', () => { before: 'eyJpZCI6IjE2In0', first: null, last: 30, + orderBy: 'created_at', + sort: 'desc', + }; + + expect(wrapper.emitted('fetch-page')).toEqual([[expectedFirstPage], [expectedNewPageInfo]]); + }); + }); + + describe('search', () => { + beforeEach(() => { + jest.spyOn(urlHelpers, 'updateHistory').mockImplementation(() => {}); + }); + + it('does not show search bar when showSearch is false', () => { + mountComponent({ showSearch: false }); + + expect(findSearch().exists()).toBe(false); + }); + + it('mounts search correctly', () => { + mountComponent({ showSearch: true, sortableFields: BASE_SORT_FIELDS }); + + expect(findSearch().props()).toMatchObject({ + filters: [], + sorting: { + orderBy: 'created_at', + sort: 'desc', + }, + sortableFields: BASE_SORT_FIELDS, + }); + }); + + it('on search submit, emits fetch-page with correct variables', () => { + mountComponent({ showSearch: true, sortableFields: BASE_SORT_FIELDS }); + + findSearch().vm.$emit('filter:submit'); + + const expectedVariables = { + orderBy: 'created_at', + sort: 'desc', + }; + + expect(wrapper.emitted('fetch-page')).toEqual([[expectedFirstPage], [expectedVariables]]); + }); + + it('on sorting changed, emits fetch-page with correct variables', () => { + mountComponent({ showSearch: true, sortableFields: BASE_SORT_FIELDS }); + + const orderBy = 'name'; + findSearch().vm.$emit('sorting:changed', { orderBy }); + + const expectedVariables = { + orderBy: 'name', + sort: 'desc', + }; + + expect(wrapper.emitted('fetch-page')).toEqual([[expectedFirstPage], [expectedVariables]]); + }); + + it('on direction changed, emits fetch-page with correct variables', () => { + mountComponent({ showSearch: true, sortableFields: BASE_SORT_FIELDS }); + + const sort = 'asc'; + findSearch().vm.$emit('sorting:changed', { sort }); + + const expectedVariables = { + orderBy: 'created_at', + sort: 'asc', }; - expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]); + expect(wrapper.emitted('fetch-page')).toEqual([[expectedFirstPage], [expectedVariables]]); }); }); }); diff --git a/spec/frontend/ml/model_registry/graphql_mock_data.js b/spec/frontend/ml/model_registry/graphql_mock_data.js index 27424fbf0df..b44963577bf 100644 --- a/spec/frontend/ml/model_registry/graphql_mock_data.js +++ b/spec/frontend/ml/model_registry/graphql_mock_data.js @@ -138,3 +138,64 @@ export const createModelResponses = { }, }, }; + +export const modelWithVersions = { + id: 'gid://gitlab/Ml::Model/1', + name: 'model_1', + versionCount: 2, + createdAt: '2023-12-06T12:41:48Z', + latestVersion: { + id: 'gid://gitlab/Ml::ModelVersion/1', + version: '1.0.0', + _links: { + showPath: '/my_project/-/ml/models/1/versions/1', + }, + }, + _links: { + showPath: '/my_project/-/ml/models/1', + }, +}; + +export const modelWithOneVersion = { + id: 'gid://gitlab/Ml::Model/2', + name: 'model_2', + versionCount: 1, + createdAt: '2023-12-06T12:41:48Z', + latestVersion: { + id: 'gid://gitlab/Ml::ModelVersion/1', + version: '1.0.0', + _links: { + showPath: '/my_project/-/ml/models/2/versions/1', + }, + }, + _links: { + showPath: '/my_project/-/ml/models/2', + }, +}; + +export const modelWithoutVersion = { + id: 'gid://gitlab/Ml::Model/3', + name: 'model_3', + versionCount: 0, + latestVersion: null, + createdAt: '2023-12-06T12:41:48Z', + _links: { + showPath: '/my_project/-/ml/models/3', + }, +}; + +export const modelsQuery = ( + models = [modelWithOneVersion, modelWithoutVersion], + pageInfo = graphqlPageInfo, +) => ({ + data: { + project: { + id: 'gid://gitlab/Project/1', + mlModels: { + count: models.length, + nodes: models, + pageInfo, + }, + }, + }, +}); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index 2b4c9604382..330eb0430f3 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -478,4 +478,23 @@ describe('Work Item Note', () => { expect(groupWorkItemResponseHandler).toHaveBeenCalled(); }); }); + + describe('when note has no author', () => { + beforeEach(() => { + createComponent({ + note: { + ...mockWorkItemCommentNote, + author: null, + }, + }); + }); + + it('should pass correct author prop to note header', () => { + expect(findNoteHeader().props('author')).toEqual({}); + }); + + it('should not allow assigning to comment author', () => { + expect(findNoteActions().props('showAssignUnassign')).toBe(false); + }); + }); }); diff --git a/spec/graphql/resolvers/ml/find_models_resolver_spec.rb b/spec/graphql/resolvers/ml/find_models_resolver_spec.rb index ce85dd62515..b19d066c369 100644 --- a/spec/graphql/resolvers/ml/find_models_resolver_spec.rb +++ b/spec/graphql/resolvers/ml/find_models_resolver_spec.rb @@ -9,20 +9,21 @@ RSpec.describe Resolvers::Ml::FindModelsResolver, feature_category: :mlops do let_it_be(:project) { create(:project) } let_it_be(:models) { create_list(:ml_models, 2, project: project) } let_it_be(:model_in_another_project) { create(:ml_models) } - let_it_be(:user) { project.owner } + let_it_be(:owner) { project.owner } + let(:current_user) { owner } let(:args) { { name: 'model', orderBy: 'CREATED_AT', sort: 'desc', invalid: 'blah' } } let(:read_model_registry) { true } before do allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?) - .with(user, :read_model_registry, project) + .with(current_user, :read_model_registry, project) .and_return(read_model_registry) end subject(:resolve_models) do - force(resolve(described_class, obj: project, ctx: { current_user: user }, args: args))&.to_a + force(resolve(described_class, obj: project, ctx: { current_user: current_user }, args: args))&.to_a end context 'when user is allowed and model exists' do @@ -36,6 +37,19 @@ sort: 'desc' }) resolve_models end + + context 'when user is nil' do + let(:current_user) { nil } + + it 'processes the request' do + expect(::Projects::Ml::ModelFinder).to receive(:new) + .with(project, { name: 'model', order_by: 'created_at', + sort: 'desc' }) + .and_call_original + + resolve_models + end + end end context 'when user does not have permission' do diff --git a/spec/graphql/resolvers/ml/model_detail_resolver_spec.rb b/spec/graphql/resolvers/ml/model_detail_resolver_spec.rb index 1da208eb4d8..a992f2dedd5 100644 --- a/spec/graphql/resolvers/ml/model_detail_resolver_spec.rb +++ b/spec/graphql/resolvers/ml/model_detail_resolver_spec.rb @@ -8,22 +8,29 @@ RSpec.describe Resolvers::Ml::ModelDetailResolver, feature_category: :mlops do describe '#resolve' do let_it_be(:project) { create(:project) } let_it_be(:model) { create(:ml_models, project: project) } - let_it_be(:user) { project.owner } + let_it_be(:owner) { project.owner } + let(:current_user) { owner } let(:args) { { id: global_id_of(model) } } let(:read_model_registry) { true } + subject { force(resolve(described_class, ctx: { current_user: current_user }, args: args)) } + before do allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?) - .with(user, :read_model_registry, project) + .with(current_user, :read_model_registry, project) .and_return(read_model_registry) end - subject { force(resolve(described_class, ctx: { current_user: user }, args: args)) } - context 'when user is allowed and model exists' do it { is_expected.to eq(model) } + + context 'when user is nil' do + let(:current_user) { nil } + + it { is_expected.to eq(model) } + end end context 'when user does not have permission' do diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index bf6b5ec5173..aab63ea0f70 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -485,6 +485,7 @@ RSpec.describe AvatarsHelper, feature_category: :source_code_management do gl-avatar-s32\s+ gl-avatar-circle\s+ gl-mr-3\s+ + gl-rounded-base!\s+ gl-avatar-identicon\s+ gl-avatar-identicon-bg\d+"\s*> \s*F\s* diff --git a/spec/helpers/projects/ml/model_registry_helper_spec.rb b/spec/helpers/projects/ml/model_registry_helper_spec.rb new file mode 100644 index 00000000000..2180d4388ca --- /dev/null +++ b/spec/helpers/projects/ml/model_registry_helper_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rspec' + +require 'spec_helper' +require 'mime/types' + +RSpec.describe Projects::Ml::ModelRegistryHelper, feature_category: :mlops do + let_it_be(:project) { build_stubbed(:project) } + let_it_be(:user) { project.owner } + + describe '#index_ml_model_data' do + subject(:parsed) { Gitlab::Json.parse(helper.index_ml_model_data(project, user)) } + + it 'generates the correct data' do + is_expected.to eq({ + 'projectPath' => project.full_path, + 'createModelPath' => "/#{project.full_path}/-/ml/models/new", + 'canWriteModelRegistry' => true, + 'mlflowTrackingUrl' => "http://localhost/api/v4/projects/#{project.id}/ml/mlflow/api/2.0/mlflow/" + }) + end + + context 'when user does not have write access to model registry' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(user, :write_model_registry, project) + .and_return(false) + end + + it 'canWriteModelRegistry is false' do + expect(parsed['canWriteModelRegistry']).to eq(false) + end + end + end +end diff --git a/spec/lib/gitlab/beyond_identity/client_spec.rb b/spec/lib/gitlab/beyond_identity/client_spec.rb new file mode 100644 index 00000000000..250db1bbb23 --- /dev/null +++ b/spec/lib/gitlab/beyond_identity/client_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::BeyondIdentity::Client, feature_category: :source_code_management do + let_it_be_with_reload(:integration) { create(:beyond_identity_integration) } + + let(:stubbed_response) do + { 'authorized' => true }.to_json + end + + let(:params) { { key_id: 'key-id', committer_email: 'email@example.com' } } + let(:status) { 200 } + + let!(:request) do + stub_request(:get, ::Gitlab::BeyondIdentity::Client::API_URL).with( + query: params, + headers: { 'Content-Type' => 'application/json', Authorization: "Bearer #{integration.token}" } + ).to_return( + status: status, + body: stubbed_response + ) + end + + subject(:client) { described_class.new(integration) } + + context 'when integration is not activated' do + it 'raises a config error' do + integration.active = false + + expect do + client.execute(params) + end.to raise_error(::Gitlab::BeyondIdentity::Client::Error).with_message( + 'integration is not activated' + ) + + expect(request).not_to have_been_requested + end + end + + it 'executes successfully' do + expect(client.execute(params)).to eq({ 'authorized' => true }) + expect(request).to have_been_requested + end + + context 'with invalid response' do + let(:stubbed_response) { 'invalid' } + + it 'executes successfully' do + expect { client.execute(params) }.to raise_error( + ::Gitlab::BeyondIdentity::Client::Error + ).with_message('invalid response format') + end + end + + context 'with an error response' do + let(:stubbed_response) do + { 'error' => { 'message' => 'gpg_key is invalid' } }.to_json + end + + let(:status) { 400 } + + it 'returns an error' do + expect { client.execute(params) }.to raise_error( + ::Gitlab::BeyondIdentity::Client::Error + ).with_message('gpg_key is invalid') + end + end + + context 'when key is unauthorized' do + let(:stubbed_response) do + { 'unauthorized' => false, 'message' => 'key is unauthorized' }.to_json + end + + it 'returns an error' do + expect { client.execute(params) }.to raise_error( + ::Gitlab::BeyondIdentity::Client::Error + ).with_message('authorization denied: key is unauthorized') + end + end +end diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb index 61f66c9cd0c..354ef377c94 100644 --- a/spec/lib/gitlab/git_spec.rb +++ b/spec/lib/gitlab/git_spec.rb @@ -68,4 +68,20 @@ RSpec.describe Gitlab::Git do end end end + + describe '.blank_ref?' do + using RSpec::Parameterized::TableSyntax + + where(:sha, :result) do + '4b825dc642cb6eb9a060e54bf8d69288fbee4904' | false + '0000000000000000000000000000000000000000' | true + '0000000000000000000000000000000000000000000000000000000000000000' | true + end + + with_them do + it 'returns the expected result' do + expect(described_class.blank_ref?(sha)).to eq(result) + end + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 9e83b7d85e9..acf39a02b28 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -585,6 +585,7 @@ project: - prometheus_integration - assembla_integration - asana_integration +- beyond_identity_integration - slack_integration - microsoft_teams_integration - mattermost_integration diff --git a/spec/lib/gitlab/instrumentation/connection_pool_spec.rb b/spec/lib/gitlab/instrumentation/connection_pool_spec.rb index b7cab2e9900..ce869659c67 100644 --- a/spec/lib/gitlab/instrumentation/connection_pool_spec.rb +++ b/spec/lib/gitlab/instrumentation/connection_pool_spec.rb @@ -4,6 +4,10 @@ require 'spec_helper' require 'support/helpers/rails_helpers' RSpec.describe Gitlab::Instrumentation::ConnectionPool, feature_category: :redis do + before do + ::ConnectionPool.prepend(::Gitlab::Instrumentation::ConnectionPool) + end + let(:option) { { name: 'test', size: 5 } } let(:pool) { ConnectionPool.new(option) { 'nothing' } } diff --git a/spec/lib/gitlab/patch/database_config_spec.rb b/spec/lib/gitlab/patch/database_config_spec.rb index 73452853050..eb987319160 100644 --- a/spec/lib/gitlab/patch/database_config_spec.rb +++ b/spec/lib/gitlab/patch/database_config_spec.rb @@ -143,6 +143,22 @@ RSpec.describe Gitlab::Patch::DatabaseConfig do end end + context 'when the parsed external command output returns invalid hash' do + before do + allow(Gitlab::Popen) + .to receive(:popen) + .and_return(["hello", 0]) + end + + it 'raises an error' do + expect { configuration.database_configuration } + .to raise_error( + Gitlab::Patch::DatabaseConfig::CommandExecutionError, + %r{database.yml: The output of `/opt/database-config.sh` must be a Hash, String given} + ) + end + end + context 'when the external command fails' do before do allow(Gitlab::Popen).to receive(:popen).and_return(["", 125]) diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index 3a96de4efe2..8e7729b1468 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -284,14 +284,29 @@ RSpec.describe Integration, feature_category: :integrations do describe '.find_or_initialize_all_non_project_specific' do shared_examples 'integration instances' do - it 'returns the available integration instances' do - expect(described_class.find_or_initialize_all_non_project_specific(described_class.for_instance).map(&:to_param)) - .to match_array(described_class.available_integration_names(include_project_specific: false)) - end + [false, true].each do |include_instance_specific| + context "with include_instance_specific value equal to #{include_instance_specific}" do + it 'returns the available integration instances' do + integrations = described_class.find_or_initialize_all_non_project_specific( + described_class.for_instance, include_instance_specific: include_instance_specific + ).map(&:to_param) + + expect(integrations).to match_array( + described_class.available_integration_names( + include_project_specific: false, + include_instance_specific: include_instance_specific) + ) + end - it 'does not create integration instances' do - expect { described_class.find_or_initialize_all_non_project_specific(described_class.for_instance) } - .not_to change(described_class, :count) + it 'does not create integration instances' do + expect do + described_class.find_or_initialize_all_non_project_specific( + described_class.for_instance, + include_instance_specific: include_instance_specific + ) + end.not_to change(described_class, :count) + end + end end end @@ -990,6 +1005,7 @@ RSpec.describe Integration, feature_category: :integrations do allow(described_class).to receive(:integration_names).and_return(%w[foo]) allow(described_class).to receive(:project_specific_integration_names).and_return(['bar']) allow(described_class).to receive(:dev_integration_names).and_return(['baz']) + allow(described_class).to receive(:instance_specific_integration_names).and_return(['instance-specific']) end it { is_expected.to include('foo', 'bar', 'baz') } @@ -997,16 +1013,23 @@ RSpec.describe Integration, feature_category: :integrations do context 'when `include_project_specific` is false' do subject { described_class.available_integration_names(include_project_specific: false) } - it { is_expected.to include('foo', 'baz') } + it { is_expected.to include('foo', 'baz', 'instance-specific') } it { is_expected.not_to include('bar') } end context 'when `include_dev` is false' do subject { described_class.available_integration_names(include_dev: false) } - it { is_expected.to include('foo', 'bar') } + it { is_expected.to include('foo', 'bar', 'instance-specific') } it { is_expected.not_to include('baz') } end + + context 'when `include_instance_specific` is false' do + subject { described_class.available_integration_names(include_instance_specific: false) } + + it { is_expected.to include('foo', 'baz', 'bar') } + it { is_expected.not_to include('instance-specific') } + end end describe '.project_specific_integration_names' do diff --git a/spec/models/integrations/beyond_identity_spec.rb b/spec/models/integrations/beyond_identity_spec.rb new file mode 100644 index 00000000000..29d428b5a03 --- /dev/null +++ b/spec/models/integrations/beyond_identity_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::BeyondIdentity, feature_category: :integrations do + subject(:integration) { create(:beyond_identity_integration) } + + describe 'validations' do + context 'when inactive' do + before do + integration.active = false + end + + it { is_expected.not_to validate_presence_of(:token) } + end + + context 'when active' do + it { is_expected.to validate_presence_of(:token) } + end + end + + describe 'attributes' do + it 'configures attributes' do + is_expected.not_to be_inheritable + expect(integration.supported_events).to be_blank + expect(integration.to_param).to eq('beyond_identity') + expect(integration.title).to eq('Beyond Identity') + + expect(integration.description).to eq( + 'Verify that GPG keys are authorized by Beyond Identity Authenticator.' + ) + + expect(integration.help).to include( + 'Verify that GPG keys are authorized by Beyond Identity Authenticator.' + ) + end + end + + describe '.api_fields' do + it 'returns api fields' do + expect(described_class.api_fields).to eq([{ + required: true, + name: :token, + type: String, + desc: 'API Token. User must have access to `git-commit-signing` endpoint.' + }]) + end + end + + describe '#execute' do + it 'performs a request to beyond identity service' do + params = { key_id: 'key-id', committer_email: 'email' } + response = 'response' + + expect_next_instance_of(::Gitlab::BeyondIdentity::Client) do |instance| + expect(instance).to receive(:execute).with(params).and_return(response) + end + + expect(integration.execute(params)).to eq(response) + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 1743c9bd89d..07ec10ab517 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -89,6 +89,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr it { is_expected.to have_one(:external_wiki_integration) } it { is_expected.to have_one(:confluence_integration) } it { is_expected.to have_one(:gitlab_slack_application_integration) } + it { is_expected.to have_one(:beyond_identity_integration) } it { is_expected.to have_one(:project_feature) } it { is_expected.to have_one(:project_repository) } it { is_expected.to have_one(:container_expiration_policy) } @@ -6619,6 +6620,14 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr ] end end + + context 'with instance specific integration' do + it 'does not contain instance specific integrations' do + expect(subject.find_or_initialize_integrations).not_to include( + have_attributes(title: 'Beyond Identity') + ) + end + end end describe '#disabled_integrations' do @@ -6695,6 +6704,12 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr expect(subject.find_or_initialize_integration('prometheus').api_url).to be_nil end end + + context 'with instance specific integrations' do + it 'does not create an instance specific integration' do + expect(subject.find_or_initialize_integration('beyond_identity')).to be_nil + end + end end describe '.for_group' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index ca2ee447b4c..f0418992ff2 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -4018,7 +4018,7 @@ RSpec.describe Repository, feature_category: :source_code_management do end context 'for SHA256 repository' do - let(:project) { create(:project, :empty_repo, object_format: Repository::FORMAT_SHA256) } + let_it_be(:project) { create(:project, :empty_repo, object_format: Repository::FORMAT_SHA256) } it { is_expected.to eq('sha256') } end @@ -4030,6 +4030,81 @@ RSpec.describe Repository, feature_category: :source_code_management do it { is_expected.to be_nil } end + + context 'caching', :request_store, :clean_gitlab_redis_cache do + let(:cache_key) { "object_format:#{repository.full_path}" } + let(:request_store_cache) { repository.__send__(:request_store_cache) } + + it 'only calls out to Gitaly once' do + expect(repository.raw).to receive(:object_format).once + + 2.times { repository.object_format } + end + + it 'calls out to Gitaly again after expiration' do + expect(repository.raw).to receive(:object_format).once + + repository.object_format + + request_store_cache.expire(cache_key) + + expect(repository.raw).to receive(:object_format).once + + 2.times { repository.object_format } + end + + it 'returns the value from the request store' do + request_store_cache.write(cache_key, Repository::FORMAT_SHA1) + + expect(repository.object_format).to eq(Repository::FORMAT_SHA1) + end + end + end + + describe '#blank_ref' do + subject { repository.blank_ref } + + context 'for existing repository' do + context 'for SHA1 repository' do + it { is_expected.to eq(::Gitlab::Git::SHA1_BLANK_SHA) } + end + + context 'for SHA256 repository' do + let_it_be(:project) { create(:project, :empty_repo, object_format: Repository::FORMAT_SHA256) } + + it { is_expected.to eq(::Gitlab::Git::SHA256_BLANK_SHA) } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(determine_blank_ref_based_on_gitaly_object_format: false) + end + + it { is_expected.to eq(::Gitlab::Git::SHA1_BLANK_SHA) } + + context 'for a SHA256 repository' do + let_it_be(:project) { create(:project, :empty_repo, object_format: Repository::FORMAT_SHA256) } + + it { is_expected.to eq(::Gitlab::Git::SHA1_BLANK_SHA) } + end + end + end + + context 'for missing repository' do + before do + allow(repository).to receive(:exists?).and_return(false) + end + + it { is_expected.to eq(::Gitlab::Git::SHA1_BLANK_SHA) } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(determine_blank_ref_based_on_gitaly_object_format: false) + end + + it { is_expected.to eq(::Gitlab::Git::SHA1_BLANK_SHA) } + end + end end describe '#get_file_attributes' do diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb index 4696be07045..36667022fc5 100644 --- a/spec/requests/api/integrations_spec.rb +++ b/spec/requests/api/integrations_spec.rb @@ -52,7 +52,7 @@ RSpec.describe API::Integrations, feature_category: :integrations do Integrations::Zentao.to_param ] - names = Integration.available_integration_names + names = Integration.available_integration_names(include_instance_specific: false) names.reject { |name| name.in?(unavailable_integration_names) } end diff --git a/spec/requests/projects/ml/models_controller_spec.rb b/spec/requests/projects/ml/models_controller_spec.rb index e469ee837bc..8f68d0c8d00 100644 --- a/spec/requests/projects/ml/models_controller_spec.rb +++ b/spec/requests/projects/ml/models_controller_spec.rb @@ -6,9 +6,6 @@ RSpec.describe Projects::Ml::ModelsController, feature_category: :mlops do let_it_be(:project) { create(:project) } let_it_be(:user) { project.first_owner } let_it_be(:model1) { create(:ml_models, :with_versions, project: project) } - let_it_be(:model2) { create(:ml_models, project: project) } - let_it_be(:model3) { create(:ml_models, project: project) } - let_it_be(:model_in_different_project) { create(:ml_models) } let(:read_model_registry) { true } let(:write_model_registry) { true } @@ -37,36 +34,6 @@ RSpec.describe Projects::Ml::ModelsController, feature_category: :mlops do expect(index_request).to render_template('projects/ml/models/index') end - it 'fetches the models using the finder' do - expect(::Projects::Ml::ModelFinder).to receive(:new).with(project, {}).and_call_original - - index_request - end - - it 'fetches the correct variables', :aggregate_failures do - stub_const("Projects::Ml::ModelsController::MAX_MODELS_PER_PAGE", 2) - - index_request - - page_models = [model3, model2] - all_models = [model3, model2, model1] - - expect(assigns(:paginator).records).to match_array(page_models) - expect(assigns(:model_count)).to be all_models.count - end - - it 'does not perform N+1 sql queries' do - list_models - - control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { list_models } - - create_list(:ml_model_versions, 2, model: model1) - create_list(:ml_model_versions, 2, model: model2) - create_list(:ml_models, 4, project: project) - - expect { list_models }.not_to exceed_all_query_limit(control_count) - end - context 'when user does not have access' do let(:read_model_registry) { false } @@ -74,40 +41,6 @@ RSpec.describe Projects::Ml::ModelsController, feature_category: :mlops do is_expected.to have_gitlab_http_status(:not_found) end end - - context 'with search params' do - let(:params) { { name: 'some_name', order_by: 'name', sort: 'asc' } } - - it 'passes down params to the finder' do - expect(Projects::Ml::ModelFinder).to receive(:new).and_call_original do |_exp, params| - expect(params.to_h).to include({ - name: 'some_name', - order_by: 'name', - sort: 'asc' - }) - end - - index_request - end - end - - describe 'pagination' do - before do - stub_const("Projects::Ml::ModelsController::MAX_MODELS_PER_PAGE", 2) - end - - it 'paginates', :aggregate_failures do - list_models - - paginator = assigns(:paginator) - - expect(paginator.records).to match_array([model3, model2]) - - list_models({ cursor: paginator.cursor_for_next_page }) - - expect(assigns(:paginator).records.first).to eq(model1) - end - end end describe 'show' do @@ -140,7 +73,7 @@ RSpec.describe Projects::Ml::ModelsController, feature_category: :mlops do end context 'when model project does not match project id' do - let(:request_project) { model_in_different_project.project } + let(:request_project) { create(:project) } it { is_expected.to have_gitlab_http_status(:not_found) } end diff --git a/spec/rubocop/cop/migration/async_post_migrate_only_spec.rb b/spec/rubocop/cop/migration/async_post_migrate_only_spec.rb new file mode 100644 index 00000000000..c883d3a6ddf --- /dev/null +++ b/spec/rubocop/cop/migration/async_post_migrate_only_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rubocop_spec_helper' +require_relative '../../../../rubocop/cop/migration/async_post_migrate_only' + +RSpec.describe RuboCop::Cop::Migration::AsyncPostMigrateOnly, feature_category: :database do + let(:sample_source) do + <<~RUBY + def up + %s + end + RUBY + end + + let(:forbidden_method_names) { described_class::FORBIDDEN_METHODS } + + context 'when outside of a migration' do + it 'does not register any offenses' do + forbidden_method_names.each do |method| + expect_no_offenses(format(sample_source, method.to_s)) + end + end + end + + context 'when in a migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + allow(cop).to receive(:time_enforced?).and_return(true) + end + + context 'when in a post deployment migration' do + before do + allow(cop).to receive(:in_post_deployment_migration?).and_return(true) + end + + it 'does not register any offenses' do + forbidden_method_names.each do |method| + expect_no_offenses(format(sample_source, method.to_s)) + end + end + end + + context 'when in a regular migration' do + it 'registers an offense' do + forbidden_method_names.each do |method| + expect_offense(<<~RUBY) + def up + #{method} + #{'^' * method.to_s.length} #{described_class::MSG} + end + RUBY + end + end + end + end +end diff --git a/spec/services/gpg_keys/create_service_spec.rb b/spec/services/gpg_keys/create_service_spec.rb index d603ce951ec..30fe52f732b 100644 --- a/spec/services/gpg_keys/create_service_spec.rb +++ b/spec/services/gpg_keys/create_service_spec.rb @@ -30,4 +30,18 @@ RSpec.describe GpgKeys::CreateService, feature_category: :source_code_management expect(gpg_key.subkeys.count).to eq(2) end end + + context 'invalid key' do + let(:params) { {} } + + it 'returns an invalid key' do + expect_next_instance_of(GpgKeys::ValidateIntegrationsService) do |instance| + expect(instance).to receive(:execute) + end + + gpg_key = subject.execute + + expect(gpg_key).not_to be_persisted + end + end end diff --git a/spec/services/gpg_keys/validate_integrations_service_spec.rb b/spec/services/gpg_keys/validate_integrations_service_spec.rb new file mode 100644 index 00000000000..41ca95f1b69 --- /dev/null +++ b/spec/services/gpg_keys/validate_integrations_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GpgKeys::ValidateIntegrationsService, feature_category: :source_code_management do + let_it_be(:user) { create(:user) } + + let(:gpg_key) { build(:gpg_key, user: user) } + + subject(:service) { described_class.new(gpg_key) } + + it 'returns true' do + expect(service.execute).to eq(true) + end + + context 'when key is invalid' do + it 'returns false' do + gpg_key.key = '' + + expect(service.execute).to eq(false) + end + end + + context 'when BeyondIdentity integration is not activated' do + let_it_be(:integration) { create(:beyond_identity_integration, active: false) } + + it 'return false' do + expect(::Gitlab::BeyondIdentity::Client).not_to receive(:new) + + expect(service.execute).to eq(true) + end + end + + context 'when BeyondIdentity integration is activated' do + let_it_be(:integration) { create(:beyond_identity_integration) } + + it 'returns true on successful check' do + expect_next_instance_of(::Gitlab::BeyondIdentity::Client) do |instance| + expect(instance).to receive(:execute).with( + { key_id: 'CCFBE19F00AC8B1D', committer_email: user.email } + ) + end + + expect(service.execute).to eq(true) + end + + it 'returns false and sets an error on unsuccessful check' do + error = 'service error' + + expect_next_instance_of(::Gitlab::BeyondIdentity::Client) do |instance| + expect(instance).to receive(:execute).with( + { key_id: 'CCFBE19F00AC8B1D', committer_email: user.email } + ).and_raise(::Gitlab::BeyondIdentity::Client::Error.new(error)) + end + + expect(service.execute).to eq(false) + expect(gpg_key.errors.full_messages).to eq(['BeyondIdentity: service error']) + end + end +end diff --git a/spec/support/shared_examples/redis/redis_shared_examples.rb b/spec/support/shared_examples/redis/redis_shared_examples.rb index b9179261f87..1c153b7c31b 100644 --- a/spec/support/shared_examples/redis/redis_shared_examples.rb +++ b/spec/support/shared_examples/redis/redis_shared_examples.rb @@ -185,6 +185,17 @@ RSpec.shared_examples "redis_shared_examples" do end end + context 'when the parsed external command output returns invalid hash' do + before do + allow(Gitlab::Popen).to receive(:popen).and_return(["hello", 0]) + end + + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Redis::Wrapper::CommandExecutionError, + %r{Redis: The output of `/opt/redis-config.sh` must be a Hash, String given}) + end + end + context 'when the command fails' do before do allow(Gitlab::Popen).to receive(:popen).and_return(["", 125]) diff --git a/spec/workers/click_house/event_paths_consistency_cron_worker_spec.rb b/spec/workers/click_house/event_paths_consistency_cron_worker_spec.rb index 76ce63ed2e4..e9efad169ff 100644 --- a/spec/workers/click_house/event_paths_consistency_cron_worker_spec.rb +++ b/spec/workers/click_house/event_paths_consistency_cron_worker_spec.rb @@ -96,18 +96,20 @@ RSpec.describe ClickHouse::EventPathsConsistencyCronWorker, feature_category: :v stub_const("#{ClickHouse::Concerns::ConsistencyWorker}::POSTGRESQL_BATCH_SIZE", 1) expect(worker).to receive(:log_extra_metadata_on_done).with(:result, - { status: :modification_limit_reached, modifications: 2 }) + { status: :limit_reached, modifications: 1 }) worker.perform paths = [ "#{namespace1.id}/", "#{namespace2.traversal_ids.join('/')}/", - "#{namespace_with_updated_parent.traversal_ids.join('/')}/" + "#{namespace_with_updated_parent.traversal_ids.join('/')}/", + "#{deleted_namespace_id}/" ] expect(leftover_paths).to match_array(paths) - expect(ClickHouse::SyncCursor.cursor_for(:event_namespace_paths_consistency_check)).to eq(deleted_namespace_id) + expect(ClickHouse::SyncCursor.cursor_for(:event_namespace_paths_consistency_check)) + .to eq(namespace_with_updated_parent.id) end end diff --git a/spec/workers/projects/git_garbage_collect_worker_spec.rb b/spec/workers/projects/git_garbage_collect_worker_spec.rb index 7daee107a74..259943af2b5 100644 --- a/spec/workers/projects/git_garbage_collect_worker_spec.rb +++ b/spec/workers/projects/git_garbage_collect_worker_spec.rb @@ -81,24 +81,6 @@ RSpec.describe Projects::GitGarbageCollectWorker, feature_category: :source_code expect(project.lfs_objects.reload).to include(lfs_object) end - - context 'when feature flag "reorder_garbage_collection_calls" is disabled' do - before do - stub_feature_flags(reorder_garbage_collection_calls: false) - end - - it 'cleans up unreferenced LFS object first' do - expect_next_instance_of(Gitlab::Cleanup::OrphanLfsFileReferences) do |svc| - expect(svc.project).to eq(project) - expect(svc.dry_run).to be_falsy - expect(svc).to receive(:run!).and_call_original - end - - expect { subject.perform(*params) }.to raise_error('Boom') - - expect(project.lfs_objects.reload).not_to include(lfs_object) - end - end end it 'catches and logs exceptions' do |