diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-03-10 09:08:10 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-03-10 09:08:10 +0000 |
commit | 82fa8a3d1e8466ef36b58604d20fcc145ea12118 (patch) | |
tree | c5c0286537405c2fa7719ecce3ed0d73d947c555 | |
parent | 232655bf32cd474d54de357b65ef43d77271117c (diff) | |
download | gitlab-ce-82fa8a3d1e8466ef36b58604d20fcc145ea12118.tar.gz |
Add latest changes from gitlab-org/gitlab@master
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 |