Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
da1268042d
commit
bbc7c5d1d9
|
|
@ -1 +1 @@
|
|||
v0.0.14
|
||||
v0.0.15
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -25,6 +25,7 @@ Gitlab::Database::Partitioning.register_models(
|
|||
Ci::FinishedPipelineChSyncEvent,
|
||||
Ci::JobAnnotation,
|
||||
Ci::JobArtifact,
|
||||
Ci::Pipeline,
|
||||
Ci::PipelineConfig,
|
||||
Ci::PipelineVariable,
|
||||
Ci::RunnerManagerBuild,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
8751aaee2fd437ce0ac0bc25ce474d447a2727de7c82762ca7b025a66fce8bf0
|
||||
|
|
@ -0,0 +1 @@
|
|||
c7e983a2d607e5df01005bae3933b404b87c67194a14484ad70c688b4099201e
|
||||
|
|
@ -0,0 +1 @@
|
|||
13593ca33500c36b36289eaf918d573c8a55aa9fd0878e88f430e7c5998fc8d2
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. | | |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ describe('IssueToken', () => {
|
|||
});
|
||||
|
||||
it('emits event when clicked', () => {
|
||||
findRemoveBtn().trigger('click');
|
||||
findRemoveBtn().vm.$emit('click');
|
||||
|
||||
const emitted = wrapper.emitted(`${eventNamespace}RemoveRequest`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ) }
|
||||
|
|
|
|||
Loading…
Reference in New Issue