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:
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue90
-rw-r--r--app/assets/javascripts/boards/index.js1
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js9
-rw-r--r--app/assets/javascripts/boards/queries/board.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/queries/group_boards.query.graphql13
-rw-r--r--app/assets/javascripts/boards/queries/project_boards.query.graphql13
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js14
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue26
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue72
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue2
-rw-r--r--app/helpers/boards_helper.rb9
-rw-r--r--app/policies/project_policy.rb1
-rw-r--r--changelogs/unreleased/20083-conflict-between-project-s-permission-settings-description-and-actu.yml5
-rw-r--r--changelogs/unreleased/207237-snippet-edit-description-vue.yml5
-rw-r--r--changelogs/unreleased/208258-update-documentation-and-common_metrics-yml-to-match-new-y_axis-pr.yml5
-rw-r--r--changelogs/unreleased/208889-optimize-event-counters.yml5
-rw-r--r--changelogs/unreleased/21811-project-create-deploy-tokens.yml5
-rw-r--r--changelogs/unreleased/gitaly_keepalive.yml5
-rw-r--r--changelogs/unreleased/replace-undefined-with-unkown-vulnerabilities.yml5
-rw-r--r--config/environments/development.rb2
-rw-r--r--config/prometheus/common_metrics.yml14
-rw-r--r--db/migrate/20200304160800_add_index_services_on_template.rb (renamed from db/migrate/20200309105539_add_index_services_on_template.rb)0
-rw-r--r--db/migrate/20200306160521_add_index_on_author_id_and_created_at_to_events.rb17
-rw-r--r--db/migrate/20200306170211_add_index_on_author_id_and_id_and_created_at_to_issues.rb17
-rw-r--r--db/post_migrate/20200302142052_update_vulnerability_severity_column.rb31
-rw-r--r--db/schema.rb5
-rw-r--r--doc/api/deploy_tokens.md37
-rw-r--r--doc/development/prometheus_metrics.md5
-rw-r--r--doc/install/aws/index.md4
-rw-r--r--doc/security/user_email_confirmation.md4
-rw-r--r--doc/user/admin_area/settings/sign_up_restrictions.md4
-rw-r--r--doc/user/application_security/dast/index.md25
-rw-r--r--doc/user/project/integrations/prometheus.md37
-rw-r--r--doc/user/project/integrations/prometheus_units.md110
-rw-r--r--lib/api/deploy_tokens.rb32
-rw-r--r--lib/api/entities/deploy_token_with_token.rb9
-rw-r--r--lib/gitlab/background_migration/remove_undefined_vulnerability_severity_level.rb13
-rw-r--r--lib/gitlab/database/batch_count.rb2
-rw-r--r--lib/gitlab/gitaly_client.rb12
-rw-r--r--locale/gitlab.pot6
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/deploy_token.json6
-rw-r--r--spec/fixtures/lib/gitlab/metrics/dashboard/schemas/axis.json10
-rw-r--r--spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json1
-rw-r--r--spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js (renamed from spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js)90
-rw-r--r--spec/frontend/boards/boards_store_spec.js17
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js145
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js124
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js63
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js434
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap48
-rw-r--r--spec/frontend/snippets/components/snippet_description_edit_spec.js52
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js30
-rw-r--r--spec/policies/project_policy_spec.rb2
-rw-r--r--spec/requests/api/deploy_tokens_spec.rb53
54 files changed, 1585 insertions, 165 deletions
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 5e8b80cd959..8b44ccfd276 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -10,6 +10,11 @@ import {
} from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import projectQuery from '../queries/project_boards.query.graphql';
+import groupQuery from '../queries/group_boards.query.graphql';
+
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
@@ -88,8 +93,9 @@ export default {
},
data() {
return {
- loading: true,
hasScrollFade: false,
+ loadingBoards: 0,
+ loadingRecentBoards: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
@@ -102,6 +108,12 @@ export default {
};
},
computed: {
+ parentType() {
+ return this.groupId ? 'group' : 'project';
+ },
+ loading() {
+ return this.loadingRecentBoards && this.loadingBoards;
+ },
currentPage() {
return this.state.currentPage;
},
@@ -147,49 +159,71 @@ export default {
return;
}
- const recentBoardsPromise = new Promise((resolve, reject) =>
- boardsStore
- .recentBoards()
- .then(resolve)
- .catch(err => {
- /**
- * If user is unauthorized we'd still want to resolve the
- * request to display all boards.
- */
- if (err.response.status === httpStatusCodes.UNAUTHORIZED) {
- resolve({ data: [] }); // recent boards are empty
- return;
- }
- reject(err);
- }),
- );
+ this.$apollo.addSmartQuery('boards', {
+ variables() {
+ return { fullPath: this.state.endpoints.fullPath };
+ },
+ query() {
+ return this.groupId ? groupQuery : projectQuery;
+ },
+ loadingKey: 'loadingBoards',
+ update(data) {
+ if (!data?.[this.parentType]) {
+ return [];
+ }
+ return data[this.parentType].boards.edges.map(({ node }) => ({
+ id: getIdFromGraphQLId(node.id),
+ name: node.name,
+ }));
+ },
+ });
- Promise.all([boardsStore.allBoards(), recentBoardsPromise])
- .then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
- .then(([allBoardsJson, recentBoardsJson]) => {
- this.loading = false;
- this.boards = allBoardsJson;
- this.recentBoards = recentBoardsJson;
+ this.loadingRecentBoards = true;
+ boardsStore
+ .recentBoards()
+ .then(res => {
+ this.recentBoards = res.data;
+ })
+ .catch(err => {
+ /**
+ * If user is unauthorized we'd still want to resolve the
+ * request to display all boards.
+ */
+ if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
+ this.recentBoards = []; // recent boards are empty
+ return;
+ }
+ throw err;
})
.then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => {
this.setScrollFade();
})
- .catch(() => {
- this.loading = false;
+ .catch(() => {})
+ .finally(() => {
+ this.loadingRecentBoards = false;
});
},
isScrolledUp() {
const { content } = this.$refs;
+
+ if (!content) {
+ return false;
+ }
+
const currentPosition = this.contentClientHeight + content.scrollTop;
- return content && currentPosition < this.maxPosition;
+ return currentPosition < this.maxPosition;
},
initScrollFade() {
- this.scrollFadeInitialized = true;
-
const { content } = this.$refs;
+ if (!content) {
+ return;
+ }
+
+ this.scrollFadeInitialized = true;
+
this.contentClientHeight = content.clientHeight;
this.maxPosition = content.scrollHeight;
},
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f1b481fc386..f72fc8d54b3 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -98,6 +98,7 @@ export default () => {
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
+ fullPath: $boardApp.dataset.fullPath,
});
boardsStore.rootPath = this.boardsEndpoint;
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index 8d22f009784..73d37459bfe 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,7 +1,15 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
export default () => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({
@@ -9,6 +17,7 @@ export default () => {
components: {
BoardsSelector,
},
+ apolloProvider,
data() {
const { dataset } = boardsSwitcherElement;
diff --git a/app/assets/javascripts/boards/queries/board.fragment.graphql b/app/assets/javascripts/boards/queries/board.fragment.graphql
new file mode 100644
index 00000000000..48f55e899bf
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board.fragment.graphql
@@ -0,0 +1,4 @@
+fragment BoardFragment on Board {
+ id,
+ name
+}
diff --git a/app/assets/javascripts/boards/queries/group_boards.query.graphql b/app/assets/javascripts/boards/queries/group_boards.query.graphql
new file mode 100644
index 00000000000..74c224add7d
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/group_boards.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/boards/queries/board.fragment.graphql"
+
+query group_boards($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ boards {
+ edges {
+ node {
+ ...BoardFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/project_boards.query.graphql b/app/assets/javascripts/boards/queries/project_boards.query.graphql
new file mode 100644
index 00000000000..a1326bd5eff
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/project_boards.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/boards/queries/board.fragment.graphql"
+
+query project_boards($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ boards {
+ edges {
+ node {
+ ...BoardFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 2a5571543fb..2a2cff3d07d 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -45,7 +45,14 @@ const boardsStore = {
},
multiSelect: { list: [] },
- setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
+ setEndpoints({
+ boardsEndpoint,
+ listsEndpoint,
+ bulkUpdatePath,
+ boardId,
+ recentBoardsEndpoint,
+ fullPath,
+ }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.state.endpoints = {
boardsEndpoint,
@@ -53,6 +60,7 @@ const boardsStore = {
listsEndpoint,
listsEndpointGenerate,
bulkUpdatePath,
+ fullPath,
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
@@ -542,10 +550,6 @@ const boardsStore = {
return axios.post(endpoint);
},
- allBoards() {
- return axios.get(this.generateBoardsPath());
- },
-
recentBoards() {
return axios.get(this.state.endpoints.recentBoardsEndpoint);
},
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 6994f83bce0..faaa65b1a16 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -165,6 +165,16 @@ export default {
showContainerRegistryPublicNote() {
return this.visibilityLevel === visibilityOptions.PUBLIC;
},
+
+ repositoryHelpText() {
+ if (this.visibilityLevel === visibilityOptions.PRIVATE) {
+ return s__('ProjectSettings|View and edit files in this project');
+ }
+
+ return s__(
+ 'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
+ );
+ },
},
watch: {
@@ -225,6 +235,7 @@ export default {
<div>
<div class="project-visibility-setting">
<project-setting-row
+ ref="project-visibility-settings"
:help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')"
>
@@ -270,6 +281,7 @@ export default {
</div>
<div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
<project-setting-row
+ ref="issues-settings"
:label="s__('ProjectSettings|Issues')"
:help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')"
>
@@ -280,8 +292,9 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ ref="repository-settings"
:label="s__('ProjectSettings|Repository')"
- :help-text="s__('ProjectSettings|View and edit files in this project')"
+ :help-text="repositoryHelpText"
>
<project-feature-setting
v-model="repositoryAccessLevel"
@@ -291,6 +304,7 @@ export default {
</project-setting-row>
<div class="project-feature-setting-group">
<project-setting-row
+ ref="merge-request-settings"
:label="s__('ProjectSettings|Merge requests')"
:help-text="s__('ProjectSettings|Submit changes to be merged upstream')"
>
@@ -302,6 +316,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ ref="fork-settings"
:label="s__('ProjectSettings|Forks')"
:help-text="
s__('ProjectSettings|Allow users to make copies of your repository to a new project')
@@ -315,6 +330,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ ref="pipeline-settings"
:label="s__('ProjectSettings|Pipelines')"
:help-text="s__('ProjectSettings|Build, test, and deploy your changes')"
>
@@ -327,6 +343,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="registryAvailable"
+ ref="container-registry-settings"
:help-path="registryHelpPath"
:label="s__('ProjectSettings|Container registry')"
:help-text="
@@ -348,6 +365,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="lfsAvailable"
+ ref="git-lfs-settings"
:help-path="lfsHelpPath"
:label="s__('ProjectSettings|Git Large File Storage')"
:help-text="
@@ -362,6 +380,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="packagesAvailable"
+ ref="package-settings"
:help-path="packagesHelpPath"
:label="s__('ProjectSettings|Packages')"
:help-text="
@@ -376,6 +395,7 @@ export default {
</project-setting-row>
</div>
<project-setting-row
+ ref="wiki-settings"
:label="s__('ProjectSettings|Wiki')"
:help-text="s__('ProjectSettings|Pages for project documentation')"
>
@@ -386,6 +406,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ ref="snippet-settings"
:label="s__('ProjectSettings|Snippets')"
:help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')"
>
@@ -397,6 +418,7 @@ export default {
</project-setting-row>
<project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
+ ref="pages-settings"
:help-path="pagesHelpPath"
:label="s__('ProjectSettings|Pages')"
:help-text="
@@ -410,7 +432,7 @@ export default {
/>
</project-setting-row>
</div>
- <project-setting-row v-if="canDisableEmails" class="mb-3">
+ <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
<input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
<input v-model="emailsDisabled" type="checkbox" />
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
new file mode 100644
index 00000000000..5b70ac5b715
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlFormInput } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import setupCollapsibleInputs from '~/snippet/collapsible_input';
+
+export default {
+ components: {
+ GlFormInput,
+ MarkdownField,
+ },
+ props: {
+ description: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ text: this.description,
+ };
+ },
+ mounted() {
+ setupCollapsibleInputs();
+ },
+};
+</script>
+<template>
+ <div class="form-group js-description-input">
+ <label>{{ s__('Snippets|Description (optional)') }}</label>
+ <div class="js-collapsible-input">
+ <div class="js-collapsed" :class="{ 'd-none': text }">
+ <gl-form-input
+ class="form-control"
+ :placeholder="
+ s__(
+ 'Snippets|Optionally add a description about what your snippet does or how to use it…',
+ )
+ "
+ data-qa-selector="description_placeholder"
+ />
+ </div>
+ <markdown-field
+ class="js-expanded"
+ :class="{ 'd-none': !text }"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ >
+ <textarea
+ id="snippet-description"
+ slot="textarea"
+ v-model="text"
+ class="note-textarea js-gfm-input js-autosize markdown-area
+ qa-description-textarea"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ >
+ </textarea>
+ </markdown-field>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index c8d69143f8d..df86725c025 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -212,6 +212,8 @@ export default {
return new MRWidgetService(this.getServiceEndpoints(store));
},
checkStatus(cb, isRebased) {
+ if (document.visibilityState !== 'visible') return Promise.resolve();
+
return this.service
.checkStatus()
.then(({ data }) => {
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index d3950219f3f..8bb079e6447 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -13,6 +13,7 @@ module BoardsHelper
disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s,
issue_link_base: build_issue_link_base,
root_path: root_path,
+ full_path: full_path,
bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar),
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
@@ -20,6 +21,14 @@ module BoardsHelper
}
end
+ def full_path
+ if board.group_board?
+ @group.full_path
+ else
+ @project.full_path
+ end
+ end
+
def build_issue_link_base
if board.group_board?
"#{group_path(@board.group)}/:project_path/issues"
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index ee47acc6041..15d60fe9cd8 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -313,6 +313,7 @@ class ProjectPolicy < BasePolicy
enable :daily_statistics
enable :admin_operations
enable :read_deploy_token
+ enable :create_deploy_token
end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
diff --git a/changelogs/unreleased/20083-conflict-between-project-s-permission-settings-description-and-actu.yml b/changelogs/unreleased/20083-conflict-between-project-s-permission-settings-description-and-actu.yml
new file mode 100644
index 00000000000..0fe1c7d6b9d
--- /dev/null
+++ b/changelogs/unreleased/20083-conflict-between-project-s-permission-settings-description-and-actu.yml
@@ -0,0 +1,5 @@
+---
+title: Update project's permission settings description to reflect actual permissions
+merge_request: 25523
+author:
+type: other
diff --git a/changelogs/unreleased/207237-snippet-edit-description-vue.yml b/changelogs/unreleased/207237-snippet-edit-description-vue.yml
new file mode 100644
index 00000000000..cc97faf5158
--- /dev/null
+++ b/changelogs/unreleased/207237-snippet-edit-description-vue.yml
@@ -0,0 +1,5 @@
+---
+title: Added Blob Description Edit component in Vue
+merge_request: 26762
+author:
+type: added
diff --git a/changelogs/unreleased/208258-update-documentation-and-common_metrics-yml-to-match-new-y_axis-pr.yml b/changelogs/unreleased/208258-update-documentation-and-common_metrics-yml-to-match-new-y_axis-pr.yml
new file mode 100644
index 00000000000..43f28b85f15
--- /dev/null
+++ b/changelogs/unreleased/208258-update-documentation-and-common_metrics-yml-to-match-new-y_axis-pr.yml
@@ -0,0 +1,5 @@
+---
+title: Update charts documentation and common_metrics.yml to enable data formatting
+merge_request: 26048
+author:
+type: added
diff --git a/changelogs/unreleased/208889-optimize-event-counters.yml b/changelogs/unreleased/208889-optimize-event-counters.yml
new file mode 100644
index 00000000000..db97c395aff
--- /dev/null
+++ b/changelogs/unreleased/208889-optimize-event-counters.yml
@@ -0,0 +1,5 @@
+---
+title: Optimize event counters query performance in usage data
+merge_request: 26444
+author:
+type: performance
diff --git a/changelogs/unreleased/21811-project-create-deploy-tokens.yml b/changelogs/unreleased/21811-project-create-deploy-tokens.yml
new file mode 100644
index 00000000000..6194efc3838
--- /dev/null
+++ b/changelogs/unreleased/21811-project-create-deploy-tokens.yml
@@ -0,0 +1,5 @@
+---
+title: Add api endpoint to create deploy tokens
+merge_request: 25270
+author:
+type: added
diff --git a/changelogs/unreleased/gitaly_keepalive.yml b/changelogs/unreleased/gitaly_keepalive.yml
new file mode 100644
index 00000000000..c975f0f0df2
--- /dev/null
+++ b/changelogs/unreleased/gitaly_keepalive.yml
@@ -0,0 +1,5 @@
+---
+title: Enable client-side GRPC keepalive for Gitaly
+merge_request: 26536
+author:
+type: changed
diff --git a/changelogs/unreleased/replace-undefined-with-unkown-vulnerabilities.yml b/changelogs/unreleased/replace-undefined-with-unkown-vulnerabilities.yml
new file mode 100644
index 00000000000..bc06524fead
--- /dev/null
+++ b/changelogs/unreleased/replace-undefined-with-unkown-vulnerabilities.yml
@@ -0,0 +1,5 @@
+---
+title: Replace undefined severity with unknown severity for vulnerabilities
+merge_request: 26305
+author:
+type: other
diff --git a/config/environments/development.rb b/config/environments/development.rb
index b6b025112fe..41d20b5062b 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -59,5 +59,7 @@ Rails.application.configure do
config.active_record.migration_error = false
config.active_record.verbose_query_logs = false
config.action_view.cache_template_loading = true
+
+ config.middleware.delete BetterErrors::Middleware
end
end
diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml
index 314ee44ed71..6eae29c3906 100644
--- a/config/prometheus/common_metrics.yml
+++ b/config/prometheus/common_metrics.yml
@@ -17,6 +17,8 @@ panel_groups:
- title: "Latency"
type: "area-chart"
y_label: "Latency (ms)"
+ y_axis:
+ format: milliseconds
weight: 1
metrics:
- id: response_metrics_nginx_ingress_latency_pod_average
@@ -26,6 +28,8 @@ panel_groups:
- title: "HTTP Error Rate"
type: "area-chart"
y_label: "HTTP Errors (%)"
+ y_axis:
+ format: percentHundred
weight: 1
metrics:
- id: response_metrics_nginx_ingress_http_error_rate
@@ -138,6 +142,8 @@ panel_groups:
- title: "HTTP Error Rate (Errors / Sec)"
type: "area-chart"
y_label: "HTTP 500 Errors / Sec"
+ y_axis:
+ precision: 0
weight: 1
metrics:
- id: response_metrics_nginx_http_error_rate
@@ -150,6 +156,8 @@ panel_groups:
- title: "Memory Usage (Total)"
type: "area-chart"
y_label: "Total Memory Used (GB)"
+ y_axis:
+ format: "gibibytes"
weight: 4
metrics:
- id: system_metrics_kubernetes_container_memory_total
@@ -168,6 +176,8 @@ panel_groups:
- title: "Memory Usage (Pod average)"
type: "line-chart"
y_label: "Memory Used per Pod (MB)"
+ y_axis:
+ format: "mebibytes"
weight: 2
metrics:
- id: system_metrics_kubernetes_container_memory_average
@@ -177,6 +187,8 @@ panel_groups:
- title: "Canary: Memory Usage (Pod Average)"
type: "line-chart"
y_label: "Memory Used per Pod (MB)"
+ y_axis:
+ format: "mebibytes"
weight: 2
metrics:
- id: system_metrics_kubernetes_container_memory_average_canary
@@ -206,6 +218,8 @@ panel_groups:
- title: "Knative function invocations"
type: "area-chart"
y_label: "Invocations"
+ y_axis:
+ precision: 0
weight: 1
metrics:
- id: system_metrics_knative_function_invocation_count
diff --git a/db/migrate/20200309105539_add_index_services_on_template.rb b/db/migrate/20200304160800_add_index_services_on_template.rb
index 731fa04123c..731fa04123c 100644
--- a/db/migrate/20200309105539_add_index_services_on_template.rb
+++ b/db/migrate/20200304160800_add_index_services_on_template.rb
diff --git a/db/migrate/20200306160521_add_index_on_author_id_and_created_at_to_events.rb b/db/migrate/20200306160521_add_index_on_author_id_and_created_at_to_events.rb
new file mode 100644
index 00000000000..3328a14bb65
--- /dev/null
+++ b/db/migrate/20200306160521_add_index_on_author_id_and_created_at_to_events.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddIndexOnAuthorIdAndCreatedAtToEvents < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :events, [:author_id, :created_at]
+ end
+
+ def down
+ remove_concurrent_index :events, [:author_id, :created_at]
+ end
+end
diff --git a/db/migrate/20200306170211_add_index_on_author_id_and_id_and_created_at_to_issues.rb b/db/migrate/20200306170211_add_index_on_author_id_and_id_and_created_at_to_issues.rb
new file mode 100644
index 00000000000..c581ca3874f
--- /dev/null
+++ b/db/migrate/20200306170211_add_index_on_author_id_and_id_and_created_at_to_issues.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddIndexOnAuthorIdAndIdAndCreatedAtToIssues < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :issues, [:author_id, :id, :created_at]
+ end
+
+ def down
+ remove_concurrent_index :issues, [:author_id, :id, :created_at]
+ end
+end
diff --git a/db/post_migrate/20200302142052_update_vulnerability_severity_column.rb b/db/post_migrate/20200302142052_update_vulnerability_severity_column.rb
new file mode 100644
index 00000000000..fa38569f35d
--- /dev/null
+++ b/db/post_migrate/20200302142052_update_vulnerability_severity_column.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class UpdateVulnerabilitySeverityColumn < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+ BATCH_SIZE = 1_000
+ INTERVAL = 2.minutes
+
+ def up
+ # create temporary index for undefined vulnerabilities
+ add_concurrent_index(:vulnerabilities, :id, where: 'severity = 0', name: 'undefined_vulnerability')
+
+ return unless Gitlab.ee?
+
+ migration = Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel
+ migration_name = migration.to_s.demodulize
+ relation = migration::Vulnerability.undefined_severity
+ queue_background_migration_jobs_by_range_at_intervals(relation,
+ migration_name,
+ INTERVAL,
+ batch_size: BATCH_SIZE)
+ end
+
+ def down
+ # no-op
+ # This migration can not be reversed because we can not know which records had undefined severity
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index deca4b3a6d0..32cc771c396 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_03_09_105539) do
+ActiveRecord::Schema.define(version: 2020_03_06_170531) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@@ -1616,6 +1616,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.string "target_type"
t.bigint "group_id"
t.index ["action"], name: "index_events_on_action"
+ t.index ["author_id", "created_at"], name: "index_events_on_author_id_and_created_at"
t.index ["author_id", "project_id"], name: "index_events_on_author_id_and_project_id"
t.index ["created_at", "author_id"], name: "analytics_index_events_on_created_at_and_author_id"
t.index ["group_id"], name: "index_events_on_group_id_partial", where: "(group_id IS NOT NULL)"
@@ -2206,6 +2207,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.integer "duplicated_to_id"
t.integer "promoted_to_epic_id"
t.integer "health_status", limit: 2
+ t.index ["author_id", "id", "created_at"], name: "index_issues_on_author_id_and_id_and_created_at"
t.index ["author_id"], name: "index_issues_on_author_id"
t.index ["closed_by_id"], name: "index_issues_on_closed_by_id"
t.index ["confidential"], name: "index_issues_on_confidential"
@@ -4454,6 +4456,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.index ["dismissed_by_id"], name: "index_vulnerabilities_on_dismissed_by_id"
t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id"
t.index ["epic_id"], name: "index_vulnerabilities_on_epic_id"
+ t.index ["id"], name: "undefined_vulnerability", where: "(severity = 0)"
t.index ["last_edited_by_id"], name: "index_vulnerabilities_on_last_edited_by_id"
t.index ["milestone_id"], name: "index_vulnerabilities_on_milestone_id"
t.index ["project_id"], name: "index_vulnerabilities_on_project_id"
diff --git a/doc/api/deploy_tokens.md b/doc/api/deploy_tokens.md
index ec7c94a6a02..e1372f714fa 100644
--- a/doc/api/deploy_tokens.md
+++ b/doc/api/deploy_tokens.md
@@ -72,6 +72,43 @@ Example response:
]
```
+### Create a project deploy token
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21811) in GitLab 12.9.
+
+Creates a new deploy token for a project.
+
+```
+POST /projects/:id/deploy_tokens
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | New deploy token's name |
+| `expires_at` | datetime | no | Expiration date for the deploy token. Does not expire if no value is provided. |
+| `username` | string | no | Username for deploy token. Default is `gitlab+deploy-token-{n}` |
+| `scopes` | array of strings | yes | Indicates the deploy token scopes. Must be at least one of `read_repository` or `read_registry`. |
+
+```shell
+curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "My deploy token", "expires_at": "2021-01-01", "username": "custom-user", "scopes": ["read_repository"]}' "https://gitlab.example.com/api/v4/projects/5/deploy_tokens/"
+```
+
+Example response:
+
+```json
+{
+ "id": 1,
+ "name": "My deploy token",
+ "username": "custom-user",
+ "expires_at": "2021-01-01T00:00:00.000Z",
+ "token": "jMRvtPNxrn3crTAGukpZ",
+ "scopes": [
+ "read_repository"
+ ]
+}
+```
+
## Group deploy tokens
These endpoints require group maintainer access or higher.
diff --git a/doc/development/prometheus_metrics.md b/doc/development/prometheus_metrics.md
index d6622c72b0d..004b1884bf0 100644
--- a/doc/development/prometheus_metrics.md
+++ b/doc/development/prometheus_metrics.md
@@ -12,7 +12,10 @@ The requirement for adding a new metric is to make each query to have an unique
- group: Response metrics (NGINX Ingress)
metrics:
- title: "Throughput"
- y_label: "Requests / Sec"
+ y_axis:
+ name: "Requests / Sec"
+ format: "number"
+ precision: 2
queries:
- id: response_metrics_nginx_ingress_throughput_status_code
query_range: 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)'
diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md
index b5e2db40dd7..3727897b4b7 100644
--- a/doc/install/aws/index.md
+++ b/doc/install/aws/index.md
@@ -52,8 +52,6 @@ Here's a list of the AWS services we will use, with links to pricing information
will apply. If you want to run it on a dedicated or reserved instance,
consult the [EC2 pricing page](https://aws.amazon.com/ec2/pricing/) for more
information on the cost.
-- **EBS**: We will also use an EBS volume to store the Git data. See the
- [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/).
- **S3**: We will use S3 to store backups, artifacts, LFS objects, etc. See the
[Amazon S3 pricing](https://aws.amazon.com/s3/pricing/).
- **ELB**: A Classic Load Balancer will be used to route requests to the
@@ -524,7 +522,7 @@ Let's create an EC2 instance where we'll install Gitaly:
1. Click **Review and launch** followed by **Launch** if you're happy with your settings.
1. Finally, acknowledge that you have access to the selected private key file or create a new one. Click **Launch Instances**.
- > **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above.
+ > **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above. See the [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/).
Now that we have our EC2 instance ready, follow the [documentation to install GitLab and set up Gitaly on its own server](../../administration/gitaly/index.md#running-gitaly-on-its-own-server).
diff --git a/doc/security/user_email_confirmation.md b/doc/security/user_email_confirmation.md
index d435d928c51..b8d882f2b80 100644
--- a/doc/security/user_email_confirmation.md
+++ b/doc/security/user_email_confirmation.md
@@ -7,9 +7,9 @@ type: howto
GitLab can be configured to require confirmation of a user's email address when
the user signs up. When this setting is enabled:
-- For GitLab 12.1 and earlier, the user is unable to sign in until they confirm their
+- For GitLab 12.7 and earlier, the user is unable to sign in until they confirm their
email address.
-- For GitLab 12.2 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
+- For GitLab 12.8 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
After 30 days, they will be unable to log in and access GitLab features.
In **Admin Area > Settings** (`/admin/application_settings/general`), go to the section
diff --git a/doc/user/admin_area/settings/sign_up_restrictions.md b/doc/user/admin_area/settings/sign_up_restrictions.md
index 6dbdf24d477..590907e5bef 100644
--- a/doc/user/admin_area/settings/sign_up_restrictions.md
+++ b/doc/user/admin_area/settings/sign_up_restrictions.md
@@ -39,9 +39,9 @@ email domains to prevent malicious users from creating accounts.
You can send confirmation emails during sign-up and require that users confirm
their email address. If this setting is selected:
-- For GitLab 12.1 and earlier, the user is unable to sign in until they confirm their
+- For GitLab 12.7 and earlier, the user is unable to sign in until they confirm their
email address.
-- For GitLab 12.2 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
+- For GitLab 12.8 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
After 30 days, they will be unable to log in and access GitLab features.
![Email confirmation](img/email_confirmation_v12_7.png)
diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md
index 7ef16ef88f0..7bc7822ae30 100644
--- a/doc/user/application_security/dast/index.md
+++ b/doc/user/application_security/dast/index.md
@@ -356,6 +356,31 @@ dast:
The DAST job does not require the project's repository to be present when running, so by default
[`GIT_STRATEGY`](../../../ci/yaml/README.md#git-strategy) is set to `none`.
+## Running DAST in an offline air-gapped installation
+
+DAST can be executed on an offline air-gapped GitLab Ultimate installation using the following process:
+
+1. Host the DAST image `registry.gitlab.com/gitlab-org/security-products/dast:latest` in your local
+ Docker container registry.
+1. Add the following configuration to your `.gitlab-ci.yml` file. You must replace `image` to refer
+ to the DAST Docker image hosted on your local Docker container registry:
+
+ ```yaml
+ include:
+ - template: DAST.gitlab-ci.yml
+
+ dast:
+ image: registry.example.com/namespace/dast:latest
+ script:
+ - export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}
+ - /analyze -t $DAST_WEBSITE --auto-update-addons false -z"-silent"
+ ```
+
+The option `--auto-update-addons false` instructs ZAP not to update add-ons.
+
+The option `-z` passes the quoted `-silent` parameter to ZAP. The `-silent` parameter ensures ZAP
+does not make any unsolicited requests including checking for updates.
+
## Reports
The DAST job can emit various reports.
diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md
index 9c98ef1f2f8..ae643127018 100644
--- a/doc/user/project/integrations/prometheus.md
+++ b/doc/user/project/integrations/prometheus.md
@@ -203,14 +203,17 @@ For example:
panel_groups:
- group: 'Group Title'
panels:
- - type: area-chart
- title: "Chart Title"
- y_label: "Y-Axis"
- metrics:
- - id: metric_of_ages
- query_range: 'http_requests_total'
- label: "Instance: {{instance}}, method: {{method}}"
- unit: "count"
+ - type: area-chart
+ title: "Chart Title"
+ y_label: "Y-Axis"
+ y_axis:
+ format: number
+ precision: 0
+ metrics:
+ - id: my_metric_id
+ query_range: 'http_requests_total'
+ label: "Instance: {{instance}}, method: {{method}}"
+ unit: "count"
```
The above sample dashboard would display a single area chart. Each file should
@@ -276,9 +279,18 @@ The following tables outline the details of expected properties.
| `type` | enum | no, defaults to `area-chart` | Specifies the chart type to use, can be: `area-chart`, `line-chart` or `anomaly-chart`. |
| `title` | string | yes | Heading for the panel. |
| `y_label` | string | no, but highly encouraged | Y-Axis label for the panel. |
+| `y_axis` | string | no | Y-Axis configuration for the panel. |
| `weight` | number | no, defaults to order in file | Order to appear within the grouping. Lower number means higher priority, which will be higher on the page. Numbers do not need to be consecutive. |
| `metrics` | array | yes | The metrics which should be displayed in the panel. Any number of metrics can be displayed when `type` is `area-chart` or `line-chart`, whereas only 3 can be displayed when `type` is `anomaly-chart`. |
+**Axis (`panels[].y_axis`) properties:**
+
+| Property | Type | Required | Description |
+| ----------- | ------ | ------------------------- | -------------------------------------------------------------------- |
+| `name` | string | no, but highly encouraged | Y-Axis label for the panel, it will replace `y_label` if set. |
+| `format` | string | no, defaults to `number` | Unit format used. See the [full list of units](prometheus_units.md). |
+| `precision` | number | no, defaults to `2` | Number of decimals to display in the number. |
+
**Metrics (`metrics`) properties:**
| Property | Type | Required | Description |
@@ -297,7 +309,7 @@ When a static label is used and a query returns multiple time series, then all t
```yaml
metrics:
- - id: metric_of_ages
+ - id: my_metric_id
query_range: 'http_requests_total'
label: "Time Series"
unit: "count"
@@ -311,7 +323,7 @@ For labels to be more explicit, using variables that reflect time series labels
```yaml
metrics:
- - id: metric_of_ages
+ - id: my_metric_id
query_range: 'http_requests_total'
label: "Instance: {{instance}}, method: {{method}}"
unit: "count"
@@ -325,7 +337,7 @@ There is also a shorthand value for dynamic dashboard labels that make use of on
```yaml
metrics:
- - id: metric_of_ages
+ - id: my_metric_id
query_range: 'http_requests_total'
label: "Method"
unit: "count"
@@ -351,6 +363,9 @@ panel_groups:
- type: area-chart # or line-chart
title: 'Area Chart Title'
y_label: "Y-Axis"
+ y_axis:
+ format: number
+ precision: 0
metrics:
- id: area_http_requests_total
query_range: 'http_requests_total'
diff --git a/doc/user/project/integrations/prometheus_units.md b/doc/user/project/integrations/prometheus_units.md
new file mode 100644
index 00000000000..9df9f52ceb1
--- /dev/null
+++ b/doc/user/project/integrations/prometheus_units.md
@@ -0,0 +1,110 @@
+# Unit formats reference
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/201999) in GitLab 12.9.
+
+You can select units to format your charts by adding `format` to your
+[axis configuration](prometheus.md#dashboard-yaml-properties).
+
+## Numbers
+
+For generic data, numbers are formatted according to the current locale.
+
+Formats: `number`
+
+**Examples:**
+
+| Data | Displayed |
+| --------- | --------- |
+| `10` | 1 |
+| `1000` | 1,000 |
+| `1000000` | 1,000,000 |
+
+## Percentage
+
+For percentage data, format numbers in the chart with a `%` symbol.
+
+Formats supported: `percent`, `percentHundred`
+
+**Examples:**
+
+| Format | Data | Displayed |
+| ---------------- | ----- | --------- |
+| `percent` | `0.5` | 50% |
+| `percent` | `1` | 100% |
+| `percent` | `2` | 200% |
+| `percentHundred` | `50` | 50% |
+| `percentHundred` | `100` | 100% |
+| `percentHundred` | `200` | 200% |
+
+## Duration
+
+For time durations, format numbers in the chart with a time unit symbol.
+
+Formats supported: `milliseconds`, `seconds`
+
+**Examples:**
+
+| Format | Data | Displayed |
+| -------------- | ------ | --------- |
+| `milliseconds` | `10` | 10ms |
+| `milliseconds` | `500` | 100ms |
+| `milliseconds` | `1000` | 1000ms |
+| `seconds` | `10` | 10s |
+| `seconds` | `500` | 500s |
+| `seconds` | `1000` | 1000s |
+
+## Digital (Metric)
+
+Converts a number of bytes using metric prefixes. It scales to
+use the unit that's the best fit.
+
+Formats supported:
+
+- `decimalBytes`
+- `kilobytes`
+- `megabytes`
+- `gigabytes`
+- `terabytes`
+- `petabytes`
+
+**Examples:**
+
+| Format | Data | Displayed |
+| -------------- | --------- | --------- |
+| `decimalBytes` | `1` | 1B |
+| `decimalBytes` | `1000` | 1kB |
+| `decimalBytes` | `1000000` | 1MB |
+| `kilobytes` | `1` | 1kB |
+| `kilobytes` | `1000` | 1MB |
+| `kilobytes` | `1000000` | 1GB |
+| `megabytes` | `1` | 1MB |
+| `megabytes` | `1000` | 1GB |
+| `megabytes` | `1000000` | 1TB |
+
+## Digital (IEC)
+
+Converts a number of bytes using binary prefixes. It scales to
+use the unit that's the best fit.
+
+Formats supported:
+
+- `bytes`
+- `kibibytes`
+- `mebibytes`
+- `gibibytes`
+- `tebibytes`
+- `pebibytes`
+
+**Examples:**
+
+| Format | Data | Displayed |
+| ----------- | ------------- | --------- |
+| `bytes` | `1` | 1B |
+| `bytes` | `1024` | 1KiB |
+| `bytes` | `1024 * 1024` | 1MiB |
+| `kibibytes` | `1` | 1KiB |
+| `kibibytes` | `1024` | 1MiB |
+| `kibibytes` | `1024 * 1024` | 1GiB |
+| `mebibytes` | `1` | 1MiB |
+| `mebibytes` | `1024` | 1GiB |
+| `mebibytes` | `1024 * 1024` | 1TiB |
diff --git a/lib/api/deploy_tokens.rb b/lib/api/deploy_tokens.rb
index e10a12b6c46..1631425ec1b 100644
--- a/lib/api/deploy_tokens.rb
+++ b/lib/api/deploy_tokens.rb
@@ -4,6 +4,17 @@ module API
class DeployTokens < Grape::API
include PaginationParams
+ helpers do
+ def scope_params
+ scopes = params.delete(:scopes)
+
+ result_hash = {}
+ result_hash[:read_registry] = scopes.include?('read_registry')
+ result_hash[:read_repository] = scopes.include?('read_repository')
+ result_hash
+ end
+ end
+
desc 'Return all deploy tokens' do
detail 'This feature was introduced in GitLab 12.9.'
success Entities::DeployToken
@@ -33,6 +44,27 @@ module API
present paginate(user_project.deploy_tokens), with: Entities::DeployToken
end
+
+ params do
+ requires :name, type: String, desc: "New deploy token's name"
+ requires :expires_at, type: DateTime, desc: 'Expiration date for the deploy token. Does not expire if no value is provided.'
+ requires :username, type: String, desc: 'Username for deploy token. Default is `gitlab+deploy-token-{n}`'
+ requires :scopes, type: Array[String], values: ::DeployToken::AVAILABLE_SCOPES.map(&:to_s),
+ desc: 'Indicates the deploy token scopes. Must be at least one of "read_repository" or "read_registry".'
+ end
+ desc 'Create a project deploy token' do
+ detail 'This feature was introduced in GitLab 12.9'
+ success Entities::DeployTokenWithToken
+ end
+ post ':id/deploy_tokens' do
+ authorize!(:create_deploy_token, user_project)
+
+ deploy_token = ::Projects::DeployTokens::CreateService.new(
+ user_project, current_user, scope_params.merge(declared(params, include_missing: false, include_parent_namespaces: false))
+ ).execute
+
+ present deploy_token, with: Entities::DeployTokenWithToken
+ end
end
params do
diff --git a/lib/api/entities/deploy_token_with_token.rb b/lib/api/entities/deploy_token_with_token.rb
new file mode 100644
index 00000000000..11efe3720fa
--- /dev/null
+++ b/lib/api/entities/deploy_token_with_token.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class DeployTokenWithToken < Entities::DeployToken
+ expose :token
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/remove_undefined_vulnerability_severity_level.rb b/lib/gitlab/background_migration/remove_undefined_vulnerability_severity_level.rb
new file mode 100644
index 00000000000..95540cd5f49
--- /dev/null
+++ b/lib/gitlab/background_migration/remove_undefined_vulnerability_severity_level.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class RemoveUndefinedVulnerabilitySeverityLevel
+ def perform(start_id, stop_id)
+ end
+ end
+ end
+end
+
+Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel.prepend_if_ee('EE::Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel')
diff --git a/lib/gitlab/database/batch_count.rb b/lib/gitlab/database/batch_count.rb
index a9d4665bc5f..728e0d423af 100644
--- a/lib/gitlab/database/batch_count.rb
+++ b/lib/gitlab/database/batch_count.rb
@@ -28,7 +28,7 @@ module Gitlab
class BatchCounter
FALLBACK = -1
- MIN_REQUIRED_BATCH_SIZE = 2_000
+ MIN_REQUIRED_BATCH_SIZE = 1_250
MAX_ALLOWED_LOOPS = 10_000
SLEEP_TIME_IN_SECONDS = 0.01 # 10 msec sleep
# Each query should take <<500ms https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22705
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index 4b5455c0ec9..3b9402da0dd 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -42,7 +42,7 @@ module Gitlab
klass = stub_class(name)
addr = stub_address(storage)
creds = stub_creds(storage)
- klass.new(addr, creds, interceptors: interceptors)
+ klass.new(addr, creds, interceptors: interceptors, channel_args: channel_args)
end
end
end
@@ -54,6 +54,16 @@ module Gitlab
end
private_class_method :interceptors
+ def self.channel_args
+ # These values match the go Gitaly client
+ # https://gitlab.com/gitlab-org/gitaly/-/blob/bf9f52bc/client/dial.go#L78
+ {
+ 'grpc.keepalive_time_ms': 20000,
+ 'grpc.keepalive_permit_without_calls': 1
+ }
+ end
+ private_class_method :channel_args
+
def self.stub_cert_paths
cert_paths = Dir["#{OpenSSL::X509::DEFAULT_CERT_DIR}/*"]
cert_paths << OpenSSL::X509::DEFAULT_CERT_FILE if File.exist? OpenSSL::X509::DEFAULT_CERT_FILE
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5892d3b65bc..ef0ce1bbad6 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -15422,6 +15422,9 @@ msgstr ""
msgid "ProjectSettings|View and edit files in this project"
msgstr ""
+msgid "ProjectSettings|View and edit files in this project. Non-project members will only have read access"
+msgstr ""
+
msgid "ProjectSettings|When conflicts arise the user is given the option to rebase"
msgstr ""
@@ -18174,6 +18177,9 @@ msgstr ""
msgid "Snippets|Optionally add a description about what your snippet does or how to use it..."
msgstr ""
+msgid "Snippets|Optionally add a description about what your snippet does or how to use it…"
+msgstr ""
+
msgid "Snowplow"
msgstr ""
diff --git a/spec/fixtures/api/schemas/public_api/v4/deploy_token.json b/spec/fixtures/api/schemas/public_api/v4/deploy_token.json
index c8a8b8d1e7d..7cb9f136b0d 100644
--- a/spec/fixtures/api/schemas/public_api/v4/deploy_token.json
+++ b/spec/fixtures/api/schemas/public_api/v4/deploy_token.json
@@ -25,7 +25,9 @@
"items": {
"type": "string"
}
+ },
+ "token": {
+ "type": "string"
}
- },
- "additionalProperties": false
+ }
} \ No newline at end of file
diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/axis.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/axis.json
new file mode 100644
index 00000000000..ed8fa58393f
--- /dev/null
+++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/axis.json
@@ -0,0 +1,10 @@
+{
+ "type": "object",
+ "required": [],
+ "properties": {
+ "name": { "type": "string" },
+ "precision": { "type": "number" },
+ "format": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json
index a16f1ef592f..9f39e9c77cb 100644
--- a/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json
+++ b/spec/fixtures/lib/gitlab/metrics/dashboard/schemas/panels.json
@@ -9,6 +9,7 @@
"title": { "type": "string" },
"type": { "type": "string" },
"y_label": { "type": "string" },
+ "y_axis": { "$ref": "axis.json" },
"weight": { "type": "number" },
"metrics": {
"type": "array",
diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js
index d175c8ba853..3b64e4910e2 100644
--- a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js
+++ b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js
@@ -3,6 +3,8 @@ import axios from '~/lib/utils/axios_utils';
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
import ClassSpecHelper from '../../helpers/class_spec_helper';
+jest.mock('sql.js');
+
describe('BalsamiqViewer', () => {
const mockArrayBuffer = new ArrayBuffer(10);
let balsamiqViewer;
@@ -34,22 +36,22 @@ describe('BalsamiqViewer', () => {
});
it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
- spyOn(axios, 'get').and.returnValue(requestSuccess);
- spyOn(bv, 'renderFile').and.stub();
+ jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
+ jest.spyOn(bv, 'renderFile').mockReturnValue();
bv.loadFile(endpoint);
expect(axios.get).toHaveBeenCalledWith(
endpoint,
- jasmine.objectContaining({
+ expect.objectContaining({
responseType: 'arraybuffer',
}),
);
});
it('should call `renderFile` on request success', done => {
- spyOn(axios, 'get').and.returnValue(requestSuccess);
- spyOn(bv, 'renderFile').and.callFake(() => {});
+ jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
+ jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint)
.then(() => {
@@ -60,8 +62,8 @@ describe('BalsamiqViewer', () => {
});
it('should not call `renderFile` on request failure', done => {
- spyOn(axios, 'get').and.returnValue(Promise.reject());
- spyOn(bv, 'renderFile');
+ jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
+ jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint)
.then(() => {
@@ -80,19 +82,21 @@ describe('BalsamiqViewer', () => {
let previews;
beforeEach(() => {
- viewer = jasmine.createSpyObj('viewer', ['appendChild']);
+ viewer = {
+ appendChild: jest.fn(),
+ };
previews = [document.createElement('ul'), document.createElement('ul')];
- balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', [
- 'initDatabase',
- 'getPreviews',
- 'renderPreview',
- ]);
+ balsamiqViewer = {
+ initDatabase: jest.fn(),
+ getPreviews: jest.fn(),
+ renderPreview: jest.fn(),
+ };
balsamiqViewer.viewer = viewer;
- balsamiqViewer.getPreviews.and.returnValue(previews);
- balsamiqViewer.renderPreview.and.callFake(preview => preview);
- viewer.appendChild.and.callFake(containerElement => {
+ balsamiqViewer.getPreviews.mockReturnValue(previews);
+ balsamiqViewer.renderPreview.mockImplementation(preview => preview);
+ viewer.appendChild.mockImplementation(containerElement => {
container = containerElement;
});
@@ -108,7 +112,7 @@ describe('BalsamiqViewer', () => {
});
it('should call .renderPreview for each preview', () => {
- const allArgs = balsamiqViewer.renderPreview.calls.allArgs();
+ const allArgs = balsamiqViewer.renderPreview.mock.calls;
expect(allArgs.length).toBe(2);
@@ -132,19 +136,15 @@ describe('BalsamiqViewer', () => {
});
describe('initDatabase', () => {
- let database;
let uint8Array;
let data;
beforeEach(() => {
uint8Array = {};
- database = {};
data = 'data';
-
balsamiqViewer = {};
-
- spyOn(window, 'Uint8Array').and.returnValue(uint8Array);
- spyOn(sqljs, 'Database').and.returnValue(database);
+ window.Uint8Array = jest.fn();
+ window.Uint8Array.mockReturnValue(uint8Array);
BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
});
@@ -158,7 +158,7 @@ describe('BalsamiqViewer', () => {
});
it('should set .database', () => {
- expect(balsamiqViewer.database).toBe(database);
+ expect(balsamiqViewer.database).not.toBe(null);
});
});
@@ -168,15 +168,17 @@ describe('BalsamiqViewer', () => {
let getPreviews;
beforeEach(() => {
- database = jasmine.createSpyObj('database', ['exec']);
+ database = {
+ exec: jest.fn(),
+ };
thumbnails = [{ values: [0, 1, 2] }];
balsamiqViewer = {
database,
};
- spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString());
- database.exec.and.returnValue(thumbnails);
+ jest.spyOn(BalsamiqViewer, 'parsePreview').mockImplementation(preview => preview.toString());
+ database.exec.mockReturnValue(thumbnails);
getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
});
@@ -186,7 +188,7 @@ describe('BalsamiqViewer', () => {
});
it('should call .parsePreview for each value', () => {
- const allArgs = BalsamiqViewer.parsePreview.calls.allArgs();
+ const allArgs = BalsamiqViewer.parsePreview.mock.calls;
expect(allArgs.length).toBe(3);
@@ -207,7 +209,9 @@ describe('BalsamiqViewer', () => {
let getResource;
beforeEach(() => {
- database = jasmine.createSpyObj('database', ['exec']);
+ database = {
+ exec: jest.fn(),
+ };
resourceID = 4;
resource = ['resource'];
@@ -215,7 +219,7 @@ describe('BalsamiqViewer', () => {
database,
};
- database.exec.and.returnValue(resource);
+ database.exec.mockReturnValue(resource);
getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
});
@@ -241,14 +245,18 @@ describe('BalsamiqViewer', () => {
innerHTML = '<a>innerHTML</a>';
previewElement = {
outerHTML: '<p>outerHTML</p>',
- classList: jasmine.createSpyObj('classList', ['add']),
+ classList: {
+ add: jest.fn(),
+ },
};
preview = {};
- balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']);
+ balsamiqViewer = {
+ renderTemplate: jest.fn(),
+ };
- spyOn(document, 'createElement').and.returnValue(previewElement);
- balsamiqViewer.renderTemplate.and.returnValue(innerHTML);
+ jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
+ balsamiqViewer.renderTemplate.mockReturnValue(innerHTML);
renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
});
@@ -290,10 +298,12 @@ describe('BalsamiqViewer', () => {
</div>
`;
- balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']);
+ balsamiqViewer = {
+ getResource: jest.fn(),
+ };
- spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name);
- balsamiqViewer.getResource.and.returnValue(resource);
+ jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
+ balsamiqViewer.getResource.mockReturnValue(resource);
renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
});
@@ -306,7 +316,7 @@ describe('BalsamiqViewer', () => {
expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
});
- it('should return the template string', function() {
+ it('should return the template string', () => {
expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
});
});
@@ -318,7 +328,7 @@ describe('BalsamiqViewer', () => {
beforeEach(() => {
preview = ['{}', '{ "id": 1 }'];
- spyOn(JSON, 'parse').and.callThrough();
+ jest.spyOn(JSON, 'parse');
parsePreview = BalsamiqViewer.parsePreview(preview);
});
@@ -337,7 +347,7 @@ describe('BalsamiqViewer', () => {
beforeEach(() => {
title = { values: [['{}', '{}', '{"name":"name"}']] };
- spyOn(JSON, 'parse').and.callThrough();
+ jest.spyOn(JSON, 'parse');
parseTitle = BalsamiqViewer.parseTitle(title);
});
diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js
index 2dc9039bc9d..5c5315fd465 100644
--- a/spec/frontend/boards/boards_store_spec.js
+++ b/spec/frontend/boards/boards_store_spec.js
@@ -440,23 +440,6 @@ describe('boardsStore', () => {
});
});
- describe('allBoards', () => {
- const url = `${endpoints.boardsEndpoint}.json`;
-
- it('makes a request to fetch all boards', () => {
- axiosMock.onGet(url).replyOnce(200, dummyResponse);
- const expectedResponse = expect.objectContaining({ data: dummyResponse });
-
- return expect(boardsStore.allBoards()).resolves.toEqual(expectedResponse);
- });
-
- it('fails for error response', () => {
- axiosMock.onGet(url).replyOnce(500);
-
- return expect(boardsStore.allBoards()).rejects.toThrow();
- });
- });
-
describe('recentBoards', () => {
const url = `${endpoints.recentBoardsEndpoint}.json`;
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index 7723af07d8c..b1ae86c2d3f 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -1,6 +1,6 @@
-import Vue from 'vue';
+import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
-import { GlDropdown } from '@gitlab/ui';
+import { GlDropdown, GlLoadingIcon } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store';
@@ -8,7 +8,8 @@ import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1;
function boardGenerator(n) {
- return new Array(n).fill().map((board, id) => {
+ return new Array(n).fill().map((board, index) => {
+ const id = `${index}`;
const name = `board${id}`;
return {
@@ -34,8 +35,17 @@ describe('BoardsSelector', () => {
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header');
+ const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
+ const $apollo = {
+ queries: {
+ boards: {
+ loading: false,
+ },
+ },
+ };
+
boardsStore.setEndpoints({
boardsEndpoint: '',
recentBoardsEndpoint: '',
@@ -45,7 +55,13 @@ describe('BoardsSelector', () => {
});
allBoardsResponse = Promise.resolve({
- data: boards,
+ data: {
+ group: {
+ boards: {
+ edges: boards.map(board => ({ node: board })),
+ },
+ },
+ },
});
recentBoardsResponse = Promise.resolve({
data: recentBoards,
@@ -54,8 +70,7 @@ describe('BoardsSelector', () => {
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
- const Component = Vue.extend(BoardsSelector);
- wrapper = mount(Component, {
+ wrapper = mount(BoardsSelector, {
propsData: {
throttleDuration,
currentBoard: {
@@ -77,13 +92,18 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
+ mocks: { $apollo },
attachToDocument: true,
});
+ wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
+ wrapper.setData({
+ [options.loadingKey]: true,
+ });
+ });
+
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
wrapper.find(GlDropdown).vm.$emit('show');
-
- return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
});
afterEach(() => {
@@ -91,64 +111,99 @@ describe('BoardsSelector', () => {
wrapper = null;
});
- describe('filtering', () => {
- it('shows all boards without filtering', () => {
- expect(getDropdownItems().length).toBe(boards.length + recentBoards.length);
+ describe('loading', () => {
+ // we are testing loading state, so don't resolve responses until after the tests
+ afterEach(() => {
+ return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
- it('shows only matching boards when filtering', () => {
- const filterTerm = 'board1';
- const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
+ it('shows loading spinner', () => {
+ expect(getDropdownHeaders()).toHaveLength(0);
+ expect(getDropdownItems()).toHaveLength(0);
+ expect(getLoadingIcon().exists()).toBe(true);
+ });
+ });
- fillSearchBox(filterTerm);
+ describe('loaded', () => {
+ beforeEach(() => {
+ return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
+ });
- return Vue.nextTick().then(() => {
- expect(getDropdownItems().length).toBe(expectedCount);
- });
+ it('hides loading spinner', () => {
+ expect(getLoadingIcon().exists()).toBe(false);
});
- it('shows message if there are no matching boards', () => {
- fillSearchBox('does not exist');
+ describe('filtering', () => {
+ beforeEach(() => {
+ wrapper.setData({
+ boards,
+ });
- return Vue.nextTick().then(() => {
- expect(getDropdownItems().length).toBe(0);
- expect(wrapper.text().includes('No matching boards found')).toBe(true);
+ return nextTick();
});
- });
- });
- describe('recent boards section', () => {
- it('shows only when boards are greater than 10', () => {
- const expectedCount = 2; // Recent + All
+ it('shows all boards without filtering', () => {
+ expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
+ });
- expect(getDropdownHeaders().length).toBe(expectedCount);
- });
+ it('shows only matching boards when filtering', () => {
+ const filterTerm = 'board1';
+ const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
- it('does not show when boards are less than 10', () => {
- wrapper.setData({
- boards: boards.slice(0, 5),
+ fillSearchBox(filterTerm);
+
+ return nextTick().then(() => {
+ expect(getDropdownItems()).toHaveLength(expectedCount);
+ });
});
- return Vue.nextTick().then(() => {
- expect(getDropdownHeaders().length).toBe(0);
+ it('shows message if there are no matching boards', () => {
+ fillSearchBox('does not exist');
+
+ return nextTick().then(() => {
+ expect(getDropdownItems()).toHaveLength(0);
+ expect(wrapper.text().includes('No matching boards found')).toBe(true);
+ });
});
});
- it('does not show when recentBoards api returns empty array', () => {
- wrapper.setData({
- recentBoards: [],
+ describe('recent boards section', () => {
+ it('shows only when boards are greater than 10', () => {
+ wrapper.setData({
+ boards,
+ });
+
+ return nextTick().then(() => {
+ expect(getDropdownHeaders()).toHaveLength(2);
+ });
});
- return Vue.nextTick().then(() => {
- expect(getDropdownHeaders().length).toBe(0);
+ it('does not show when boards are less than 10', () => {
+ wrapper.setData({
+ boards: boards.slice(0, 5),
+ });
+
+ return nextTick().then(() => {
+ expect(getDropdownHeaders()).toHaveLength(0);
+ });
+ });
+
+ it('does not show when recentBoards api returns empty array', () => {
+ wrapper.setData({
+ recentBoards: [],
+ });
+
+ return nextTick().then(() => {
+ expect(getDropdownHeaders()).toHaveLength(0);
+ });
});
- });
- it('does not show when search is active', () => {
- fillSearchBox('Random string');
+ it('does not show when search is active', () => {
+ fillSearchBox('Random string');
- return Vue.nextTick().then(() => {
- expect(getDropdownHeaders().length).toBe(0);
+ return nextTick().then(() => {
+ expect(getDropdownHeaders()).toHaveLength(0);
+ });
});
});
});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
new file mode 100644
index 00000000000..8ab5426a005
--- /dev/null
+++ b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
@@ -0,0 +1,124 @@
+import { mount, shallowMount } from '@vue/test-utils';
+
+import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
+import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
+
+describe('Project Feature Settings', () => {
+ const defaultProps = {
+ name: 'Test',
+ options: [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]],
+ value: 1,
+ disabledInput: false,
+ };
+ let wrapper;
+
+ const mountComponent = customProps => {
+ const propsData = { ...defaultProps, ...customProps };
+ return shallowMount(projectFeatureSetting, { propsData });
+ };
+
+ beforeEach(() => {
+ wrapper = mountComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Hidden name input', () => {
+ it('should set the hidden name input if the name exists', () => {
+ expect(wrapper.find({ name: 'Test' }).props().value).toBe(1);
+ });
+
+ it('should not set the hidden name input if the name does not exist', () => {
+ wrapper.setProps({ name: null });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ name: 'Test' }).exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Feature toggle', () => {
+ it('should enable the feature toggle if the value is not 0', () => {
+ expect(wrapper.find(projectFeatureToggle).props().value).toBe(true);
+ });
+
+ it('should enable the feature toggle if the value is less than 0', () => {
+ wrapper.setProps({ value: -1 });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(projectFeatureToggle).props().value).toBe(true);
+ });
+ });
+
+ it('should disable the feature toggle if the value is 0', () => {
+ wrapper.setProps({ value: 0 });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(projectFeatureToggle).props().value).toBe(false);
+ });
+ });
+
+ it('should disable the feature toggle if disabledInput is set', () => {
+ wrapper.setProps({ disabledInput: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(projectFeatureToggle).props().disabledInput).toBe(true);
+ });
+ });
+
+ it('should emit a change event when the feature toggle changes', () => {
+ // Needs to be fully mounted to be able to trigger the click event on the internal button
+ wrapper = mount(projectFeatureSetting, { propsData: defaultProps });
+
+ expect(wrapper.emitted().change).toBeUndefined();
+ wrapper
+ .find(projectFeatureToggle)
+ .find('button')
+ .trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted().change.length).toBe(1);
+ expect(wrapper.emitted().change[0]).toEqual([0]);
+ });
+ });
+ });
+
+ describe('Project repo select', () => {
+ it.each`
+ disabledInput | value | options | isDisabled
+ ${true} | ${0} | ${[[1, 1]]} | ${true}
+ ${true} | ${1} | ${[[1, 1], [2, 2], [3, 3]]} | ${true}
+ ${false} | ${0} | ${[[1, 1], [2, 2], [3, 3]]} | ${true}
+ ${false} | ${1} | ${[[1, 1]]} | ${true}
+ ${false} | ${1} | ${[[1, 1], [2, 2], [3, 3]]} | ${false}
+ `(
+ 'should set disabled to $isDisabled when disabledInput is $disabledInput, the value is $value and options are $options',
+ ({ disabledInput, value, options, isDisabled }) => {
+ wrapper.setProps({ disabledInput, value, options });
+
+ return wrapper.vm.$nextTick(() => {
+ if (isDisabled) {
+ expect(wrapper.find('select').attributes().disabled).toEqual('disabled');
+ } else {
+ expect(wrapper.find('select').attributes().disabled).toBeUndefined();
+ }
+ });
+ },
+ );
+
+ it('should emit the change when a new option is selected', () => {
+ expect(wrapper.emitted().change).toBeUndefined();
+ wrapper
+ .findAll('option')
+ .at(1)
+ .trigger('change');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted().change.length).toBe(1);
+ expect(wrapper.emitted().change[0]).toEqual([2]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
new file mode 100644
index 00000000000..7cbcbdcdd1f
--- /dev/null
+++ b/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
@@ -0,0 +1,63 @@
+import { shallowMount } from '@vue/test-utils';
+
+import projectSettingRow from '~/pages/projects/shared/permissions/components/project_setting_row.vue';
+
+describe('Project Setting Row', () => {
+ let wrapper;
+
+ const mountComponent = (customProps = {}) => {
+ const propsData = { ...customProps };
+ return shallowMount(projectSettingRow, { propsData });
+ };
+
+ beforeEach(() => {
+ wrapper = mountComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should show the label if it is set', () => {
+ wrapper.setProps({ label: 'Test label' });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('label').text()).toEqual('Test label');
+ });
+ });
+
+ it('should hide the label if it is not set', () => {
+ expect(wrapper.find('label').exists()).toBe(false);
+ });
+
+ it('should show the help icon with the correct help path if it is set', () => {
+ wrapper.setProps({ label: 'Test label', helpPath: '/123' });
+
+ return wrapper.vm.$nextTick(() => {
+ const link = wrapper.find('a');
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes().href).toEqual('/123');
+ });
+ });
+
+ it('should hide the help icon if no help path is set', () => {
+ wrapper.setProps({ label: 'Test label' });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('a').exists()).toBe(false);
+ });
+ });
+
+ it('should show the help text if it is set', () => {
+ wrapper.setProps({ helpText: 'Test text' });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('span').text()).toEqual('Test text');
+ });
+ });
+
+ it('should hide the help text if it is set', () => {
+ expect(wrapper.find('span').exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
new file mode 100644
index 00000000000..c304dfd2048
--- /dev/null
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -0,0 +1,434 @@
+import { shallowMount } from '@vue/test-utils';
+
+import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue';
+import {
+ featureAccessLevel,
+ visibilityLevelDescriptions,
+ visibilityOptions,
+} from '~/pages/projects/shared/permissions/constants';
+
+const defaultProps = {
+ currentSettings: {
+ visibilityLevel: 10,
+ requestAccessEnabled: true,
+ issuesAccessLevel: 20,
+ repositoryAccessLevel: 20,
+ forkingAccessLevel: 20,
+ mergeRequestsAccessLevel: 20,
+ buildsAccessLevel: 20,
+ wikiAccessLevel: 20,
+ snippetsAccessLevel: 20,
+ pagesAccessLevel: 10,
+ containerRegistryEnabled: true,
+ lfsEnabled: true,
+ emailsDisabled: false,
+ packagesEnabled: true,
+ },
+ canDisableEmails: true,
+ canChangeVisibilityLevel: true,
+ allowedVisibilityOptions: [0, 10, 20],
+ visibilityHelpPath: '/help/public_access/public_access',
+ registryAvailable: false,
+ registryHelpPath: '/help/user/packages/container_registry/index',
+ lfsAvailable: true,
+ lfsHelpPath: '/help/workflow/lfs/manage_large_binaries_with_git_lfs',
+ pagesAvailable: true,
+ pagesAccessControlEnabled: false,
+ pagesAccessControlForced: false,
+ pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control-core',
+ packagesAvailable: false,
+ packagesHelpPath: '/help/user/packages/index',
+};
+
+describe('Settings Panel', () => {
+ let wrapper;
+
+ const mountComponent = customProps => {
+ const propsData = { ...defaultProps, ...customProps };
+ return shallowMount(settingsPanel, { propsData });
+ };
+
+ const overrideCurrentSettings = (currentSettingsProps, extraProps = {}) => {
+ return mountComponent({
+ ...extraProps,
+ currentSettings: {
+ ...defaultProps.currentSettings,
+ ...currentSettingsProps,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ wrapper = mountComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Project Visibility', () => {
+ it('should set the project visibility help path', () => {
+ expect(wrapper.find({ ref: 'project-visibility-settings' }).props().helpPath).toBe(
+ defaultProps.visibilityHelpPath,
+ );
+ });
+
+ it('should not disable the visibility level dropdown', () => {
+ wrapper.setProps({ canChangeVisibilityLevel: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(
+ wrapper.find('[name="project[visibility_level]"]').attributes().disabled,
+ ).toBeUndefined();
+ });
+ });
+
+ it('should disable the visibility level dropdown', () => {
+ wrapper.setProps({ canChangeVisibilityLevel: false });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find('[name="project[visibility_level]"]').attributes().disabled).toBe(
+ 'disabled',
+ );
+ });
+ });
+
+ it.each`
+ option | allowedOptions | disabled
+ ${visibilityOptions.PRIVATE} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${false}
+ ${visibilityOptions.PRIVATE} | ${[visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${true}
+ ${visibilityOptions.INTERNAL} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${false}
+ ${visibilityOptions.INTERNAL} | ${[visibilityOptions.PRIVATE, visibilityOptions.PUBLIC]} | ${true}
+ ${visibilityOptions.PUBLIC} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${false}
+ ${visibilityOptions.PUBLIC} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL]} | ${true}
+ `(
+ 'sets disabled to $disabled for the visibility option $option when given $allowedOptions',
+ ({ option, allowedOptions, disabled }) => {
+ wrapper.setProps({ allowedVisibilityOptions: allowedOptions });
+
+ return wrapper.vm.$nextTick(() => {
+ const attributeValue = wrapper
+ .find(`[name="project[visibility_level]"] option[value="${option}"]`)
+ .attributes().disabled;
+
+ if (disabled) {
+ expect(attributeValue).toBe('disabled');
+ } else {
+ expect(attributeValue).toBeUndefined();
+ }
+ });
+ },
+ );
+
+ it('should set the visibility level description based upon the selected visibility level', () => {
+ wrapper.find('[name="project[visibility_level]"]').setValue(visibilityOptions.INTERNAL);
+
+ expect(wrapper.find({ ref: 'project-visibility-settings' }).text()).toContain(
+ visibilityLevelDescriptions[visibilityOptions.INTERNAL],
+ );
+ });
+
+ it('should show the request access checkbox if the visibility level is not private', () => {
+ wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.INTERNAL });
+
+ expect(wrapper.find('[name="project[request_access_enabled]"]').exists()).toBe(true);
+ });
+
+ it('should not show the request access checkbox if the visibility level is private', () => {
+ wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
+
+ expect(wrapper.find('[name="project[request_access_enabled]"]').exists()).toBe(false);
+ });
+ });
+
+ describe('Repository', () => {
+ it('should set the repository help text when the visibility level is set to private', () => {
+ wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
+
+ expect(wrapper.find({ ref: 'repository-settings' }).props().helpText).toEqual(
+ 'View and edit files in this project',
+ );
+ });
+
+ it('should set the repository help text with a read access warning when the visibility level is set to non-private', () => {
+ wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PUBLIC });
+
+ expect(wrapper.find({ ref: 'repository-settings' }).props().helpText).toEqual(
+ 'View and edit files in this project. Non-project members will only have read access',
+ );
+ });
+ });
+
+ describe('Merge requests', () => {
+ it('should enable the merge requests access level input when the repository is enabled', () => {
+ wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.EVERYONE });
+
+ expect(
+ wrapper
+ .find('[name="project[project_feature_attributes][merge_requests_access_level]"]')
+ .props().disabledInput,
+ ).toEqual(false);
+ });
+
+ it('should disable the merge requests access level input when the repository is disabled', () => {
+ wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED });
+
+ expect(
+ wrapper
+ .find('[name="project[project_feature_attributes][merge_requests_access_level]"]')
+ .props().disabledInput,
+ ).toEqual(true);
+ });
+ });
+
+ describe('Forks', () => {
+ it('should enable the forking access level input when the repository is enabled', () => {
+ wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.EVERYONE });
+
+ expect(
+ wrapper.find('[name="project[project_feature_attributes][forking_access_level]"]').props()
+ .disabledInput,
+ ).toEqual(false);
+ });
+
+ it('should disable the forking access level input when the repository is disabled', () => {
+ wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED });
+
+ expect(
+ wrapper.find('[name="project[project_feature_attributes][forking_access_level]"]').props()
+ .disabledInput,
+ ).toEqual(true);
+ });
+ });
+
+ describe('Pipelines', () => {
+ it('should enable the builds access level input when the repository is enabled', () => {
+ wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.EVERYONE });
+
+ expect(
+ wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]').props()
+ .disabledInput,
+ ).toEqual(false);
+ });
+
+ it('should disable the builds access level input when the repository is disabled', () => {
+ wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED });
+
+ expect(
+ wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]').props()
+ .disabledInput,
+ ).toEqual(true);
+ });
+ });
+
+ describe('Container registry', () => {
+ it('should show the container registry settings if the registry is available', () => {
+ wrapper.setProps({ registryAvailable: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'container-registry-settings' }).exists()).toBe(true);
+ });
+ });
+
+ it('should hide the container registry settings if the registry is not available', () => {
+ wrapper.setProps({ registryAvailable: false });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'container-registry-settings' }).exists()).toBe(false);
+ });
+ });
+
+ it('should set the container registry settings help path', () => {
+ wrapper.setProps({ registryAvailable: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'container-registry-settings' }).props().helpPath).toBe(
+ defaultProps.registryHelpPath,
+ );
+ });
+ });
+
+ it('should show the container registry public note if the visibility level is public and the registry is available', () => {
+ wrapper = overrideCurrentSettings(
+ { visibilityLevel: visibilityOptions.PUBLIC },
+ { registryAvailable: true },
+ );
+
+ expect(wrapper.find({ ref: 'container-registry-settings' }).text()).toContain(
+ 'Note: the container registry is always visible when a project is public',
+ );
+ });
+
+ it('should hide the container registry public note if the visibility level is private and the registry is available', () => {
+ wrapper = overrideCurrentSettings(
+ { visibilityLevel: visibilityOptions.PRIVATE },
+ { registryAvailable: true },
+ );
+
+ expect(wrapper.find({ ref: 'container-registry-settings' }).text()).not.toContain(
+ 'Note: the container registry is always visible when a project is public',
+ );
+ });
+
+ it('should enable the container registry input when the repository is enabled', () => {
+ wrapper = overrideCurrentSettings(
+ { repositoryAccessLevel: featureAccessLevel.EVERYONE },
+ { registryAvailable: true },
+ );
+
+ expect(
+ wrapper.find('[name="project[container_registry_enabled]"]').props().disabledInput,
+ ).toEqual(false);
+ });
+
+ it('should disable the container registry input when the repository is disabled', () => {
+ wrapper = overrideCurrentSettings(
+ { repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
+ { registryAvailable: true },
+ );
+
+ expect(
+ wrapper.find('[name="project[container_registry_enabled]"]').props().disabledInput,
+ ).toEqual(true);
+ });
+ });
+
+ describe('Git Large File Storage', () => {
+ it('should show the LFS settings if LFS is available', () => {
+ wrapper.setProps({ lfsAvailable: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'git-lfs-settings' }).exists()).toEqual(true);
+ });
+ });
+
+ it('should hide the LFS settings if LFS is not available', () => {
+ wrapper.setProps({ lfsAvailable: false });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'git-lfs-settings' }).exists()).toEqual(false);
+ });
+ });
+
+ it('should set the LFS settings help path', () => {
+ expect(wrapper.find({ ref: 'git-lfs-settings' }).props().helpPath).toBe(
+ defaultProps.lfsHelpPath,
+ );
+ });
+
+ it('should enable the LFS input when the repository is enabled', () => {
+ wrapper = overrideCurrentSettings(
+ { repositoryAccessLevel: featureAccessLevel.EVERYONE },
+ { lfsAvailable: true },
+ );
+
+ expect(wrapper.find('[name="project[lfs_enabled]"]').props().disabledInput).toEqual(false);
+ });
+
+ it('should disable the LFS input when the repository is disabled', () => {
+ wrapper = overrideCurrentSettings(
+ { repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
+ { lfsAvailable: true },
+ );
+
+ expect(wrapper.find('[name="project[lfs_enabled]"]').props().disabledInput).toEqual(true);
+ });
+ });
+
+ describe('Packages', () => {
+ it('should show the packages settings if packages are available', () => {
+ wrapper.setProps({ packagesAvailable: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'package-settings' }).exists()).toEqual(true);
+ });
+ });
+
+ it('should hide the packages settings if packages are not available', () => {
+ wrapper.setProps({ packagesAvailable: false });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'package-settings' }).exists()).toEqual(false);
+ });
+ });
+
+ it('should set the package settings help path', () => {
+ wrapper.setProps({ packagesAvailable: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'package-settings' }).props().helpPath).toBe(
+ defaultProps.packagesHelpPath,
+ );
+ });
+ });
+
+ it('should enable the packages input when the repository is enabled', () => {
+ wrapper = overrideCurrentSettings(
+ { repositoryAccessLevel: featureAccessLevel.EVERYONE },
+ { packagesAvailable: true },
+ );
+
+ expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toEqual(
+ false,
+ );
+ });
+
+ it('should disable the packages input when the repository is disabled', () => {
+ wrapper = overrideCurrentSettings(
+ { repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
+ { packagesAvailable: true },
+ );
+
+ expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toEqual(
+ true,
+ );
+ });
+ });
+
+ describe('Pages', () => {
+ it.each`
+ pagesAvailable | pagesAccessControlEnabled | visibility
+ ${true} | ${true} | ${'show'}
+ ${true} | ${false} | ${'hide'}
+ ${false} | ${true} | ${'hide'}
+ ${false} | ${false} | ${'hide'}
+ `(
+ 'should $visibility the page settings if pagesAvailable is $pagesAvailable and pagesAccessControlEnabled is $pagesAccessControlEnabled',
+ ({ pagesAvailable, pagesAccessControlEnabled, visibility }) => {
+ wrapper.setProps({ pagesAvailable, pagesAccessControlEnabled });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'pages-settings' }).exists()).toBe(visibility === 'show');
+ });
+ },
+ );
+
+ it('should set the pages settings help path', () => {
+ wrapper.setProps({ pagesAvailable: true, pagesAccessControlEnabled: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'pages-settings' }).props().helpPath).toBe(
+ defaultProps.pagesHelpPath,
+ );
+ });
+ });
+ });
+
+ describe('Email notifications', () => {
+ it('should show the disable email notifications input if emails an be disabled', () => {
+ wrapper.setProps({ canDisableEmails: true });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'email-settings' }).exists()).toBe(true);
+ });
+ });
+
+ it('should hide the disable email notifications input if emails cannot be disabled', () => {
+ wrapper.setProps({ canDisableEmails: false });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find({ ref: 'email-settings' }).exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
new file mode 100644
index 00000000000..3c3f9764f64
--- /dev/null
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap
@@ -0,0 +1,48 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Snippet Description Edit component rendering matches the snapshot 1`] = `
+<div
+ class="form-group js-description-input"
+>
+ <label>
+ Description (optional)
+ </label>
+
+ <div
+ class="js-collapsible-input"
+ >
+ <div
+ class="js-collapsed d-none"
+ >
+ <gl-form-input-stub
+ class="form-control"
+ data-qa-selector="description_placeholder"
+ placeholder="Optionally add a description about what your snippet does or how to use it…"
+ />
+ </div>
+
+ <markdown-field-stub
+ addspacingclasses="true"
+ canattachfile="true"
+ class="js-expanded"
+ enableautocomplete="true"
+ helppagepath=""
+ markdowndocspath="help/"
+ markdownpreviewpath="foo/"
+ note="[object Object]"
+ quickactionsdocspath=""
+ textareavalue=""
+ >
+ <textarea
+ aria-label="Description"
+ class="note-textarea js-gfm-input js-autosize markdown-area
+ qa-description-textarea"
+ data-supports-quick-actions="false"
+ dir="auto"
+ id="snippet-description"
+ placeholder="Write a comment or drag your files here…"
+ />
+ </markdown-field-stub>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/snippets/components/snippet_description_edit_spec.js b/spec/frontend/snippets/components/snippet_description_edit_spec.js
new file mode 100644
index 00000000000..167489dc004
--- /dev/null
+++ b/spec/frontend/snippets/components/snippet_description_edit_spec.js
@@ -0,0 +1,52 @@
+import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('Snippet Description Edit component', () => {
+ let wrapper;
+ const defaultDescription = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
+ const markdownPreviewPath = 'foo/';
+ const markdownDocsPath = 'help/';
+
+ function createComponent(description = defaultDescription) {
+ wrapper = shallowMount(SnippetDescriptionEdit, {
+ propsData: {
+ description,
+ markdownPreviewPath,
+ markdownDocsPath,
+ },
+ });
+ }
+
+ function isHidden(sel) {
+ return wrapper.find(sel).classes('d-none');
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('rendering', () => {
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders the field expanded when description exists', () => {
+ expect(wrapper.find('.js-collapsed').classes('d-none')).toBe(true);
+ expect(wrapper.find('.js-expanded').classes('d-none')).toBe(false);
+
+ expect(isHidden('.js-collapsed')).toBe(true);
+ expect(isHidden('.js-expanded')).toBe(false);
+ });
+
+ it('renders the field collapsed if there is no description yet', () => {
+ createComponent('');
+
+ expect(isHidden('.js-collapsed')).toBe(false);
+ expect(isHidden('.js-expanded')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index 5edf41b1ec6..ef95cb1b8f2 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -259,16 +259,40 @@ describe('mrWidgetOptions', () => {
describe('methods', () => {
describe('checkStatus', () => {
- it('should tell service to check status', () => {
+ let cb;
+ let isCbExecuted;
+
+ beforeEach(() => {
jest.spyOn(vm.service, 'checkStatus').mockReturnValue(returnPromise(mockData));
jest.spyOn(vm.mr, 'setData').mockImplementation(() => {});
jest.spyOn(vm, 'handleNotification').mockImplementation(() => {});
- let isCbExecuted = false;
- const cb = () => {
+ isCbExecuted = false;
+ cb = () => {
isCbExecuted = true;
};
+ });
+
+ it('should not tell service to check status if document is not visible', () => {
+ Object.defineProperty(document, 'visibilityState', {
+ value: 'hidden',
+ configurable: true,
+ });
+ vm.checkStatus(cb);
+
+ return vm.$nextTick().then(() => {
+ expect(vm.service.checkStatus).not.toHaveBeenCalled();
+ expect(vm.mr.setData).not.toHaveBeenCalled();
+ expect(vm.handleNotification).not.toHaveBeenCalled();
+ expect(isCbExecuted).toBeFalsy();
+ Object.defineProperty(document, 'visibilityState', {
+ value: 'visible',
+ configurable: true,
+ });
+ });
+ });
+ it('should tell service to check status if document is visible', () => {
vm.checkStatus(cb);
return vm.$nextTick().then(() => {
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index 635349955b1..5f22208a3ac 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -52,7 +52,7 @@ describe ProjectPolicy do
admin_snippet admin_project_member admin_note admin_wiki admin_project
admin_commit_status admin_build admin_container_image
admin_pipeline admin_environment admin_deployment destroy_release add_cluster
- daily_statistics read_deploy_token
+ daily_statistics read_deploy_token create_deploy_token
]
end
diff --git a/spec/requests/api/deploy_tokens_spec.rb b/spec/requests/api/deploy_tokens_spec.rb
index 14153fae42f..8076b0958a4 100644
--- a/spec/requests/api/deploy_tokens_spec.rb
+++ b/spec/requests/api/deploy_tokens_spec.rb
@@ -133,4 +133,57 @@ describe API::DeployTokens do
end
end
end
+
+ describe 'POST /projects/:id/deploy_tokens' do
+ let(:params) do
+ {
+ name: 'Foo',
+ expires_at: 1.year.from_now,
+ scopes: [
+ 'read_repository'
+ ],
+ username: 'Bar'
+ }
+ end
+
+ subject do
+ post api("/projects/#{project.id}/deploy_tokens", user), params: params
+ response
+ end
+
+ context 'when unauthenticated' do
+ let(:user) { nil }
+
+ it { is_expected.to have_gitlab_http_status(:not_found) }
+ end
+
+ context 'when authenticated as non-admin user' do
+ before do
+ project.add_developer(user)
+ end
+
+ it { is_expected.to have_gitlab_http_status(:forbidden) }
+ end
+
+ context 'when authenticated as maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'creates the deploy token' do
+ expect { subject }.to change { DeployToken.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/deploy_token')
+ end
+
+ context 'with an invalid scope' do
+ before do
+ params[:scopes] = %w[read_repository all_access]
+ end
+
+ it { is_expected.to have_gitlab_http_status(:bad_request) }
+ end
+ end
+ end
end