Add latest changes from gitlab-org/gitlab@master
|
|
@ -1,6 +1,5 @@
|
|||
<script>
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import eventHub from '../eventhub';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
@ -11,10 +10,6 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
|
@ -30,6 +25,10 @@ export default {
|
|||
required: false,
|
||||
default: '',
|
||||
},
|
||||
mutation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -39,10 +38,15 @@ export default {
|
|||
methods: {
|
||||
doAction() {
|
||||
this.isLoading = true;
|
||||
|
||||
eventHub.$emit(`${this.type}.key`, this.deployKey, () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: this.mutation,
|
||||
variables: { id: this.deployKey.id },
|
||||
})
|
||||
.catch((error) => this.$emit('error', error))
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -50,6 +54,7 @@ export default {
|
|||
|
||||
<template>
|
||||
<gl-button
|
||||
v-bind="$attrs"
|
||||
:category="category"
|
||||
:variant="variant"
|
||||
:icon="icon"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
<script>
|
||||
import { GlButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
|
||||
import { GlButton, GlIcon, GlLoadingIcon, GlPagination } from '@gitlab/ui';
|
||||
import { createAlert } from '~/alert';
|
||||
import { s__ } from '~/locale';
|
||||
import { s__, __, sprintf } from '~/locale';
|
||||
import { captureException } from '~/sentry/sentry_browser_wrapper';
|
||||
import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql';
|
||||
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
|
||||
import eventHub from '../eventhub';
|
||||
import DeployKeysService from '../service';
|
||||
import DeployKeysStore from '../store';
|
||||
import deployKeysQuery from '../graphql/queries/deploy_keys.query.graphql';
|
||||
import currentPageQuery from '../graphql/queries/current_page.query.graphql';
|
||||
import currentScopeQuery from '../graphql/queries/current_scope.query.graphql';
|
||||
import confirmRemoveKeyQuery from '../graphql/queries/confirm_remove_key.query.graphql';
|
||||
import updateCurrentScopeMutation from '../graphql/mutations/update_current_scope.mutation.graphql';
|
||||
import updateCurrentPageMutation from '../graphql/mutations/update_current_page.mutation.graphql';
|
||||
import confirmDisableMutation from '../graphql/mutations/confirm_action.mutation.graphql';
|
||||
import disableKeyMutation from '../graphql/mutations/disable_key.mutation.graphql';
|
||||
import ConfirmModal from './confirm_modal.vue';
|
||||
import KeysPanel from './keys_panel.vue';
|
||||
|
||||
|
|
@ -17,120 +24,147 @@ export default {
|
|||
GlButton,
|
||||
GlIcon,
|
||||
GlLoadingIcon,
|
||||
GlPagination,
|
||||
},
|
||||
props: {
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projectId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projectPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
deployKeys: {
|
||||
query: deployKeysQuery,
|
||||
variables() {
|
||||
return {
|
||||
projectPath: this.projectPath,
|
||||
scope: this.currentScope,
|
||||
page: this.currentPage,
|
||||
};
|
||||
},
|
||||
update(data) {
|
||||
return data?.project?.deployKeys || [];
|
||||
},
|
||||
error(error) {
|
||||
createAlert({
|
||||
message: s__('DeployKeys|Error getting deploy keys'),
|
||||
captureError: true,
|
||||
error,
|
||||
});
|
||||
},
|
||||
},
|
||||
pageInfo: {
|
||||
query: pageInfoQuery,
|
||||
variables() {
|
||||
return { input: { page: this.currentPage, scope: this.currentScope } };
|
||||
},
|
||||
update({ pageInfo }) {
|
||||
return pageInfo || {};
|
||||
},
|
||||
},
|
||||
currentPage: {
|
||||
query: currentPageQuery,
|
||||
},
|
||||
currentScope: {
|
||||
query: currentScopeQuery,
|
||||
},
|
||||
deployKeyToRemove: {
|
||||
query: confirmRemoveKeyQuery,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentTab: 'enabled_keys',
|
||||
isLoading: false,
|
||||
store: new DeployKeysStore(),
|
||||
removeKey: () => {},
|
||||
cancel: () => {},
|
||||
confirmModalVisible: false,
|
||||
deployKeys: [],
|
||||
pageInfo: {},
|
||||
deployKeyToRemove: null,
|
||||
};
|
||||
},
|
||||
scopes: {
|
||||
enabled_keys: s__('DeployKeys|Enabled deploy keys'),
|
||||
available_project_keys: s__('DeployKeys|Privately accessible deploy keys'),
|
||||
public_keys: s__('DeployKeys|Publicly accessible deploy keys'),
|
||||
enabledKeys: s__('DeployKeys|Enabled deploy keys'),
|
||||
availableProjectKeys: s__('DeployKeys|Privately accessible deploy keys'),
|
||||
availablePublicKeys: s__('DeployKeys|Publicly accessible deploy keys'),
|
||||
},
|
||||
i18n: {
|
||||
loading: s__('DeployKeys|Loading deploy keys'),
|
||||
addButton: s__('DeployKeys|Add new key'),
|
||||
prevPage: __('Go to previous page'),
|
||||
nextPage: __('Go to next page'),
|
||||
next: __('Next'),
|
||||
prev: __('Prev'),
|
||||
goto: (page) => sprintf(__('Go to page %{page}'), { page }),
|
||||
},
|
||||
computed: {
|
||||
tabs() {
|
||||
return Object.keys(this.$options.scopes).map((scope) => {
|
||||
const count = Array.isArray(this.keys[scope]) ? this.keys[scope].length : null;
|
||||
|
||||
return Object.entries(this.$options.scopes).map(([scope, name]) => {
|
||||
return {
|
||||
name: this.$options.scopes[scope],
|
||||
name,
|
||||
scope,
|
||||
isActive: scope === this.currentTab,
|
||||
count,
|
||||
isActive: scope === this.currentScope,
|
||||
};
|
||||
});
|
||||
},
|
||||
hasKeys() {
|
||||
return Object.keys(this.keys).length;
|
||||
confirmModalVisible() {
|
||||
return Boolean(this.deployKeyToRemove);
|
||||
},
|
||||
keys() {
|
||||
return this.store.keys;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.service = new DeployKeysService(this.endpoint);
|
||||
|
||||
eventHub.$on('enable.key', this.enableKey);
|
||||
eventHub.$on('remove.key', this.confirmRemoveKey);
|
||||
eventHub.$on('disable.key', this.confirmRemoveKey);
|
||||
},
|
||||
mounted() {
|
||||
this.fetchKeys();
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('enable.key', this.enableKey);
|
||||
eventHub.$off('remove.key', this.confirmRemoveKey);
|
||||
eventHub.$off('disable.key', this.confirmRemoveKey);
|
||||
},
|
||||
methods: {
|
||||
onChangeTab(tab) {
|
||||
this.currentTab = tab;
|
||||
},
|
||||
fetchKeys() {
|
||||
this.isLoading = true;
|
||||
|
||||
return this.service
|
||||
.getKeys()
|
||||
.then((data) => {
|
||||
this.isLoading = false;
|
||||
this.store.keys = data;
|
||||
onChangeTab(scope) {
|
||||
return this.$apollo
|
||||
.mutate({
|
||||
mutation: updateCurrentScopeMutation,
|
||||
variables: { scope },
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
this.store.keys = {};
|
||||
return createAlert({
|
||||
message: s__('DeployKeys|Error getting deploy keys'),
|
||||
.then(() => {
|
||||
this.$apollo.queries.deployKeys.refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
captureException(error, {
|
||||
tags: {
|
||||
deployKeyScope: scope,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
enableKey(deployKey) {
|
||||
this.service
|
||||
.enableKey(deployKey.id)
|
||||
.then(this.fetchKeys)
|
||||
.catch(() =>
|
||||
createAlert({
|
||||
message: s__('DeployKeys|Error enabling deploy key'),
|
||||
}),
|
||||
);
|
||||
moveNext() {
|
||||
return this.movePage('next');
|
||||
},
|
||||
confirmRemoveKey(deployKey, callback) {
|
||||
const hideModal = () => {
|
||||
this.confirmModalVisible = false;
|
||||
callback?.();
|
||||
};
|
||||
this.removeKey = () => {
|
||||
this.service
|
||||
.disableKey(deployKey.id)
|
||||
.then(this.fetchKeys)
|
||||
.then(hideModal)
|
||||
.catch(() =>
|
||||
createAlert({
|
||||
message: s__('DeployKeys|Error removing deploy key'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
this.cancel = hideModal;
|
||||
this.confirmModalVisible = true;
|
||||
movePrevious() {
|
||||
return this.movePage('previous');
|
||||
},
|
||||
movePage(direction) {
|
||||
return this.moveToPage(this.pageInfo[`${direction}Page`]);
|
||||
},
|
||||
moveToPage(page) {
|
||||
return this.$apollo.mutate({ mutation: updateCurrentPageMutation, variables: { page } });
|
||||
},
|
||||
removeKey() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: disableKeyMutation,
|
||||
variables: { id: this.deployKeyToRemove.id },
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.deployKeys.length) {
|
||||
return this.movePage('previous');
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.then(() => this.$apollo.queries.deployKeys.refetch())
|
||||
.catch(() => {
|
||||
createAlert({
|
||||
message: s__('DeployKeys|Error removing deploy key'),
|
||||
});
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.$apollo.mutate({
|
||||
mutation: confirmDisableMutation,
|
||||
variables: { id: null },
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -139,47 +173,59 @@ export default {
|
|||
<template>
|
||||
<div class="deploy-keys">
|
||||
<confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" />
|
||||
<div class="gl-new-card-header gl-align-items-center gl-py-0 gl-pl-0">
|
||||
<div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0">
|
||||
<div class="fade-left">
|
||||
<gl-icon name="chevron-lg-left" :size="12" />
|
||||
</div>
|
||||
<div class="fade-right">
|
||||
<gl-icon name="chevron-lg-right" :size="12" />
|
||||
</div>
|
||||
|
||||
<navigation-tabs
|
||||
:tabs="tabs"
|
||||
scope="deployKeys"
|
||||
class="gl-rounded-lg"
|
||||
@onChangeTab="onChangeTab"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="gl-new-card-actions">
|
||||
<gl-button
|
||||
size="small"
|
||||
class="js-toggle-button js-toggle-content"
|
||||
data-testid="add-new-deploy-key-button"
|
||||
>
|
||||
{{ $options.i18n.addButton }}
|
||||
</gl-button>
|
||||
</div>
|
||||
</div>
|
||||
<gl-loading-icon
|
||||
v-if="isLoading && !hasKeys"
|
||||
v-if="$apollo.queries.deployKeys.loading"
|
||||
:label="$options.i18n.loading"
|
||||
size="sm"
|
||||
size="md"
|
||||
class="gl-m-5"
|
||||
/>
|
||||
<template v-else-if="hasKeys">
|
||||
<div class="gl-new-card-header gl-align-items-center gl-pt-0 gl-pb-0 gl-pl-0">
|
||||
<div class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-b-0">
|
||||
<div class="fade-left">
|
||||
<gl-icon name="chevron-lg-left" :size="12" />
|
||||
</div>
|
||||
<div class="fade-right">
|
||||
<gl-icon name="chevron-lg-right" :size="12" />
|
||||
</div>
|
||||
|
||||
<navigation-tabs
|
||||
:tabs="tabs"
|
||||
scope="deployKeys"
|
||||
class="gl-rounded-lg"
|
||||
@onChangeTab="onChangeTab"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="gl-new-card-actions">
|
||||
<gl-button
|
||||
size="small"
|
||||
class="js-toggle-button js-toggle-content"
|
||||
data-testid="add-new-deploy-key-button"
|
||||
>
|
||||
{{ $options.i18n.addButton }}
|
||||
</gl-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<keys-panel
|
||||
:project-id="projectId"
|
||||
:keys="keys[currentTab]"
|
||||
:store="store"
|
||||
:endpoint="endpoint"
|
||||
:keys="deployKeys"
|
||||
data-testid="project-deploy-keys-container"
|
||||
/>
|
||||
<gl-pagination
|
||||
align="center"
|
||||
:total-items="pageInfo.total"
|
||||
:per-page="pageInfo.perPage"
|
||||
:value="currentPage"
|
||||
:next="$options.i18n.next"
|
||||
:prev="$options.i18n.prev"
|
||||
:label-previous-page="$options.i18n.prevPage"
|
||||
:label-next-page="$options.i18n.nextPage"
|
||||
:label-page="$options.i18n.goto"
|
||||
@next="moveNext()"
|
||||
@previous="movePrevious()"
|
||||
@input="moveToPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
<script>
|
||||
import { GlBadge, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { head, tail } from 'lodash';
|
||||
import { createAlert } from '~/alert';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import timeagoMixin from '~/vue_shared/mixins/timeago';
|
||||
import currentScopeQuery from '../graphql/queries/current_scope.query.graphql';
|
||||
import enableKeyMutation from '../graphql/mutations/enable_key.mutation.graphql';
|
||||
import confirmDisableMutation from '../graphql/mutations/confirm_action.mutation.graphql';
|
||||
|
||||
import ActionBtn from './action_btn.vue';
|
||||
|
||||
|
|
@ -23,48 +27,25 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projectId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
currentScope: {
|
||||
query: currentScopeQuery,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
projectsExpanded: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
editDeployKeyPath() {
|
||||
return `${this.endpoint}/${this.deployKey.id}/edit`;
|
||||
},
|
||||
projects() {
|
||||
const projects = [...this.deployKey.deploy_keys_projects];
|
||||
|
||||
if (this.projectId !== null) {
|
||||
const indexOfCurrentProject = projects.findIndex(
|
||||
(project) =>
|
||||
project &&
|
||||
project.project &&
|
||||
project.project.id &&
|
||||
project.project.id.toString() === this.projectId,
|
||||
);
|
||||
|
||||
if (indexOfCurrentProject > -1) {
|
||||
const currentProject = projects.splice(indexOfCurrentProject, 1);
|
||||
currentProject[0].project.full_name = s__('DeployKeys|Current project');
|
||||
return currentProject.concat(projects);
|
||||
}
|
||||
}
|
||||
return projects;
|
||||
return this.deployKey.deployKeysProjects;
|
||||
},
|
||||
firstProject() {
|
||||
return head(this.projects);
|
||||
|
|
@ -81,13 +62,11 @@ export default {
|
|||
return sprintf(s__('DeployKeys|+%{count} others'), { count: this.restProjects.length });
|
||||
},
|
||||
isEnabled() {
|
||||
return this.store.isEnabled(this.deployKey.id);
|
||||
return this.currentScope === 'enabledKeys';
|
||||
},
|
||||
isRemovable() {
|
||||
return (
|
||||
this.store.isEnabled(this.deployKey.id) &&
|
||||
this.deployKey.destroyed_when_orphaned &&
|
||||
this.deployKey.almost_orphaned
|
||||
this.isEnabled && this.deployKey.destroyedWhenOrphaned && this.deployKey.almostOrphaned
|
||||
);
|
||||
},
|
||||
isExpandable() {
|
||||
|
|
@ -99,14 +78,37 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
projectTooltipTitle(project) {
|
||||
return project.can_push
|
||||
return project.canPush
|
||||
? s__('DeployKeys|Grant write permissions to this key')
|
||||
: s__('DeployKeys|Read access only');
|
||||
},
|
||||
toggleExpanded() {
|
||||
this.projectsExpanded = !this.projectsExpanded;
|
||||
},
|
||||
isCurrentProject({ project } = {}) {
|
||||
if (this.projectId !== null) {
|
||||
return Boolean(project?.id?.toString() === this.projectId);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
projectName(project) {
|
||||
if (this.isCurrentProject(project)) {
|
||||
return s__('DeployKeys|Current project');
|
||||
}
|
||||
|
||||
return project?.project?.fullName;
|
||||
},
|
||||
onEnableError(error) {
|
||||
createAlert({
|
||||
message: s__('DeployKeys|Error enabling deploy key'),
|
||||
captureError: true,
|
||||
error,
|
||||
});
|
||||
},
|
||||
},
|
||||
enableKeyMutation,
|
||||
confirmDisableMutation,
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -128,7 +130,7 @@ export default {
|
|||
<dl class="gl-font-sm gl-mb-0">
|
||||
<dt>{{ __('SHA256') }}</dt>
|
||||
<dd class="fingerprint" data-testid="key-sha256-fingerprint-content">
|
||||
{{ deployKey.fingerprint_sha256 }}
|
||||
{{ deployKey.fingerprintSha256 }}
|
||||
</dd>
|
||||
<template v-if="deployKey.fingerprint">
|
||||
<dt>
|
||||
|
|
@ -150,10 +152,10 @@ export default {
|
|||
<gl-badge
|
||||
v-gl-tooltip
|
||||
:title="projectTooltipTitle(firstProject)"
|
||||
:icon="firstProject.can_push ? 'lock-open' : 'lock'"
|
||||
:icon="firstProject.canPush ? 'lock-open' : 'lock'"
|
||||
class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
|
||||
>
|
||||
<span class="gl-text-truncate">{{ firstProject.project.full_name }}</span>
|
||||
<span class="gl-text-truncate">{{ projectName(firstProject) }}</span>
|
||||
</gl-badge>
|
||||
|
||||
<gl-badge
|
||||
|
|
@ -170,14 +172,14 @@ export default {
|
|||
<gl-badge
|
||||
v-for="deployKeysProject in restProjects"
|
||||
v-else-if="isExpanded"
|
||||
:key="deployKeysProject.project.full_path"
|
||||
:key="deployKeysProject.project.fullPath"
|
||||
v-gl-tooltip
|
||||
:href="deployKeysProject.project.full_path"
|
||||
:href="deployKeysProject.project.fullPath"
|
||||
:title="projectTooltipTitle(deployKeysProject)"
|
||||
:icon="deployKeysProject.can_push ? 'lock-open' : 'lock'"
|
||||
:icon="deployKeysProject.canPush ? 'lock-open' : 'lock'"
|
||||
class="deploy-project-label gl-mr-2 gl-mb-2 gl-truncate"
|
||||
>
|
||||
<span class="gl-text-truncate">{{ deployKeysProject.project.full_name }}</span>
|
||||
<span class="gl-text-truncate">{{ projectName(deployKeysProject) }}</span>
|
||||
</gl-badge>
|
||||
</template>
|
||||
<span v-else class="gl-text-secondary">{{ __('None') }}</span>
|
||||
|
|
@ -188,8 +190,8 @@ export default {
|
|||
{{ __('Created') }}
|
||||
</div>
|
||||
<div class="table-mobile-content gl-text-gray-700 key-created-at">
|
||||
<span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
|
||||
<gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span>
|
||||
<span v-gl-tooltip :title="tooltipTitle(deployKey.createdAt)">
|
||||
<gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -199,12 +201,12 @@ export default {
|
|||
</div>
|
||||
<div class="table-mobile-content gl-text-gray-700 key-expires-at">
|
||||
<span
|
||||
v-if="deployKey.expires_at"
|
||||
v-if="deployKey.expiresAt"
|
||||
v-gl-tooltip
|
||||
:title="tooltipTitle(deployKey.expires_at)"
|
||||
:title="tooltipTitle(deployKey.expiresAt)"
|
||||
data-testid="expires-at-tooltip"
|
||||
>
|
||||
<gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span>
|
||||
<gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expiresAt) }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span data-testid="expires-never">{{ __('Never') }}</span>
|
||||
|
|
@ -213,13 +215,19 @@ export default {
|
|||
</div>
|
||||
<div class="table-section section-10 table-button-footer deploy-key-actions">
|
||||
<div class="btn-group table-action-buttons">
|
||||
<action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
|
||||
<action-btn
|
||||
v-if="!isEnabled"
|
||||
:deploy-key="deployKey"
|
||||
:mutation="$options.enableKeyMutation"
|
||||
category="secondary"
|
||||
@error="onEnableError"
|
||||
>
|
||||
{{ __('Enable') }}
|
||||
</action-btn>
|
||||
<gl-button
|
||||
v-if="deployKey.can_edit"
|
||||
v-if="deployKey.editPath"
|
||||
v-gl-tooltip
|
||||
:href="editDeployKeyPath"
|
||||
:href="deployKey.editPath"
|
||||
:title="__('Edit')"
|
||||
:aria-label="__('Edit')"
|
||||
data-container="body"
|
||||
|
|
@ -232,10 +240,10 @@ export default {
|
|||
:deploy-key="deployKey"
|
||||
:title="__('Remove')"
|
||||
:aria-label="__('Remove')"
|
||||
:mutation="$options.confirmDisableMutation"
|
||||
category="secondary"
|
||||
variant="danger"
|
||||
icon="remove"
|
||||
type="remove"
|
||||
data-container="body"
|
||||
/>
|
||||
<action-btn
|
||||
|
|
@ -244,7 +252,7 @@ export default {
|
|||
:deploy-key="deployKey"
|
||||
:title="__('Disable')"
|
||||
:aria-label="__('Disable')"
|
||||
type="disable"
|
||||
:mutation="$options.confirmDisableMutation"
|
||||
data-container="body"
|
||||
icon="cancel"
|
||||
category="secondary"
|
||||
|
|
|
|||
|
|
@ -10,14 +10,6 @@ export default {
|
|||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
endpoint: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projectId: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
|
@ -48,8 +40,6 @@ export default {
|
|||
v-for="deployKey in keys"
|
||||
:key="deployKey.id"
|
||||
:deploy-key="deployKey"
|
||||
:store="store"
|
||||
:endpoint="endpoint"
|
||||
:project-id="projectId"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export const mapDeployKey = (deployKey) => ({
|
|||
__typename: 'LocalDeployKey',
|
||||
});
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 5;
|
||||
|
||||
export const resolvers = (endpoints) => ({
|
||||
Project: {
|
||||
deployKeys(_, { scope, page }, { client }) {
|
||||
|
|
@ -25,19 +27,21 @@ export const resolvers = (endpoints) => ({
|
|||
endpoint = endpoints.enabledKeysEndpoint;
|
||||
}
|
||||
|
||||
return axios.get(endpoint, { params: { page } }).then(({ headers, data }) => {
|
||||
const normalizedHeaders = normalizeHeaders(headers);
|
||||
const pageInfo = {
|
||||
...parseIntPagination(normalizedHeaders),
|
||||
__typename: 'LocalPageInfo',
|
||||
};
|
||||
client.writeQuery({
|
||||
query: pageInfoQuery,
|
||||
variables: { input: { page, scope } },
|
||||
data: { pageInfo },
|
||||
return axios
|
||||
.get(endpoint, { params: { page, per_page: DEFAULT_PAGE_SIZE } })
|
||||
.then(({ headers, data }) => {
|
||||
const normalizedHeaders = normalizeHeaders(headers);
|
||||
const pageInfo = {
|
||||
...parseIntPagination(normalizedHeaders),
|
||||
__typename: 'LocalPageInfo',
|
||||
};
|
||||
client.writeQuery({
|
||||
query: pageInfoQuery,
|
||||
variables: { input: { page, scope } },
|
||||
data: { pageInfo },
|
||||
});
|
||||
return data?.keys?.map(mapDeployKey) || [];
|
||||
});
|
||||
return data?.keys?.map(mapDeployKey) || [];
|
||||
});
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
|
|
@ -48,6 +52,13 @@ export const resolvers = (endpoints) => ({
|
|||
});
|
||||
},
|
||||
currentScope(_, { scope }, { client }) {
|
||||
const key = `${scope}Endpoint`;
|
||||
const { [key]: endpoint } = endpoints;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error(`invalid deploy key scope selected: ${scope}`);
|
||||
}
|
||||
|
||||
client.writeQuery({
|
||||
query: currentPageQuery,
|
||||
data: { currentPage: 1 },
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import DeployKeysApp from './components/app.vue';
|
||||
import { createApolloProvider } from './graphql/client';
|
||||
|
||||
export default () =>
|
||||
new Vue({
|
||||
el: document.getElementById('js-deploy-keys'),
|
||||
components: {
|
||||
DeployKeysApp,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
endpoint: this.$options.el.dataset.endpoint,
|
||||
projectId: this.$options.el.dataset.projectId,
|
||||
};
|
||||
},
|
||||
Vue.use(VueApollo);
|
||||
|
||||
export default () => {
|
||||
const el = document.getElementById('js-deploy-keys');
|
||||
return new Vue({
|
||||
el,
|
||||
apolloProvider: createApolloProvider({
|
||||
enabledKeysEndpoint: el.dataset.enabledEndpoint,
|
||||
availableProjectKeysEndpoint: el.dataset.availableProjectEndpoint,
|
||||
availablePublicKeysEndpoint: el.dataset.availablePublicEndpoint,
|
||||
}),
|
||||
render(createElement) {
|
||||
return createElement('deploy-keys-app', {
|
||||
return createElement(DeployKeysApp, {
|
||||
props: {
|
||||
endpoint: this.endpoint,
|
||||
projectId: this.projectId,
|
||||
projectId: el.dataset.projectId,
|
||||
projectPath: el.dataset.projectPath,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
export default class DeployKeysService {
|
||||
constructor(endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
getKeys() {
|
||||
return axios.get(this.endpoint).then((response) => response.data);
|
||||
}
|
||||
|
||||
enableKey(id) {
|
||||
return axios.put(`${this.endpoint}/${id}/enable`).then((response) => response.data);
|
||||
}
|
||||
|
||||
disableKey(id) {
|
||||
return axios.put(`${this.endpoint}/${id}/disable`).then((response) => response.data);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export default class DeployKeysStore {
|
||||
constructor() {
|
||||
this.keys = {};
|
||||
}
|
||||
|
||||
isEnabled(id) {
|
||||
return this.keys.enabled_keys.some((key) => key.id === id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
import { GlCard, GlTable, GlLoadingIcon } from '@gitlab/ui';
|
||||
import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql';
|
||||
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
|
||||
import { s__ } from '~/locale';
|
||||
|
||||
const PAGINATION_DEFAULT_PER_PAGE = 10;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsBlock,
|
||||
GlCard,
|
||||
GlTable,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
inject: ['projectPath'],
|
||||
i18n: {
|
||||
settingBlockTitle: s__('PackageRegistry|Protected packages'),
|
||||
settingBlockDescription: s__(
|
||||
'PackageRegistry|When a package is protected then only certain user roles are able to update and delete the protected package. This helps to avoid tampering with the package.',
|
||||
),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fetchSettingsError: false,
|
||||
packageProtectionRules: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tableItems() {
|
||||
return this.packageProtectionRules.map((packagesProtectionRule) => {
|
||||
return {
|
||||
col_1_package_name_pattern: packagesProtectionRule.packageNamePattern,
|
||||
col_2_package_type: packagesProtectionRule.packageType,
|
||||
col_3_push_protected_up_to_access_level:
|
||||
packagesProtectionRule.pushProtectedUpToAccessLevel,
|
||||
};
|
||||
});
|
||||
},
|
||||
totalItems() {
|
||||
return this.packageProtectionRules.length;
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
packageProtectionRules: {
|
||||
query: packagesProtectionRuleQuery,
|
||||
variables() {
|
||||
return {
|
||||
projectPath: this.projectPath,
|
||||
first: PAGINATION_DEFAULT_PER_PAGE,
|
||||
};
|
||||
},
|
||||
update: (data) => {
|
||||
return data.project?.packagesProtectionRules?.nodes || [];
|
||||
},
|
||||
error(e) {
|
||||
this.fetchSettingsError = e;
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
key: 'col_1_package_name_pattern',
|
||||
label: s__('PackageRegistry|Package name pattern'),
|
||||
},
|
||||
{ key: 'col_2_package_type', label: s__('PackageRegistry|Package type') },
|
||||
{
|
||||
key: 'col_3_push_protected_up_to_access_level',
|
||||
label: s__('PackageRegistry|Push protected up to access level'),
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<settings-block>
|
||||
<template #title>{{ $options.i18n.settingBlockTitle }}</template>
|
||||
|
||||
<template #description>
|
||||
{{ $options.i18n.settingBlockDescription }}
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<gl-card
|
||||
class="gl-new-card"
|
||||
header-class="gl-new-card-header"
|
||||
body-class="gl-new-card-body gl-px-0"
|
||||
>
|
||||
<template #header>
|
||||
<div class="gl-new-card-title-wrapper gl-justify-content-space-between">
|
||||
<h3 class="gl-new-card-title">{{ $options.i18n.settingBlockTitle }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<gl-table
|
||||
:items="tableItems"
|
||||
:fields="$options.fields"
|
||||
show-empty
|
||||
stacked="md"
|
||||
class="mb-3"
|
||||
>
|
||||
<template #table-busy>
|
||||
<gl-loading-icon size="sm" class="gl-my-5" />
|
||||
</template>
|
||||
</gl-table>
|
||||
</template>
|
||||
</gl-card>
|
||||
</template>
|
||||
</settings-block>
|
||||
</template>
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '~/packages_and_registries/settings/project/constants';
|
||||
import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue';
|
||||
import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
@ -18,7 +19,10 @@ export default {
|
|||
),
|
||||
GlAlert,
|
||||
PackagesCleanupPolicy,
|
||||
PackagesProtectionRules: () =>
|
||||
import('~/packages_and_registries/settings/project/components/packages_protection_rules.vue'),
|
||||
},
|
||||
mixins: [glFeatureFlagsMixin()],
|
||||
inject: [
|
||||
'showContainerRegistrySettings',
|
||||
'showPackageRegistrySettings',
|
||||
|
|
@ -32,6 +36,11 @@ export default {
|
|||
showAlert: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showProtectedPackagesSettings() {
|
||||
return this.showPackageRegistrySettings && this.glFeatures.packagesProtectedPackages;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.checkAlert();
|
||||
},
|
||||
|
|
@ -60,6 +69,7 @@ export default {
|
|||
>
|
||||
{{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }}
|
||||
</gl-alert>
|
||||
<packages-protection-rules v-if="showProtectedPackagesSettings" />
|
||||
<packages-cleanup-policy v-if="showPackageRegistrySettings" />
|
||||
<container-expiration-policy v-if="showContainerRegistrySettings" />
|
||||
<dependency-proxy-packages-settings v-if="showDependencyProxySettings" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
query getProjectPackageProtectionRules($projectPath: ID!, $first: Int) {
|
||||
project(fullPath: $projectPath) {
|
||||
id
|
||||
packagesProtectionRules(first: $first) {
|
||||
nodes {
|
||||
id
|
||||
packageNamePattern
|
||||
packageType
|
||||
pushProtectedUpToAccessLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,28 +57,31 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="widgetName" class="gl-new-card" :aria-expanded="isOpenString">
|
||||
<div :id="widgetName" class="gl-new-card">
|
||||
<div class="gl-new-card-header">
|
||||
<div class="gl-new-card-title-wrapper">
|
||||
<h3 class="gl-new-card-title">
|
||||
<gl-link
|
||||
:id="anchorLinkId"
|
||||
class="gl-text-decoration-none"
|
||||
:href="anchorLink"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 class="gl-new-card-title">
|
||||
<div aria-hidden="true">
|
||||
<gl-link
|
||||
:id="anchorLinkId"
|
||||
class="gl-text-decoration-none gl-display-none"
|
||||
:href="anchorLink"
|
||||
/>
|
||||
</div>
|
||||
<slot name="header"></slot>
|
||||
</h3>
|
||||
</h2>
|
||||
<slot name="header-suffix"></slot>
|
||||
</div>
|
||||
<slot name="header-right"></slot>
|
||||
<div class="gl-new-card-toggle">
|
||||
<!-- https://www.w3.org/TR/wai-aria-1.2/#aria-expanded -->
|
||||
<gl-button
|
||||
category="tertiary"
|
||||
size="small"
|
||||
:icon="toggleIcon"
|
||||
:aria-label="toggleLabel"
|
||||
data-testid="widget-toggle"
|
||||
:aria-expanded="isOpenString"
|
||||
@click="toggle"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ module Projects
|
|||
urgency :low
|
||||
|
||||
def show
|
||||
push_frontend_feature_flag(:packages_protected_packages, project)
|
||||
end
|
||||
|
||||
def cleanup_tags
|
||||
|
|
|
|||
|
|
@ -13,4 +13,9 @@
|
|||
.gl-new-card-add-form.gl-m-3.gl-display-none.js-toggle-content
|
||||
= render @deploy_keys.form_partial_path
|
||||
|
||||
#js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } }
|
||||
#js-deploy-keys{ data: { project_id: @project.id,
|
||||
project_path: @project.full_path,
|
||||
enabled_endpoint: enabled_keys_project_deploy_keys_path(@project),
|
||||
available_project_endpoint: available_project_keys_project_deploy_keys_path(@project),
|
||||
available_public_endpoint: available_public_keys_project_deploy_keys_path(@project)
|
||||
} }
|
||||
|
|
|
|||
|
|
@ -195,6 +195,8 @@
|
|||
- 1
|
||||
- - compliance_management_standards_gitlab_at_least_two_approvals
|
||||
- 1
|
||||
- - compliance_management_standards_gitlab_at_least_two_approvals_group
|
||||
- 1
|
||||
- - compliance_management_standards_gitlab_base
|
||||
- 1
|
||||
- - compliance_management_standards_gitlab_group_base
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
migration_job_name: UpdateWorkspacesConfigVersion3
|
||||
description: Update config_version to 3 and force_include_all_resources to true for existing workspaces
|
||||
feature_category: remote_development
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140972
|
||||
milestone: '16.8'
|
||||
queued_migration_version: 20240104085448
|
||||
finalize_after: "2024-02-15"
|
||||
finalized_by: # version of the migration that finalized this BBM
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ValidateFindingIdOnVulnerabilities < Gitlab::Database::Migration[2.2]
|
||||
# obtained by running `\d vulnerabilities` on https://console.postgres.ai
|
||||
FK_NAME = :fk_4e64972902
|
||||
|
||||
milestone '16.8'
|
||||
|
||||
# validated asynchronously in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131979
|
||||
def up
|
||||
validate_foreign_key :vulnerabilities, :finding_id, name: FK_NAME
|
||||
end
|
||||
|
||||
def down
|
||||
# Can be safely a no-op if we don't roll back the inconsistent data.
|
||||
# https://docs.gitlab.com/ee/development/database/add_foreign_key_to_existing_column.html#add-a-migration-to-validate-the-fk-synchronously
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class QueueUpdateWorkspacesConfigVersion3 < Gitlab::Database::Migration[2.2]
|
||||
milestone '16.8'
|
||||
|
||||
MIGRATION = "UpdateWorkspacesConfigVersion3"
|
||||
DELAY_INTERVAL = 2.minutes
|
||||
BATCH_SIZE = 1000
|
||||
SUB_BATCH_SIZE = 100
|
||||
|
||||
restrict_gitlab_migration gitlab_schema: :gitlab_main
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
queue_batched_background_migration(
|
||||
MIGRATION,
|
||||
:workspaces,
|
||||
:config_version,
|
||||
job_interval: DELAY_INTERVAL,
|
||||
batch_size: BATCH_SIZE,
|
||||
sub_batch_size: SUB_BATCH_SIZE
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
delete_batched_background_migration(MIGRATION, :workspaces, :config_version, [])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
6b9244a1ef9a87f192548bde7346e1b6b18d036ca14dcbde04046842d461dc36
|
||||
|
|
@ -0,0 +1 @@
|
|||
57e5c890ac0ebb837a5894b09717322c2053694cc4a91270508a652f091e457c
|
||||
|
|
@ -37651,7 +37651,7 @@ ALTER TABLE ONLY namespace_commit_emails
|
|||
ADD CONSTRAINT fk_4d6ba63ba5 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY vulnerabilities
|
||||
ADD CONSTRAINT fk_4e64972902 FOREIGN KEY (finding_id) REFERENCES vulnerability_occurrences(id) ON DELETE CASCADE NOT VALID;
|
||||
ADD CONSTRAINT fk_4e64972902 FOREIGN KEY (finding_id) REFERENCES vulnerability_occurrences(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY ml_model_versions
|
||||
ADD CONSTRAINT fk_4e8b59e7a8 FOREIGN KEY (model_id) REFERENCES ml_models(id) ON DELETE CASCADE;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 22 KiB |
|
|
@ -178,7 +178,7 @@ Breaking changes are:
|
|||
allowed so long as all scalar type fields of the object continue to serialize in the same way.
|
||||
- Raising the [complexity](#max-complexity) of a field or complexity multipliers in a resolver.
|
||||
- Changing a field from being _not_ nullable (`null: false`) to nullable (`null: true`), as
|
||||
discussed in [Nullable fields](#nullable-fields).
|
||||
discussed in [Nullable fields](#nullable-fields).
|
||||
- Changing an argument from being optional (`required: false`) to being required (`required: true`).
|
||||
- Changing the [max page size](#page-size-limit) of a connection.
|
||||
- Lowering the global limits for query complexity and depth.
|
||||
|
|
@ -1515,8 +1515,8 @@ To find the parent object in your `Presenter` class:
|
|||
```
|
||||
|
||||
1. Declare your field's method in your Presenter class and have it accept the `parent` keyword argument.
|
||||
This argument contains the parent **GraphQL context**, so you have to access the parent object with
|
||||
`parent[:parent_object]` or whatever key you used in your `Resolver`:
|
||||
This argument contains the parent **GraphQL context**, so you have to access the parent object with
|
||||
`parent[:parent_object]` or whatever key you used in your `Resolver`:
|
||||
|
||||
```ruby
|
||||
# in ChildPresenter
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ Like static roles, custom roles are [inherited](../../user/project/members/index
|
|||
- A Group or project membership can be associated with any custom role that is defined on the root-level group of the group or project.
|
||||
- The `member_roles` table includes individual permissions and a `base_access_level` value.
|
||||
- The `base_access_level` must be a [valid access level](../../api/access_requests.md#valid-access-levels).
|
||||
The `base_access_level` determines which abilities are included in the custom role. For example, if the `base_access_level` is `10`, the custom role will include any abilities that a static Guest role would receive, plus any additional abilities that are enabled by the `member_roles` record by setting an attribute, such as `read_code`, to true.
|
||||
The `base_access_level` determines which abilities are included in the custom role. For example, if the `base_access_level` is `10`, the custom role will include any abilities that a static Guest role would receive, plus any additional abilities that are enabled by the `member_roles` record by setting an attribute, such as `read_code`, to true.
|
||||
- A custom role can enable additional abilities for a `base_access_level` but it cannot disable a permission. As a result, custom roles are "additive only". The rationale for this choice is [in this comment](https://gitlab.com/gitlab-org/gitlab/-/issues/352891#note_1059561579).
|
||||
- Custom role abilities are supported at project level and group level.
|
||||
|
||||
|
|
|
|||
|
|
@ -997,7 +997,7 @@ To create both an indexing and a non-indexing Sidekiq process in one node:
|
|||
```
|
||||
|
||||
1. Save the file and [reconfigure GitLab](../../administration/restart_gitlab.md)
|
||||
for the changes to take effect.
|
||||
for the changes to take effect.
|
||||
1. On all other Rails and Sidekiq nodes, ensure that `sidekiq['routing_rules']` is the same as above.
|
||||
1. Run the Rake task to [migrate existing jobs](../../administration/sidekiq/sidekiq_job_migration.md):
|
||||
|
||||
|
|
@ -1029,7 +1029,7 @@ To handle these queue groups on two nodes:
|
|||
```
|
||||
|
||||
1. Save the file and [reconfigure GitLab](../../administration/restart_gitlab.md)
|
||||
for the changes to take effect.
|
||||
for the changes to take effect.
|
||||
|
||||
1. To set up the non-indexing Sidekiq process, on your non-indexing Sidekiq node, change the `/etc/gitlab/gitlab.rb` file to:
|
||||
|
||||
|
|
@ -1052,7 +1052,7 @@ for the changes to take effect.
|
|||
|
||||
1. On all other Rails and Sidekiq nodes, ensure that `sidekiq['routing_rules']` is the same as above.
|
||||
1. Save the file and [reconfigure GitLab](../../administration/restart_gitlab.md)
|
||||
for the changes to take effect.
|
||||
for the changes to take effect.
|
||||
1. Run the Rake task to [migrate existing jobs](../../administration/sidekiq/sidekiq_job_migration.md):
|
||||
|
||||
```shell
|
||||
|
|
|
|||
|
|
@ -124,8 +124,8 @@ curl \
|
|||
To create a new customer subscription from a Marketplace partner client application,
|
||||
|
||||
- Make an authorized POST request to the
|
||||
[`/api/v1/marketplace/subscriptions`](https://customers.staging.gitlab.com/openapi_docs/marketplace#/marketplace/post_api_v1_marketplace_subscriptions)
|
||||
endpoint in the Customers Portal with the following parameters in JSON format:
|
||||
[`/api/v1/marketplace/subscriptions`](https://customers.staging.gitlab.com/openapi_docs/marketplace#/marketplace/post_api_v1_marketplace_subscriptions)
|
||||
endpoint in the Customers Portal with the following parameters in JSON format:
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------------------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
|
|
@ -144,8 +144,8 @@ If the subscription creation is unsuccessful, the response body includes an erro
|
|||
To get the status of a given subscription,
|
||||
|
||||
- Make an authorized GET request to the
|
||||
[`/api/v1/marketplace/subscriptions/{external_subscription_id}`](https://customers.staging.gitlab.com/openapi_docs/marketplace#/marketplace/get_api_v1_marketplace_subscriptions__external_subscription_id_)
|
||||
endpoint in the Customers Portal.
|
||||
[`/api/v1/marketplace/subscriptions/{external_subscription_id}`](https://customers.staging.gitlab.com/openapi_docs/marketplace#/marketplace/get_api_v1_marketplace_subscriptions__external_subscription_id_)
|
||||
endpoint in the Customers Portal.
|
||||
|
||||
The request must include the Marketplace partner system ID of the subscription to fetch the status for.
|
||||
|
||||
|
|
|
|||
|
|
@ -666,10 +666,12 @@ IdPs, contact your provider's support.
|
|||
Prerequisites:
|
||||
|
||||
- Make sure you have access to a
|
||||
[Google Workspace Super Admin account](https://support.google.com/a/answer/2405986#super_admin).
|
||||
[Google Workspace Super Admin account](https://support.google.com/a/answer/2405986#super_admin).
|
||||
|
||||
To set up a Google Workspace:
|
||||
|
||||
1. Use the following information, and follow the instructions in
|
||||
[Set up your own custom SAML application in Google Workspace](https://support.google.com/a/answer/6087519?hl=en).
|
||||
[Set up your own custom SAML application in Google Workspace](https://support.google.com/a/answer/6087519?hl=en).
|
||||
|
||||
| | Typical value | Description |
|
||||
|:-----------------|:---------------------------------------------------|:----------------------------------------------------------------------------------------------|
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ If you are using an HTTPS connection to GitLab, you must [configure HTTPS](https
|
|||
|
||||
1. Navigate to the site Admin Area in Sourcegraph.
|
||||
1. [Configure your GitLab external service](https://docs.sourcegraph.com/admin/external_service/gitlab).
|
||||
You can skip this step if you already have your GitLab repositories searchable in Sourcegraph.
|
||||
You can skip this step if you already have your GitLab repositories searchable in Sourcegraph.
|
||||
1. Validate that you can search your repositories from GitLab in your Sourcegraph instance by running a test query.
|
||||
1. Add your GitLab instance URL to the [`corsOrigin` setting](https://docs.sourcegraph.com/admin/config/site_config#corsOrigin) in your site configuration.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 13 KiB |
|
|
@ -15,8 +15,7 @@ and thumbs-ups. React with emoji on:
|
|||
|
||||
- [Issues](project/issues/index.md).
|
||||
- [Tasks](tasks.md).
|
||||
- [Merge requests](project/merge_requests/index.md),
|
||||
[snippets](snippets.md).
|
||||
- [Merge requests](project/merge_requests/index.md), [snippets](snippets.md).
|
||||
- [Epics](../user/group/epics/index.md).
|
||||
- [Objectives and key results](okrs.md).
|
||||
- Anywhere else you can have a comment thread.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 17 KiB |
|
|
@ -149,9 +149,9 @@ The first workaround is:
|
|||
|
||||
1. Have the end user [link SAML to their existing GitLab.com account](index.md#link-saml-to-your-existing-gitlabcom-account).
|
||||
1. After the user has done this, initiate a SCIM sync from your identity provider.
|
||||
If the SCIM sync completes without the same error, GitLab has
|
||||
successfully linked the SCIM identity to the existing user account, and the user
|
||||
should now be able to sign in using SAML SSO.
|
||||
If the SCIM sync completes without the same error, GitLab has
|
||||
successfully linked the SCIM identity to the existing user account, and the user
|
||||
should now be able to sign in using SAML SSO.
|
||||
|
||||
If the error persists, the user most likely already exists, has both a SAML and
|
||||
SCIM identity, and a SCIM identity that is set to `active: false`. To resolve
|
||||
|
|
@ -166,7 +166,7 @@ this:
|
|||
If any of this information does not match, [contact GitLab Support](https://support.gitlab.com/).
|
||||
1. Use the API to [update the SCIM provisioned user's `active` value to `true`](/ee/development/internal_api/index.md#update-a-single-scim-provisioned-user).
|
||||
1. If the update returns a status code `204`, have the user attempt to sign in
|
||||
using SAML SSO.
|
||||
using SAML SSO.
|
||||
|
||||
## Azure Active Directory
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ To view an objective:
|
|||
1. On the left sidebar, select **Search or go to** and find your project.
|
||||
1. Select **Plan > Issues**.
|
||||
1. [Filter the list of issues](project/issues/managing_issues.md#filter-the-list-of-issues)
|
||||
for `Type = objective`.
|
||||
for `Type = objective`.
|
||||
1. Select the title of an objective from the list.
|
||||
|
||||
## View a key result
|
||||
|
|
@ -82,7 +82,7 @@ To view a key result:
|
|||
1. On the left sidebar, select **Search or go to** and find your project.
|
||||
1. Select **Plan > Issues**.
|
||||
1. [Filter the list of issues](project/issues/managing_issues.md#filter-the-list-of-issues)
|
||||
for `Type = key_result`.
|
||||
for `Type = key_result`.
|
||||
1. Select the title of a key result from the list.
|
||||
|
||||
Alternatively, you can access a key result from the **Child objectives and key results** section in
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ commit author. Changelog formats [can be customized](#customize-the-changelog-ou
|
|||
Each section in the default changelog has a title containing the version
|
||||
number and release date, like this:
|
||||
|
||||
````markdown
|
||||
```markdown
|
||||
## 1.0.0 (2021-01-05)
|
||||
|
||||
### Features (4 changes)
|
||||
|
|
@ -24,7 +24,7 @@ number and release date, like this:
|
|||
- [Feature 2](gitlab-org/gitlab@456abc) ([merge request](gitlab-org/gitlab!456))
|
||||
- [Feature 3](gitlab-org/gitlab@234abc) by @steve
|
||||
- [Feature 4](gitlab-org/gitlab@456)
|
||||
````
|
||||
```
|
||||
|
||||
The date format for sections can be customized, but the rest of the title cannot.
|
||||
When adding new sections, GitLab parses these titles to determine where to place
|
||||
|
|
@ -121,11 +121,11 @@ these variables:
|
|||
`### Features`, `### Bug fixes`, and `### Performance improvements`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
categories:
|
||||
feature: Features
|
||||
bug: Bug fixes
|
||||
performance: Performance improvements
|
||||
---
|
||||
categories:
|
||||
feature: Features
|
||||
bug: Bug fixes
|
||||
performance: Performance improvements
|
||||
```
|
||||
|
||||
### Custom templates
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ For examples of using issue boards along with [epics](../group/epics/index.md),
|
|||
|
||||
- [How to use GitLab for Agile portfolio planning and project management](https://about.gitlab.com/blog/2020/11/11/gitlab-for-agile-portfolio-planning-project-management/) blog post (November 2020)
|
||||
- <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
|
||||
[Cross-project Agile work management with GitLab](https://www.youtube.com/watch?v=5J0bonGoECs) (15 min, July 2020)
|
||||
[Cross-project Agile work management with GitLab](https://www.youtube.com/watch?v=5J0bonGoECs) (15 min, July 2020)
|
||||
|
||||
### Use cases for a single issue board
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ you're interested in.
|
|||
Labels are a key part of [issue boards](issue_board.md). With labels you can:
|
||||
|
||||
- Categorize [epics](../group/epics/index.md), issues, and merge requests using colors and descriptive titles like
|
||||
`bug`, `feature request`, or `docs`.
|
||||
`bug`, `feature request`, or `docs`.
|
||||
- Dynamically filter and manage [epics](../group/epics/index.md), issues, and merge requests.
|
||||
- Search lists of issues, merge requests, and epics, as well as issue boards.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 18 KiB |
|
|
@ -70,7 +70,7 @@ You should create a release as one of the last steps in your CI/CD pipeline.
|
|||
Prerequisites:
|
||||
|
||||
- You must have at least the Developer role for a project. For more information, read
|
||||
[Release permissions](#release-permissions).
|
||||
[Release permissions](#release-permissions).
|
||||
|
||||
To create a release in the Releases page:
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ instance can help reduce race conditions by syncing changes more frequently.
|
|||
Prerequisites:
|
||||
|
||||
- You have configured the [push](push.md#set-up-a-push-mirror-to-another-gitlab-instance-with-2fa-activated)
|
||||
and [pull](pull.md#pull-from-a-remote-repository) mirrors in the upstream GitLab instance.
|
||||
and [pull](pull.md#pull-from-a-remote-repository) mirrors in the upstream GitLab instance.
|
||||
|
||||
To create the webhook in the downstream instance:
|
||||
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ Items that are **not** exported include:
|
|||
### Preparation
|
||||
|
||||
- To preserve the member list and their respective permissions on imported groups, review the users in these groups. Make
|
||||
sure these users exist before importing the desired groups.
|
||||
sure these users exist before importing the desired groups.
|
||||
- Users must set a public email in the source GitLab instance that matches their confirmed primary email in the destination GitLab instance. Most users receive an email asking them to confirm their email address.
|
||||
|
||||
### Enable export for a group
|
||||
|
|
@ -407,11 +407,11 @@ Default [modified](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50
|
|||
|
||||
To help avoid abuse, by default, users are rate limited to:
|
||||
|
||||
| Request Type | Limit |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| Export | 6 groups per minute |
|
||||
| Download export | 1 download per group per minute |
|
||||
| Import | 6 groups per minute |
|
||||
| Request Type | Limit |
|
||||
|-----------------|-------|
|
||||
| Export | 6 groups per minute |
|
||||
| Download export | 1 download per group per minute |
|
||||
| Import | 6 groups per minute |
|
||||
|
||||
## Related topics
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ For more information, see the [GitLab CLI endpoint documentation](../editor_exte
|
|||
The storage management and cleanup automation methods described in this page use:
|
||||
|
||||
- The [`python-gitlab`](https://python-gitlab.readthedocs.io/en/stable/) library, which provides
|
||||
a feature-rich programming interface.
|
||||
a feature-rich programming interface.
|
||||
- The `get_all_projects_top_level_namespace_storage_analysis_cleanup_example.py` script in the [GitLab API with Python](https://gitlab.com/gitlab-de/use-cases/gitlab-api/gitlab-api-python/) project.
|
||||
|
||||
For more information about use cases for the `python-gitlab` library,
|
||||
|
|
|
|||
|
|
@ -163,11 +163,11 @@ Storage limits are included in GitLab subscription terms but do not apply. At le
|
|||
GitLab will notify you of namespaces that exceed, or are close to exceeding, the storage limit.
|
||||
|
||||
- In the command-line interface, a notification displays after each `git push`
|
||||
action when your namespace has reached between 95% and 100% of your namespace storage quota.
|
||||
action when your namespace has reached between 95% and 100% of your namespace storage quota.
|
||||
- In the GitLab UI, a notification displays when your namespace has reached between
|
||||
75% and 100% of your namespace storage quota.
|
||||
75% and 100% of your namespace storage quota.
|
||||
- GitLab sends an email to members with the Owner role to notify them when namespace
|
||||
storage usage is at 70%, 85%, 95%, and 100%.
|
||||
storage usage is at 70%, 85%, 95%, and 100%.
|
||||
|
||||
## Manage storage usage
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module BackgroundMigration
|
||||
# No op on ce
|
||||
class UpdateWorkspacesConfigVersion3 < BatchedMigrationJob
|
||||
feature_category :remote_development
|
||||
def perform; end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Gitlab::BackgroundMigration::UpdateWorkspacesConfigVersion3.prepend_mod_with('Gitlab::BackgroundMigration::UpdateWorkspacesConfigVersion3') # rubocop:disable Layout/LineLength -- Injecting extension modules must be done on the last line of this file, outside of any class or module definitions
|
||||
|
|
@ -2,10 +2,8 @@
|
|||
|
||||
module Gitlab
|
||||
class NamespacedSessionStore
|
||||
delegate :[], :[]=, to: :store
|
||||
|
||||
def initialize(key, session = Session.current)
|
||||
@key = key
|
||||
@namespace_key = key
|
||||
@session = session
|
||||
end
|
||||
|
||||
|
|
@ -13,11 +11,17 @@ module Gitlab
|
|||
!session.nil?
|
||||
end
|
||||
|
||||
def store
|
||||
def [](key)
|
||||
return unless session
|
||||
|
||||
session[@key] ||= {}
|
||||
session[@key]
|
||||
session[@namespace_key]&.fetch(key, nil)
|
||||
end
|
||||
|
||||
def []=(key, value)
|
||||
return unless session
|
||||
|
||||
session[@namespace_key] ||= {}
|
||||
session[@namespace_key][key] = value
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -34601,6 +34601,12 @@ msgid_plural "PackageRegistry|Package has %{updatesCount} archived updates"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "PackageRegistry|Package name pattern"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Package type"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -34625,6 +34631,9 @@ msgstr ""
|
|||
msgid "PackageRegistry|Project-level"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Protected packages"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Publish packages if their name or version matches this regex."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -34643,6 +34652,9 @@ msgstr ""
|
|||
msgid "PackageRegistry|Published to the %{project} Package Registry %{datetime}"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Push protected up to access level"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|PyPI"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -34748,6 +34760,9 @@ msgstr ""
|
|||
msgid "PackageRegistry|Unable to load package"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|When a package is protected then only certain user roles are able to update and delete the protected package. This helps to avoid tampering with the package."
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|When a package with same name and version is uploaded to the registry, more assets are added to the package. To save storage space, keep only the most recent assets."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "spec_helper"
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe "User interacts with deploy keys", :js, feature_category: :continuous_delivery do
|
||||
let(:project) { create(:project, :repository) }
|
||||
|
|
@ -10,43 +10,59 @@ RSpec.describe "User interacts with deploy keys", :js, feature_category: :contin
|
|||
sign_in(user)
|
||||
end
|
||||
|
||||
shared_examples "attaches a key" do
|
||||
it "attaches key" do
|
||||
shared_examples 'attaches a key' do
|
||||
it 'attaches key' do
|
||||
visit(project_deploy_keys_path(project))
|
||||
|
||||
page.within(".deploy-keys") do
|
||||
find(".badge", text: "1").click
|
||||
page.within('.deploy-keys') do
|
||||
click_link(scope)
|
||||
|
||||
click_button("Enable")
|
||||
click_button('Enable')
|
||||
|
||||
expect(page).not_to have_selector(".gl-spinner")
|
||||
expect(page).not_to have_selector('.gl-spinner')
|
||||
expect(page).to have_current_path(project_settings_repository_path(project), ignore_query: true)
|
||||
|
||||
find(".js-deployKeys-tab-enabled_keys").click
|
||||
click_link('Enabled deploy keys')
|
||||
|
||||
expect(page).to have_content(deploy_key.title)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "viewing deploy keys" do
|
||||
context 'viewing deploy keys' do
|
||||
let(:deploy_key) { create(:deploy_key) }
|
||||
|
||||
context "when project has keys" do
|
||||
context 'when project has keys' do
|
||||
before do
|
||||
create(:deploy_keys_project, project: project, deploy_key: deploy_key)
|
||||
end
|
||||
|
||||
it "shows deploy keys" do
|
||||
it 'shows deploy keys' do
|
||||
visit(project_deploy_keys_path(project))
|
||||
|
||||
page.within(".deploy-keys") do
|
||||
page.within('.deploy-keys') do
|
||||
expect(page).to have_content(deploy_key.title)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when another project has keys" do
|
||||
context 'when the project has many deploy keys' do
|
||||
before do
|
||||
create(:deploy_keys_project, project: project, deploy_key: deploy_key)
|
||||
create_list(:deploy_keys_project, 5, project: project)
|
||||
end
|
||||
|
||||
it 'shows pagination' do
|
||||
visit(project_deploy_keys_path(project))
|
||||
|
||||
page.within('.deploy-keys') do
|
||||
expect(page).to have_link('Next')
|
||||
expect(page).to have_link('2')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when another project has keys' do
|
||||
let(:another_project) { create(:project) }
|
||||
|
||||
before do
|
||||
|
|
@ -55,26 +71,25 @@ RSpec.describe "User interacts with deploy keys", :js, feature_category: :contin
|
|||
another_project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it "shows deploy keys" do
|
||||
it 'shows deploy keys' do
|
||||
visit(project_deploy_keys_path(project))
|
||||
|
||||
page.within(".deploy-keys") do
|
||||
find('.js-deployKeys-tab-available_project_keys').click
|
||||
page.within('.deploy-keys') do
|
||||
click_link('Privately accessible deploy keys')
|
||||
|
||||
expect(page).to have_content(deploy_key.title)
|
||||
expect(find(".js-deployKeys-tab-available_project_keys .badge")).to have_content("1")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when there are public deploy keys" do
|
||||
context 'when there are public deploy keys' do
|
||||
let!(:deploy_key) { create(:deploy_key, public: true) }
|
||||
|
||||
it "shows public deploy keys" do
|
||||
it 'shows public deploy keys' do
|
||||
visit(project_deploy_keys_path(project))
|
||||
|
||||
page.within(".deploy-keys") do
|
||||
find(".js-deployKeys-tab-public_keys").click
|
||||
page.within('.deploy-keys') do
|
||||
click_link('Publicly accessible deploy keys')
|
||||
|
||||
expect(page).to have_content(deploy_key.title)
|
||||
end
|
||||
|
|
@ -82,43 +97,44 @@ RSpec.describe "User interacts with deploy keys", :js, feature_category: :contin
|
|||
end
|
||||
end
|
||||
|
||||
context "adding deploy keys" do
|
||||
context 'adding deploy keys' do
|
||||
before do
|
||||
visit(project_deploy_keys_path(project))
|
||||
end
|
||||
|
||||
it "adds new key" do
|
||||
it 'adds new key' do
|
||||
deploy_key_title = attributes_for(:key)[:title]
|
||||
deploy_key_body = attributes_for(:key)[:key]
|
||||
|
||||
click_button("Add new key")
|
||||
fill_in("deploy_key_title", with: deploy_key_title)
|
||||
fill_in("deploy_key_key", with: deploy_key_body)
|
||||
click_button('Add new key')
|
||||
fill_in('deploy_key_title', with: deploy_key_title)
|
||||
fill_in('deploy_key_key', with: deploy_key_body)
|
||||
|
||||
click_button("Add key")
|
||||
click_button('Add key')
|
||||
|
||||
expect(page).to have_current_path(project_settings_repository_path(project), ignore_query: true)
|
||||
|
||||
page.within(".deploy-keys") do
|
||||
page.within('.deploy-keys') do
|
||||
expect(page).to have_content(deploy_key_title)
|
||||
end
|
||||
end
|
||||
|
||||
it "click on cancel hides the form" do
|
||||
click_button("Add new key")
|
||||
it 'click on cancel hides the form' do
|
||||
click_button('Add new key')
|
||||
|
||||
expect(page).to have_css('.gl-new-card-add-form')
|
||||
|
||||
click_button("Cancel")
|
||||
click_button('Cancel')
|
||||
|
||||
expect(page).not_to have_css('.gl-new-card-add-form')
|
||||
end
|
||||
end
|
||||
|
||||
context "attaching existing keys" do
|
||||
context "from another project" do
|
||||
context 'attaching existing keys' do
|
||||
context 'from another project' do
|
||||
let(:another_project) { create(:project) }
|
||||
let(:deploy_key) { create(:deploy_key) }
|
||||
let(:scope) { 'Privately accessible deploy keys' }
|
||||
|
||||
before do
|
||||
create(:deploy_keys_project, project: another_project, deploy_key: deploy_key)
|
||||
|
|
@ -126,13 +142,14 @@ RSpec.describe "User interacts with deploy keys", :js, feature_category: :contin
|
|||
another_project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it_behaves_like "attaches a key"
|
||||
it_behaves_like 'attaches a key'
|
||||
end
|
||||
|
||||
context "when keys are public" do
|
||||
context 'when keys are public' do
|
||||
let!(:deploy_key) { create(:deploy_key, public: true) }
|
||||
let(:scope) { 'Publicly accessible deploy keys' }
|
||||
|
||||
it_behaves_like "attaches a key"
|
||||
it_behaves_like 'attaches a key'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ RSpec.describe 'Work item linked items', :js, feature_category: :team_planning d
|
|||
let_it_be(:work_item) { create(:work_item, project: project) }
|
||||
let(:work_items_path) { project_work_item_path(project, work_item.iid) }
|
||||
let_it_be(:task) { create(:work_item, :task, project: project, title: 'Task 1') }
|
||||
let_it_be(:milestone) { create(:milestone, project: project, title: '1.0') }
|
||||
let_it_be(:label) { create(:label, project: project) }
|
||||
let_it_be(:objective) do
|
||||
create(:work_item, :objective, project: project, milestone: milestone,
|
||||
title: 'Objective 1', labels: [label])
|
||||
end
|
||||
|
||||
context 'for signed in user' do
|
||||
let(:token_input_selector) { '[data-testid="work-item-token-select-input"] .gl-token-selector-input' }
|
||||
|
|
@ -111,6 +117,33 @@ RSpec.describe 'Work item linked items', :js, feature_category: :team_planning d
|
|||
expect(page).not_to have_content('Task 1')
|
||||
end
|
||||
end
|
||||
|
||||
it 'passes axe automated accessibility testing for linked items empty state' do
|
||||
expect(page).to be_axe_clean.within('.work-item-relationships').skipping :'link-in-text-block'
|
||||
end
|
||||
|
||||
it 'passes axe automated accessibility testing for linked items' do
|
||||
page.within('.work-item-relationships') do
|
||||
click_button 'Add'
|
||||
|
||||
find_by_testid('work-item-token-select-input').set(objective.title)
|
||||
wait_for_all_requests
|
||||
|
||||
form_selector = '.work-item-relationships'
|
||||
expect(page).to be_axe_clean.within(form_selector).skipping :'aria-input-field-name',
|
||||
:'aria-required-children'
|
||||
|
||||
within_testid('link-work-item-form') do
|
||||
click_button objective.title
|
||||
|
||||
click_button 'Add'
|
||||
end
|
||||
|
||||
wait_for_all_requests
|
||||
|
||||
expect(page).to be_axe_clean.within(form_selector)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def verify_linked_item_added(input)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,44 @@
|
|||
import VueApollo from 'vue-apollo';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import data from 'test_fixtures/deploy_keys/keys.json';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import enableKeyMutation from '~/deploy_keys/graphql/mutations/enable_key.mutation.graphql';
|
||||
import actionBtn from '~/deploy_keys/components/action_btn.vue';
|
||||
import eventHub from '~/deploy_keys/eventhub';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('Deploy keys action btn', () => {
|
||||
const deployKey = data.enabled_keys[0];
|
||||
let wrapper;
|
||||
let enableKeyMock;
|
||||
|
||||
const findButton = () => wrapper.findComponent(GlButton);
|
||||
|
||||
beforeEach(() => {
|
||||
enableKeyMock = jest.fn();
|
||||
|
||||
const mockResolvers = {
|
||||
Mutation: {
|
||||
enableKey: enableKeyMock,
|
||||
},
|
||||
};
|
||||
|
||||
const apolloProvider = createMockApollo([], mockResolvers);
|
||||
wrapper = shallowMount(actionBtn, {
|
||||
propsData: {
|
||||
deployKey,
|
||||
type: 'enable',
|
||||
category: 'primary',
|
||||
variant: 'confirm',
|
||||
icon: 'edit',
|
||||
mutation: enableKeyMutation,
|
||||
},
|
||||
slots: {
|
||||
default: 'Enable',
|
||||
},
|
||||
apolloProvider,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -38,13 +54,26 @@ describe('Deploy keys action btn', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('sends eventHub event with btn type', async () => {
|
||||
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
|
||||
|
||||
it('fires the passed mutation', async () => {
|
||||
findButton().vm.$emit('click');
|
||||
|
||||
await nextTick();
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything());
|
||||
expect(enableKeyMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ id: deployKey.id },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits the mutation error', async () => {
|
||||
const error = new Error('oops!');
|
||||
enableKeyMock.mockRejectedValue(error);
|
||||
findButton().vm.$emit('click');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.emitted('error')).toEqual([[error]]);
|
||||
});
|
||||
|
||||
it('shows loading spinner after click', async () => {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,45 @@
|
|||
import VueApollo from 'vue-apollo';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { nextTick } from 'vue';
|
||||
import data from 'test_fixtures/deploy_keys/keys.json';
|
||||
import { GlPagination } from '@gitlab/ui';
|
||||
import enabledKeys from 'test_fixtures/deploy_keys/enabled_keys.json';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { TEST_HOST } from 'spec/test_constants';
|
||||
import { captureException } from '~/sentry/sentry_browser_wrapper';
|
||||
import { mapDeployKey } from '~/deploy_keys/graphql/resolvers';
|
||||
import deployKeysQuery from '~/deploy_keys/graphql/queries/deploy_keys.query.graphql';
|
||||
import deployKeysApp from '~/deploy_keys/components/app.vue';
|
||||
import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue';
|
||||
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
|
||||
import eventHub from '~/deploy_keys/eventhub';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
|
||||
|
||||
const TEST_ENDPOINT = `${TEST_HOST}/dummy/`;
|
||||
jest.mock('~/sentry/sentry_browser_wrapper');
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('Deploy keys app component', () => {
|
||||
let wrapper;
|
||||
let mock;
|
||||
let deployKeyMock;
|
||||
let currentPageMock;
|
||||
let currentScopeMock;
|
||||
let confirmRemoveKeyMock;
|
||||
let pageInfoMock;
|
||||
let pageMutationMock;
|
||||
let scopeMutationMock;
|
||||
let disableKeyMock;
|
||||
let resolvers;
|
||||
|
||||
const mountComponent = () => {
|
||||
const apolloProvider = createMockApollo([[deployKeysQuery, deployKeyMock]], resolvers);
|
||||
|
||||
wrapper = mount(deployKeysApp, {
|
||||
propsData: {
|
||||
endpoint: TEST_ENDPOINT,
|
||||
projectPath: 'test/project',
|
||||
projectId: '8',
|
||||
},
|
||||
apolloProvider,
|
||||
});
|
||||
|
||||
return waitForPromises();
|
||||
|
|
@ -30,7 +47,28 @@ describe('Deploy keys app component', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onGet(TEST_ENDPOINT).reply(HTTP_STATUS_OK, data);
|
||||
deployKeyMock = jest.fn();
|
||||
currentPageMock = jest.fn();
|
||||
currentScopeMock = jest.fn();
|
||||
confirmRemoveKeyMock = jest.fn();
|
||||
pageInfoMock = jest.fn();
|
||||
scopeMutationMock = jest.fn();
|
||||
pageMutationMock = jest.fn();
|
||||
disableKeyMock = jest.fn();
|
||||
|
||||
resolvers = {
|
||||
Query: {
|
||||
currentPage: currentPageMock,
|
||||
currentScope: currentScopeMock,
|
||||
deployKeyToRemove: confirmRemoveKeyMock,
|
||||
pageInfo: pageInfoMock,
|
||||
},
|
||||
Mutation: {
|
||||
currentPage: pageMutationMock,
|
||||
currentScope: scopeMutationMock,
|
||||
disableKey: disableKeyMock,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -43,8 +81,7 @@ describe('Deploy keys app component', () => {
|
|||
const findNavigationTabs = () => wrapper.findComponent(NavigationTabs);
|
||||
|
||||
it('renders loading icon while waiting for request', async () => {
|
||||
mock.onGet(TEST_ENDPOINT).reply(() => new Promise());
|
||||
|
||||
deployKeyMock.mockReturnValue(new Promise(() => {}));
|
||||
mountComponent();
|
||||
|
||||
await nextTick();
|
||||
|
|
@ -52,85 +89,190 @@ describe('Deploy keys app component', () => {
|
|||
});
|
||||
|
||||
it('renders keys panels', async () => {
|
||||
const deployKeys = enabledKeys.keys.map(mapDeployKey);
|
||||
deployKeyMock.mockReturnValue({
|
||||
data: {
|
||||
project: { id: 1, deployKeys, __typename: 'Project' },
|
||||
},
|
||||
});
|
||||
await mountComponent();
|
||||
expect(findKeyPanels().length).toBe(3);
|
||||
});
|
||||
|
||||
it.each`
|
||||
selector
|
||||
${'.js-deployKeys-tab-enabled_keys'}
|
||||
${'.js-deployKeys-tab-available_project_keys'}
|
||||
${'.js-deployKeys-tab-public_keys'}
|
||||
`('$selector title exists', ({ selector }) => {
|
||||
return mountComponent().then(() => {
|
||||
describe.each`
|
||||
scope
|
||||
${'enabledKeys'}
|
||||
${'availableProjectKeys'}
|
||||
${'availablePublicKeys'}
|
||||
`('tab $scope', ({ scope }) => {
|
||||
let selector;
|
||||
|
||||
beforeEach(async () => {
|
||||
selector = `.js-deployKeys-tab-${scope}`;
|
||||
const deployKeys = enabledKeys.keys.map(mapDeployKey);
|
||||
deployKeyMock.mockReturnValue({
|
||||
data: {
|
||||
project: { id: 1, deployKeys, __typename: 'Project' },
|
||||
},
|
||||
});
|
||||
|
||||
await mountComponent();
|
||||
});
|
||||
|
||||
it('displays the title', () => {
|
||||
const element = wrapper.find(selector);
|
||||
expect(element.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render key panels when keys object is empty', () => {
|
||||
mock.onGet(TEST_ENDPOINT).reply(HTTP_STATUS_OK, []);
|
||||
it('triggers changing the scope on click', async () => {
|
||||
await findNavigationTabs().vm.$emit('onChangeTab', scope);
|
||||
|
||||
return mountComponent().then(() => {
|
||||
expect(findKeyPanels().length).toBe(0);
|
||||
expect(scopeMutationMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ scope },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('captures a failed tab change', async () => {
|
||||
const scope = 'fake scope';
|
||||
const error = new Error('fail!');
|
||||
|
||||
const deployKeys = enabledKeys.keys.map(mapDeployKey);
|
||||
deployKeyMock.mockReturnValue({
|
||||
data: {
|
||||
project: { id: 1, deployKeys, __typename: 'Project' },
|
||||
},
|
||||
});
|
||||
|
||||
scopeMutationMock.mockRejectedValue(error);
|
||||
await mountComponent();
|
||||
await findNavigationTabs().vm.$emit('onChangeTab', scope);
|
||||
await waitForPromises();
|
||||
|
||||
expect(captureException).toHaveBeenCalledWith(error, { tags: { deployKeyScope: scope } });
|
||||
});
|
||||
|
||||
it('hasKeys returns true when there are keys', async () => {
|
||||
const deployKeys = enabledKeys.keys.map(mapDeployKey);
|
||||
deployKeyMock.mockReturnValue({
|
||||
data: {
|
||||
project: { id: 1, deployKeys, __typename: 'Project' },
|
||||
},
|
||||
});
|
||||
await mountComponent();
|
||||
|
||||
expect(findNavigationTabs().exists()).toBe(true);
|
||||
expect(findLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('enabling and disabling keys', () => {
|
||||
const key = data.public_keys[0];
|
||||
let getMethodMock;
|
||||
let putMethodMock;
|
||||
describe('disabling keys', () => {
|
||||
const key = mapDeployKey(enabledKeys.keys[0]);
|
||||
|
||||
const removeKey = async (keyEvent) => {
|
||||
eventHub.$emit(keyEvent, key, () => {});
|
||||
beforeEach(() => {
|
||||
deployKeyMock.mockReturnValue({
|
||||
data: {
|
||||
project: { id: 1, deployKeys: [key], __typename: 'Project' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('re-fetches deploy keys when disabling a key', async () => {
|
||||
confirmRemoveKeyMock.mockReturnValue(key);
|
||||
await mountComponent();
|
||||
expect(deployKeyMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await nextTick();
|
||||
expect(findModal().props('visible')).toBe(true);
|
||||
findModal().vm.$emit('remove');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getMethodMock = jest.spyOn(axios, 'get');
|
||||
putMethodMock = jest.spyOn(axios, 'put');
|
||||
await waitForPromises();
|
||||
expect(deployKeyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getMethodMock.mockClear();
|
||||
putMethodMock.mockClear();
|
||||
});
|
||||
describe('pagination', () => {
|
||||
const key = mapDeployKey(enabledKeys.keys[0]);
|
||||
let page;
|
||||
let pageInfo;
|
||||
let glPagination;
|
||||
|
||||
it('re-fetches deploy keys when enabling a key', async () => {
|
||||
beforeEach(async () => {
|
||||
page = 2;
|
||||
pageInfo = {
|
||||
total: 20,
|
||||
perPage: 5,
|
||||
nextPage: 3,
|
||||
page,
|
||||
previousPage: 1,
|
||||
__typename: 'LocalPageInfo',
|
||||
};
|
||||
deployKeyMock.mockReturnValue({
|
||||
data: {
|
||||
project: { id: 1, deployKeys: [], __typename: 'Project' },
|
||||
},
|
||||
});
|
||||
|
||||
confirmRemoveKeyMock.mockReturnValue(key);
|
||||
pageInfoMock.mockReturnValue(pageInfo);
|
||||
currentPageMock.mockReturnValue(page);
|
||||
await mountComponent();
|
||||
|
||||
eventHub.$emit('enable.key', key);
|
||||
|
||||
expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/enable`);
|
||||
expect(getMethodMock).toHaveBeenCalled();
|
||||
glPagination = wrapper.findComponent(GlPagination);
|
||||
});
|
||||
|
||||
it('re-fetches deploy keys when disabling a key', async () => {
|
||||
await mountComponent();
|
||||
|
||||
await removeKey('disable.key');
|
||||
|
||||
expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`);
|
||||
expect(getMethodMock).toHaveBeenCalled();
|
||||
it('shows pagination with correct page info', () => {
|
||||
expect(glPagination.exists()).toBe(true);
|
||||
expect(glPagination.props()).toMatchObject({
|
||||
totalItems: pageInfo.total,
|
||||
perPage: pageInfo.perPage,
|
||||
value: page,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls disableKey when removing a key', async () => {
|
||||
await mountComponent();
|
||||
it('moves back a page', async () => {
|
||||
await glPagination.vm.$emit('previous');
|
||||
|
||||
await removeKey('remove.key');
|
||||
expect(pageMutationMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ page: page - 1 },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`);
|
||||
expect(getMethodMock).toHaveBeenCalled();
|
||||
it('moves forward a page', async () => {
|
||||
await glPagination.vm.$emit('next');
|
||||
|
||||
expect(pageMutationMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ page: page + 1 },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('moves to specified page', async () => {
|
||||
await glPagination.vm.$emit('input', 5);
|
||||
|
||||
expect(pageMutationMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ page: 5 },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('moves a page back if there are no more keys on this page', async () => {
|
||||
await findModal().vm.$emit('remove');
|
||||
await waitForPromises();
|
||||
|
||||
expect(pageMutationMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ page: page - 1 },
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,64 +1,85 @@
|
|||
import VueApollo from 'vue-apollo';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import data from 'test_fixtures/deploy_keys/keys.json';
|
||||
import enabledKeys from 'test_fixtures/deploy_keys/enabled_keys.json';
|
||||
import availablePublicKeys from 'test_fixtures/deploy_keys/available_public_keys.json';
|
||||
import { createAlert } from '~/alert';
|
||||
import { mapDeployKey } from '~/deploy_keys/graphql/resolvers';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
|
||||
import key from '~/deploy_keys/components/key.vue';
|
||||
import DeployKeysStore from '~/deploy_keys/store';
|
||||
import ActionBtn from '~/deploy_keys/components/action_btn.vue';
|
||||
import { getTimeago, localeDateFormat } from '~/lib/utils/datetime_utility';
|
||||
|
||||
jest.mock('~/alert');
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('Deploy keys key', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
let currentScopeMock;
|
||||
|
||||
const findTextAndTrim = (selector) => wrapper.find(selector).text().trim();
|
||||
|
||||
const createComponent = (propsData) => {
|
||||
const createComponent = async (propsData) => {
|
||||
const resolvers = {
|
||||
Query: {
|
||||
currentScope: currentScopeMock,
|
||||
},
|
||||
};
|
||||
|
||||
const apolloProvider = createMockApollo([], resolvers);
|
||||
wrapper = mount(key, {
|
||||
propsData: {
|
||||
store,
|
||||
endpoint: 'https://test.host/dummy/endpoint',
|
||||
...propsData,
|
||||
},
|
||||
apolloProvider,
|
||||
directives: {
|
||||
GlTooltip: createMockDirective('gl-tooltip'),
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = new DeployKeysStore();
|
||||
store.keys = data;
|
||||
currentScopeMock = jest.fn();
|
||||
});
|
||||
|
||||
describe('enabled key', () => {
|
||||
const deployKey = data.enabled_keys[0];
|
||||
const deployKey = mapDeployKey(enabledKeys.keys[0]);
|
||||
|
||||
it('renders the keys title', () => {
|
||||
createComponent({ deployKey });
|
||||
beforeEach(() => {
|
||||
currentScopeMock.mockReturnValue('enabledKeys');
|
||||
});
|
||||
|
||||
it('renders the keys title', async () => {
|
||||
await createComponent({ deployKey });
|
||||
|
||||
expect(findTextAndTrim('.title')).toContain('My title');
|
||||
});
|
||||
|
||||
it('renders human friendly formatted created date', () => {
|
||||
createComponent({ deployKey });
|
||||
it('renders human friendly formatted created date', async () => {
|
||||
await createComponent({ deployKey });
|
||||
|
||||
expect(findTextAndTrim('.key-created-at')).toBe(
|
||||
`${getTimeago().format(deployKey.created_at)}`,
|
||||
`${getTimeago().format(deployKey.createdAt)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders human friendly expiration date', () => {
|
||||
it('renders human friendly expiration date', async () => {
|
||||
const expiresAt = new Date();
|
||||
createComponent({
|
||||
deployKey: { ...deployKey, expires_at: expiresAt },
|
||||
await createComponent({
|
||||
deployKey: { ...deployKey, expiresAt },
|
||||
});
|
||||
|
||||
expect(findTextAndTrim('.key-expires-at')).toBe(`${getTimeago().format(expiresAt)}`);
|
||||
});
|
||||
it('shows tooltip for expiration date', () => {
|
||||
it('shows tooltip for expiration date', async () => {
|
||||
const expiresAt = new Date();
|
||||
createComponent({
|
||||
deployKey: { ...deployKey, expires_at: expiresAt },
|
||||
await createComponent({
|
||||
deployKey: { ...deployKey, expiresAt },
|
||||
});
|
||||
|
||||
const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]');
|
||||
|
|
@ -68,55 +89,57 @@ describe('Deploy keys key', () => {
|
|||
`${localeDateFormat.asDateTimeFull.format(expiresAt)}`,
|
||||
);
|
||||
});
|
||||
it('renders never when no expiration date', () => {
|
||||
createComponent({
|
||||
deployKey: { ...deployKey, expires_at: null },
|
||||
it('renders never when no expiration date', async () => {
|
||||
await createComponent({
|
||||
deployKey: { ...deployKey, expiresAt: null },
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="expires-never"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows pencil button for editing', () => {
|
||||
createComponent({ deployKey });
|
||||
it('shows pencil button for editing', async () => {
|
||||
await createComponent({ deployKey });
|
||||
|
||||
expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows disable button when the project is not deletable', () => {
|
||||
createComponent({ deployKey });
|
||||
it('shows disable button when the project is not deletable', async () => {
|
||||
await createComponent({ deployKey });
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find('.btn [data-testid="cancel-icon"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows remove button when the project is deletable', () => {
|
||||
createComponent({
|
||||
deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true },
|
||||
it('shows remove button when the project is deletable', async () => {
|
||||
await createComponent({
|
||||
deployKey: { ...deployKey, destroyedWhenOrphaned: true, almostOrphaned: true },
|
||||
});
|
||||
await waitForPromises();
|
||||
expect(wrapper.find('.btn [data-testid="remove-icon"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploy key labels', () => {
|
||||
const deployKey = data.enabled_keys[0];
|
||||
const deployKeysProjects = [...deployKey.deploy_keys_projects];
|
||||
it('shows write access title when key has write access', () => {
|
||||
deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: true };
|
||||
createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
|
||||
const deployKey = mapDeployKey(enabledKeys.keys[0]);
|
||||
const deployKeysProjects = [...deployKey.deployKeysProjects];
|
||||
it('shows write access title when key has write access', async () => {
|
||||
deployKeysProjects[0] = { ...deployKeysProjects[0], canPush: true };
|
||||
await createComponent({ deployKey: { ...deployKey, deployKeysProjects } });
|
||||
|
||||
expect(wrapper.find('.deploy-project-label').attributes('title')).toBe(
|
||||
'Grant write permissions to this key',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show write access title when key has write access', () => {
|
||||
deployKeysProjects[0] = { ...deployKeysProjects[0], can_push: false };
|
||||
createComponent({ deployKey: { ...deployKey, deploy_keys_projects: deployKeysProjects } });
|
||||
it('does not show write access title when key has write access', async () => {
|
||||
deployKeysProjects[0] = { ...deployKeysProjects[0], canPush: false };
|
||||
await createComponent({ deployKey: { ...deployKey, deployKeysProjects } });
|
||||
|
||||
expect(wrapper.find('.deploy-project-label').attributes('title')).toBe('Read access only');
|
||||
});
|
||||
|
||||
it('shows expandable button if more than two projects', () => {
|
||||
createComponent({ deployKey });
|
||||
it('shows expandable button if more than two projects', async () => {
|
||||
await createComponent({ deployKey });
|
||||
const labels = wrapper.findAll('.deploy-project-label');
|
||||
|
||||
expect(labels.length).toBe(2);
|
||||
|
|
@ -125,53 +148,68 @@ describe('Deploy keys key', () => {
|
|||
});
|
||||
|
||||
it('expands all project labels after click', async () => {
|
||||
createComponent({ deployKey });
|
||||
const { length } = deployKey.deploy_keys_projects;
|
||||
await createComponent({ deployKey });
|
||||
const { length } = deployKey.deployKeysProjects;
|
||||
wrapper.findAll('.deploy-project-label').at(1).trigger('click');
|
||||
|
||||
await nextTick();
|
||||
const labels = wrapper.findAll('.deploy-project-label');
|
||||
|
||||
expect(labels.length).toBe(length);
|
||||
expect(labels).toHaveLength(length);
|
||||
expect(labels.at(1).text()).not.toContain(`+${length} others`);
|
||||
expect(labels.at(1).attributes('title')).not.toContain('Expand');
|
||||
});
|
||||
|
||||
it('shows two projects', () => {
|
||||
createComponent({
|
||||
deployKey: { ...deployKey, deploy_keys_projects: [...deployKeysProjects].slice(0, 2) },
|
||||
it('shows two projects', async () => {
|
||||
await createComponent({
|
||||
deployKey: { ...deployKey, deployKeysProjects: [...deployKeysProjects].slice(0, 2) },
|
||||
});
|
||||
|
||||
const labels = wrapper.findAll('.deploy-project-label');
|
||||
|
||||
expect(labels.length).toBe(2);
|
||||
expect(labels.at(1).text()).toContain(deployKey.deploy_keys_projects[1].project.full_name);
|
||||
expect(labels.at(1).text()).toContain(deployKey.deployKeysProjects[1].project.fullName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public keys', () => {
|
||||
const deployKey = data.public_keys[0];
|
||||
const deployKey = mapDeployKey(availablePublicKeys.keys[0]);
|
||||
|
||||
it('renders deploy keys without any enabled projects', () => {
|
||||
createComponent({ deployKey: { ...deployKey, deploy_keys_projects: [] } });
|
||||
it('renders deploy keys without any enabled projects', async () => {
|
||||
await createComponent({ deployKey: { ...deployKey, deployKeysProjects: [] } });
|
||||
|
||||
expect(findTextAndTrim('.deploy-project-list')).toBe('None');
|
||||
});
|
||||
|
||||
it('shows enable button', () => {
|
||||
createComponent({ deployKey });
|
||||
it('shows enable button', async () => {
|
||||
await createComponent({ deployKey });
|
||||
expect(findTextAndTrim('.btn')).toBe('Enable');
|
||||
});
|
||||
|
||||
it('shows pencil button for editing', () => {
|
||||
createComponent({ deployKey });
|
||||
it('shows an error on enable failure', async () => {
|
||||
await createComponent({ deployKey });
|
||||
|
||||
const error = new Error('oops!');
|
||||
wrapper.findComponent(ActionBtn).vm.$emit('error', error);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(createAlert).toHaveBeenCalledWith({
|
||||
message: 'Error enabling deploy key',
|
||||
captureError: true,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows pencil button for editing', async () => {
|
||||
await createComponent({ deployKey });
|
||||
expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows disable button when key is enabled', () => {
|
||||
store.keys.enabled_keys.push(deployKey);
|
||||
|
||||
createComponent({ deployKey });
|
||||
it('shows disable button when key is enabled', async () => {
|
||||
currentScopeMock.mockReturnValue('enabledKeys');
|
||||
await createComponent({ deployKey });
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.find('.btn [data-testid="cancel-icon"]').exists()).toBe(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import data from 'test_fixtures/deploy_keys/keys.json';
|
||||
import enabledKeys from 'test_fixtures/deploy_keys/enabled_keys.json';
|
||||
import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue';
|
||||
import DeployKeysStore from '~/deploy_keys/store';
|
||||
import { mapDeployKey } from '~/deploy_keys/graphql/resolvers';
|
||||
|
||||
const keys = enabledKeys.keys.map(mapDeployKey);
|
||||
|
||||
describe('Deploy keys panel', () => {
|
||||
let wrapper;
|
||||
|
|
@ -9,14 +11,11 @@ describe('Deploy keys panel', () => {
|
|||
const findTableRowHeader = () => wrapper.find('.table-row-header');
|
||||
|
||||
const mountComponent = (props) => {
|
||||
const store = new DeployKeysStore();
|
||||
store.keys = data;
|
||||
wrapper = mount(deployKeysPanel, {
|
||||
propsData: {
|
||||
title: 'test',
|
||||
keys: data.enabled_keys,
|
||||
keys,
|
||||
showHelpBox: true,
|
||||
store,
|
||||
endpoint: 'https://test.host/dummy/endpoint',
|
||||
...props,
|
||||
},
|
||||
|
|
@ -25,7 +24,7 @@ describe('Deploy keys panel', () => {
|
|||
|
||||
it('renders list of keys', () => {
|
||||
mountComponent();
|
||||
expect(wrapper.findAll('.deploy-key').length).toBe(wrapper.vm.keys.length);
|
||||
expect(wrapper.findAll('.deploy-key').length).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('renders table header', () => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe('~/deploy_keys/graphql/resolvers', () => {
|
|||
const scope = 'enabledKeys';
|
||||
const page = 2;
|
||||
mock
|
||||
.onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page } })
|
||||
.onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page, per_page: 5 } })
|
||||
.reply(HTTP_STATUS_OK, { keys: [key] });
|
||||
|
||||
const keys = await mockResolvers.Project.deployKeys(null, { scope, page }, { client });
|
||||
|
|
@ -157,6 +157,11 @@ describe('~/deploy_keys/graphql/resolvers', () => {
|
|||
data: { currentPage: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws failure on bad scope', () => {
|
||||
scope = 'bad scope';
|
||||
expect(() => mockResolvers.Mutation.currentScope(null, { scope }, { client })).toThrow(scope);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableKey', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { GlTable } from '@gitlab/ui';
|
||||
import { shallowMount, mount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue';
|
||||
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
|
||||
import packagesProtectionRuleQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql';
|
||||
|
||||
import { packagesProtectionRuleQueryPayload, packagesProtectionRulesData } from '../mock_data';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('Packages protection rules project settings', () => {
|
||||
let wrapper;
|
||||
let fakeApollo;
|
||||
|
||||
const defaultProvidedValues = {
|
||||
projectPath: 'path',
|
||||
};
|
||||
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
|
||||
const findTable = () => wrapper.findComponent(GlTable);
|
||||
const findTableRows = () => findTable().find('tbody').findAll('tr');
|
||||
|
||||
const mountComponent = (mountFn = shallowMount, provide = defaultProvidedValues, config) => {
|
||||
wrapper = mountFn(PackagesProtectionRules, {
|
||||
stubs: {
|
||||
SettingsBlock,
|
||||
},
|
||||
provide,
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
const createComponent = ({
|
||||
mountFn = shallowMount,
|
||||
provide = defaultProvidedValues,
|
||||
resolver = jest.fn().mockResolvedValue(packagesProtectionRuleQueryPayload()),
|
||||
} = {}) => {
|
||||
const requestHandlers = [[packagesProtectionRuleQuery, resolver]];
|
||||
|
||||
fakeApollo = createMockApollo(requestHandlers);
|
||||
|
||||
mountComponent(mountFn, provide, {
|
||||
apolloProvider: fakeApollo,
|
||||
});
|
||||
};
|
||||
|
||||
it('renders the setting block with table', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findSettingsBlock().exists()).toBe(true);
|
||||
expect(findTable().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders table with container registry protection rules', async () => {
|
||||
createComponent({ mountFn: mount });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findTable().exists()).toBe(true);
|
||||
|
||||
packagesProtectionRulesData.forEach((protectionRule, i) => {
|
||||
expect(findTableRows().at(i).text()).toContain(protectionRule.packageNamePattern);
|
||||
expect(findTableRows().at(i).text()).toContain(protectionRule.packageType);
|
||||
expect(findTableRows().at(i).text()).toContain(protectionRule.pushProtectedUpToAccessLevel);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders table with pagination', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findTable().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import * as commonUtils from '~/lib/utils/common_utils';
|
|||
import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue';
|
||||
import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue';
|
||||
import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue';
|
||||
import PackagesProtectionRules from '~/packages_and_registries/settings/project/components/packages_protection_rules.vue';
|
||||
import DependencyProxyPackagesSettings from 'ee_component/packages_and_registries/settings/project/components/dependency_proxy_packages_settings.vue';
|
||||
import {
|
||||
SHOW_SETUP_SUCCESS_ALERT,
|
||||
|
|
@ -19,6 +20,7 @@ describe('Registry Settings app', () => {
|
|||
|
||||
const findContainerExpirationPolicy = () => wrapper.findComponent(ContainerExpirationPolicy);
|
||||
const findPackagesCleanupPolicy = () => wrapper.findComponent(PackagesCleanupPolicy);
|
||||
const findPackagesProtectionRules = () => wrapper.findComponent(PackagesProtectionRules);
|
||||
const findDependencyProxyPackagesSettings = () =>
|
||||
wrapper.findComponent(DependencyProxyPackagesSettings);
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
|
|
@ -29,6 +31,7 @@ describe('Registry Settings app', () => {
|
|||
showPackageRegistrySettings: true,
|
||||
showDependencyProxySettings: false,
|
||||
...(IS_EE && { showDependencyProxySettings: true }),
|
||||
glFeatures: { packagesProtectedPackages: true },
|
||||
};
|
||||
|
||||
const mountComponent = (provide = defaultProvide) => {
|
||||
|
|
@ -95,6 +98,7 @@ describe('Registry Settings app', () => {
|
|||
|
||||
expect(findContainerExpirationPolicy().exists()).toBe(showContainerRegistrySettings);
|
||||
expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings);
|
||||
expect(findPackagesProtectionRules().exists()).toBe(showPackageRegistrySettings);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -108,5 +112,20 @@ describe('Registry Settings app', () => {
|
|||
expect(findDependencyProxyPackagesSettings().exists()).toBe(value);
|
||||
});
|
||||
}
|
||||
|
||||
describe('when feature flag "packagesProtectedPackages" is disabled', () => {
|
||||
it.each([true, false])(
|
||||
'package protection rules settings is hidden if showPackageRegistrySettings is %s',
|
||||
(showPackageRegistrySettings) => {
|
||||
mountComponent({
|
||||
...defaultProvide,
|
||||
showPackageRegistrySettings,
|
||||
glFeatures: { packagesProtectedPackages: false },
|
||||
});
|
||||
|
||||
expect(findPackagesProtectionRules().exists()).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -79,3 +79,36 @@ export const packagesCleanupPolicyMutationPayload = ({ override, errors = [] } =
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const packagesProtectionRulesData = [
|
||||
{
|
||||
id: `gid://gitlab/Packages::Protection::Rule/14`,
|
||||
packageNamePattern: `@flight/flight-maintainer-14-*`,
|
||||
packageType: 'NPM',
|
||||
pushProtectedUpToAccessLevel: 'MAINTAINER',
|
||||
},
|
||||
{
|
||||
id: `gid://gitlab/Packages::Protection::Rule/15`,
|
||||
packageNamePattern: `@flight/flight-maintainer-15-*`,
|
||||
packageType: 'NPM',
|
||||
pushProtectedUpToAccessLevel: 'MAINTAINER',
|
||||
},
|
||||
{
|
||||
id: 'gid://gitlab/Packages::Protection::Rule/16',
|
||||
packageNamePattern: '@flight/flight-owner-16-*',
|
||||
packageType: 'NPM',
|
||||
pushProtectedUpToAccessLevel: 'OWNER',
|
||||
},
|
||||
];
|
||||
|
||||
export const packagesProtectionRuleQueryPayload = ({ override, errors = [] } = {}) => ({
|
||||
data: {
|
||||
project: {
|
||||
id: '1',
|
||||
packagesProtectionRules: {
|
||||
nodes: override || packagesProtectionRulesData,
|
||||
},
|
||||
errors,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,19 +8,28 @@ RSpec.describe Gitlab::NamespacedSessionStore do
|
|||
context 'current session' do
|
||||
subject { described_class.new(key) }
|
||||
|
||||
it 'stores data under the specified key' do
|
||||
Gitlab::Session.with_session({}) do
|
||||
subject[:new_data] = 123
|
||||
|
||||
expect(Thread.current[:session_storage][key]).to eq(new_data: 123)
|
||||
end
|
||||
end
|
||||
|
||||
it 'retrieves data from the given key' do
|
||||
Thread.current[:session_storage] = { key => { existing_data: 123 } }
|
||||
|
||||
expect(subject[:existing_data]).to eq 123
|
||||
end
|
||||
|
||||
context 'when namespace key does not exist' do
|
||||
before do
|
||||
Thread.current[:session_storage] = {}
|
||||
end
|
||||
|
||||
it 'does not create namespace key when reading a value' do
|
||||
expect(subject[:non_existent_key]).to eq(nil)
|
||||
expect(Thread.current[:session_storage]).to eq({})
|
||||
end
|
||||
|
||||
it 'stores data under the specified key' do
|
||||
subject[:new_data] = 123
|
||||
|
||||
expect(Thread.current[:session_storage][key]).to eq(new_data: 123)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'passed in session' do
|
||||
|
|
|
|||