Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2024-01-05 15:13:40 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2024-01-05 15:13:40 +0300
commit7120254aee218529320c061696a2af530494e6aa (patch)
treec33bc6d9d7881b7ab22bbe09297b2c49aef9a3a9
parentfc23bd54a1a49003eda83bc2331d9b8b8417a91b (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue23
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue280
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue110
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue10
-rw-r--r--app/assets/javascripts/deploy_keys/graphql/resolvers.js35
-rw-r--r--app/assets/javascripts/deploy_keys/index.js32
-rw-r--r--app/assets/javascripts/deploy_keys/service/index.js19
-rw-r--r--app/assets/javascripts/deploy_keys/store/index.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue111
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql13
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue21
-rw-r--r--app/controllers/projects/settings/packages_and_registries_controller.rb1
-rw-r--r--app/views/shared/deploy_keys/_index.html.haml7
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/docs/batched_background_migrations/update_workspaces_config_version3.yml9
-rw-r--r--db/post_migrate/20240102101409_validate_finding_id_on_vulnerabilities.rb18
-rw-r--r--db/post_migrate/20240104085448_queue_update_workspaces_config_version3.rb28
-rw-r--r--db/schema_migrations/202401021014091
-rw-r--r--db/schema_migrations/202401040854481
-rw-r--r--db/structure.sql2
-rw-r--r--doc/architecture/blueprints/gitlab_steps/data.drawio.pngbin42192 -> 19270 bytes
-rw-r--r--doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.pngbin70107 -> 32938 bytes
-rw-r--r--doc/ci/environments/img/kubernetes_summary_ui.pngbin47714 -> 13822 bytes
-rw-r--r--doc/ci/testing/img/code_quality_inline_indicator_v16_7.pngbin53078 -> 22405 bytes
-rw-r--r--doc/development/api_graphql_styleguide.md6
-rw-r--r--doc/development/permissions/custom_roles.md2
-rw-r--r--doc/integration/advanced_search/elasticsearch.md6
-rw-r--r--doc/integration/partner_marketplace.md8
-rw-r--r--doc/integration/saml.md6
-rw-r--r--doc/integration/sourcegraph.md2
-rw-r--r--doc/user/analytics/img/enhanced_issue_analytics_v16_7.pngbin61942 -> 17415 bytes
-rw-r--r--doc/user/application_security/policies/img/scan_results_evaluation_white-bg.pngbin169020 -> 53334 bytes
-rw-r--r--doc/user/application_security/sast/img/sast_inline_indicator_v16_7.pngbin89080 -> 32977 bytes
-rw-r--r--doc/user/application_security/sast/img/sast_mr_widget_v16_7.pngbin39147 -> 13298 bytes
-rw-r--r--doc/user/emoji_reactions.md3
-rw-r--r--doc/user/group/issues_analytics/img/enhanced_issue_analytics_v16_7.pngbin61942 -> 17415 bytes
-rw-r--r--doc/user/group/saml_sso/troubleshooting_scim.md8
-rw-r--r--doc/user/okrs.md4
-rw-r--r--doc/user/project/changelogs.md14
-rw-r--r--doc/user/project/issue_board.md2
-rw-r--r--doc/user/project/labels.md2
-rw-r--r--doc/user/project/merge_requests/approvals/img/group_access_example_01_v16_8.pngbin63530 -> 20153 bytes
-rw-r--r--doc/user/project/merge_requests/approvals/img/group_access_example_02_v16_8.pngbin61309 -> 18583 bytes
-rw-r--r--doc/user/project/releases/index.md2
-rw-r--r--doc/user/project/repository/mirror/bidirectional.md2
-rw-r--r--doc/user/project/settings/import_export.md12
-rw-r--r--doc/user/storage_management_automation.md2
-rw-r--r--doc/user/usage_quotas.md6
-rw-r--r--lib/gitlab/background_migration/update_workspaces_config_version3.rb13
-rw-r--r--lib/gitlab/namespaced_session_store.rb16
-rw-r--r--locale/gitlab.pot15
-rw-r--r--spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb89
-rw-r--r--spec/features/projects/work_items/linked_work_items_spec.rb33
-rw-r--r--spec/frontend/deploy_keys/components/action_btn_spec.js43
-rw-r--r--spec/frontend/deploy_keys/components/app_spec.js244
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js154
-rw-r--r--spec/frontend/deploy_keys/components/keys_panel_spec.js13
-rw-r--r--spec/frontend/deploy_keys/graphql/resolvers_spec.js7
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js80
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js19
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/mock_data.js33
-rw-r--r--spec/lib/gitlab/namespaced_session_store_spec.rb25
63 files changed, 1143 insertions, 470 deletions
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
index 7bc1eb5d652..4a2da487e9b 100644
--- a/app/assets/javascripts/deploy_keys/components/action_btn.vue
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -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"
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index ec17bbea48f..7168a209b52 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -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: {
+ projectId: {
type: String,
required: true,
},
- projectId: {
+ 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;
- },
- keys() {
- return this.store.keys;
+ confirmModalVisible() {
+ return Boolean(this.deployKeyToRemove);
},
},
- 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;
+ onChangeTab(scope) {
+ return this.$apollo
+ .mutate({
+ mutation: updateCurrentScopeMutation,
+ variables: { scope },
+ })
+ .then(() => {
+ this.$apollo.queries.deployKeys.refetch();
+ })
+ .catch((error) => {
+ captureException(error, {
+ tags: {
+ deployKeyScope: scope,
+ },
+ });
+ });
},
- fetchKeys() {
- this.isLoading = true;
-
- return this.service
- .getKeys()
- .then((data) => {
- this.isLoading = false;
- this.store.keys = data;
+ moveNext() {
+ return this.movePage('next');
+ },
+ 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(() => {
- this.isLoading = false;
- this.store.keys = {};
- return createAlert({
- message: s__('DeployKeys|Error getting deploy keys'),
+ createAlert({
+ message: s__('DeployKeys|Error removing deploy key'),
});
});
},
- enableKey(deployKey) {
- this.service
- .enableKey(deployKey.id)
- .then(this.fetchKeys)
- .catch(() =>
- createAlert({
- message: s__('DeployKeys|Error enabling deploy key'),
- }),
- );
- },
- 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;
+ 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>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 16c745d8cff..d4b140f1adb 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -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"
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index dac63188aa5..088b85e6093 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -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>
diff --git a/app/assets/javascripts/deploy_keys/graphql/resolvers.js b/app/assets/javascripts/deploy_keys/graphql/resolvers.js
index 1993801636e..a8693665b90 100644
--- a/app/assets/javascripts/deploy_keys/graphql/resolvers.js
+++ b/app/assets/javascripts/deploy_keys/graphql/resolvers.js
@@ -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 },
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
index 83601d5b2e3..673462073f0 100644
--- a/app/assets/javascripts/deploy_keys/index.js
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -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,
},
});
},
});
+};
diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js
deleted file mode 100644
index 2837fc8ed88..00000000000
--- a/app/assets/javascripts/deploy_keys/service/index.js
+++ /dev/null
@@ -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);
- }
-}
diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js
deleted file mode 100644
index dcd77e921cd..00000000000
--- a/app/assets/javascripts/deploy_keys/store/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-export default class DeployKeysStore {
- constructor() {
- this.keys = {};
- }
-
- isEnabled(id) {
- return this.keys.enabled_keys.some((key) => key.id === id);
- }
-}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue
new file mode 100644
index 00000000000..32f05d0e298
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_protection_rules.vue
@@ -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>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 06af69ff250..8e4c50b199b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -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" />
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql
new file mode 100644
index 00000000000..e0a072b93e4
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_packages_protection_rules.query.graphql
@@ -0,0 +1,13 @@
+query getProjectPackageProtectionRules($projectPath: ID!, $first: Int) {
+ project(fullPath: $projectPath) {
+ id
+ packagesProtectionRules(first: $first) {
+ nodes {
+ id
+ packageNamePattern
+ packageType
+ pushProtectedUpToAccessLevel
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
index 27de858fe4e..6feae8dd94e 100644
--- a/app/assets/javascripts/work_items/components/widget_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -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>
diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb
index 76c9cead360..fd4dbdab95f 100644
--- a/app/controllers/projects/settings/packages_and_registries_controller.rb
+++ b/app/controllers/projects/settings/packages_and_registries_controller.rb
@@ -12,6 +12,7 @@ module Projects
urgency :low
def show
+ push_frontend_feature_flag(:packages_protected_packages, project)
end
def cleanup_tags
diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml
index 5188c530672..95c99f20380 100644
--- a/app/views/shared/deploy_keys/_index.html.haml
+++ b/app/views/shared/deploy_keys/_index.html.haml
@@ -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)
+ } }
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index fc36a9d0b04..23b4fb3959f 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -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
diff --git a/db/docs/batched_background_migrations/update_workspaces_config_version3.yml b/db/docs/batched_background_migrations/update_workspaces_config_version3.yml
new file mode 100644
index 00000000000..253feea0469
--- /dev/null
+++ b/db/docs/batched_background_migrations/update_workspaces_config_version3.yml
@@ -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
diff --git a/db/post_migrate/20240102101409_validate_finding_id_on_vulnerabilities.rb b/db/post_migrate/20240102101409_validate_finding_id_on_vulnerabilities.rb
new file mode 100644
index 00000000000..deed2462f93
--- /dev/null
+++ b/db/post_migrate/20240102101409_validate_finding_id_on_vulnerabilities.rb
@@ -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
diff --git a/db/post_migrate/20240104085448_queue_update_workspaces_config_version3.rb b/db/post_migrate/20240104085448_queue_update_workspaces_config_version3.rb
new file mode 100644
index 00000000000..574b6cdb4c2
--- /dev/null
+++ b/db/post_migrate/20240104085448_queue_update_workspaces_config_version3.rb
@@ -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
diff --git a/db/schema_migrations/20240102101409 b/db/schema_migrations/20240102101409
new file mode 100644
index 00000000000..b125c4f6cb8
--- /dev/null
+++ b/db/schema_migrations/20240102101409
@@ -0,0 +1 @@
+6b9244a1ef9a87f192548bde7346e1b6b18d036ca14dcbde04046842d461dc36 \ No newline at end of file
diff --git a/db/schema_migrations/20240104085448 b/db/schema_migrations/20240104085448
new file mode 100644
index 00000000000..a73b6a090a3
--- /dev/null
+++ b/db/schema_migrations/20240104085448
@@ -0,0 +1 @@
+57e5c890ac0ebb837a5894b09717322c2053694cc4a91270508a652f091e457c \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index b0bd513e601..950792e9d52 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -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;
diff --git a/doc/architecture/blueprints/gitlab_steps/data.drawio.png b/doc/architecture/blueprints/gitlab_steps/data.drawio.png
index 59436093fb7..5ffe2964134 100644
--- a/doc/architecture/blueprints/gitlab_steps/data.drawio.png
+++ b/doc/architecture/blueprints/gitlab_steps/data.drawio.png
Binary files differ
diff --git a/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png b/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png
index 9f6a6dcad9f..57029733b3c 100644
--- a/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png
+++ b/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png
Binary files differ
diff --git a/doc/ci/environments/img/kubernetes_summary_ui.png b/doc/ci/environments/img/kubernetes_summary_ui.png
index ce51cd8e96f..f8eae88745e 100644
--- a/doc/ci/environments/img/kubernetes_summary_ui.png
+++ b/doc/ci/environments/img/kubernetes_summary_ui.png
Binary files differ
diff --git a/doc/ci/testing/img/code_quality_inline_indicator_v16_7.png b/doc/ci/testing/img/code_quality_inline_indicator_v16_7.png
index 0d7d5bb3062..91285493562 100644
--- a/doc/ci/testing/img/code_quality_inline_indicator_v16_7.png
+++ b/doc/ci/testing/img/code_quality_inline_indicator_v16_7.png
Binary files differ
diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md
index cfe82fe9b81..606d8c77432 100644
--- a/doc/development/api_graphql_styleguide.md
+++ b/doc/development/api_graphql_styleguide.md
@@ -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
diff --git a/doc/development/permissions/custom_roles.md b/doc/development/permissions/custom_roles.md
index 844c3546f06..7c2e847c2bb 100644
--- a/doc/development/permissions/custom_roles.md
+++ b/doc/development/permissions/custom_roles.md
@@ -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.
diff --git a/doc/integration/advanced_search/elasticsearch.md b/doc/integration/advanced_search/elasticsearch.md
index 0a456e6c73e..896ad18033b 100644
--- a/doc/integration/advanced_search/elasticsearch.md
+++ b/doc/integration/advanced_search/elasticsearch.md
@@ -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
diff --git a/doc/integration/partner_marketplace.md b/doc/integration/partner_marketplace.md
index 36aef0a0a90..cbcb8f70164 100644
--- a/doc/integration/partner_marketplace.md
+++ b/doc/integration/partner_marketplace.md
@@ -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.
diff --git a/doc/integration/saml.md b/doc/integration/saml.md
index 3423b1bde6d..ac831b98a90 100644
--- a/doc/integration/saml.md
+++ b/doc/integration/saml.md
@@ -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 |
|:-----------------|:---------------------------------------------------|:----------------------------------------------------------------------------------------------|
diff --git a/doc/integration/sourcegraph.md b/doc/integration/sourcegraph.md
index f6fb387f016..198dda7ec95 100644
--- a/doc/integration/sourcegraph.md
+++ b/doc/integration/sourcegraph.md
@@ -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.
diff --git a/doc/user/analytics/img/enhanced_issue_analytics_v16_7.png b/doc/user/analytics/img/enhanced_issue_analytics_v16_7.png
index 519e56acaa5..b253fe336ee 100644
--- a/doc/user/analytics/img/enhanced_issue_analytics_v16_7.png
+++ b/doc/user/analytics/img/enhanced_issue_analytics_v16_7.png
Binary files differ
diff --git a/doc/user/application_security/policies/img/scan_results_evaluation_white-bg.png b/doc/user/application_security/policies/img/scan_results_evaluation_white-bg.png
index d2f5466e383..ac3164842a4 100644
--- a/doc/user/application_security/policies/img/scan_results_evaluation_white-bg.png
+++ b/doc/user/application_security/policies/img/scan_results_evaluation_white-bg.png
Binary files differ
diff --git a/doc/user/application_security/sast/img/sast_inline_indicator_v16_7.png b/doc/user/application_security/sast/img/sast_inline_indicator_v16_7.png
index c86f536afc4..582e55f1d0a 100644
--- a/doc/user/application_security/sast/img/sast_inline_indicator_v16_7.png
+++ b/doc/user/application_security/sast/img/sast_inline_indicator_v16_7.png
Binary files differ
diff --git a/doc/user/application_security/sast/img/sast_mr_widget_v16_7.png b/doc/user/application_security/sast/img/sast_mr_widget_v16_7.png
index 199f8b6d322..b293989ae21 100644
--- a/doc/user/application_security/sast/img/sast_mr_widget_v16_7.png
+++ b/doc/user/application_security/sast/img/sast_mr_widget_v16_7.png
Binary files differ
diff --git a/doc/user/emoji_reactions.md b/doc/user/emoji_reactions.md
index 10385da7cdc..a72c15bb229 100644
--- a/doc/user/emoji_reactions.md
+++ b/doc/user/emoji_reactions.md
@@ -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.
diff --git a/doc/user/group/issues_analytics/img/enhanced_issue_analytics_v16_7.png b/doc/user/group/issues_analytics/img/enhanced_issue_analytics_v16_7.png
index 519e56acaa5..b253fe336ee 100644
--- a/doc/user/group/issues_analytics/img/enhanced_issue_analytics_v16_7.png
+++ b/doc/user/group/issues_analytics/img/enhanced_issue_analytics_v16_7.png
Binary files differ
diff --git a/doc/user/group/saml_sso/troubleshooting_scim.md b/doc/user/group/saml_sso/troubleshooting_scim.md
index 3dcb2d93096..e5af37e7327 100644
--- a/doc/user/group/saml_sso/troubleshooting_scim.md
+++ b/doc/user/group/saml_sso/troubleshooting_scim.md
@@ -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
diff --git a/doc/user/okrs.md b/doc/user/okrs.md
index e08b245f088..1bed94a302b 100644
--- a/doc/user/okrs.md
+++ b/doc/user/okrs.md
@@ -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
diff --git a/doc/user/project/changelogs.md b/doc/user/project/changelogs.md
index a15bd39f1b7..df6df1653ac 100644
--- a/doc/user/project/changelogs.md
+++ b/doc/user/project/changelogs.md
@@ -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
diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md
index e6fd302e4f0..39353480908 100644
--- a/doc/user/project/issue_board.md
+++ b/doc/user/project/issue_board.md
@@ -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
diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md
index 2cc38e6a31c..f064d867e0f 100644
--- a/doc/user/project/labels.md
+++ b/doc/user/project/labels.md
@@ -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.
diff --git a/doc/user/project/merge_requests/approvals/img/group_access_example_01_v16_8.png b/doc/user/project/merge_requests/approvals/img/group_access_example_01_v16_8.png
index dc2940f9492..0b5fbf7e075 100644
--- a/doc/user/project/merge_requests/approvals/img/group_access_example_01_v16_8.png
+++ b/doc/user/project/merge_requests/approvals/img/group_access_example_01_v16_8.png
Binary files differ
diff --git a/doc/user/project/merge_requests/approvals/img/group_access_example_02_v16_8.png b/doc/user/project/merge_requests/approvals/img/group_access_example_02_v16_8.png
index e1305ff845c..49df19f2c46 100644
--- a/doc/user/project/merge_requests/approvals/img/group_access_example_02_v16_8.png
+++ b/doc/user/project/merge_requests/approvals/img/group_access_example_02_v16_8.png
Binary files differ
diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md
index 6c31b2ad5d3..1d721d71444 100644
--- a/doc/user/project/releases/index.md
+++ b/doc/user/project/releases/index.md
@@ -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:
diff --git a/doc/user/project/repository/mirror/bidirectional.md b/doc/user/project/repository/mirror/bidirectional.md
index d4ab550cb8a..dc789d28a4f 100644
--- a/doc/user/project/repository/mirror/bidirectional.md
+++ b/doc/user/project/repository/mirror/bidirectional.md
@@ -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:
diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md
index dea50ecc408..f2faa0676b5 100644
--- a/doc/user/project/settings/import_export.md
+++ b/doc/user/project/settings/import_export.md
@@ -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
diff --git a/doc/user/storage_management_automation.md b/doc/user/storage_management_automation.md
index f25ae8ba92d..e04a7f1bdee 100644
--- a/doc/user/storage_management_automation.md
+++ b/doc/user/storage_management_automation.md
@@ -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,
diff --git a/doc/user/usage_quotas.md b/doc/user/usage_quotas.md
index 973ad9d0b07..2dc5c1ef819 100644
--- a/doc/user/usage_quotas.md
+++ b/doc/user/usage_quotas.md
@@ -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
diff --git a/lib/gitlab/background_migration/update_workspaces_config_version3.rb b/lib/gitlab/background_migration/update_workspaces_config_version3.rb
new file mode 100644
index 00000000000..8626f7f608d
--- /dev/null
+++ b/lib/gitlab/background_migration/update_workspaces_config_version3.rb
@@ -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
diff --git a/lib/gitlab/namespaced_session_store.rb b/lib/gitlab/namespaced_session_store.rb
index f0f24c081c3..957e8fe9b9f 100644
--- a/lib/gitlab/namespaced_session_store.rb
+++ b/lib/gitlab/namespaced_session_store.rb
@@ -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[@namespace_key]&.fetch(key, nil)
+ end
+
+ def []=(key, value)
return unless session
- session[@key] ||= {}
- session[@key]
+ session[@namespace_key] ||= {}
+ session[@namespace_key][key] = value
end
private
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7f269728ced..21862bdba69 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb b/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb
index 9305467cbe4..3d9addfe456 100644
--- a/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb
+++ b/spec/features/projects/settings/user_interacts_with_deploy_keys_spec.rb
@@ -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
diff --git a/spec/features/projects/work_items/linked_work_items_spec.rb b/spec/features/projects/work_items/linked_work_items_spec.rb
index 963be23e5a8..f9cdd7b78ab 100644
--- a/spec/features/projects/work_items/linked_work_items_spec.rb
+++ b/spec/features/projects/work_items/linked_work_items_spec.rb
@@ -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)
diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js
index c4c7a9aea2d..e94734da4ce 100644
--- a/spec/frontend/deploy_keys/components/action_btn_spec.js
+++ b/spec/frontend/deploy_keys/components/action_btn_spec.js
@@ -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 () => {
diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js
index de4112134ce..5e012bc1c51 100644
--- a/spec/frontend/deploy_keys/components/app_spec.js
+++ b/spec/frontend/deploy_keys/components/app_spec.js
@@ -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('triggers changing the scope on click', async () => {
+ await findNavigationTabs().vm.$emit('onChangeTab', scope);
+
+ expect(scopeMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { scope },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
});
- it('does not render key panels when keys object is empty', () => {
- mock.onGet(TEST_ENDPOINT).reply(HTTP_STATUS_OK, []);
+ it('captures a failed tab change', async () => {
+ const scope = 'fake scope';
+ const error = new Error('fail!');
- return mountComponent().then(() => {
- expect(findKeyPanels().length).toBe(0);
+ 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]);
+
+ beforeEach(() => {
+ deployKeyMock.mockReturnValue({
+ data: {
+ project: { id: 1, deployKeys: [key], __typename: 'Project' },
+ },
+ });
+ });
- const removeKey = async (keyEvent) => {
- eventHub.$emit(keyEvent, key, () => {});
+ 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 () => {
- await mountComponent();
+ 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' },
+ },
+ });
- eventHub.$emit('enable.key', key);
+ confirmRemoveKeyMock.mockReturnValue(key);
+ pageInfoMock.mockReturnValue(pageInfo);
+ currentPageMock.mockReturnValue(page);
+ await mountComponent();
+ glPagination = wrapper.findComponent(GlPagination);
+ });
- expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/enable`);
- 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('re-fetches deploy keys when disabling a key', async () => {
- await mountComponent();
+ it('moves back a page', async () => {
+ await glPagination.vm.$emit('previous');
- await removeKey('disable.key');
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: page - 1 },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('moves forward a page', async () => {
+ await glPagination.vm.$emit('next');
- expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`);
- expect(getMethodMock).toHaveBeenCalled();
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: page + 1 },
+ expect.anything(),
+ expect.anything(),
+ );
});
- it('calls disableKey when removing a key', async () => {
- await mountComponent();
+ it('moves to specified page', async () => {
+ await glPagination.vm.$emit('input', 5);
+
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: 5 },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
- await removeKey('remove.key');
+ it('moves a page back if there are no more keys on this page', async () => {
+ await findModal().vm.$emit('remove');
+ await waitForPromises();
- expect(putMethodMock).toHaveBeenCalledWith(`${TEST_ENDPOINT}/${key.id}/disable`);
- expect(getMethodMock).toHaveBeenCalled();
+ expect(pageMutationMock).toHaveBeenCalledWith(
+ expect.anything(),
+ { page: page - 1 },
+ expect.anything(),
+ expect.anything(),
+ );
});
});
});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index e57da4df150..5410914da04 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -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 });
- expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
+ 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 disable button when key is enabled', () => {
- store.keys.enabled_keys.push(deployKey);
+ it('shows pencil button for editing', async () => {
+ await createComponent({ deployKey });
+ expect(wrapper.find('.btn [data-testid="pencil-icon"]').exists()).toBe(true);
+ });
- 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);
});
diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js
index e63b269fe23..6e653010d8f 100644
--- a/spec/frontend/deploy_keys/components/keys_panel_spec.js
+++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js
@@ -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', () => {
diff --git a/spec/frontend/deploy_keys/graphql/resolvers_spec.js b/spec/frontend/deploy_keys/graphql/resolvers_spec.js
index 458232697cb..486cbc525d1 100644
--- a/spec/frontend/deploy_keys/graphql/resolvers_spec.js
+++ b/spec/frontend/deploy_keys/graphql/resolvers_spec.js
@@ -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', () => {
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js
new file mode 100644
index 00000000000..26d4e4dbb4f
--- /dev/null
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_protection_rules_spec.js
@@ -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);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
index dfcabd14489..1afc9b62ba2 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
@@ -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);
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
index 3204ca01f99..5c546289b14 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
@@ -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,
+ },
+ },
+});
diff --git a/spec/lib/gitlab/namespaced_session_store_spec.rb b/spec/lib/gitlab/namespaced_session_store_spec.rb
index 2c258ce3da6..4e9b35e6859 100644
--- a/spec/lib/gitlab/namespaced_session_store_spec.rb
+++ b/spec/lib/gitlab/namespaced_session_store_spec.rb
@@ -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