Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-11-07 15:20:35 +00:00
parent da1268042d
commit bbc7c5d1d9
91 changed files with 1278 additions and 862 deletions

View File

@ -1 +1 @@
v0.0.14
v0.0.15

View File

@ -119,7 +119,6 @@ export default {
<template v-if="hasClosingMergeRequest && !isFetchingMergeRequests" #description>
{{ closingMergeRequestsText }}
</template>
<ul class="content-list related-items-list !-gl-mx-3 !gl-my-3">
<li v-for="mr in mergeRequests" :key="mr.id" class="!gl-border-b-0 !gl-py-0">
<related-issuable-item

View File

@ -203,7 +203,7 @@ export default {
},
i18n: {
createModelVersionLinkTitle: s__('MlModelRegistry|Create new version'),
editModelButtonLabel: s__('MlModelRegistry|Edit model'),
editModelButtonLabel: s__('MlModelRegistry|Edit'),
tabModelCardTitle: s__('MlModelRegistry|Model card'),
tabVersionsTitle: s__('MlModelRegistry|Versions'),
versionCountTitle: s__('MlModelRegistry|Total versions'),
@ -249,7 +249,6 @@ export default {
<gl-button
v-if="canWriteModelRegistry"
data-testid="edit-model-button"
variant="confirm"
:href="editModelPath"
>{{ $options.i18n.editModelButtonLabel }}</gl-button
>
@ -305,9 +304,9 @@ export default {
</gl-link>
</div>
</div>
<div class="gl-mt-5">
<div v-if="showModelLatestVersion" class="gl-mt-5" data-testid="latest-version-label">
<div class="gl-text-lg gl-font-bold">{{ $options.i18n.latestVersionTitle }}</div>
<div v-if="showModelLatestVersion" class="gl-pt-2 gl-text-gray-500">
<div class="gl-pt-2 gl-text-gray-500">
<gl-link
data-testid="sidebar-latest-version-link"
:href="model.latestVersion._links.showPath"

View File

@ -1,12 +1,11 @@
<script>
import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { visitUrlWithAlerts } from '~/lib/utils/url_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { semverRegex, noSpacesRegex } from '~/lib/utils/regexp';
import createModelVersionMutation from '../graphql/mutations/create_model_version.mutation.graphql';
import { noSpacesRegex } from '~/lib/utils/regexp';
import createModelMutation from '../graphql/mutations/create_model.mutation.graphql';
export default {
@ -18,7 +17,6 @@ export default {
GlForm,
GlFormGroup,
GlFormInput,
ImportArtifactZone: () => import('./import_artifact_zone.vue'),
},
inject: ['projectPath', 'maxAllowedFileSize', 'markdownPreviewPath'],
props: {
@ -31,57 +29,26 @@ export default {
data() {
return {
name: null,
version: null,
description: '',
versionDescription: '',
errorMessage: null,
modelData: null,
versionData: null,
markdownDocPath: helpPagePath('user/markdown'),
markdownEditorRestrictedToolBarItems: ['full-screen'],
importErrorsText: null,
};
},
computed: {
showImportArtifactZone() {
return this.version && this.name;
},
autocompleteDataSources() {
return gl.GfmAutoComplete?.dataSources;
},
modelNameIsValid() {
return this.name && noSpacesRegex.test(this.name);
},
isSemver() {
return semverRegex.test(this.version);
},
isVersionValid() {
return !this.version || this.isSemver;
},
submitButtonDisabled() {
return !this.isVersionValid || !this.modelNameIsValid;
},
validVersionFeedback() {
if (this.isSemver) {
return this.$options.i18n.versionValid;
}
return null;
return !this.modelNameIsValid;
},
modelNameDescription() {
return !this.name || this.modelNameIsValid ? this.$options.i18n.nameDescription : '';
},
versionDescriptionText() {
return !this.version ? this.$options.i18n.versionDescription : '';
},
importErrorsAlert() {
return {
id: 'import-artifact-alert',
variant: this.importErrorsText ? 'danger' : 'info',
message: this.importErrorsText
? `${this.$options.i18n.someFailed} ${this.importErrorsText}`
: this.$options.i18n.allSucceeded,
};
},
},
methods: {
async createModel() {
@ -95,18 +62,6 @@ export default {
});
return data;
},
async createModelVersion(modelGid) {
const { data } = await this.$apollo.mutate({
mutation: createModelVersionMutation,
variables: {
projectPath: this.projectPath,
modelId: modelGid,
version: this.version,
description: this.versionDescription,
},
});
return data;
},
async create() {
this.errorMessage = '';
try {
@ -118,25 +73,9 @@ export default {
if (modelErrors.length) {
this.errorMessage = modelErrors.join(', ');
this.modelData = null;
} else if (this.version) {
// Attempt creating a version if needed
if (!this.versionData) {
this.versionData = await this.createModelVersion(this.modelData.mlModelCreate.model.id);
}
const versionErrors = this.versionData?.mlModelVersionCreate?.errors || [];
if (versionErrors.length) {
this.errorMessage = versionErrors.join(', ');
this.versionData = null;
} else {
// Attempt importing model artifacts
const { showPath, importPath } =
this.versionData.mlModelVersionCreate.modelVersion._links;
await this.$refs.importArtifactZoneRef.uploadArtifact(importPath);
visitUrlWithAlerts(showPath, [this.importErrorsAlert]);
}
} else {
const { showPath } = this.modelData.mlModelCreate.model._links;
visitUrlWithAlerts(showPath, [this.importErrorsAlert]);
visitUrl(showPath);
}
} catch (error) {
Sentry.captureException(error);
@ -145,13 +84,9 @@ export default {
},
resetForm() {
this.name = null;
this.version = null;
this.description = '';
this.versionDescription = '';
this.errorMessage = null;
this.modelData = null;
this.versionData = null;
this.importErrorsText = null;
},
hideAlert() {
this.errorMessage = null;
@ -161,14 +96,6 @@ export default {
this.description = newText;
}
},
setVersionDescription(newVersionText) {
if (!this.isSubmitting) {
this.versionDescription = newVersionText;
}
},
onImportError(error) {
this.importErrorsText = error;
},
},
descriptionFormFieldProps: {
placeholder: s__('MlModelRegistry|Enter a model description'),
@ -176,36 +103,16 @@ export default {
name: 'model-description',
},
i18n: {
allSucceeded: s__('MlModelRegistry|Artifacts uploaded successfully.'),
someFailed: s__('MlModelRegistry|Artifact uploads completed with errors.'),
actionPrimaryText: s__('MlModelRegistry|Create'),
actionSecondaryText: __('Cancel'),
nameDescriptionLabel: s__('MlModelRegistry|Must be unique. May not contain spaces.'),
nameDescription: s__('MlModelRegistry|Example: my-model'),
nameInvalid: s__('MlModelRegistry|May not contain spaces.'),
namePlaceholder: s__('MlModelRegistry|Enter a model name'),
versionDescription: s__('MlModelRegistry|Example: 1.0.0'),
versionPlaceholder: s__('MlModelRegistry|Enter a semantic version'),
nameDescriptionPlaceholder: s__('MlModelRegistry|Enter a model description'),
versionDescriptionTitle: s__('MlModelRegistry|Version description'),
versionDescriptionLabel: s__(
'MlModelRegistry|Must be a semantic version. Leave blank to skip version creation.',
),
versionValid: s__('MlModelRegistry|Version is a valid semantic version.'),
versionInvalid: s__('MlModelRegistry|Must be a semantic version. Example: 1.0.0'),
versionDescriptionPlaceholder: s__('MlModelRegistry|Enter a version description'),
title: s__('MlModelRegistry|Create model, version & import artifacts'),
title: s__('MlModelRegistry|Create model'),
modelName: s__('MlModelRegistry|Model name'),
modelDescription: __('Model description'),
version: __('Version'),
uploadLabel: __('Upload artifacts'),
modelSuccessButVersionArtifactFailAlert: {
id: 'ml-model-success-version-artifact-failed',
message: s__(
'MlModelRegistry|Model has been created but version or artifacts could not be uploaded. Try creating model version.',
),
variant: 'warning',
},
optionalText: s__('MlModelRegistry|(Optional)'),
},
};
@ -256,65 +163,6 @@ export default {
@input="setDescription"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.version"
:label-description="$options.i18n.versionDescriptionLabel"
data-testid="versionGroupId"
label-for="versionId"
:state="isVersionValid"
:invalid-feedback="$options.i18n.versionInvalid"
:valid-feedback="validVersionFeedback"
:description="versionDescriptionText"
optional
:optional-text="$options.i18n.optionalText"
>
<gl-form-input
id="versionId"
v-model="version"
data-testid="versionId"
type="text"
:placeholder="$options.i18n.versionPlaceholder"
autocomplete="off"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.versionDescriptionTitle"
data-testid="versionDescriptionGroupId"
label-for="versionDescriptionId"
optional
:optional-text="$options.i18n.optionalText"
class="common-note-form gfm-form js-main-target-form new-note gl-grow"
>
<markdown-editor
ref="markdownEditor"
data-testid="versionDescriptionId"
:value="versionDescription"
enable-autocomplete
:autocomplete-data-sources="autocompleteDataSources"
:enable-content-editor="true"
:form-field-props="$options.descriptionFormFieldProps"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocPath"
:disable-attachments="disableAttachments"
:placeholder="$options.i18n.versionDescriptionPlaceholder"
:restricted-tool-bar-items="markdownEditorRestrictedToolBarItems"
@input="setVersionDescription"
/>
</gl-form-group>
<gl-form-group
v-if="showImportArtifactZone"
data-testid="importArtifactZoneLabel"
:label="$options.i18n.uploadLabel"
label-for="versionImportArtifactZone"
>
<import-artifact-zone
id="versionImportArtifactZone"
ref="importArtifactZoneRef"
class="gl-px-3 gl-py-0"
:submit-on-select="false"
@error="onImportError"
/>
</gl-form-group>
</gl-form>
<gl-alert v-if="errorMessage" data-testid="create-alert" variant="danger" @dismiss="hideAlert"

View File

@ -60,7 +60,7 @@ export default {
'MlModelRegistry|No description available. To add a description, click "Edit model" above.',
),
description: s__('MlModelRegistry|Use versions to track performance, parameters, and metadata'),
primaryText: s__('MlModelRegistry|Create model version'),
primaryText: s__('MlModelRegistry|Create new version'),
},
};
</script>

View File

@ -1,5 +1,5 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import relatedIssuableMixin from '~/issuable/mixins/related_issuable_mixin';
@ -7,6 +7,7 @@ export default {
name: 'IssueToken',
components: {
GlIcon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -101,23 +102,23 @@ export default {
{{ displayReference }}
</component>
</component>
<button
<gl-button
v-if="canRemove"
ref="removeButton"
v-gl-tooltip
:class="{
'issue-token-remove-button gl-flex gl-items-center gl-rounded-br-small gl-rounded-tr-small gl-border-0 gl-px-3 gl-text-gray-500':
'issue-token-remove-button !gl-rounded-l-none !gl-rounded-r-small gl-text-subtle':
isCondensed,
'btn btn-default': !isCondensed,
}"
:title="removeButtonLabel"
:aria-label="removeButtonLabel"
:disabled="removeDisabled"
data-testid="removeBtn"
type="button"
category="tertiary"
size="small"
icon="close"
@click="onRemoveRequest"
>
<gl-icon name="close" />
</button>
/>
</div>
</template>

View File

@ -73,10 +73,17 @@ export default {
required: false,
default: null,
},
persistCollapsedState: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
collapsed: false,
collapsed:
this.persistCollapsedState &&
localStorage.getItem(this.getLocalStorageKeyName()) === 'true',
isFormVisible: false,
};
},
@ -109,6 +116,9 @@ export default {
methods: {
toggleCollapse() {
this.collapsed = !this.collapsed;
if (this.persistCollapsedState) {
localStorage.setItem(this.getLocalStorageKeyName(), this.collapsed);
}
},
showForm() {
this.isFormVisible = true;
@ -126,6 +136,9 @@ export default {
this.showForm();
}
},
getLocalStorageKeyName() {
return `crud-collapse-${this.anchorId}`;
},
},
};
</script>

View File

@ -8,7 +8,7 @@ import {
GlFormSelect,
} from '@gitlab/ui';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { __, getPreferredLocales, s__, sprintf } from '~/locale';
import { getPreferredLocales, s__, sprintf } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { fetchPolicies } from '~/lib/graphql';
import { addHierarchyChild, setNewWorkItemCache } from '~/work_items/graphql/cache_utils';
@ -125,9 +125,11 @@ export default {
isRelatedToItem: true,
error: null,
workItemTypes: [],
selectedProjectFullPath: null,
selectedProjectFullPath: this.fullPath || null,
selectedWorkItemTypeId: null,
loading: false,
initialLoadingWorkItem: true,
initialLoadingWorkItemTypes: true,
showWorkItemTypeSelect: false,
};
},
@ -142,11 +144,14 @@ export default {
};
},
skip() {
return !this.fullPath || !this.selectedWorkItemTypeName;
return this.skipWorkItemQuery;
},
update(data) {
return data?.workspace?.workItem ?? {};
},
result() {
this.initialLoadingWorkItem = false;
},
error() {
this.error = i18n.fetchError;
},
@ -168,6 +173,7 @@ export default {
return data.workspace?.workItemTypes?.nodes;
},
async result() {
this.initialLoadingWorkItemTypes = false;
if (!this.workItemTypes?.length) {
return;
}
@ -204,7 +210,12 @@ export default {
return newWorkItemFullPath(this.fullPath, this.selectedWorkItemTypeName);
},
isLoading() {
return this.$apollo.queries.workItemTypes.loading || this.$apollo.queries.workItem.loading;
return (
this.initialLoadingWorkItemTypes || (this.initialLoadingWorkItem && !this.skipWorkItemQuery)
);
},
skipWorkItemQuery() {
return !this.fullPath || !this.selectedWorkItemTypeName;
},
hasWidgets() {
return this.workItem?.widgets?.length > 0;
@ -394,11 +405,6 @@ export default {
return;
}
if (this.showProjectSelector && !this.selectedProjectFullPath) {
this.error = __('Please select a project.');
return;
}
this.loading = true;
const workItemCreateInput = {

View File

@ -182,7 +182,7 @@ export default {
},
});
this.$emit('workItemCreated', workItem);
if (this.workItemTypes && this.workItemTypes[0]) {
if (this.workItemTypes && this.workItemTypes[0] && this.workItemTypeName) {
setNewWorkItemCache(
this.fullPath,
this.workItemTypes[0]?.widgetDefinitions,

View File

@ -217,10 +217,12 @@ export default {
<crud-component
v-if="hasDesignsAndVersions"
anchor-name="designs"
anchor-id="designs"
:title="s__('DesignManagement|Designs')"
data-testid="designs-root"
class="gl-mt-5"
is-collapsible
persist-collapsed-state
>
<template #count>
<design-version-dropdown :all-versions="allVersions" />

View File

@ -36,6 +36,7 @@ import {
I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL,
I18N_WORK_ITEM_ERROR_COPY_REFERENCE,
I18N_WORK_ITEM_ERROR_COPY_EMAIL,
I18N_WORK_ITEM_NEW_RELATED_ITEM,
TEST_ID_LOCK_ACTION,
TEST_ID_REPORT_ABUSE,
TEST_ID_NEW_RELATED_WORK_ITEM,
@ -226,6 +227,7 @@ export default {
I18N_WORK_ITEM_ERROR_COPY_EMAIL,
this.workItemType,
),
newRelatedItemLabel: sprintfWorkItem(I18N_WORK_ITEM_NEW_RELATED_ITEM, this.workItemType),
};
},
areYouSureDeleteMessage() {
@ -458,11 +460,11 @@ export default {
/>
<gl-disclosure-dropdown-item
v-if="canCreateRelatedItem && canUpdate && isEpic"
v-if="canCreateRelatedItem && canUpdate"
:data-testid="$options.newRelatedItemTestId"
@action="isCreateWorkItemModalVisible = true"
>
<template #list-item>{{ __('New related Epic') }}</template>
<template #list-item>{{ i18n.newRelatedItemLabel }}</template>
</gl-disclosure-dropdown-item>
<gl-disclosure-dropdown-item
@ -547,8 +549,10 @@ export default {
<create-work-item-modal
:visible="isCreateWorkItemModalVisible"
:related-item="relatedItemData"
:work-item-type-name="$options.WORK_ITEM_TYPE_ENUM_EPIC"
:work-item-type-name="workItemType.toUpperCase()"
:show-project-selector="!isEpic"
hide-button
@workItemCreated="$emit('workItemCreated')"
@hideModal="isCreateWorkItemModalVisible = false"
/>
</div>

View File

@ -431,6 +431,9 @@ export default {
}
},
methods: {
handleWorkItemCreated() {
this.$apollo.queries.workItem.refetch();
},
enableEditMode() {
this.editMode = true;
},
@ -764,6 +767,7 @@ export default {
@promotedToObjective="$emit('promotedToObjective', iid)"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@toggleReportAbuseModal="toggleReportAbuseModal"
@workItemCreated="handleWorkItemCreated"
/>
</div>
<gl-button

View File

@ -189,6 +189,8 @@ export default {
'.tippy-content .gl-new-dropdown-panel',
'#blocked-by-issues-modal',
'#open-children-warning-modal',
'#create-work-item-modal',
'#work-item-confirm-delete',
],
};
</script>

View File

@ -1,6 +1,7 @@
<script>
import { GlCollapsibleListbox } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import { __, s__ } from '~/locale';
import { STORAGE_KEY } from '~/super_sidebar/constants';
@ -64,7 +65,6 @@ export default {
result() {
this.selectedProject = this.findSelectedProject(this.selectedProjectFullPath);
},
debounce: SEARCH_DEBOUNCE,
},
},
computed: {
@ -130,6 +130,12 @@ export default {
return items;
},
},
created() {
this.debouncedSearch = debounce(this.handleSearch, SEARCH_DEBOUNCE);
},
beforeDestroy() {
this.debouncedSearch?.cancel();
},
methods: {
handleSearch(keyword) {
this.searchKey = keyword;
@ -215,7 +221,7 @@ export default {
:searching="projectsLoading"
fluid-width
class="gl-relative"
@search="handleSearch"
@search="debouncedSearch"
@select="handleSelect"
@shown="handleDropdownShow"
>

View File

@ -342,6 +342,7 @@ export default {
:anchor-id="widgetName"
:is-loading="isLoadingChildren && !fetchNextPageInProgress"
is-collapsible
persist-collapsed-state
data-testid="work-item-tree"
>
<template #count>

View File

@ -307,6 +307,7 @@ export default {
:title="$options.i18n.title"
:is-loading="isLoading"
is-collapsible
persist-collapsed-state
data-testid="work-item-relationships"
>
<template #count>

View File

@ -105,6 +105,7 @@ export const I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR = s__(
export const I18N_NEW_WORK_ITEM_BUTTON_LABEL = s__('WorkItem|New %{workItemType}');
export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}');
export const I18N_WORK_ITEM_NEW_RELATED_ITEM = s__('WorkItem|New related %{workItemType}');
export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}');
export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s');
export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__(

View File

@ -25,8 +25,10 @@ const updateWidget = (draftData, widgetType, newData, nodePath) => {
/** we have to make sure we do not pass values when custom types are introduced */
if (newData === undefined) return;
const widget = findWidget(widgetType, draftData.workspace.workItem);
set(widget, nodePath, newData);
if (draftData.workspace) {
const widget = findWidget(widgetType, draftData.workspace.workItem);
set(widget, nodePath, newData);
}
};
const updateRolledUpDatesWidget = (draftData, rolledUpDates) => {

View File

@ -74,19 +74,12 @@ class GroupsFinder < UnionFinder
# rubocop: disable CodeReuse/ActiveRecord
def groups_with_min_access_level
if Feature.enabled?(:cte_for_group_search_access_level, Feature.current_request)
inner_query = current_user
.groups
.where('members.access_level >= ?', params[:min_access_level])
.self_and_descendants
cte = Gitlab::SQL::CTE.new(:groups_with_min_access_level_cte, inner_query)
cte.apply_to(Group.where({}))
else
current_user
.groups
.where('members.access_level >= ?', params[:min_access_level])
.self_and_descendants
end
inner_query = current_user
.groups
.where('members.access_level >= ?', params[:min_access_level])
.self_and_descendants
cte = Gitlab::SQL::CTE.new(:groups_with_min_access_level_cte, inner_query)
cte.apply_to(Group.where({}))
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -19,6 +19,7 @@ module Ci
include EachBatch
include FastDestroyAll::Helpers
self.table_name = :p_ci_pipelines
self.primary_key = :id
self.sequence_name = :ci_pipelines_id_seq
@ -35,15 +36,13 @@ module Ci
INITIAL_PARTITION_VALUE = 100
SECOND_PARTITION_VALUE = 101
NEXT_PARTITION_VALUE = 102
ROUTING_FEATURE_FLAG = :pipelines_routing_table
paginates_per 15
sha_attribute :source_sha
sha_attribute :target_sha
query_constraints :id, :partition_id
partitionable scope: ->(pipeline) { Ci::Pipeline.current_partition_value(pipeline.project) },
through: { table: :p_ci_pipelines, flag: ROUTING_FEATURE_FLAG }
partitionable scope: ->(pipeline) { Ci::Pipeline.current_partition_value(pipeline.project) }, partitioned: true
# Ci::CreatePipelineService returns Ci::Pipeline so this is the only place
# where we can pass additional information from the service. This accessor

View File

@ -13,7 +13,12 @@ module CommitSignatures
validates :gpg_key_primary_keyid, presence: true
def signed_by_user
gpg_key&.user
return gpg_key.user if gpg_key
# system signed gpg keys may not have a gpg key in rails.
# instead take the user from the gpg signature.
User.find_by_any_email(gpg_key_user_email) if verified_system? && Feature.enabled?(
:check_for_mailmapped_commit_emails, project)
end
def type

View File

@ -14,7 +14,10 @@ module FileStoreMounter
define_method("update_#{file_field}_store") do
# The file.object_store is set during `uploader.store!` and `uploader.migrate!`
update_column("#{file_field}_store", public_send(file_field).object_store) # rubocop:disable GitlabSecurity/PublicSend
file_field_object_store = public_send(file_field).object_store # rubocop:disable GitlabSecurity/PublicSend
return if self["#{file_field}_store"] == file_field_object_store # update only if necessary
update_column("#{file_field}_store", file_field_object_store)
end
define_method("store_#{file_field}_now!") do

View File

@ -39,9 +39,7 @@ class ProjectFeature < ApplicationRecord
metrics_dashboard: Gitlab::Access::REPORTER,
container_registry: Gitlab::Access::REPORTER,
package_registry: Gitlab::Access::REPORTER,
environments: Gitlab::Access::REPORTER,
model_experiments: Gitlab::Access::REPORTER,
model_registry: Gitlab::Access::REPORTER
environments: Gitlab::Access::REPORTER
}.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze

View File

@ -60,6 +60,7 @@ class IssuePolicy < IssuablePolicy
rule { ~notes_widget_enabled }.policy do
prevent :create_note
prevent :read_note
prevent :admin_note
prevent :read_internal_note
prevent :set_note_created_at
prevent :mark_note_as_internal
@ -83,6 +84,8 @@ class IssuePolicy < IssuablePolicy
enable :read_note
end
rule { can?(:maintainer_access) }.enable :admin_note
rule { ~can?(:read_issue) }.policy do
prevent :create_note
prevent :read_note

View File

@ -1019,7 +1019,11 @@ class ProjectPolicy < BasePolicy
enable :read_namespace_catalog
end
rule { model_registry_enabled }.policy do
rule { public_project & model_registry_enabled }.policy do
enable :read_model_registry
end
rule { ~public_project & guest & model_registry_enabled }.policy do
enable :read_model_registry
end
@ -1027,7 +1031,11 @@ class ProjectPolicy < BasePolicy
enable :write_model_registry
end
rule { model_experiments_enabled }.policy do
rule { public_project & model_experiments_enabled }.policy do
enable :read_model_experiments
end
rule { ~public_project & guest & model_experiments_enabled }.policy do
enable :read_model_experiments
end

View File

@ -156,6 +156,14 @@ class GitlabUploader < CarrierWave::Uploader::Base
end
end
def check_remote_file_existence_on_upload?
true
end
def sync_model_object_store?
false
end
private
# Designed to be overridden by child uploaders that have a dynamic path

View File

@ -317,6 +317,7 @@ module ObjectStorage
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def object_store=(value)
@object_store = value || Store::LOCAL
model[store_serialization_column] = @object_store if sync_model_object_store? && persist_object_store?
@storage = storage_for(object_store)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
@ -504,7 +505,7 @@ module ObjectStorage
# instead of using custom upload directory,
# using tmp/cache makes this implementation way easier than it is today
CarrierWave::Storage::Fog::File.new(self, storage_for(Store::REMOTE), file_path).tap do |file|
raise RemoteStoreError, 'Missing file' unless file.exists?
raise RemoteStoreError, 'Missing file' if check_remote_file_existence_on_upload? && !file.exists?
# Remote stored file, we force to store on remote storage
self.object_store = Store::REMOTE

View File

@ -3,6 +3,7 @@
module VirtualRegistries
class CachedResponseUploader < GitlabUploader
include ObjectStorage::Concern
extend ::Gitlab::Utils::Override
storage_location :dependency_proxy
@ -16,6 +17,16 @@ module VirtualRegistries
dynamic_segment
end
override :check_remote_file_existence_on_upload?
def check_remote_file_existence_on_upload?
false
end
override :sync_model_object_store?
def sync_model_object_store?
true
end
private
def set_content_type(file)

View File

@ -3,25 +3,39 @@
module Releases
class ManageEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
data_consistency :always
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
data_consistency :sticky
feature_category :release_evidence
TIMEOUT_EXCEPTIONS = [ActiveRecord::StatementTimeout, ActiveRecord::ConnectionTimeoutError,
ActiveRecord::AdapterTimeout, ActiveRecord::LockWaitTimeout,
ActiveRecord::QueryCanceled].freeze
def perform
releases = Release.without_evidence.released_within_2hrs
releases.each do |release|
project = release.project
params = { tag: release.tag }
evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute
# perform_at released_at
::Releases::CreateEvidenceWorker.perform_async(release.id, evidence_pipeline&.id)
process_release(release)
rescue *TIMEOUT_EXCEPTIONS, StandardError => e
Gitlab::ErrorTracking.track_exception(
e,
release_id: release.id,
project_id: release.project_id
)
next
end
end
private
def process_release(release)
return unless release.project
evidence_pipeline = Releases::EvidencePipelineFinder.new(release.project, tag: release.tag).execute
# perform_at released_at
::Releases::CreateEvidenceWorker.perform_async(release.id, evidence_pipeline&.id)
end
end
end

View File

@ -1,9 +0,0 @@
---
name: cte_for_group_search_access_level
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/502465
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/171735
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/502657
milestone: '17.6'
group: group::tenant scale
type: gitlab_com_derisk
default_enabled: false

View File

@ -1,9 +0,0 @@
---
name: pipelines_routing_table
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/482674
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169438
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/499486
milestone: '17.6'
group: group::ci platform
type: gitlab_com_derisk
default_enabled: false

View File

@ -25,6 +25,7 @@ Gitlab::Database::Partitioning.register_models(
Ci::FinishedPipelineChSyncEvent,
Ci::JobAnnotation,
Ci::JobArtifact,
Ci::Pipeline,
Ci::PipelineConfig,
Ci::PipelineVariable,
Ci::RunnerManagerBuild,

View File

@ -31,6 +31,7 @@ if Gitlab::Metrics.enabled? && Gitlab::Runtime.application?
if Gitlab::Runtime.puma?
Gitlab::Metrics::RequestsRackMiddleware.initialize_metrics
Gitlab::Metrics::Middleware::PathTraversalCheck.initialize_slis!
Gitlab::Metrics::GlobalSearchSlis.initialize_slis!
elsif Gitlab::Runtime.sidekiq?
Gitlab::Metrics::GlobalSearchIndexingSlis.initialize_slis! if Gitlab.ee?

View File

@ -761,6 +761,8 @@
- 1
- - search_zoekt_index_over_watermark_event
- 1
- - search_zoekt_index_watermark_changed_event
- 1
- - search_zoekt_indexing_task
- 1
- - search_zoekt_initial_indexing_event

View File

@ -1,8 +1,9 @@
---
migration_job_name: BackfillDesignManagementRepositoriesNamespaceId
description: Backfills sharding key `design_management_repositories.namespace_id` from `projects`.
description: Backfills sharding key `design_management_repositories.namespace_id`
from `projects`.
feature_category: design_management
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/153150
milestone: '17.1'
queued_migration_version: 20240515155723
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20241105232537'

View File

@ -5,4 +5,4 @@ feature_category: code_review_workflow
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/155403
milestone: '17.1'
queued_migration_version: 20240605090456
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20241105232624'

View File

@ -5,7 +5,5 @@ feature_category: dependency_management
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/164399
milestone: '17.4'
queued_migration_version: 20240829182925
# Dependency list exports are scheduled for deletion 1 hour after they finish,
# so the table will be small on all instances. We can finalize the migration in 17.5.
finalize_after: '2024-09-19'
finalized_by: # version of the migration that finalized this BBM
finalized_by: 20241106181154

View File

@ -2,7 +2,6 @@
table_name: p_ci_pipelines
classes:
- Ci::Pipeline
- Ci::Pipeline::Partitioned
feature_categories:
- continuous_integration
description: Routing table for ci_pipelines

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeBackfillDesignManagementRepositoriesNamespaceId < Gitlab::Database::Migration[2.2]
milestone '17.6'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillDesignManagementRepositoriesNamespaceId',
table_name: :design_management_repositories,
column_name: :id,
job_arguments: [:namespace_id, :projects, :namespace_id, :project_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeBackfillDraftNotesProjectId < Gitlab::Database::Migration[2.2]
milestone '17.6'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillDraftNotesProjectId',
table_name: :draft_notes,
column_name: :id,
job_arguments: [:project_id, :merge_requests, :target_project_id, :merge_request_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class FinalizeBackfillProjectIdToDependencyListExports < Gitlab::Database::Migration[2.2]
milestone '17.6'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillProjectIdToDependencyListExports',
table_name: :dependency_list_exports,
column_name: :id,
job_arguments: [],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1 @@
8751aaee2fd437ce0ac0bc25ce474d447a2727de7c82762ca7b025a66fce8bf0

View File

@ -0,0 +1 @@
c7e983a2d607e5df01005bae3933b404b87c67194a14484ad70c688b4099201e

View File

@ -0,0 +1 @@
13593ca33500c36b36289eaf918d573c8a55aa9fd0878e88f430e7c5998fc8d2

View File

@ -5604,7 +5604,7 @@ Input type: `GroupAuditEventStreamingDestinationsCreateInput`
| <a id="mutationgroupauditeventstreamingdestinationscreateconfig"></a>`config` | [`JSON!`](#json) | Destination config. |
| <a id="mutationgroupauditeventstreamingdestinationscreategrouppath"></a>`groupPath` | [`ID!`](#id) | Group path. |
| <a id="mutationgroupauditeventstreamingdestinationscreatename"></a>`name` | [`String`](#string) | Destination name. |
| <a id="mutationgroupauditeventstreamingdestinationscreatesecrettoken"></a>`secretToken` | [`String!`](#string) | Secret token. |
| <a id="mutationgroupauditeventstreamingdestinationscreatesecrettoken"></a>`secretToken` | [`String`](#string) | Secret token. |
#### Fields
@ -6024,7 +6024,7 @@ Input type: `InstanceAuditEventStreamingDestinationsCreateInput`
| <a id="mutationinstanceauditeventstreamingdestinationscreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationinstanceauditeventstreamingdestinationscreateconfig"></a>`config` | [`JSON!`](#json) | Destination config. |
| <a id="mutationinstanceauditeventstreamingdestinationscreatename"></a>`name` | [`String`](#string) | Destination name. |
| <a id="mutationinstanceauditeventstreamingdestinationscreatesecrettoken"></a>`secretToken` | [`String!`](#string) | Secret token. |
| <a id="mutationinstanceauditeventstreamingdestinationscreatesecrettoken"></a>`secretToken` | [`String`](#string) | Secret token. |
#### Fields
@ -25616,6 +25616,7 @@ Represents an external destination to stream group level audit events.
| <a id="groupauditeventstreamingdestinationid"></a>`id` | [`ID!`](#id) | ID of the destination. |
| <a id="groupauditeventstreamingdestinationname"></a>`name` | [`String!`](#string) | Name of the external destination to send audit events to. |
| <a id="groupauditeventstreamingdestinationnamespacefilters"></a>`namespaceFilters` | [`[GroupAuditEventNamespaceFilter!]`](#groupauditeventnamespacefilter) | List of subgroup or project filters for the destination. |
| <a id="groupauditeventstreamingdestinationsecrettoken"></a>`secretToken` | [`String!`](#string) | Secret token for the destination, will be non-empty value only for http category. |
### `GroupDataTransfer`
@ -26056,6 +26057,7 @@ Represents an external destination to stream instance level audit events.
| <a id="instanceauditeventstreamingdestinationid"></a>`id` | [`ID!`](#id) | ID of the destination. |
| <a id="instanceauditeventstreamingdestinationname"></a>`name` | [`String!`](#string) | Name of the external destination to send audit events to. |
| <a id="instanceauditeventstreamingdestinationnamespacefilters"></a>`namespaceFilters` | [`[InstanceAuditEventNamespaceFilter!]`](#instanceauditeventnamespacefilter) | List of subgroup or project filters for the destination. |
| <a id="instanceauditeventstreamingdestinationsecrettoken"></a>`secretToken` | [`String!`](#string) | Secret token for the destination, will be non-empty value only for http category. |
### `InstanceExternalAuditEventDestination`
@ -41868,6 +41870,7 @@ Implementations:
| <a id="auditeventstreamingdestinationinterfaceeventtypefilters"></a>`eventTypeFilters` | [`[String!]!`](#string) | List of event type filters added for streaming. |
| <a id="auditeventstreamingdestinationinterfaceid"></a>`id` | [`ID!`](#id) | ID of the destination. |
| <a id="auditeventstreamingdestinationinterfacename"></a>`name` | [`String!`](#string) | Name of the external destination to send audit events to. |
| <a id="auditeventstreamingdestinationinterfacesecrettoken"></a>`secretToken` | [`String!`](#string) | Secret token for the destination, will be non-empty value only for http category. |
#### `BaseDiscussionInterface`

View File

@ -1,6 +1,6 @@
---
stage: Fulfillment
group: Subscription management
group: Provision
description: Seat assignment, GitLab Duo add-on
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
@ -14,9 +14,7 @@ DETAILS:
You can purchase GitLab Duo seats to give users in your organization access to more GitLab features. GitLab Duo is only available for Premium and Ultimate customers.
Access to features provided by GitLab Duo is managed through seat assignment. GitLab Duo can be assigned to any user in your group namespace or instance.
## Purchase GitLab Duo seats
You can purchase additional GitLab Duo Pro or GitLab Duo Enterprise seats for your group namespace or self-managed instance. After you complete the purchase, you must assign the seats to users so that they can use GitLab Duo.
## Purchase GitLab Duo
To purchase GitLab Duo Pro seats, you can use the Customers Portal, or you can contact the [GitLab Sales team](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/). To purchase GitLab Duo Enterprise, contact the [GitLab Sales team](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/).
@ -28,6 +26,44 @@ To purchase GitLab Duo Pro seats, you can use the Customers Portal, or you can c
1. From the **Payment method** dropdown list, select your payment method.
1. Select **Purchase seats**.
## Purchase additional GitLab Duo seats
You can purchase additional GitLab Duo Pro or GitLab Duo Enterprise seats for your group namespace or self-managed instance. After you complete the purchase, the seats are added to the total number of GitLab Duo seats in your subscription.
Prerequisites:
- You must purchase the GitLab Duo Pro or GitLab Duo Enterprise add-on.
### For GitLab.com
Prerequisites:
- You must have the Owner role.
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > Usage Quotas**.
1. Select the **GitLab Duo** tab.
1. Select **Add seats**.
1. In the Customers Portal, in the **Add additional seats** field, enter the number of seats. The amount
cannot be higher than the number of seats in the subscription associated with your group namespace.
1. In the **Billing information** section, select the payment method from the dropdown list.
1. Select the **Privacy Policy** and **Terms of Service** checkbox.
1. Select **Purchase seats**.
1. Select the **GitLab SaaS** tab and refresh the page.
### For self-managed and GitLab Dedicated
Prerequisites:
- You must be an administrator.
1. Sign in to the [GitLab Customers Portal](https://customers.gitlab.com/).
1. On the **GitLab Duo Pro** section of your subscription card select **Add seats**.
1. Enter the number of seats. The amount cannot be higher than the number of seats in the subscription.
1. Review the **Purchase summary** section.
1. From the **Payment method** dropdown list, select your payment method.
1. Select **Purchase seats**.
## Assign GitLab Duo seats
Prerequisites:
@ -41,6 +77,10 @@ After you purchase GitLab Duo, you can assign seats to users to grant access to
### For GitLab.com
Prerequisites:
- You must have the Owner role.
To use GitLab Duo features in any project or group, you must assign the user to a seat in at least one top-level group.
1. On the left sidebar, select **Search or go to** and find your group.
@ -80,8 +120,8 @@ You can assign or remove seats in bulk for multiple users.
### For GitLab.com
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > Usage Quotas**.
1. Select the **GitLab Duo** tab.
1. Select **Settings > GitLab Duo**.
1. On the bottom right, you can adjust the page display to show **50** or **100** items to increase the number of users available for selection.
1. Select the users to assign or remove seats for:
- To select multiple users, to the left of each user, select the checkbox.
- To select all, select the checkbox at the top of the table.
@ -91,21 +131,38 @@ You can assign or remove seats in bulk for multiple users.
### For self-managed
Administrators of self-managed instances can use a [Rake task](../raketasks/user_management.md#bulk-assign-users-to-gitlab-duo-pro) to assign or remove seats in bulk.
Prerequisites:
- You must be an administrator.
- You must have GitLab 17.5 or later.
1. On the left sidebar, at the bottom, select **Admin**.
1. Select **GitLab Duo**.
1. On the bottom right, you can adjust the page display to show **50** or **100** items to increase the number of users available for selection.
1. Select the users to assign or remove seats for:
- To select multiple users, to the left of each user, select the checkbox.
- To select all, select the checkbox at the top of the table.
1. Assign or remove seats:
- To assign seats, select **Assign seat**, then **Assign seats** to confirm.
- To remove users from seats, select **Remove seat**, then **Remove seats** to confirm.
1. To the right of the user, turn on the toggle to assign a GitLab Duo seat.
Administrators of self-managed instances can also use a [Rake task](../raketasks/user_management.md#bulk-assign-users-to-gitlab-duo-pro) to assign or remove seats in bulk.
## View assigned GitLab Duo users
Prerequisites:
- You must purchase a GitLab Duo add-on, or have an active GitLab Duo trial.
- For self-managed and GitLab Dedicated:
- The GitLab Duo Pro add-on is available in GitLab 16.8 and later.
- The GitLab Duo Enterprise add-on is only available in GitLab 17.3 and later.
After you purchase GitLab Duo, you can assign seats to users to grant access to the add-on.
### For GitLab.com
Prerequisites:
- You must have the Owner role.
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > GitLab Duo**.
1. From the filter bar, select **Assigned seat** and **Yes**.
@ -126,40 +183,7 @@ Prerequisites:
1. In **Subscription details**, to the right of **Last sync**, select
synchronize subscription (**{retry}**).
1. To filter by users assigned to a GitLab Duo seat, in the **Filter users** bar, select **Assigned seat**, then select **Yes**.
## Purchase additional GitLab Duo seats
You can purchase additional GitLab Duo Pro or GitLab Duo Enterprise seats for your group namespace or self-managed instance. After you complete the purchase, the seats are added to the total number of GitLab Duo seats in your subscription.
Prerequisites:
- You must purchase the GitLab Duo Pro or GitLab Duo Enterprise add-on.
### For GitLab.com
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Settings > Usage Quotas**.
1. Select the **GitLab Duo** tab.
1. Select **Add seats**.
1. In the Customers Portal, in the **Add additional seats** field, enter the number of seats. The amount
cannot be higher than the number of seats in the subscription associated with your group namespace.
1. In the **Billing information** section, select the payment method from the dropdown list.
1. Select the **Privacy Policy** and **Terms of Service** checkbox.
1. Select **Purchase seats**.
1. Select the **GitLab SaaS** tab and refresh the page.
### For self-managed and GitLab Dedicated
Prerequisites:
- You must be an administrator.
1. Sign in to the [GitLab Customers Portal](https://customers.gitlab.com/).
1. On the **GitLab Duo Pro** section of your subscription card select **Add seats**.
1. Enter the number of seats. The amount cannot be higher than the number of seats in the subscription.
1. Review the **Purchase summary** section.
1. From the **Payment method** dropdown list, select your payment method.
1. Select **Purchase seats**.
1. User list is filtered to only users assigned a GitLab Duo seat.
## Start GitLab Duo Pro trial

View File

@ -9,16 +9,13 @@ info: "To determine the technical writer assigned to the Stage/Group associated
Git is a [free and open source](https://git-scm.com/about/free-and-open-source)
distributed version control system. It handles projects of all sizes quickly and
efficiently, while providing support for rolling back changes when needed.
efficiently, and provides support for rolling back changes when needed.
GitLab is built on top of (and with) Git, and provides you a Git-based, fully-integrated
platform for software development. GitLab adds many powerful
[features](https://about.gitlab.com/features/) on top of Git to enhance your workflow.
| | | |
|:--------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------|:-|
| [**Getting started**](get_started.md)<br>Overview of how features fit together. | [**Install Git**](how_to_install_git/index.md)<br>Download, configuration, system requirements. | [**Tutorial: Create your first commit**](../../tutorials/make_first_git_commit/index.md)<br>Initial commit, Git basics, repository setup. |
| [**Clone a repository to your local machine**](clone.md)<br>Local repository, clone, remote repository, SSH. | [**Create a branch for your changes**](branch.md)<br>Branching, branch switch, checkout. | [**Add files to your branch**](add_files.md)<br>Git add, staging changes, file management, commits. |
| [**Stash changes for later**](stash.md)<br>Temporary storage, work in progress, context switching. | [**Stage, commit, and push changes**](commit.md)<br>Staging area, commits, push changes to remote. | [**Undo changes**](undo.md)<br>Reverting commits, removing changes, Git reset, unstage. |
| [**Merge your branch into the main branch**](merge.md)<br>Create merge request, change review, merge. | [**Rebase to address merge conflicts**](git_rebase.md)<br>Conflict resolution, rebase, branch management. | [**Common Git commands**](commands.md)<br>Git cheatsheet, basic operations, command line. |
| [**Tutorial: Update Git remote URLs**](../../tutorials/update_git_remote_url/index.md)<br>Change the push/pull URL on a working copy. | [**Troubleshooting**](troubleshooting_git.md)<br>Error resolution, common issues, debugging, Git problems. | |
| | | |
|--|--|--|
| [**Getting started**](get_started.md)<br>Understand Git, install, common commands, tutorial. | [**Basic operations**](basics.md)<br>Create a project, clone a repository, stash changes, branches, forks. | [**Advanced operations**](advanced.md)<br>Rebase, cherry-pick, revert changes, repository and file management. |
| [**Troubleshooting**](troubleshooting_git.md)<br>Tips to resolve Git issues. | | |

View File

@ -33,7 +33,7 @@ To ensure your preferred tooling is supported, review the:
## Step 3: Enable Code Suggestions
First, [purchase seats for GitLab Duo](../../subscriptions/subscription-add-ons.md#purchase-gitlab-duo-seats).
First, [purchase seats for GitLab Duo](../../subscriptions/subscription-add-ons.md#purchase-gitlab-duo).
Then, assign seats to users to grant access to GitLab Duo for:

View File

@ -388,7 +388,7 @@ you can also do the following:
In addition to [turning on GitLab Duo features](turn_on_off.md#prerequisites),
you can also do the following:
1. Verify that [subscription seats have been purchased](../../subscriptions/subscription-add-ons.md#purchase-gitlab-duo-seats).
1. Verify that [subscription seats have been purchased](../../subscriptions/subscription-add-ons.md#purchase-gitlab-duo).
1. Ensure that [seats are assigned to users](../../subscriptions/subscription-add-ons.md#assign-gitlab-duo-seats).
1. For IDE users with the [GitLab Duo extension](../../user/project/repository/code_suggestions/supported_extensions.md#supported-editor-extensions):
- Verify that the extension is up-to-date.

View File

@ -183,9 +183,17 @@ Project permissions for [compliance](compliance/index.md) features including com
### Machine learning model registry and experiment
Access to the model registry is based on a combination of the user's access level for the project, the model registry, and to experiments.
Project permissions for [model registry](project/ml/model_registry/index.md) and [model experiments](project/ml/experiment_tracking/index.md).
More updates to this documentation are planned for GitLab 17.6.
| Action | Guest | Reporter | Developer | Maintainer | Owner | Notes |
|-------------------------------------------------------------------|:-----:|:--------:|:---------:|:----------:|:-----:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|
| View [models and versions](project/ml/model_registry/index.md) | ✓ | ✓ | ✓ | ✓ | ✓ | Non-members can only view models and versions in public projects with the **Everyone with access** visibility level. Non-members can't view internal projects, even if they're logged in. |
| View [model experiments](project/ml/experiment_tracking/index.md) | ✓ | ✓ | ✓ | ✓ | ✓ | Non-members can only view model experiments in public projects with the **Everyone with access** visibility level. Non-members can't view internal projects, even if they're logged in. |
| Create models, versions, and artifacts | | | ✓ | ✓ | ✓ | You can also upload and download artifacts with the package registry API, which uses it's own set of permissions. |
| Edit models, versions, and artifacts | | | ✓ | ✓ | ✓ | |
| Create experiments and candidates | | | ✓ | ✓ | ✓ | |
| Edit experiments and candidates | | | ✓ | ✓ | ✓ | |
| Delete experiments and candidates | | | ✓ | ✓ | ✓ | |
### Monitoring
@ -221,7 +229,6 @@ Project permissions for [issues](project/issues/index.md):
| Update metadata on issues | | ✓ | ✓ | ✓ | ✓ | Metadata includes labels, assignees, milestones, epics, weight, confidentiality, time tracking, and more.<br /><br />Guest users can only set metadata when creating an issue. They cannot change the metadata on existing issues. |
| Close / reopen issues | | ✓ | ✓ | ✓ | ✓ | Authors and assignees can close and reopen issues even if they don't have the Reporter role. |
| Manage [design management](project/issues/design_management.md) files | | ✓ | ✓ | ✓ | ✓ | |
| View email addresses of [external participants](project/service_desk/external_participants.md) | | ✓ | ✓ | ✓ | ✓ | Email addresses of external participants are obfuscated for Guest users and non-members. |
| Manage [issue boards](project/issue_board.md) | | ✓ | ✓ | ✓ | ✓ | |
| Manage [milestones](project/milestones/index.md) | | ✓ | ✓ | ✓ | ✓ | |
| Archive or reopen [requirements](project/requirements/index.md) | | ✓ | ✓ | ✓ | ✓ | Authors and assignees can archive and re-open even if they don't have the Reporter role. |

View File

@ -85,6 +85,12 @@ module API
status :ok
body ''
end
def ok_empty_response
status :ok
env['api.format'] = :binary # to return data as-is
body ''
end
end
after_validation do

View File

@ -150,7 +150,7 @@ module API
).execute
send_error_response_from!(service_response: service_response) if service_response.error?
status :ok
ok_empty_response
end
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -81,7 +81,7 @@ module Gitlab
gpg_key: gpg_key,
gpg_key_primary_keyid: gpg_key&.keyid || verified_signature&.fingerprint,
gpg_key_user_name: user_infos[:name],
gpg_key_user_email: user_infos[:email],
gpg_key_user_email: gpg_key_user_email(user_infos, verification_status),
verification_status: verification_status
}
end
@ -122,6 +122,13 @@ module Gitlab
GpgKey.find_by_primary_keyid(fingerprint) || GpgKeySubkey.find_by_keyid(fingerprint)
end
end
def gpg_key_user_email(user_infos, verification_status)
return user_infos[:email] unless Feature.enabled?(:check_for_mailmapped_commit_emails,
@commit.project) && verification_status == :verified_system
user_infos[:email] || author_email
end
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Gitlab
module Instrumentation
module Middleware
class PathTraversalCheck
DURATION_LABEL = :path_traversal_check_duration_s
def self.duration=(duration)
return unless Gitlab::SafeRequestStore.active?
::Gitlab::SafeRequestStore[DURATION_LABEL] = ::Gitlab::InstrumentationHelper.round_elapsed_time(0, duration)
end
def self.duration
::Gitlab::SafeRequestStore[DURATION_LABEL] || 0
end
def self.payload
{ DURATION_LABEL => duration }
end
end
end
end
end

View File

@ -19,6 +19,7 @@ module Gitlab
instrument_active_record(payload)
instrument_external_http(payload)
instrument_rack_attack(payload)
instrument_middleware_path_traversal_check(payload)
instrument_cpu(payload)
instrument_thread_memory_allocations(payload)
instrument_load_balancing(payload)
@ -148,6 +149,14 @@ module Gitlab
payload.merge!(Gitlab::Instrumentation::ExclusiveLock.payload)
end
def instrument_middleware_path_traversal_check(payload)
duration = ::Gitlab::Instrumentation::Middleware::PathTraversalCheck.duration
return if duration == 0
payload.merge!(::Gitlab::Instrumentation::Middleware::PathTraversalCheck.payload)
end
# Returns the queuing duration for a Sidekiq job in seconds, as a float, if the
# `enqueued_at` field or `created_at` field is available.
#

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
module Gitlab
module Metrics
module Middleware
class PathTraversalCheck
DURATION_APDEX_NAME = :path_traversal_check_request_duration_s
DURATION_APDEX_FEATURE_CATEGORY = { feature_category: :shared }.freeze
DURATION_APDEX_SLI_DEFINITION = [
DURATION_APDEX_NAME,
[
DURATION_APDEX_FEATURE_CATEGORY.merge(path_traversal_attempt_rejected: true),
DURATION_APDEX_FEATURE_CATEGORY.merge(path_traversal_attempt_rejected: false)
]
].freeze
DURATION_APDEX_THRESHOLD = 0.001.seconds
def self.initialize_slis!
Gitlab::Metrics::Sli::Apdex.initialize_sli(*DURATION_APDEX_SLI_DEFINITION)
end
def self.increment(labels:, duration:)
::Gitlab::Metrics::Sli::Apdex[DURATION_APDEX_NAME].increment(
labels: labels.merge(DURATION_APDEX_FEATURE_CATEGORY),
success: duration <= DURATION_APDEX_THRESHOLD
)
end
end
end
end
end

View File

@ -34,8 +34,8 @@ module Gitlab
def call(env)
return @app.call(env) unless Feature.enabled?(:check_path_traversal_middleware, Feature.current_request)
log_params = {}
request = ::Rack::Request.new(env.dup)
log_params = {}
return @app.call(env) unless path_traversal_attempt?(request, log_params)
@ -55,18 +55,26 @@ module Gitlab
private
def path_traversal_attempt?(request, log_params)
original_fullpath = request.fullpath
exclude_query_parameters(request)
with_duration_metric do |metric_labels|
original_fullpath = request.fullpath
exclude_query_parameters(request)
decoded_fullpath = CGI.unescape(request.fullpath)
decoded_fullpath = CGI.unescape(request.fullpath)
return false unless Gitlab::PathTraversal.path_traversal?(decoded_fullpath, match_new_line: false)
if Gitlab::PathTraversal.path_traversal?(decoded_fullpath, match_new_line: false)
metric_labels[:path_traversal_attempt_rejected] =
Feature.enabled?(:check_path_traversal_middleware_reject_requests, Feature.current_request)
log_params[:method] = request.request_method
log_params[:fullpath] = original_fullpath
log_params[:message] = PATH_TRAVERSAL_MESSAGE
log_params[:method] = request.request_method
log_params[:fullpath] = original_fullpath
log_params[:message] = PATH_TRAVERSAL_MESSAGE
true
true
else
metric_labels[:path_traversal_attempt_rejected] = false
false
end
end
end
def exclude_query_parameters(request)
@ -86,9 +94,22 @@ module Gitlab
end
def log(payload)
Gitlab::AppLogger.warn(
payload.merge(class_name: self.class.name)
)
::Gitlab::InstrumentationHelper.add_instrumentation_data(payload)
Gitlab::AppLogger.warn(payload.merge(class_name: self.class.name))
end
def with_duration_metric
result = nil
labels = {}
duration = Benchmark.realtime do
result = yield(labels)
end
::Gitlab::Instrumentation::Middleware::PathTraversalCheck.duration = duration
::Gitlab::Metrics::Middleware::PathTraversalCheck.increment(labels: labels, duration: duration)
result
end
end
end

View File

@ -22,11 +22,22 @@ module Gitlab
cached_signature = lazy_signature&.itself
# We need to update the cache if there is no user for a verified system commit.
# This is because of the introduction of mailmap. See https://gitlab.com/gitlab-org/gitlab/-/issues/425042#note_1997022896.
if cached_signature.present? && verified_system_user_missing?(cached_signature) && Feature.enabled?(
:check_for_mailmapped_commit_emails, @commit.project)
return @signature = update_signature!(cached_signature)
end
return @signature = cached_signature if cached_signature.present?
@signature = create_cached_signature!
end
def verified_system_user_missing?(cached_signature)
cached_signature.verified_system? && cached_signature.user.nil? && author_email.present?
end
def update_signature!(cached_signature)
cached_signature.update!(attributes)
@signature = cached_signature
@ -50,6 +61,12 @@ module Gitlab
end
end
def author_email
strong_memoize(:author_email) do
@signature_data.itself ? @signature_data[:author_email] : nil
end
end
private
def signature_class

View File

@ -10,14 +10,14 @@ module Gitlab
end
def attributes
signature = ::Gitlab::Ssh::Signature.new(signature_text, signed_text, signer, @commit)
signature = ::Gitlab::Ssh::Signature.new(signature_text, signed_text, signer, @commit, author_email)
{
commit_sha: @commit.sha,
project: @commit.project,
key_id: signature.signed_by_key&.id,
key_fingerprint_sha256: signature.key_fingerprint,
user_id: signature.signed_by_key&.user_id,
user_id: signature.user_id,
verification_status: signature.verification_status
}
end

View File

@ -11,12 +11,13 @@ module Gitlab
GIT_NAMESPACE = 'git'
def initialize(signature_text, signed_text, signer, commit)
def initialize(signature_text, signed_text, signer, commit, author_email)
@signature_text = signature_text
@signed_text = signed_text
@signer = signer
@commit = commit
@committer_email = commit.committer_email
@author_email = author_email
end
def verification_status
@ -47,9 +48,18 @@ module Gitlab
end
end
def user_id
if verification_status == :verified_system && Feature.enabled?(:check_for_mailmapped_commit_emails,
@commit.project)
return User.find_by_any_email(author_email)&.id
end
signed_by_key&.user_id
end
private
attr_reader :commit, :committer_email
attr_reader :commit, :committer_email, :author_email
def all_attributes_present?
# Signing an empty string is valid, but signature_text and committer_email

View File

@ -32876,6 +32876,9 @@ msgstr ""
msgid "Manage rules"
msgstr ""
msgid "Manage secret detection behavior for all projects in your GitLab instance"
msgstr ""
msgid "Manage two-factor authentication"
msgstr ""
@ -34927,9 +34930,6 @@ msgstr ""
msgid "MlModelRegistry|Create model version"
msgstr ""
msgid "MlModelRegistry|Create model, version & import artifacts"
msgstr ""
msgid "MlModelRegistry|Create new version"
msgstr ""
@ -34972,6 +34972,9 @@ msgstr ""
msgid "MlModelRegistry|Drop to start upload"
msgstr ""
msgid "MlModelRegistry|Edit"
msgstr ""
msgid "MlModelRegistry|Edit model"
msgstr ""
@ -34987,24 +34990,15 @@ msgstr ""
msgid "MlModelRegistry|Enter a model version description"
msgstr ""
msgid "MlModelRegistry|Enter a semantic version"
msgstr ""
msgid "MlModelRegistry|Enter a semantic version."
msgstr ""
msgid "MlModelRegistry|Enter a subfolder name to organize your artifacts."
msgstr ""
msgid "MlModelRegistry|Enter a version description"
msgstr ""
msgid "MlModelRegistry|Enter some description"
msgstr ""
msgid "MlModelRegistry|Example: 1.0.0"
msgstr ""
msgid "MlModelRegistry|Example: my-model"
msgstr ""
@ -35068,9 +35062,6 @@ msgstr ""
msgid "MlModelRegistry|Model deleted successfully"
msgstr ""
msgid "MlModelRegistry|Model has been created but version or artifacts could not be uploaded. Try creating model version."
msgstr ""
msgid "MlModelRegistry|Model name"
msgstr ""
@ -35089,15 +35080,9 @@ msgstr ""
msgid "MlModelRegistry|Must be a semantic version."
msgstr ""
msgid "MlModelRegistry|Must be a semantic version. Example: 1.0.0"
msgstr ""
msgid "MlModelRegistry|Must be a semantic version. Latest version is %{latestVersion}"
msgstr ""
msgid "MlModelRegistry|Must be a semantic version. Leave blank to skip version creation."
msgstr ""
msgid "MlModelRegistry|Must be unique. May not contain spaces."
msgstr ""
@ -35188,12 +35173,6 @@ msgstr ""
msgid "MlModelRegistry|Version created %{timeAgo} by %{author}"
msgstr ""
msgid "MlModelRegistry|Version description"
msgstr ""
msgid "MlModelRegistry|Version is a valid semantic version."
msgstr ""
msgid "MlModelRegistry|Version is not a valid semantic version."
msgstr ""
@ -36115,9 +36094,6 @@ msgstr ""
msgid "New related %{issueType}"
msgstr ""
msgid "New related Epic"
msgstr ""
msgid "New release"
msgstr ""
@ -41329,9 +41305,6 @@ msgstr ""
msgid "Please select a Jira project"
msgstr ""
msgid "Please select a project."
msgstr ""
msgid "Please select a valid target branch"
msgstr ""
@ -51898,6 +51871,9 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Settings for the License Compliance feature"
msgstr ""
msgid "Settings saved successfully."
msgstr ""
@ -62776,6 +62752,9 @@ msgstr ""
msgid "WorkItem|New %{workItemType}"
msgstr ""
msgid "WorkItem|New related %{workItemType}"
msgstr ""
msgid "WorkItem|New task"
msgstr ""

View File

@ -121,7 +121,7 @@ describe('IssueToken', () => {
});
it('emits event when clicked', () => {
findRemoveBtn().trigger('click');
findRemoveBtn().vm.$emit('click');
const emitted = wrapper.emitted(`${eventNamespace}RemoveRequest`);

View File

@ -72,6 +72,7 @@ describe('ml/model_registry/apps/show_ml_model', () => {
modelDetailsResolver = jest.fn().mockResolvedValue(modelDetailQuery),
destroyMutationResolver = jest.fn().mockResolvedValue(destroyModelResponses.success),
canWriteModelRegistry = true,
latestVersion = '1.0.0',
} = {}) => {
const requestHandlers = [
[getModelQuery, modelDetailsResolver],
@ -90,7 +91,7 @@ describe('ml/model_registry/apps/show_ml_model', () => {
mlflowTrackingUrl: 'path/to/tracking',
canWriteModelRegistry,
maxAllowedFileSize: 99999,
latestVersion: '1.0.0',
latestVersion,
markdownPreviewPath: '/markdown-preview',
createModelVersionPath: 'project/path/create/model/version',
},
@ -163,6 +164,7 @@ describe('ml/model_registry/apps/show_ml_model', () => {
beforeEach(() => createWrapper());
it('displays version creation button', () => {
expect(findModelVersionCreateButton().exists()).toBe(true);
expect(findModelVersionCreateButton().text()).toBe('Create new version');
});
@ -180,9 +182,9 @@ describe('ml/model_registry/apps/show_ml_model', () => {
it('displays model edit button', () => {
expect(findModelEditButton().props()).toMatchObject({
variant: 'confirm',
category: 'primary',
});
expect(findModelEditButton().text()).toBe('Edit');
});
describe('when user has no permission to write model registry', () => {
@ -323,11 +325,19 @@ describe('ml/model_registry/apps/show_ml_model', () => {
expect(findAvatar().props('src')).toBe('path/to/avatar');
});
it('displays sidebar latest version link', () => {
expect(findLatestVersionLink().attributes('href')).toBe(
'/root/test-project/-/ml/models/1/versions/5000',
);
expect(findLatestVersionLink().text()).toBe('1.0.4999');
describe('latest version', () => {
it('displays sidebar latest version link', () => {
expect(findLatestVersionLink().attributes('href')).toBe(
'/root/test-project/-/ml/models/1/versions/5000',
);
expect(findLatestVersionLink().text()).toBe('1.0.4999');
});
it('does not display sidebar latest version link when model does not have a latest version', () => {
createWrapper({ latestVersion: null });
expect(findLatestVersionLink().exists()).toBe(false);
expect(wrapper.findByTestId('latest-version-label').exists()).toBe(false);
});
});
it('displays sidebar version count', () => {

View File

@ -2,38 +2,26 @@ import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { visitUrlWithAlerts } from '~/lib/utils/url_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import ModelCreate from '~/ml/model_registry/components/model_create.vue';
import ImportArtifactZone from '~/ml/model_registry/components/import_artifact_zone.vue';
import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import { uploadModel } from '~/ml/model_registry/services/upload_model';
import createModelMutation from '~/ml/model_registry/graphql/mutations/create_model.mutation.graphql';
import createModelVersionMutation from '~/ml/model_registry/graphql/mutations/create_model_version.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { createModelResponses, createModelVersionResponses } from '../graphql_mock_data';
import { createModelResponses } from '../graphql_mock_data';
Vue.use(VueApollo);
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrlWithAlerts: jest.fn(),
}));
jest.mock('~/ml/model_registry/services/upload_model', () => ({
uploadModel: jest.fn(() => Promise.resolve()),
visitUrl: jest.fn(),
}));
describe('ModelCreate', () => {
let wrapper;
let apolloProvider;
const file = { name: 'file.txt', size: 1024 };
const anotherFile = { name: 'another file.txt', size: 10 };
const files = [file, anotherFile];
beforeEach(() => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
});
@ -44,13 +32,9 @@ describe('ModelCreate', () => {
const createWrapper = (
createModelResolver = jest.fn().mockResolvedValue(createModelResponses.success),
createModelVersionResolver = jest.fn().mockResolvedValue(createModelVersionResponses.success),
createModelVisible = false,
) => {
const requestHandlers = [
[createModelMutation, createModelResolver],
[createModelVersionMutation, createModelVersionResolver],
];
const requestHandlers = [[createModelMutation, createModelResolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(ModelCreate, {
@ -63,29 +47,19 @@ describe('ModelCreate', () => {
markdownPreviewPath: '/markdown-preview',
},
apolloProvider,
stubs: {
ImportArtifactZone,
},
});
};
const findPrimaryButton = () => wrapper.findByTestId('primary-button');
const findSecondaryButton = () => wrapper.findByTestId('secondary-button');
const findNameInput = () => wrapper.findByTestId('nameId');
const findVersionInput = () => wrapper.findByTestId('versionId');
const findVersionGroup = () => wrapper.findByTestId('versionGroupId');
const findVersionDescriptionGroup = () => wrapper.findByTestId('versionDescriptionGroupId');
const findDescriptionGroup = () => wrapper.findByTestId('descriptionGroupId');
const findDescriptionInput = () => wrapper.findByTestId('descriptionId');
const findVersionDescriptionInput = () => wrapper.findByTestId('versionDescriptionId');
const findImportArtifactZone = () => wrapper.findComponent(ImportArtifactZone);
const zone = () => wrapper.findComponent(UploadDropzone);
const findGlAlert = () => wrapper.findByTestId('create-alert');
const submitForm = async () => {
findPrimaryButton().vm.$emit('click');
await waitForPromises();
};
const findArtifactZoneLabel = () => wrapper.findByTestId('importArtifactZoneLabel');
const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
const findModelNameGroup = () => wrapper.findByTestId('nameGroupId');
@ -111,11 +85,7 @@ describe('ModelCreate', () => {
describe('Form', () => {
beforeEach(() => {
createWrapper(
jest.fn().mockResolvedValue(createModelResponses.success),
jest.fn().mockResolvedValue(createModelVersionResponses.success),
true,
);
createWrapper(jest.fn().mockResolvedValue(createModelResponses.success), true);
});
it('renders the name input', () => {
@ -132,37 +102,6 @@ describe('ModelCreate', () => {
expect(findModelNameGroup().attributes('label')).toBe(ModelCreate.i18n.modelName);
});
it('renders the version input', () => {
expect(findVersionInput().exists()).toBe(true);
});
it('renders the version label', () => {
expect(findVersionGroup().attributes('label')).toBe('Version');
});
it('renders the version placeholder', () => {
expect(findVersionInput().attributes('placeholder')).toBe(
ModelCreate.i18n.versionPlaceholder,
);
});
it('renders the version group', () => {
expect(findVersionGroup().attributes()).toMatchObject({
description: 'Example: 1.0.0',
optional: 'true',
optionaltext: '(Optional)',
label: 'Version',
});
});
it('renders the version description group', () => {
expect(findVersionDescriptionGroup().attributes()).toMatchObject({
optional: 'true',
optionaltext: '(Optional)',
label: 'Version description',
});
});
it('renders the description group', () => {
expect(findDescriptionGroup().attributes()).toMatchObject({
optionaltext: '(Optional)',
@ -175,40 +114,6 @@ describe('ModelCreate', () => {
expect(findDescriptionInput().exists()).toBe(true);
});
it('renders the description input text', () => {
expect(findVersionGroup().attributes('valid-feedback')).toBe(ModelCreate.i18n.validVersion);
});
it('renders the version description input', () => {
expect(findVersionDescriptionInput().exists()).toBe(true);
});
it('renders the import artifact zone input', () => {
expect(findImportArtifactZone().exists()).toBe(false);
});
it('does not displays the title of the artifacts uploader', () => {
expect(findArtifactZoneLabel().exists()).toBe(false);
});
it('displays the title of the artifacts uploader when a version is entered', async () => {
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
expect(findArtifactZoneLabel().attributes('label')).toBe('Upload artifacts');
});
it('renders the import artifact zone input with version entered', async () => {
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
await waitForPromises();
expect(findImportArtifactZone().props()).toEqual({
path: null,
submitOnSelect: false,
});
});
it('renders the create button', () => {
expect(findPrimaryButton().props()).toMatchObject({
variant: 'confirm',
@ -232,58 +137,6 @@ describe('ModelCreate', () => {
beforeEach(() => {
createWrapper();
});
it('renders the version input label for initial state', () => {
expect(findVersionGroup().attributes('state')).toBe('true');
expect(findPrimaryButton().props()).toMatchObject({
variant: 'confirm',
disabled: true,
});
});
it.each(['1.0', '1', 'abc', '1.abc', '1.0.0.0'])(
'renders the version input label for invalid state',
async (version) => {
findVersionInput().vm.$emit('input', version);
await nextTick();
expect(findVersionGroup().attributes()).not.toContain('state');
expect(findVersionGroup().attributes('invalid-feedback')).toBe(
ModelCreate.i18n.versionInvalid,
);
expect(findVersionGroup().attributes('description')).toBe('');
expect(findPrimaryButton().props()).toMatchObject({
variant: 'confirm',
disabled: true,
});
},
);
it.each(['1.0.0', '0.0.0-b', '24.99.99-b99'])(
'renders the version input label for valid state',
async (version) => {
findVersionInput().vm.$emit('input', version);
await nextTick();
expect(findVersionGroup().attributes('state')).toBe('true');
expect(findVersionGroup().attributes('valid-feedback')).toBe(
ModelCreate.i18n.versionValid,
);
expect(findVersionGroup().attributes('description')).toBe('');
expect(findPrimaryButton().props()).toMatchObject({
variant: 'confirm',
disabled: true,
});
},
);
it.each(['1.0.0', '0.0.0-b', '24.99.99-b99'])(
'renders the version input label for valid state',
async (version) => {
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', version);
await nextTick();
expect(findVersionGroup().attributes('state')).toBe('true');
expect(findPrimaryButton().props()).toMatchObject({
variant: 'confirm',
disabled: false,
});
},
);
it.each(['model name', ' modelname', 'modelname ', ' ', ''])(
'renders the modelnames as invalid',
@ -310,70 +163,8 @@ describe('ModelCreate', () => {
await findSecondaryButton().vm.$emit('click');
expect(findVersionInput().attributes('value')).toBe(undefined);
});
});
describe('Successful flow with version', () => {
beforeEach(async () => {
createWrapper();
findNameInput().vm.$emit('input', 'gpt-alice-1');
findMarkdownEditor().vm.$emit('input', 'My model description');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
zone().vm.$emit('change', files);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
await submitForm();
});
it('Makes a create model mutation upon confirm', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createModelMutation,
variables: {
projectPath: 'some/project',
name: 'gpt-alice-1',
description: 'My model description',
},
}),
);
});
it('Makes a create model version mutation upon confirm', () => {
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createModelVersionMutation,
variables: {
modelId: 'gid://gitlab/Ml::Model/1',
projectPath: 'some/project',
version: '1.0.0',
description: 'My version description',
},
}),
);
});
it('Uploads a files mutation upon confirm', () => {
expect(uploadModel).toHaveBeenCalledWith({
file,
importPath: '/api/v4/projects/1/packages/ml_models/1/files/',
subfolder: '',
maxAllowedFileSize: 99999,
onUploadProgress: expect.any(Function),
cancelToken: expect.any(Object),
});
});
it('Visits the model versions page upon successful create mutation', () => {
expect(visitUrlWithAlerts).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1', [
{
id: 'import-artifact-alert',
message: 'Artifacts uploaded successfully.',
variant: 'info',
},
]);
expect(findNameInput().element.value).toBe(undefined);
expect(findDescriptionInput().element.value).toBe(undefined);
});
});
@ -388,104 +179,7 @@ describe('ModelCreate', () => {
});
it('Visits the model page upon successful create mutation without a version', () => {
expect(visitUrlWithAlerts).toHaveBeenCalledWith('/some/project/-/ml/models/1', [
{
id: 'import-artifact-alert',
message: 'Artifacts uploaded successfully.',
variant: 'info',
},
]);
});
});
describe('Failed flow with version', () => {
beforeEach(async () => {
const failedCreateModelVersionResolver = jest
.fn()
.mockResolvedValue(createModelVersionResponses.failure);
createWrapper(undefined, failedCreateModelVersionResolver);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
zone().vm.$emit('change', files);
await submitForm();
});
it('Displays an alert upon failed model create mutation', () => {
expect(findGlAlert().text()).toBe('Version is invalid');
});
});
describe('Failed flow with version retried', () => {
beforeEach(async () => {
const failedCreateModelVersionResolver = jest
.fn()
.mockResolvedValueOnce(createModelVersionResponses.failure);
createWrapper(undefined, failedCreateModelVersionResolver);
jest.spyOn(apolloProvider.defaultClient, 'mutate');
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findVersionDescriptionInput().vm.$emit('input', 'My retried version description');
await submitForm();
});
it('Displays an alert upon failed model create mutation', async () => {
expect(findGlAlert().text()).toBe('Version is invalid');
await submitForm();
expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith(
expect.objectContaining({
mutation: createModelVersionMutation,
variables: {
modelId: 'gid://gitlab/Ml::Model/1',
projectPath: 'some/project',
version: '1.0.0',
description: 'My retried version description',
},
}),
);
});
});
describe('Failed flow with file upload retried', () => {
beforeEach(async () => {
createWrapper();
findNameInput().vm.$emit('input', 'gpt-alice-1');
findVersionInput().vm.$emit('input', '1.0.0');
findDescriptionInput().vm.$emit('input', 'My model description');
findVersionDescriptionInput().vm.$emit('input', 'My version description');
await Vue.nextTick();
zone().vm.$emit('change', files);
uploadModel.mockRejectedValueOnce('Artifact import error.');
await submitForm();
});
it('Visits the model versions page upon successful create mutation', async () => {
await submitForm(); // retry submit
expect(visitUrlWithAlerts).toHaveBeenCalledWith('/some/project/-/ml/models/1/versions/1', [
{
id: 'import-artifact-alert',
message: 'Artifact uploads completed with errors. file.txt: Artifact import error.',
variant: 'danger',
},
]);
});
it('Uploads a file mutation upon confirm', async () => {
await submitForm(); // retry submit
expect(uploadModel).toHaveBeenCalledWith({
file,
importPath: '/api/v4/projects/1/packages/ml_models/1/files/',
subfolder: '',
maxAllowedFileSize: 99999,
onUploadProgress: expect.any(Function),
cancelToken: expect.any(Object),
});
expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1');
});
});

View File

@ -71,7 +71,7 @@ describe('ShowMlModel', () => {
expect(findEmptyState().props()).toMatchObject({
title: 'Manage versions of your machine learning model',
description: 'Use versions to track performance, parameters, and metadata',
primaryText: 'Create model version',
primaryText: 'Create new version',
primaryLink: 'versions/new',
});
});

View File

@ -2,8 +2,11 @@ import { nextTick } from 'vue';
import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CrudComponent from '~/vue_shared/components/crud_component.vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
describe('CRUD Component', () => {
useLocalStorageSpy();
let wrapper;
const createComponent = (propsData, slots = {}) => {
@ -31,6 +34,11 @@ describe('CRUD Component', () => {
const findBody = () => wrapper.findByTestId('crud-body');
const findFooter = () => wrapper.findByTestId('crud-footer');
const findPagination = () => wrapper.findByTestId('crud-pagination');
const findCollapseToggle = () => wrapper.findByTestId('crud-collapse-toggle');
afterEach(() => {
localStorage.clear();
});
it('renders title', () => {
createComponent();
@ -127,4 +135,59 @@ describe('CRUD Component', () => {
expect(findPagination().text()).toBe('Pagination slot');
});
describe('with persistCollapsedState=true', () => {
describe('when the localStorage key is false or undefined', () => {
beforeEach(() => {
createComponent(
{
isCollapsible: true,
persistCollapsedState: true,
anchorId: 'test-anchor',
toggleText: 'Form action toggle',
},
{ default: '<p>Body slot</p>' },
);
});
it('the collapsible area is not collapsed initially', () => {
expect(findBody().text()).toBe('Body slot');
});
it('toggles the collapsible area and sets the localStorage key to true', async () => {
findCollapseToggle().vm.$emit('click');
await nextTick();
expect(localStorage.setItem).toHaveBeenCalledWith('crud-collapse-test-anchor', true);
expect(findBody().exists()).toBe(false);
});
});
describe('when the localStorage key is true', () => {
beforeEach(() => {
localStorage.setItem('crud-collapse-test-anchor', 'true');
createComponent(
{
isCollapsible: true,
persistCollapsedState: true,
anchorId: 'test-anchor',
toggleText: 'Form action toggle',
},
{ default: '<p>Body slot</p>' },
);
});
it('the collapsible area is collapsed initially', () => {
expect(findBody().exists()).toBe(false);
});
it('toggles the collapsible area and sets the localStorage key to false', async () => {
findCollapseToggle().vm.$emit('click');
await nextTick();
expect(localStorage.setItem).toHaveBeenCalledWith('crud-collapse-test-anchor', false);
expect(findBody().text()).toBe('Body slot');
});
});
});
});

View File

@ -130,8 +130,9 @@ describe('Create work item component', () => {
const initialiseComponentAndSelectWorkItem = async ({
props = {},
mutationHandler = createWorkItemSuccessHandler,
workItemTypeName = WORK_ITEM_TYPE_ENUM_EPIC,
} = {}) => {
createComponent({ props, mutationHandler });
createComponent({ props, mutationHandler, workItemTypeName });
await waitForPromises();
@ -247,6 +248,14 @@ describe('Create work item component', () => {
expect(findProjectsSelector().exists()).toBe(showProjectSelector);
},
);
it('defaults the selected project to the injected `fullPath` value', async () => {
createComponent({ props: { showProjectSelector: true } });
await waitForPromises();
expect(findProjectsSelector().props('selectedProjectFullPath')).toBe('full-path');
});
});
describe('Work item types dropdown', () => {
@ -384,16 +393,6 @@ describe('Create work item component', () => {
expect(findCreateButton().props('disabled')).toBe(false);
});
it('shows an alert when no project is selected', async () => {
await initialiseComponentAndSelectWorkItem({ props: { showProjectSelector: true } });
await updateWorkItemTitle();
wrapper.find('form').trigger('submit');
await nextTick();
expect(findAlert().text()).toBe('Please select a project.');
expect(createWorkItemSuccessHandler).not.toHaveBeenCalled();
});
it('shows an alert on mutation error', async () => {
await initialiseComponentAndSelectWorkItem({ mutationHandler: errorHandler });

View File

@ -201,6 +201,10 @@ describe('WorkItemActions component', () => {
testId: TEST_ID_TOGGLE_ACTION,
text: '',
},
{
testId: TEST_ID_NEW_RELATED_WORK_ITEM,
text: 'New related task',
},
{
testId: TEST_ID_LOCK_ACTION,
text: 'Lock discussion',
@ -231,14 +235,14 @@ describe('WorkItemActions component', () => {
]);
});
it('includes a new related item option when the work item is the correct type', () => {
createComponent({ workItemType: 'Epic' });
it('includes a new related item option', () => {
createComponent({ workItemType: 'Task' });
expect(findDropdownItemsActual()).toEqual(
expect.arrayContaining([
{
testId: TEST_ID_NEW_RELATED_WORK_ITEM,
text: 'New related Epic',
text: 'New related task',
},
]),
);
@ -545,12 +549,36 @@ describe('WorkItemActions component', () => {
describe('new related item', () => {
it('opens the create work item modal', async () => {
createComponent({ workItemType: 'Epic' });
createComponent({ workItemType: 'Task' });
findNewRelatedItemButton().vm.$emit('action');
await nextTick();
expect(findCreateWorkItemModal().props('visible')).toBe(true);
});
it.each`
isProjectSelectorVisible | workItemType
${false} | ${'Epic'}
${true} | ${'Issue'}
${true} | ${'Task'}
`(
'when workItemType is $workItemType, sets `CreateWorkItemModal` `showProjectSelector` prop to $isProjectSelectorVisible',
({ isProjectSelectorVisible, workItemType }) => {
createComponent({ workItemType });
expect(findCreateWorkItemModal().props('showProjectSelector')).toBe(
isProjectSelectorVisible,
);
},
);
it('emits `workItemCreated` when `CreateWorkItemModal` emits `workItemCreated`', () => {
createComponent();
findCreateWorkItemModal().vm.$emit('workItemCreated');
expect(wrapper.emitted('workItemCreated')).toHaveLength(1);
});
});
});

View File

@ -32,11 +32,13 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati
import workItemUpdatedSubscription from '~/work_items/graphql/work_item_updated.subscription.graphql';
import getAllowedWorkItemChildTypes from '~/work_items/graphql/work_item_allowed_children.query.graphql';
import workspacePermissionsQuery from '~/work_items/graphql/workspace_permissions.query.graphql';
import workItemLinkedItemsQuery from '~/work_items/graphql/work_item_linked_items.query.graphql';
import {
mockParent,
workItemByIidResponseFactory,
workItemQueryResponse,
workItemLinkedItemsResponse,
objectiveType,
epicType,
mockWorkItemCommentNote,
@ -97,6 +99,10 @@ describe('WorkItemDetail component', () => {
.fn()
.mockResolvedValue(mockUploadErrorDesignMutationResponse);
const workItemLinkedItemsSuccessHandler = jest
.fn()
.mockResolvedValue(workItemLinkedItemsResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findWorkItemLoading = () => wrapper.findComponent(WorkItemLoading);
@ -149,6 +155,7 @@ describe('WorkItemDetail component', () => {
[getAllowedWorkItemChildTypes, allowedChildrenTypesHandler],
[workspacePermissionsQuery, workspacePermissionsHandler],
[uploadDesignMutation, uploadDesignMutationHandler],
[workItemLinkedItemsQuery, workItemLinkedItemsSuccessHandler],
]),
isLoggedIn: isLoggedIn(),
propsData: {
@ -685,6 +692,20 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemRelationships().exists()).toBe(false);
});
it('re-fetches workItem query when `WorkItemActions` emits `workItemCreated` event', async () => {
createComponent();
await waitForPromises();
expect(successHandler).toHaveBeenCalledTimes(1);
findWorkItemActions().vm.$emit('workItemCreated');
await waitForPromises();
expect(successHandler).toHaveBeenCalledTimes(2);
});
describe('work item has children', () => {
const mockWorkItemLinkedItem = workItemByIidResponseFactory({
linkedItems: mockBlockingLinkedItem,

View File

@ -6,7 +6,6 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemProjectsListbox from '~/work_items/components/work_item_links/work_item_projects_listbox.vue';
import namespaceProjectsForLinksWidgetQuery from '~/work_items/graphql/namespace_projects_for_links_widget.query.graphql';
import { SEARCH_DEBOUNCE } from '~/work_items/constants';
import { namespaceProjectsList, mockFrequentlyUsedProjects } from '../../mock_data';
Vue.use(VueApollo);
@ -58,7 +57,6 @@ describe('WorkItemProjectsListbox', () => {
},
});
jest.advanceTimersByTime(SEARCH_DEBOUNCE);
await waitForPromises();
};
@ -139,7 +137,7 @@ describe('WorkItemProjectsListbox', () => {
findDropdown().vm.$emit('search', 'project a');
await nextTick();
await waitForPromises();
content = findRecentDropdownItems();
@ -231,7 +229,7 @@ describe('WorkItemProjectsListbox', () => {
findDropdown().vm.$emit('search', 'project a');
await nextTick();
await waitForPromises();
content = findRecentDropdownItems();

View File

@ -21,7 +21,8 @@ RSpec.describe Gitlab::Gpg::Commit, feature_category: :source_code_management do
{
signature: GpgHelpers::User1.signed_commit_signature,
signed_text: GpgHelpers::User1.signed_commit_base_data,
signer: signer
signer: signer,
author_email: user_email
}
end
@ -355,11 +356,29 @@ RSpec.describe Gitlab::Gpg::Commit, feature_category: :source_code_management do
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: nil,
gpg_key_user_email: nil,
gpg_key_user_email: user_email,
verification_status: 'verified_system'
)
end
context 'when check_for_mailmapped_commit_emails feature flag is disabled' do
before do
stub_feature_flags(check_for_mailmapped_commit_emails: false)
end
it 'returns a valid signature' do
expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: nil,
gpg_key_user_email: nil,
verification_status: 'verified_system'
)
end
end
it_behaves_like 'returns the cached signature on second call'
end
end
@ -379,5 +398,36 @@ RSpec.describe Gitlab::Gpg::Commit, feature_category: :source_code_management do
change { signature.reload.verification_status }.from('unknown_key').to('verified')
)
end
context 'when signature is system verified and gpg_key_user_email is nil' do
let(:signer) { :SIGNER_SYSTEM }
it 'update gpg_key_user_email with signature_data author_email' do
signature
stored_signature = CommitSignatures::GpgSignature.find_by_commit_sha(commit_sha)
stored_signature.update!(gpg_key_user_email: nil)
expect { described_class.new(commit).update_signature!(stored_signature) }.to(
change { signature.reload.gpg_key_user_email }.from(nil).to(user_email)
)
end
context 'when check_for_mailmapped_commit_emails feature flag is disabled' do
before do
stub_feature_flags(check_for_mailmapped_commit_emails: false)
end
it 'does not update gpg_key_user_email with signature_data author_email' do
signature
stored_signature = CommitSignatures::GpgSignature.find_by_commit_sha(commit_sha)
stored_signature.update!(gpg_key_user_email: nil)
expect { described_class.new(commit).update_signature!(stored_signature) }.to(
not_change { signature.reload.gpg_key_user_email })
end
end
end
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Instrumentation::Middleware::PathTraversalCheck, :request_store, feature_category: :shared do
describe '.duration' do
it 'returns the value from Gitlab::SafeRequestStore' do
expect(Gitlab::SafeRequestStore).to receive(:[]).with(described_class::DURATION_LABEL).and_return(2.3)
expect(described_class.duration).to eq(2.3)
end
it 'returns 0 if the value is not set in Gitlab::SafeRequestStore' do
expect(Gitlab::SafeRequestStore).to receive(:[]).with(described_class::DURATION_LABEL).and_return(nil)
expect(described_class.duration).to eq(0)
end
end
describe '.duration=' do
it 'sets the value' do
expect { described_class.duration = 0.12345678901 }
.to change { described_class.duration }.from(0).to(0.123457) # precision is set to 6
end
context 'with Gitlab::SafeRequestStore not active' do
before do
allow(Gitlab::SafeRequestStore).to receive(:active?).and_return(false)
end
it 'does not set the value' do
expect { described_class.duration = 2.3 }
.not_to change { described_class.duration }
end
end
end
end

View File

@ -292,6 +292,31 @@ RSpec.describe Gitlab::InstrumentationHelper, :clean_gitlab_redis_repository_cac
)
end
end
context 'for middleware path traversal check' do
let(:duration) { 0.123456789 }
let(:expected_logged_duration) { 0.123457 }
before do
::Gitlab::Instrumentation::Middleware::PathTraversalCheck.duration = duration
end
it 'includes the duration in the payload' do
subject
expect(payload).to include(path_traversal_check_duration_s: expected_logged_duration)
end
context 'with a 0 duration' do
let(:duration) { 0 }
it 'does not include the duration in the payload' do
subject
expect(payload).not_to include(:path_traversal_check_duration_s)
end
end
end
end
describe '.queue_duration_for_job' do

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Metrics::Middleware::PathTraversalCheck, feature_category: :shared do
describe '.initialize_slis!' do
subject(:initialize_slis!) { described_class.initialize_slis! }
it 'initializes all metrics' do
expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli)
.with(*described_class::DURATION_APDEX_SLI_DEFINITION)
initialize_slis!
end
end
describe '.increment' do
let(:labels) { { path_traversal_attempt_rejected: true } }
let(:duration) { 1.5 }
subject(:increment) { described_class.increment(labels: labels, duration: 1.5) }
it 'increments the apdex' do
expect(::Gitlab::Metrics::Sli::Apdex[described_class::DURATION_APDEX_NAME]).to receive(:increment)
.with(labels: labels.merge(described_class::DURATION_APDEX_FEATURE_CATEGORY), success: false)
increment
end
end
end

View File

@ -8,11 +8,11 @@ RSpec.describe ::Gitlab::Middleware::PathTraversalCheck, feature_category: :shar
let(:fake_response_status) { 200 }
let(:fake_response) { [fake_response_status, { 'Content-Type' => 'text/plain' }, ['OK']] }
let(:fake_app) { ->(_) { fake_response } }
let(:middleware) { described_class.new(fake_app) }
describe '#call' do
let(:fullpath) { ::Rack::Request.new(env).fullpath }
let(:decoded_fullpath) { CGI.unescape(fullpath) }
let(:graphql_query) do
<<~QUERY
{
@ -28,7 +28,7 @@ RSpec.describe ::Gitlab::Middleware::PathTraversalCheck, feature_category: :shar
Rack::MockRequest.env_for(path_with_query_params, method: method)
end
subject { middleware.call(env) }
subject { described_class.new(fake_app).call(env) }
shared_examples 'no issue' do
it 'does not log or reject the request' do
@ -37,6 +37,14 @@ RSpec.describe ::Gitlab::Middleware::PathTraversalCheck, feature_category: :shar
.with(decoded_fullpath, match_new_line: false)
.and_call_original
expect(::Gitlab::AppLogger).not_to receive(:warn)
expect(::Gitlab::Instrumentation::Middleware::PathTraversalCheck)
.to receive(:duration=).with(an_instance_of(Float))
expect(::Gitlab::Metrics::Middleware::PathTraversalCheck)
.to receive(:increment).with(
labels: { path_traversal_attempt_rejected: false },
duration: an_instance_of(Float)
)
expect(subject).to eq(fake_response)
end
end
@ -49,13 +57,20 @@ RSpec.describe ::Gitlab::Middleware::PathTraversalCheck, feature_category: :shar
.and_call_original
expect(::Gitlab::AppLogger)
.to receive(:warn)
.with({
.with(hash_including(
class_name: described_class.name,
message: described_class::PATH_TRAVERSAL_MESSAGE,
fullpath: fullpath,
method: method.upcase,
path_traversal_attempt_rejected: true
}).and_call_original
)).and_call_original
expect(::Gitlab::Instrumentation::Middleware::PathTraversalCheck)
.to receive(:duration=).with(an_instance_of(Float))
expect(::Gitlab::Metrics::Middleware::PathTraversalCheck)
.to receive(:increment).with(
labels: { path_traversal_attempt_rejected: true },
duration: an_instance_of(Float)
)
expect(subject).to eq(described_class::REJECT_RESPONSE)
end
@ -158,6 +173,8 @@ RSpec.describe ::Gitlab::Middleware::PathTraversalCheck, feature_category: :shar
it 'does not check for path traversals' do
expect(::Gitlab::PathTraversal).not_to receive(:path_traversal?)
expect(::Gitlab::Instrumentation::Middleware::PathTraversalCheck).not_to receive(:duration)
expect(::Gitlab::Metrics::Middleware::PathTraversalCheck).not_to receive(:increment)
subject
end
@ -179,13 +196,20 @@ RSpec.describe ::Gitlab::Middleware::PathTraversalCheck, feature_category: :shar
.and_call_original
expect(::Gitlab::AppLogger)
.to receive(:warn)
.with({
.with(hash_including(
class_name: described_class.name,
message: described_class::PATH_TRAVERSAL_MESSAGE,
fullpath: fullpath,
method: method.upcase,
status: fake_response_status
}).and_call_original
)).and_call_original
expect(::Gitlab::Instrumentation::Middleware::PathTraversalCheck)
.to receive(:duration=).with(an_instance_of(Float))
expect(::Gitlab::Metrics::Middleware::PathTraversalCheck)
.to receive(:increment).with(
labels: { path_traversal_attempt_rejected: false },
duration: an_instance_of(Float)
)
expect(subject).to eq(fake_response)
end

View File

@ -10,7 +10,12 @@ RSpec.describe Gitlab::Ssh::Commit, feature_category: :source_code_management do
let(:signature_text) { 'signature_text' }
let(:signed_text) { 'signed_text' }
let(:signer) { :SIGNER_USER }
let(:signature_data) { { signature: signature_text, signed_text: signed_text, signer: signer } }
let(:user_author) { create(:user) }
let(:author_email) { user_author.email }
let(:signature_data) do
{ signature: signature_text, signed_text: signed_text, signer: signer, author_email: author_email }
end
let(:verifier) { instance_double('Gitlab::Ssh::Signature') }
let(:verification_status) { :verified }
@ -27,8 +32,10 @@ RSpec.describe Gitlab::Ssh::Commit, feature_category: :source_code_management do
key_fingerprint: fingerprint
})
allow(verifier).to receive(:user_id).and_return(user_author.id)
allow(Gitlab::Ssh::Signature).to receive(:new)
.with(signature_text, signed_text, signer, commit)
.with(signature_text, signed_text, signer, commit, author_email)
.and_return(verifier)
end
@ -50,7 +57,7 @@ RSpec.describe Gitlab::Ssh::Commit, feature_category: :source_code_management do
project: project,
key_id: signed_by_key.id,
key_fingerprint_sha256: signed_by_key.fingerprint_sha256,
user_id: signed_by_key.user_id,
user_id: user_author.id,
verification_status: 'verified'
)
end
@ -68,11 +75,71 @@ RSpec.describe Gitlab::Ssh::Commit, feature_category: :source_code_management do
project: project,
key_id: nil,
key_fingerprint_sha256: nil,
user_id: nil,
user_id: user_author.id,
verification_status: 'unknown_key'
)
end
end
context 'when signature is verified_system' do
before do
allow(verifier).to receive(:verification_status).and_return(:verified_system)
end
let(:signer) { :VERIFIED_SYSTEM }
it 'uses the author email to set the user id' do
expect(signature).to have_attributes(
commit_sha: commit.sha,
user_id: user_author.id,
verification_status: 'verified_system'
)
end
context 'when a stored signature is present for the commit with user nil' do
let(:signature_with_no_user) do
create(:ssh_signature,
commit_sha: commit.sha,
verification_status: :verified_system,
user_id: nil,
project: project,
key_fingerprint_sha256: fingerprint,
key_id: signed_by_key.id
)
end
before do
allow(CommitSignatures::SshSignature)
.to receive(:by_commit_sha)
.with([commit.id])
.and_return([signature_with_no_user])
end
context 'when author_email is present' do
it 'updates stored signature with user_id from signature author_email' do
expect(signature.user).to eq(user_author)
end
end
context 'when signature author_email is not present' do
let(:author_email) { nil }
it 'does not update the stored signature' do
expect(signature.user).to be_nil
end
end
context 'when feature flag check_for_mailmapped_commit_emails is disabled' do
before do
stub_feature_flags(check_for_mailmapped_commit_emails: false)
end
it 'does not update the stored signature' do
expect(signature.user).to be_nil
end
end
end
end
end
describe '#update_signature!' do

View File

@ -13,6 +13,7 @@ RSpec.describe Gitlab::Ssh::Signature, feature_category: :source_code_management
let(:commit) { project.commit }
let(:signed_text) { 'This message was signed by an ssh key' }
let(:signer) { :SIGNER_USER }
let(:author_email) { 'blob@example.com' }
let(:signature_text) do
# ssh-keygen -Y sign -n git -f id_test message.txt
@ -35,7 +36,8 @@ RSpec.describe Gitlab::Ssh::Signature, feature_category: :source_code_management
signature_text,
signed_text,
signer,
commit
commit,
author_email
)
end
@ -315,4 +317,24 @@ RSpec.describe Gitlab::Ssh::Signature, feature_category: :source_code_management
end
end
end
describe '#user_id' do
it 'returns the user id from signed by key' do
expect(signature.user_id).to eq(user.id)
end
context 'for system verified commits' do
let(:signer) { :SIGNER_SYSTEM }
let(:new_user) { create(:user) }
before do
allow(User).to receive(:find_by_any_email)
.with(author_email).and_return(new_user)
end
it 'returns the user id from author email' do
expect(signature.user_id).to eq(new_user.id)
end
end
end
end

View File

@ -6052,26 +6052,4 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category:
end
end
end
describe 'routing table switch' do
context 'with ff disabled' do
before do
stub_feature_flags(described_class::ROUTING_FEATURE_FLAG => false)
end
it 'uses the legacy table' do
expect(described_class.table_name).to eq('ci_pipelines')
end
end
context 'with ff enabled' do
before do
stub_feature_flags(described_class::ROUTING_FEATURE_FLAG => true)
end
it 'uses the routing table' do
expect(described_class.table_name).to eq('p_ci_pipelines')
end
end
end
end

View File

@ -91,6 +91,28 @@ RSpec.describe CommitSignatures::GpgSignature do
it 'retrieves the gpg_key user' do
expect(signature.signed_by_user).to eq(gpg_key.user)
end
context 'when signature is verified system and no key is stored' do
let(:user) { create(:user) }
before do
signature.update!(gpg_key_id: nil, gpg_key_user_email: user.email, verification_status: :verified_system)
end
it 'retrieves the user from the gpg signature email' do
expect(signature.signed_by_user).to eq(user)
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(check_for_mailmapped_commit_emails: false)
end
it 'returns nil' do
expect(signature.signed_by_user).to be_nil
end
end
end
end
describe '#reverified_status' do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe FileStoreMounter, :aggregate_failures do
RSpec.describe FileStoreMounter, :aggregate_failures, feature_category: :shared do
let(:uploader_class) do
Class.new do
def object_store
@ -73,10 +73,21 @@ RSpec.describe FileStoreMounter, :aggregate_failures do
it 'calls update column' do
expect(instance).to receive(:file).and_return(uploader_instance)
expect(instance).to receive(:[]).with('file_store').and_return(nil)
expect(instance).to receive(:update_column).with('file_store', :object_store)
update_file_store
end
context 'when the model file store is set to the same value' do
it 'does not call update column' do
expect(instance).to receive(:file).and_return(uploader_instance)
expect(instance).to receive(:[]).with('file_store').and_return(:object_store)
expect(instance).not_to receive(:update_column)
update_file_store
end
end
end
describe '#store_file_now!' do

View File

@ -323,13 +323,6 @@ RSpec.describe Packages::PackageFile, type: :model, feature_category: :package_r
.to receive(:update_file_store)
.and_call_original
# This expectation uses a stub because we can no longer test a change from
# `nil` to `1`, because the field is no longer nullable, and it defaults
# to `1`.
expect(package_file)
.to receive(:update_column)
.with('file_store', ::Packages::PackageFileUploader::Store::LOCAL)
expect { subject }.to change { package_file.size }.from(nil).to(3513)
end
end

View File

@ -453,8 +453,8 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
it 'does not allow accessing notes' do
# if notes widget is disabled not even maintainer can access notes
expect(permissions(maintainer, issue)).to be_disallowed(:create_note, :read_note, :mark_note_as_internal, :read_internal_note)
expect(permissions(admin, issue)).to be_disallowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal, :set_note_created_at)
expect(permissions(maintainer, issue)).to be_disallowed(:create_note, :read_note, :mark_note_as_internal, :read_internal_note, :admin_note)
expect(permissions(admin, issue)).to be_disallowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal, :set_note_created_at, :admin_note)
end
end
@ -462,10 +462,11 @@ RSpec.describe IssuePolicy, feature_category: :team_planning do
it 'allows accessing notes' do
# with notes widget enabled, even guests can access notes
expect(permissions(guest, issue)).to be_allowed(:create_note, :read_note)
expect(permissions(guest, issue)).to be_disallowed(:read_internal_note, :mark_note_as_internal, :set_note_created_at)
expect(permissions(guest, issue)).to be_disallowed(:read_internal_note, :mark_note_as_internal, :set_note_created_at, :admin_note)
expect(permissions(reporter, issue)).to be_allowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal)
expect(permissions(maintainer, issue)).to be_allowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal)
expect(permissions(owner, issue)).to be_allowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal, :set_note_created_at)
expect(permissions(reporter, issue)).to be_disallowed(:admin_note)
expect(permissions(maintainer, issue)).to be_allowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal, :admin_note)
expect(permissions(owner, issue)).to be_allowed(:create_note, :read_note, :read_internal_note, :mark_note_as_internal, :set_note_created_at, :admin_note)
end
end
end

View File

@ -3621,40 +3621,124 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
end
describe 'read_model_registry' do
using RSpec::Parameterized::TableSyntax
context 'for public projects' do
using RSpec::Parameterized::TableSyntax
where(:current_user, :access_level, :allowed) do
ref(:anonymous) | Featurable::ENABLED | true
ref(:anonymous) | Featurable::PRIVATE | false
ref(:anonymous) | Featurable::DISABLED | false
ref(:non_member) | Featurable::ENABLED | true
ref(:non_member) | Featurable::PRIVATE | false
ref(:non_member) | Featurable::DISABLED | false
ref(:guest) | Featurable::ENABLED | true
ref(:guest) | Featurable::PRIVATE | false
ref(:guest) | Featurable::DISABLED | false
ref(:reporter) | Featurable::ENABLED | true
ref(:reporter) | Featurable::PRIVATE | true
ref(:reporter) | Featurable::DISABLED | false
ref(:developer) | Featurable::ENABLED | true
ref(:developer) | Featurable::PRIVATE | true
ref(:developer) | Featurable::DISABLED | false
ref(:maintainer) | Featurable::ENABLED | true
ref(:maintainer) | Featurable::PRIVATE | true
ref(:maintainer) | Featurable::DISABLED | false
ref(:owner) | Featurable::ENABLED | true
ref(:owner) | Featurable::PRIVATE | true
ref(:owner) | Featurable::DISABLED | false
end
with_them do
before do
project.project_feature.update!(model_registry_access_level: access_level)
where(:access_level, :current_user, :allowed) do
Featurable::DISABLED | ref(:anonymous) | false
Featurable::DISABLED | ref(:non_member) | false
Featurable::DISABLED | ref(:guest) | false
Featurable::DISABLED | ref(:reporter) | false
Featurable::DISABLED | ref(:developer) | false
Featurable::DISABLED | ref(:maintainer) | false
Featurable::DISABLED | ref(:owner) | false
Featurable::ENABLED | ref(:anonymous) | true
Featurable::ENABLED | ref(:non_member) | true
Featurable::ENABLED | ref(:guest) | true
Featurable::ENABLED | ref(:reporter) | true
Featurable::ENABLED | ref(:developer) | true
Featurable::ENABLED | ref(:maintainer) | true
Featurable::ENABLED | ref(:owner) | true
Featurable::PRIVATE | ref(:anonymous) | false
Featurable::PRIVATE | ref(:non_member) | false
Featurable::PRIVATE | ref(:guest) | true
Featurable::PRIVATE | ref(:reporter) | true
Featurable::PRIVATE | ref(:developer) | true
Featurable::PRIVATE | ref(:maintainer) | true
Featurable::PRIVATE | ref(:owner) | true
end
with_them do
before do
project.project_feature.update!(model_registry_access_level: access_level)
end
if params[:allowed]
it { expect_allowed(:read_model_registry) }
else
it { expect_disallowed(:read_model_registry) }
if params[:allowed]
it { expect_allowed(:read_model_registry) }
else
it { expect_disallowed(:read_model_registry) }
end
end
end
context 'for private projects' do
using RSpec::Parameterized::TableSyntax
let(:project) { private_project }
where(:access_level, :current_user, :allowed) do
Featurable::DISABLED | ref(:anonymous) | false
Featurable::DISABLED | ref(:non_member) | false
Featurable::DISABLED | ref(:guest) | false
Featurable::DISABLED | ref(:reporter) | false
Featurable::DISABLED | ref(:developer) | false
Featurable::DISABLED | ref(:maintainer) | false
Featurable::DISABLED | ref(:owner) | false
Featurable::ENABLED | ref(:anonymous) | false
Featurable::ENABLED | ref(:non_member) | false
Featurable::ENABLED | ref(:guest) | true
Featurable::ENABLED | ref(:reporter) | true
Featurable::ENABLED | ref(:developer) | true
Featurable::ENABLED | ref(:maintainer) | true
Featurable::ENABLED | ref(:owner) | true
Featurable::PRIVATE | ref(:anonymous) | false
Featurable::PRIVATE | ref(:non_member) | false
Featurable::PRIVATE | ref(:guest) | true
Featurable::PRIVATE | ref(:reporter) | true
Featurable::PRIVATE | ref(:developer) | true
Featurable::PRIVATE | ref(:maintainer) | true
Featurable::PRIVATE | ref(:owner) | true
end
with_them do
before do
project.project_feature.update!(model_registry_access_level: access_level)
end
if params[:allowed]
it { expect_allowed(:read_model_registry) }
else
it { expect_disallowed(:read_model_registry) }
end
end
end
context 'for internal projects' do
using RSpec::Parameterized::TableSyntax
let(:project) { internal_project }
where(:access_level, :current_user, :allowed) do
Featurable::DISABLED | ref(:anonymous) | false
Featurable::DISABLED | ref(:non_member) | false
Featurable::DISABLED | ref(:guest) | false
Featurable::DISABLED | ref(:reporter) | false
Featurable::DISABLED | ref(:developer) | false
Featurable::DISABLED | ref(:maintainer) | false
Featurable::DISABLED | ref(:owner) | false
Featurable::ENABLED | ref(:anonymous) | false
Featurable::ENABLED | ref(:non_member) | false
Featurable::ENABLED | ref(:guest) | true
Featurable::ENABLED | ref(:reporter) | true
Featurable::ENABLED | ref(:developer) | true
Featurable::ENABLED | ref(:maintainer) | true
Featurable::ENABLED | ref(:owner) | true
Featurable::PRIVATE | ref(:anonymous) | false
Featurable::PRIVATE | ref(:non_member) | false
Featurable::PRIVATE | ref(:guest) | true
Featurable::PRIVATE | ref(:reporter) | true
Featurable::PRIVATE | ref(:developer) | true
Featurable::PRIVATE | ref(:maintainer) | true
Featurable::PRIVATE | ref(:owner) | true
end
with_them do
before do
project.project_feature.update!(model_registry_access_level: access_level)
end
if params[:allowed]
it { expect_allowed(:read_model_registry) }
else
it { expect_disallowed(:read_model_registry) }
end
end
end
end
@ -3699,42 +3783,124 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
end
describe ':read_model_experiments' do
using RSpec::Parameterized::TableSyntax
context 'for public projects' do
using RSpec::Parameterized::TableSyntax
where(:ff_ml_experiment_tracking, :current_user, :access_level, :allowed) do
false | ref(:owner) | Featurable::ENABLED | false
true | ref(:anonymous) | Featurable::ENABLED | true
true | ref(:anonymous) | Featurable::PRIVATE | false
true | ref(:anonymous) | Featurable::DISABLED | false
true | ref(:non_member) | Featurable::ENABLED | true
true | ref(:non_member) | Featurable::PRIVATE | false
true | ref(:non_member) | Featurable::DISABLED | false
true | ref(:guest) | Featurable::ENABLED | true
true | ref(:guest) | Featurable::PRIVATE | false
true | ref(:guest) | Featurable::DISABLED | false
true | ref(:reporter) | Featurable::ENABLED | true
true | ref(:reporter) | Featurable::PRIVATE | true
true | ref(:reporter) | Featurable::DISABLED | false
true | ref(:developer) | Featurable::ENABLED | true
true | ref(:developer) | Featurable::PRIVATE | true
true | ref(:developer) | Featurable::DISABLED | false
true | ref(:maintainer) | Featurable::ENABLED | true
true | ref(:maintainer) | Featurable::PRIVATE | true
true | ref(:maintainer) | Featurable::DISABLED | false
true | ref(:owner) | Featurable::ENABLED | true
true | ref(:owner) | Featurable::PRIVATE | true
true | ref(:owner) | Featurable::DISABLED | false
end
with_them do
before do
stub_feature_flags(ml_experiment_tracking: ff_ml_experiment_tracking)
project.project_feature.update!(model_experiments_access_level: access_level)
where(:access_level, :current_user, :allowed) do
Featurable::DISABLED | ref(:anonymous) | false
Featurable::DISABLED | ref(:non_member) | false
Featurable::DISABLED | ref(:guest) | false
Featurable::DISABLED | ref(:reporter) | false
Featurable::DISABLED | ref(:developer) | false
Featurable::DISABLED | ref(:maintainer) | false
Featurable::DISABLED | ref(:owner) | false
Featurable::ENABLED | ref(:anonymous) | true
Featurable::ENABLED | ref(:non_member) | true
Featurable::ENABLED | ref(:guest) | true
Featurable::ENABLED | ref(:reporter) | true
Featurable::ENABLED | ref(:developer) | true
Featurable::ENABLED | ref(:maintainer) | true
Featurable::ENABLED | ref(:owner) | true
Featurable::PRIVATE | ref(:anonymous) | false
Featurable::PRIVATE | ref(:non_member) | false
Featurable::PRIVATE | ref(:guest) | true
Featurable::PRIVATE | ref(:reporter) | true
Featurable::PRIVATE | ref(:developer) | true
Featurable::PRIVATE | ref(:maintainer) | true
Featurable::PRIVATE | ref(:owner) | true
end
with_them do
before do
project.project_feature.update!(model_experiments_access_level: access_level)
end
if params[:allowed]
it { is_expected.to be_allowed(:read_model_experiments) }
else
it { is_expected.not_to be_allowed(:read_model_experiments) }
if params[:allowed]
it { expect_allowed(:read_model_experiments) }
else
it { expect_disallowed(:read_model_experiments) }
end
end
end
context 'for private projects' do
using RSpec::Parameterized::TableSyntax
let(:project) { private_project }
where(:access_level, :current_user, :allowed) do
Featurable::DISABLED | ref(:anonymous) | false
Featurable::DISABLED | ref(:non_member) | false
Featurable::DISABLED | ref(:guest) | false
Featurable::DISABLED | ref(:reporter) | false
Featurable::DISABLED | ref(:developer) | false
Featurable::DISABLED | ref(:maintainer) | false
Featurable::DISABLED | ref(:owner) | false
Featurable::ENABLED | ref(:anonymous) | false
Featurable::ENABLED | ref(:non_member) | false
Featurable::ENABLED | ref(:guest) | true
Featurable::ENABLED | ref(:reporter) | true
Featurable::ENABLED | ref(:developer) | true
Featurable::ENABLED | ref(:maintainer) | true
Featurable::ENABLED | ref(:owner) | true
Featurable::PRIVATE | ref(:anonymous) | false
Featurable::PRIVATE | ref(:non_member) | false
Featurable::PRIVATE | ref(:guest) | true
Featurable::PRIVATE | ref(:reporter) | true
Featurable::PRIVATE | ref(:developer) | true
Featurable::PRIVATE | ref(:maintainer) | true
Featurable::PRIVATE | ref(:owner) | true
end
with_them do
before do
project.project_feature.update!(model_experiments_access_level: access_level)
end
if params[:allowed]
it { expect_allowed(:read_model_experiments) }
else
it { expect_disallowed(:read_model_experiments) }
end
end
end
context 'for internal projects' do
using RSpec::Parameterized::TableSyntax
let(:project) { internal_project }
where(:access_level, :current_user, :allowed) do
Featurable::DISABLED | ref(:anonymous) | false
Featurable::DISABLED | ref(:non_member) | false
Featurable::DISABLED | ref(:guest) | false
Featurable::DISABLED | ref(:reporter) | false
Featurable::DISABLED | ref(:developer) | false
Featurable::DISABLED | ref(:maintainer) | false
Featurable::DISABLED | ref(:owner) | false
Featurable::ENABLED | ref(:anonymous) | false
Featurable::ENABLED | ref(:non_member) | false
Featurable::ENABLED | ref(:guest) | true
Featurable::ENABLED | ref(:reporter) | true
Featurable::ENABLED | ref(:developer) | true
Featurable::ENABLED | ref(:maintainer) | true
Featurable::ENABLED | ref(:owner) | true
Featurable::PRIVATE | ref(:anonymous) | false
Featurable::PRIVATE | ref(:non_member) | false
Featurable::PRIVATE | ref(:guest) | true
Featurable::PRIVATE | ref(:reporter) | true
Featurable::PRIVATE | ref(:developer) | true
Featurable::PRIVATE | ref(:maintainer) | true
Featurable::PRIVATE | ref(:owner) | true
end
with_them do
before do
project.project_feature.update!(model_experiments_access_level: access_level)
end
if params[:allowed]
it { expect_allowed(:read_model_experiments) }
else
it { expect_disallowed(:read_model_experiments) }
end
end
end
end

View File

@ -41,92 +41,52 @@ RSpec.describe ::API::MlModelPackages, feature_category: :mlops do
# rubocop:disable Metrics/AbcSize
# :valid_token, :user_role, :visibility, :member, :token_type, :expected_status
def authorize_permissions_table
false | :developer | :private | false | :job_token | :unauthorized
false | :developer | :private | false | :personal_access_token | :unauthorized
false | :developer | :private | true | :job_token | :unauthorized
false | :developer | :private | true | :personal_access_token | :unauthorized
false | :developer | :public | false | :job_token | :unauthorized
false | :developer | :public | false | :personal_access_token | :unauthorized
false | :developer | :public | true | :job_token | :unauthorized
false | :developer | :public | true | :personal_access_token | :unauthorized
false | :guest | :private | false | :job_token | :unauthorized
false | :guest | :private | false | :personal_access_token | :unauthorized
false | :guest | :private | true | :job_token | :unauthorized
false | :guest | :private | true | :personal_access_token | :unauthorized
false | :guest | :public | false | :job_token | :unauthorized
false | :guest | :public | false | :personal_access_token | :unauthorized
false | :guest | :public | true | :job_token | :unauthorized
false | :guest | :public | true | :personal_access_token | :unauthorized
true | :anonymous | :private | false | :personal_access_token | :unauthorized
true | :anonymous | :public | false | :personal_access_token | :unauthorized
true | :developer | :private | false | :job_token | :forbidden
true | :developer | :private | false | :personal_access_token | :not_found
true | :developer | :private | true | :job_token | :success
true | :developer | :private | true | :personal_access_token | :success
true | :developer | :public | false | :job_token | :forbidden
true | :developer | :public | false | :personal_access_token | :forbidden
true | :developer | :public | true | :job_token | :success
true | :developer | :public | true | :personal_access_token | :success
true | :guest | :private | false | :job_token | :forbidden
true | :guest | :private | false | :personal_access_token | :not_found
true | :guest | :private | true | :job_token | :forbidden
true | :guest | :private | true | :personal_access_token | :forbidden
true | :guest | :public | false | :job_token | :forbidden
true | :guest | :public | false | :personal_access_token | :forbidden
true | :guest | :public | true | :job_token | :forbidden
true | :guest | :public | true | :personal_access_token | :forbidden
true | :reporter | :private | false | :job_token | :forbidden
true | :reporter | :private | false | :personal_access_token | :not_found
true | :reporter | :private | true | :job_token | :forbidden
true | :reporter | :private | true | :personal_access_token | :forbidden
true | :reporter | :public | false | :job_token | :forbidden
true | :reporter | :public | false | :personal_access_token | :forbidden
true | :reporter | :public | true | :job_token | :forbidden
true | :reporter | :public | true | :personal_access_token | :forbidden
end
# ::valid_token, :user_role, visibility, :member, :token_type, :expected_status
def download_permissions_tables
false | :developer | :private | false | :job_token | :unauthorized
false | :developer | :private | false | :personal_access_token | :unauthorized
false | :developer | :private | true | :job_token | :unauthorized
false | :developer | :private | true | :personal_access_token | :unauthorized
false | :developer | :public | false | :job_token | :unauthorized
false | :developer | :public | false | :personal_access_token | :unauthorized
false | :developer | :public | true | :job_token | :unauthorized
false | :developer | :public | true | :personal_access_token | :unauthorized
false | :guest | :private | false | :job_token | :unauthorized
false | :guest | :private | false | :personal_access_token | :unauthorized
false | :guest | :private | true | :job_token | :unauthorized
false | :guest | :private | true | :personal_access_token | :unauthorized
false | :guest | :public | false | :job_token | :unauthorized
false | :guest | :public | false | :personal_access_token | :unauthorized
false | :guest | :public | true | :job_token | :unauthorized
false | :guest | :public | true | :personal_access_token | :unauthorized
true | :anonymous | :private | false | :personal_access_token | :not_found
true | :anonymous | :public | false | :personal_access_token | :success
true | :developer | :private | false | :job_token | :forbidden
true | :developer | :private | false | :personal_access_token | :not_found
true | :developer | :private | true | :job_token | :success
true | :developer | :private | true | :personal_access_token | :success
true | :developer | :public | false | :job_token | :success
true | :developer | :public | false | :personal_access_token | :success
true | :developer | :public | true | :job_token | :success
true | :developer | :public | true | :personal_access_token | :success
true | :guest | :private | false | :job_token | :forbidden
true | :guest | :private | false | :personal_access_token | :not_found
true | :guest | :private | true | :job_token | :forbidden
true | :guest | :private | true | :personal_access_token | :forbidden
true | :guest | :public | false | :job_token | :success
true | :guest | :public | false | :personal_access_token | :success
true | :guest | :public | true | :job_token | :success
true | :guest | :public | true | :personal_access_token | :success
true | :reporter | :private | false | :job_token | :forbidden
true | :reporter | :private | false | :personal_access_token | :not_found
true | :reporter | :private | true | :job_token | :success
true | :reporter | :private | true | :personal_access_token | :success
true | :reporter | :public | false | :job_token | :success
true | :reporter | :public | false | :personal_access_token | :success
true | :reporter | :public | true | :job_token | :success
true | :reporter | :public | true | :personal_access_token | :success
end

View File

@ -1334,6 +1334,7 @@ RSpec.describe API::VirtualRegistries::Packages::Maven, :aggregate_failures, fea
expect { request }.to change { upstream.cached_responses.count }.by(1)
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to eq('')
expect(upstream.cached_responses.last).to have_attributes(
relative_path: "/#{path}",
upstream_etag: nil,

View File

@ -196,11 +196,6 @@ RSpec.shared_examples 'Debian Component File' do |container_type, can_freeze|
.to receive(:update_file_store)
.and_call_original
expect(component_file)
.to receive(:update_column)
.with('file_store', ::Packages::PackageFileUploader::Store::LOCAL)
.and_call_original
expect { subject }.to change { component_file.size }.from(nil).to(74)
end
end

View File

@ -180,6 +180,18 @@ RSpec.describe GitlabUploader, feature_category: :shared do
it { is_expected.to eq(%w[Running gitlab-runner]) }
end
describe '#check_remote_file_existence_on_upload?' do
subject { uploader.check_remote_file_existence_on_upload? }
it { is_expected.to be(true) }
end
describe '#sync_model_object_store?' do
subject { uploader.sync_model_object_store? }
it { is_expected.to be(false) }
end
describe '.version' do
subject { uploader_class.version }

View File

@ -135,6 +135,30 @@ RSpec.describe ObjectStorage, :clean_gitlab_redis_shared_state, feature_category
end
end
end
context 'with a model that persist object store' do
before do
allow(uploader).to receive_messages(sync_model_object_store?: false, persist_object_store?: true)
end
it 'does not sync with the model' do
expect(object).not_to receive(:"[]=")
uploader.object_store = described_class::Store::LOCAL
end
context 'with an uploader that sync with the model' do
before do
allow(uploader).to receive(:sync_model_object_store?).and_return(true)
end
it 'syncs with the model' do
expect(object).to receive(:"[]=").with(:file_store, described_class::Store::LOCAL)
uploader.object_store = described_class::Store::LOCAL
end
end
end
end
describe '#object_store' do
@ -994,6 +1018,16 @@ RSpec.describe ObjectStorage, :clean_gitlab_redis_shared_state, feature_category
it 'raises an error' do
expect { subject }.to raise_error(uploader_class::RemoteStoreError, /Missing file/)
end
context 'when check_remote_file_existence_on_upload? is set to false' do
before do
allow(uploader).to receive(:check_remote_file_existence_on_upload?).and_return(false)
end
it 'does not raise an error' do
expect { subject }.not_to raise_error
end
end
end
context 'when empty remote_id is specified' do

View File

@ -12,13 +12,29 @@ RSpec.describe ::VirtualRegistries::CachedResponseUploader, feature_category: :v
)
end
subject(:uploader) { described_class.new(cached_response, :file) }
let(:uploader) { described_class.new(cached_response, :file) }
it { is_expected.to include_module(::ObjectStorage::Concern) }
describe 'inclusions' do
subject { uploader }
it { is_expected.to include_module(::ObjectStorage::Concern) }
end
describe '#store_dir' do
it 'uses the object_storage_key' do
expect(uploader.store_dir).to eq(object_storage_key)
end
subject { uploader.store_dir }
it { is_expected.to eq(object_storage_key) }
end
describe '#check_remote_file_existence_on_upload?' do
subject { uploader.check_remote_file_existence_on_upload? }
it { is_expected.to be(false) }
end
describe '#sync_model_object_store?' do
subject { uploader.sync_model_object_store? }
it { is_expected.to be(true) }
end
end

View File

@ -27,6 +27,48 @@ RSpec.describe Releases::ManageEvidenceWorker, feature_category: :release_eviden
end
end
context 'when pipeline finder times out' do
let!(:release_without_evidence) { create(:release, project: project, released_at: 1.hour.since) }
let!(:release_with_evidence) { create(:release, project: project, released_at: 1.hour.since) }
let!(:evidence) { create(:evidence, release: release_with_evidence) }
let(:finder) { instance_double(Releases::EvidencePipelineFinder) }
it 'continues processing other releases', :sidekiq_inline do
allow(Releases::EvidencePipelineFinder).to receive(:new)
.with(release_without_evidence.project, tag: release_without_evidence.tag)
.and_return(finder)
allow(finder).to receive(:execute).and_raise(ActiveRecord::StatementTimeout)
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(ActiveRecord::StatementTimeout),
release_id: release_without_evidence.id,
project_id: project.id
)
expect { described_class.new.perform }.to change { Releases::Evidence.count }.by(0)
end
end
context 'when pipeline finder raises error' do
let(:finder) { instance_double(Releases::EvidencePipelineFinder) }
let!(:release) { create(:release, project: project, released_at: 1.hour.since) }
it 'tracks error and continues' do
allow(Releases::EvidencePipelineFinder).to receive(:new)
.with(release.project, tag: release.tag)
.and_return(finder)
allow(finder).to receive(:execute).and_raise(StandardError)
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
instance_of(StandardError),
release_id: release.id,
project_id: project.id
)
expect { described_class.new.perform }.not_to raise_error
end
end
context 'when evidence has already been created' do
let(:release) { create(:release, project: project, released_at: 1.hour.since) }
let!(:evidence) { create(:evidence, release: release ) }