Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-01-05 12:13:40 +00:00
parent fc23bd54a1
commit 7120254aee
63 changed files with 1150 additions and 477 deletions

View File

@ -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"

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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 },

View File

@ -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,
},
});
},
});
};

View File

@ -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);
}
}

View File

@ -1,9 +0,0 @@
export default class DeployKeysStore {
constructor() {
this.keys = {};
}
isEnabled(id) {
return this.keys.enabled_keys.some((key) => key.id === id);
}
}

View File

@ -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>

View File

@ -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" />

View File

@ -0,0 +1,13 @@
query getProjectPackageProtectionRules($projectPath: ID!, $first: Int) {
project(fullPath: $projectPath) {
id
packagesProtectionRules(first: $first) {
nodes {
id
packageNamePattern
packageType
pushProtectedUpToAccessLevel
}
}
}
}

View File

@ -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>

View File

@ -12,6 +12,7 @@ module Projects
urgency :low
def show
push_frontend_feature_flag(:packages_protected_packages, project)
end
def cleanup_tags

View File

@ -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)
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
6b9244a1ef9a87f192548bde7346e1b6b18d036ca14dcbde04046842d461dc36

View File

@ -0,0 +1 @@
57e5c890ac0ebb837a5894b09717322c2053694cc4a91270508a652f091e457c

View File

@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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 |
|:-----------------|:---------------------------------------------------|:----------------------------------------------------------------------------------------------|

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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)

View File

@ -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 () => {

View File

@ -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(),
);
});
});
});

View File

@ -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);
});

View File

@ -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', () => {

View File

@ -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', () => {

View File

@ -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);
});
});

View File

@ -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);
},
);
});
});
});

View File

@ -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,
},
},
});

View File

@ -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