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>2024-01-19 21:09:33 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2024-01-19 21:09:33 +0300
commitd1be3e6f776e1c77976537548c1daa9af2fb2650 (patch)
tree387d3c8f06e18bbfa24a4b0b015a7245e166927c /spec
parent8f3a9dbb94b5a9ae4570a22bbc2a75e7572407c8 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/groups/settings/integrations_controller_spec.rb8
-rw-r--r--spec/factories/integrations.rb7
-rw-r--r--spec/features/admin/integrations/instance_integrations_spec.rb6
-rw-r--r--spec/frontend/ci/catalog/components/list/catalog_tabs_spec.js8
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js27
-rw-r--r--spec/frontend/ml/model_registry/apps/index_ml_models_spec.js243
-rw-r--r--spec/frontend/ml/model_registry/components/model_row_spec.js33
-rw-r--r--spec/frontend/ml/model_registry/components/searchable_list_spec.js94
-rw-r--r--spec/frontend/ml/model_registry/graphql_mock_data.js61
-rw-r--r--spec/frontend/work_items/components/notes/work_item_note_spec.js19
-rw-r--r--spec/graphql/resolvers/ml/find_models_resolver_spec.rb20
-rw-r--r--spec/graphql/resolvers/ml/model_detail_resolver_spec.rb15
-rw-r--r--spec/helpers/avatars_helper_spec.rb1
-rw-r--r--spec/helpers/projects/ml/model_registry_helper_spec.rb37
-rw-r--r--spec/lib/gitlab/beyond_identity/client_spec.rb81
-rw-r--r--spec/lib/gitlab/git_spec.rb16
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/instrumentation/connection_pool_spec.rb4
-rw-r--r--spec/lib/gitlab/patch/database_config_spec.rb16
-rw-r--r--spec/models/integration_spec.rb41
-rw-r--r--spec/models/integrations/beyond_identity_spec.rb62
-rw-r--r--spec/models/project_spec.rb15
-rw-r--r--spec/models/repository_spec.rb77
-rw-r--r--spec/requests/api/integrations_spec.rb2
-rw-r--r--spec/requests/projects/ml/models_controller_spec.rb69
-rw-r--r--spec/rubocop/cop/migration/async_post_migrate_only_spec.rb56
-rw-r--r--spec/services/gpg_keys/create_service_spec.rb14
-rw-r--r--spec/services/gpg_keys/validate_integrations_service_spec.rb60
-rw-r--r--spec/support/shared_examples/redis/redis_shared_examples.rb11
-rw-r--r--spec/workers/click_house/event_paths_consistency_cron_worker_spec.rb8
-rw-r--r--spec/workers/projects/git_garbage_collect_worker_spec.rb18
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