diff options
159 files changed, 3613 insertions, 3696 deletions
diff --git a/.gitlab/ci/build-images.gitlab-ci.yml b/.gitlab/ci/build-images.gitlab-ci.yml index 3c7056a92c1..a60a5f6040c 100644 --- a/.gitlab/ci/build-images.gitlab-ci.yml +++ b/.gitlab/ci/build-images.gitlab-ci.yml @@ -1,7 +1,13 @@ .base-image-build: extends: .use-kaniko variables: - GIT_LFS_SKIP_SMUDGE: 1 + GIT_LFS_SKIP_SMUDGE: 1 # disable pulling objects from lfs + retry: 2 + +.base-image-build-buildx: + extends: .use-buildx + variables: + GIT_LFS_SKIP_SMUDGE: 1 # disable pulling objects from lfs retry: 2 # This image is used by: @@ -10,12 +16,12 @@ # See https://docs.gitlab.com/ee/development/testing_guide/end_to_end/index.html#testing-code-in-merge-requests for more details. build-qa-image: extends: - - .base-image-build + - .base-image-build-buildx - .build-images:rules:build-qa-image stage: build-images needs: [] script: - - ./scripts/build_qa_image + - run_timed_command "scripts/build_qa_image" # This image is used by: # - The `CNG` pipelines (via the `review-build-cng` job): https://gitlab.com/gitlab-org/build/CNG/-/blob/cfc67136d711e1c8c409bf8e57427a644393da2f/.gitlab-ci.yml#L335 diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index d461183a2ca..add728a9983 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -46,7 +46,7 @@ files: - GITALY_SERVER_VERSION - lib/gitlab/setup_helper.rb - prefix: "gitaly-binaries-${GITALY_SERVER_VERSION}-debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}" + prefix: "gitaly-binaries-debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}" paths: - ${TMP_TEST_FOLDER}/gitaly/_build/bin/ - ${TMP_TEST_FOLDER}/gitaly/_build/deps/git/install/ @@ -361,3 +361,20 @@ tags: # See https://gitlab.com/gitlab-com/www-gitlab-com/-/issues/7019 for tag descriptions - gitlab-org-docker + +.use-buildx: + extends: .use-docker-in-docker + image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-bullseye-slim:docker-${DOCKER_VERSION}-buildx-0.8 + variables: + QEMU_IMAGE: tonistiigi/binfmt:qemu-v7.0.0 + before_script: + - source scripts/utils.sh + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + - | + if [[ "${ARCH}" =~ arm64 ]]; then + echo -e "\033[1;33mInstalling latest qemu emulators\033[0m" + docker pull -q ${QEMU_IMAGE}; + docker run --rm --privileged ${QEMU_IMAGE} --uninstall qemu-*; + docker run --rm --privileged ${QEMU_IMAGE} --install all; + fi + - docker buildx create --use # creates and set's to active buildkit builder diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml index 7ecc8db3b1b..85c5c7d1b1d 100644 --- a/.gitlab/ci/review-apps/main.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml @@ -32,14 +32,15 @@ review-build-cng-env: extends: - .default-retry - .review:rules:review-build-cng - image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:3.0-alpine3.13 + image: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/debian-${DEBIAN_VERSION}-ruby-${RUBY_VERSION}:bundler-2.3 stage: prepare needs: [] before_script: - source ./scripts/utils.sh - install_gitlab_gem script: - - 'ruby -r./scripts/trigger-build.rb -e "puts Trigger.variables_for_env_file(Trigger::CNG.new.variables)" > build.env' + - ruby -r./scripts/trigger-build.rb -e "puts Trigger.variables_for_env_file(Trigger::CNG.new.variables)" > build.env + - ruby -e 'puts "FULL_RUBY_VERSION=#{RUBY_VERSION}"' >> build.env - cat build.env artifacts: reports: @@ -77,6 +78,7 @@ review-build-cng: GITLAB_SHELL_VERSION: "${GITLAB_SHELL_VERSION}" GITLAB_WORKHORSE_VERSION: "${GITLAB_WORKHORSE_VERSION}" GITALY_SERVER_VERSION: "${GITALY_SERVER_VERSION}" + RUBY_VERSION: "${FULL_RUBY_VERSION}" trigger: project: gitlab-org/build/CNG-mirror branch: $TRIGGER_BRANCH @@ -89,7 +91,7 @@ review-build-cng: variables: HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" - GITLAB_HELM_CHART_REF: "138c146a5ba787942f66d4c7d795d224d6ba206a" + GITLAB_HELM_CHART_REF: "ed813953079c1d81aa69d4cb8171c69aa9741f01" # 6.5.4: https://gitlab.com/gitlab-org/charts/gitlab/-/commit/ed813953079c1d81aa69d4cb8171c69aa9741f01 environment: name: review/${CI_COMMIT_REF_SLUG}${SCHEDULE_TYPE} # No separator for SCHEDULE_TYPE so it's compatible as before and looks nice without it url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index db7b6473c06..c6cfb491e61 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -685,7 +685,11 @@ changes: *code-qa-patterns - <<: *if-auto-deploy-branches - <<: *if-default-branch-or-tag + variables: + ARCH: amd64,arm64 - <<: *if-dot-com-gitlab-org-schedule + variables: + ARCH: amd64,arm64 - <<: *if-force-ci - <<: *if-ruby3-branch @@ -578,3 +578,6 @@ gem 'arr-pm', '~> 0.0.12' # Apple plist parsing gem 'CFPropertyList' + +# For phone verification +gem 'telesignenterprise', '~> 2.2' diff --git a/Gemfile.checksum b/Gemfile.checksum index c9d8615cf5e..f8202c49abe 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -582,6 +582,8 @@ {"name":"sys-filesystem","version":"1.4.3","platform":"ruby","checksum":"390919de89822ad6d3ba3daf694d720be9d83ed95cdf7adf54d4573c98b17421"}, {"name":"sysexits","version":"1.2.0","platform":"ruby","checksum":"598241c4ae57baa403c125182dfdcc0d1ac4c0fb606dd47fbed57e4aaf795662"}, {"name":"tanuki_emoji","version":"0.6.0","platform":"ruby","checksum":"4ce91aefed2d076b73fba3eff50e89660c3d25691787a9fe4c0dfabb4218c12a"}, +{"name":"telesign","version":"2.2.4","platform":"ruby","checksum":"dcc6e96ea7bcb4da1e2ae786bfe7a4d670a4b5f94ae95dfcdde77d547c544c42"}, +{"name":"telesignenterprise","version":"2.2.2","platform":"ruby","checksum":"f147a03263a8c2fe0a0db1a7a9454a6ee37d9e8abd58eaca305bdd8081f9f1b3"}, {"name":"temple","version":"0.8.2","platform":"ruby","checksum":"c12071214346c606dbd219b4117276d04a9f2c20d65e66a66b2c4ec18efc1f18"}, {"name":"term-ansicolor","version":"1.7.1","platform":"ruby","checksum":"92339ffec77c4bddc786a29385c91601dd52fc68feda23609bba0491229b05f7"}, {"name":"terminal-table","version":"1.8.0","platform":"ruby","checksum":"13371f069af18e9baa4e44d404a4ada9301899ce0530c237ac1a96c19f652294"}, diff --git a/Gemfile.lock b/Gemfile.lock index 33cf0d9dae9..c6345cb7f5d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1435,6 +1435,10 @@ GEM ffi (~> 1.1) sysexits (1.2.0) tanuki_emoji (0.6.0) + telesign (2.2.4) + net-http-persistent (>= 3.0.0, < 5.0) + telesignenterprise (2.2.2) + telesign (~> 2.2.3) temple (0.8.2) term-ansicolor (1.7.1) tins (~> 1.0) @@ -1843,6 +1847,7 @@ DEPENDENCIES state_machines-activerecord (~> 0.8.0) sys-filesystem (~> 1.4.3) tanuki_emoji (~> 0.6) + telesignenterprise (~> 2.2) terser (= 1.0.2) test-prof (~> 1.0.7) test_file_finder (~> 0.1.3) diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue new file mode 100644 index 00000000000..70974f2e725 --- /dev/null +++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue @@ -0,0 +1,171 @@ +<script> +import { GlButton, GlFormInput, GlModal, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { sprintf, s__, __ } from '~/locale'; + +export const i18n = { + deleteButtonText: s__('Branches|Delete merged branches'), + buttonTooltipText: s__("Branches|Delete all branches that are merged into '%{defaultBranch}'"), + modalTitle: s__('Branches|Delete all merged branches?'), + modalMessage: s__( + 'Branches|You are about to %{strongStart}delete all branches%{strongEnd} that were merged into %{codeStart}%{defaultBranch}%{codeEnd}.', + ), + notVisibleBranchesWarning: s__( + 'Branches|This may include merged branches that are not visible on the current screen.', + ), + protectedBranchWarning: s__( + "Branches|A branch won't be deleted if it is protected or associated with an open merge request.", + ), + permanentEffectWarning: s__( + 'Branches|This bulk action is %{strongStart}permanent and cannot be undone or recovered%{strongEnd}.', + ), + confirmationMessage: s__( + 'Branches|Plese type the following to confirm: %{codeStart}delete%{codeEnd}.', + ), + cancelButtonText: __('Cancel'), +}; + +export default { + csrf, + components: { + GlModal, + GlButton, + GlFormInput, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + formPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + }, + data() { + return { + areAllBranchesVisible: false, + enteredText: '', + }; + }, + computed: { + buttonTooltipText() { + return sprintf(this.$options.i18n.buttonTooltipText, { defaultBranch: this.defaultBranch }); + }, + modalMessage() { + return sprintf(this.$options.i18n.modalMessage, { + defaultBranch: this.defaultBranch, + }); + }, + isDeletingConfirmed() { + return this.enteredText.trim().toLowerCase() === 'delete'; + }, + isDeleteButtonDisabled() { + return !this.isDeletingConfirmed; + }, + }, + methods: { + openModal() { + this.$refs.modal.show(); + }, + submitForm() { + if (!this.isDeleteButtonDisabled) { + this.$refs.form.submit(); + } + }, + closeModal() { + this.$refs.modal.hide(); + }, + }, + i18n, +}; +</script> + +<template> + <div> + <gl-button + v-gl-tooltip="buttonTooltipText" + class="gl-mr-3" + data-qa-selector="delete_merged_branches_button" + category="secondary" + variant="danger" + @click="openModal" + >{{ $options.i18n.deleteButtonText }} + </gl-button> + <gl-modal + ref="modal" + size="sm" + modal-id="delete-merged-branches" + :title="$options.i18n.modalTitle" + > + <form ref="form" :action="formPath" method="post" @submit.prevent> + <p> + <gl-sprintf :message="modalMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p> + {{ $options.i18n.notVisibleBranchesWarning }} + </p> + <p> + {{ $options.i18n.protectedBranchWarning }} + </p> + <p> + <gl-sprintf :message="$options.i18n.permanentEffectWarning"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.i18n.confirmationMessage"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + <gl-form-input + v-model="enteredText" + data-qa-selector="delete_merged_branches_input" + type="text" + size="sm" + class="gl-mt-2" + aria-labelledby="input-label" + autocomplete="off" + @keyup.enter="submitForm" + /> + </p> + + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + </form> + + <template #modal-footer> + <div + class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0 gl-mr-3" + > + <gl-button data-testid="delete-merged-branches-cancel-button" @click="closeModal"> + {{ $options.i18n.cancelButtonText }} + </gl-button> + <gl-button + ref="deleteMergedBrancesButton" + :disabled="isDeleteButtonDisabled" + variant="danger" + data-qa-selector="delete_merged_branches_confirmation_button" + data-testid="delete-merged-branches-confirmation-button" + @click="submitForm" + >{{ $options.i18n.deleteButtonText }}</gl-button + > + </div> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/branches/init_delete_merged_branches.js b/app/assets/javascripts/branches/init_delete_merged_branches.js new file mode 100644 index 00000000000..998db07d8de --- /dev/null +++ b/app/assets/javascripts/branches/init_delete_merged_branches.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import DeleteMergedBranches from '~/branches/components/delete_merged_branches.vue'; + +export default function initDeleteMergedBranchesModal() { + const el = document.querySelector('.js-delete-merged-branches'); + if (!el) { + return false; + } + + const { formPath, defaultBranch } = el.dataset; + + return new Vue({ + el, + render(createComponent) { + return createComponent(DeleteMergedBranches, { + props: { + formPath, + defaultBranch, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue deleted file mode 100644 index ecb39f214ec..00000000000 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; -import { __, sprintf } from '~/locale'; - -export default { - name: 'CiEnvironmentsDropdown', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlSearchBoxByType, - }, - props: { - value: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - searchTerm: '', - }; - }, - computed: { - ...mapGetters(['joinedEnvironments']), - composedCreateButtonLabel() { - return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); - }, - shouldRenderCreateButton() { - return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm); - }, - filteredResults() { - const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.joinedEnvironments.filter((resultString) => - resultString.toLowerCase().includes(lowerCasedSearchTerm), - ); - }, - }, - methods: { - selectEnvironment(selected) { - this.$emit('selectEnvironment', selected); - this.searchTerm = ''; - }, - createClicked() { - this.$emit('createClicked', this.searchTerm); - this.searchTerm = ''; - }, - isSelected(env) { - return this.value === env; - }, - clearSearch() { - this.searchTerm = ''; - }, - }, -}; -</script> -<template> - <gl-dropdown :text="value" @show="clearSearch"> - <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> - <gl-dropdown-item - v-for="environment in filteredResults" - :key="environment" - :is-checked="isSelected(environment)" - is-check-item - @click="selectEnvironment(environment)" - > - {{ environment }} - </gl-dropdown-item> - <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ - __('No matching results') - }}</gl-dropdown-item> - <template v-if="shouldRenderCreateButton"> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked"> - {{ composedCreateButtonLabel }} - </gl-dropdown-item> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue deleted file mode 100644 index fa90e0e3e6c..00000000000 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue +++ /dev/null @@ -1,429 +0,0 @@ -<script> -import { - GlAlert, - GlButton, - GlCollapse, - GlFormCheckbox, - GlFormCombobox, - GlFormGroup, - GlFormSelect, - GlFormInput, - GlFormTextarea, - GlIcon, - GlLink, - GlModal, - GlSprintf, -} from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import { getCookie, setCookie } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; -import Tracking from '~/tracking'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { mapComputed } from '~/vuex_shared/bindings'; -import { - AWS_TOKEN_CONSTANTS, - ADD_CI_VARIABLE_MODAL_ID, - AWS_TIP_DISMISSED_COOKIE_NAME, - AWS_TIP_MESSAGE, - CONTAINS_VARIABLE_REFERENCE_MESSAGE, - ENVIRONMENT_SCOPE_LINK_TITLE, - EVENT_LABEL, - EVENT_ACTION, -} from '../constants'; -import LegacyCiEnvironmentsDropdown from './legacy_ci_environments_dropdown.vue'; -import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; - -const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); - -export default { - modalId: ADD_CI_VARIABLE_MODAL_ID, - tokens: awsTokens, - tokenList: awsTokenList, - awsTipMessage: AWS_TIP_MESSAGE, - containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, - environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, - components: { - LegacyCiEnvironmentsDropdown, - GlAlert, - GlButton, - GlCollapse, - GlFormCheckbox, - GlFormCombobox, - GlFormGroup, - GlFormSelect, - GlFormInput, - GlFormTextarea, - GlIcon, - GlLink, - GlModal, - GlSprintf, - }, - mixins: [glFeatureFlagsMixin(), trackingMixin], - data() { - return { - isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', - validationErrorEventProperty: '', - }; - }, - computed: { - ...mapState([ - 'projectId', - 'environments', - 'typeOptions', - 'variable', - 'variableBeingEdited', - 'isGroup', - 'maskableRegex', - 'selectedEnvironment', - 'isProtectedByDefault', - 'awsLogoSvgPath', - 'awsTipDeployLink', - 'awsTipCommandsLink', - 'awsTipLearnLink', - 'containsVariableReferenceLink', - 'protectedEnvironmentVariablesLink', - 'maskedEnvironmentVariablesLink', - 'environmentScopeLink', - ]), - ...mapComputed( - [ - { key: 'key', updateFn: 'updateVariableKey' }, - { key: 'secret_value', updateFn: 'updateVariableValue' }, - { key: 'variable_type', updateFn: 'updateVariableType' }, - { key: 'environment_scope', updateFn: 'setEnvironmentScope' }, - { key: 'protected_variable', updateFn: 'updateVariableProtected' }, - { key: 'masked', updateFn: 'updateVariableMasked' }, - ], - false, - 'variable', - ), - isTipVisible() { - return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); - }, - canSubmit() { - return ( - this.variableValidationState && - this.variable.key !== '' && - this.variable.secret_value !== '' - ); - }, - canMask() { - const regex = RegExp(this.maskableRegex); - return regex.test(this.variable.secret_value); - }, - containsVariableReference() { - const regex = /\$/; - return regex.test(this.variable.secret_value); - }, - displayMaskedError() { - return !this.canMask && this.variable.masked; - }, - maskedState() { - if (this.displayMaskedError) { - return false; - } - return true; - }, - modalActionText() { - return this.variableBeingEdited ? __('Update variable') : __('Add variable'); - }, - maskedFeedback() { - return this.displayMaskedError ? __('This variable can not be masked.') : ''; - }, - tokenValidationFeedback() { - const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; - if (!this.tokenValidationState && tokenSpecificFeedback) { - return tokenSpecificFeedback; - } - return ''; - }, - tokenValidationState() { - const validator = this.$options.tokens?.[this.variable.key]?.validation; - - if (validator) { - return validator(this.variable.secret_value); - } - - return true; - }, - scopedVariablesAvailable() { - return !this.isGroup || this.glFeatures.groupScopedCiVariables; - }, - variableValidationFeedback() { - return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; - }, - variableValidationState() { - return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); - }, - }, - watch: { - variable: { - handler() { - this.trackVariableValidationErrors(); - }, - deep: true, - }, - }, - methods: { - ...mapActions([ - 'addVariable', - 'updateVariable', - 'resetEditing', - 'displayInputValue', - 'clearModal', - 'deleteVariable', - 'setEnvironmentScope', - 'addWildCardScope', - 'resetSelectedEnvironment', - 'setSelectedEnvironment', - 'setVariableProtected', - ]), - dismissTip() { - setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); - this.isTipDismissed = true; - }, - deleteVarAndClose() { - this.deleteVariable(); - this.hideModal(); - }, - hideModal() { - this.$refs.modal.hide(); - }, - resetModalHandler() { - if (this.variableBeingEdited) { - this.resetEditing(); - } - - this.clearModal(); - this.resetSelectedEnvironment(); - this.resetValidationErrorEvents(); - }, - updateOrAddVariable() { - if (this.variableBeingEdited) { - this.updateVariable(); - } else { - this.addVariable(); - } - this.hideModal(); - }, - setVariableProtectedByDefault() { - if (this.isProtectedByDefault && !this.variableBeingEdited) { - this.setVariableProtected(); - } - }, - trackVariableValidationErrors() { - const property = this.getTrackingErrorProperty(); - if (!this.validationErrorEventProperty && property) { - this.track(EVENT_ACTION, { property }); - this.validationErrorEventProperty = property; - } - }, - getTrackingErrorProperty() { - let property; - if (this.variable.secret_value?.length && !property) { - if (this.displayMaskedError && this.maskableRegex?.length) { - const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); - const regex = new RegExp(supportedChars, 'g'); - property = this.variable.secret_value.replace(regex, ''); - } - if (this.containsVariableReference) { - property = '$'; - } - } - - return property; - }, - resetValidationErrorEvents() { - this.validationErrorEventProperty = ''; - }, - }, -}; -</script> - -<template> - <gl-modal - ref="modal" - :modal-id="$options.modalId" - :title="modalActionText" - static - lazy - @hidden="resetModalHandler" - @shown="setVariableProtectedByDefault" - > - <form> - <gl-form-combobox - v-model="key" - :token-list="$options.tokenList" - :label-text="__('Key')" - data-testid="pipeline-form-ci-variable-key" - data-qa-selector="ci_variable_key_field" - /> - - <gl-form-group - :label="__('Value')" - label-for="ci-variable-value" - :state="variableValidationState" - :invalid-feedback="variableValidationFeedback" - > - <gl-form-textarea - id="ci-variable-value" - ref="valueField" - v-model="secret_value" - :state="variableValidationState" - rows="3" - max-rows="10" - data-testid="pipeline-form-ci-variable-value" - data-qa-selector="ci_variable_value_field" - class="gl-font-monospace!" - spellcheck="false" - /> - </gl-form-group> - - <div class="d-flex"> - <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5"> - <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> - </gl-form-group> - - <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope"> - <template #label> - {{ __('Environment scope') }} - <gl-link - :title="$options.environmentScopeLinkTitle" - :href="environmentScopeLink" - target="_blank" - data-testid="environment-scope-link" - > - <gl-icon name="question" :size="12" /> - </gl-link> - </template> - <legacy-ci-environments-dropdown - v-if="scopedVariablesAvailable" - class="w-100" - :value="environment_scope" - @selectEnvironment="setEnvironmentScope" - @createClicked="addWildCardScope" - /> - - <gl-form-input v-else v-model="environment_scope" class="w-100" readonly /> - </gl-form-group> - </div> - - <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> - <gl-form-checkbox - v-model="protected_variable" - class="mb-0" - data-testid="ci-variable-protected-checkbox" - > - {{ __('Protect variable') }} - <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> - <gl-icon name="question" :size="12" /> - </gl-link> - <p class="gl-mt-2 text-secondary"> - {{ __('Export variable to pipelines running on protected branches and tags only.') }} - </p> - </gl-form-checkbox> - - <gl-form-checkbox - ref="masked-ci-variable" - v-model="masked" - data-testid="ci-variable-masked-checkbox" - > - {{ __('Mask variable') }} - <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> - <gl-icon name="question" :size="12" /> - </gl-link> - <p class="gl-mt-2 gl-mb-0 text-secondary"> - {{ __('Variable will be masked in job logs.') }} - <span - :class="{ - 'bold text-plain': displayMaskedError, - }" - > - {{ __('Requires values to meet regular expression requirements.') }}</span - > - <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ - __('More information') - }}</gl-link> - </p> - </gl-form-checkbox> - </gl-form-group> - </form> - <gl-collapse :visible="isTipVisible"> - <gl-alert - :title="__('Deploying to AWS is easy with GitLab')" - variant="tip" - data-testid="aws-guidance-tip" - @dismiss="dismissTip" - > - <div class="gl-display-flex gl-flex-direction-row"> - <div> - <p> - <gl-sprintf :message="$options.awsTipMessage"> - <template #deployLink="{ content }"> - <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link> - </template> - <template #commandsLink="{ content }"> - <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <p> - <gl-button - :href="awsTipLearnLink" - target="_blank" - category="secondary" - variant="info" - class="gl-overflow-wrap-break" - >{{ __('Learn more about deploying to AWS') }}</gl-button - > - </p> - </div> - <img - class="gl-mt-3" - :alt="__('Amazon Web Services Logo')" - :src="awsLogoSvgPath" - height="32" - /> - </div> - </gl-alert> - </gl-collapse> - <gl-alert - v-if="containsVariableReference" - :title="__('Value might contain a variable reference')" - :dismissible="false" - variant="warning" - data-testid="contains-variable-reference" - > - <gl-sprintf :message="$options.containsVariableReferenceMessage"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - <template #docsLink="{ content }"> - <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> - <template #modal-footer> - <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> - <gl-button - v-if="variableBeingEdited" - ref="deleteCiVariable" - variant="danger" - category="secondary" - data-qa-selector="ci_variable_delete_button" - @click="deleteVarAndClose" - >{{ __('Delete variable') }}</gl-button - > - <gl-button - ref="updateOrAddVariable" - :disabled="!canSubmit" - variant="confirm" - category="primary" - data-testid="ciUpdateOrAddVariableBtn" - data-qa-selector="ci_variable_save_button" - @click="updateOrAddVariable" - >{{ modalActionText }} - </gl-button> - </template> - </gl-modal> -</template> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue deleted file mode 100644 index f1fe188348d..00000000000 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { mapState, mapActions } from 'vuex'; -import LegacyCiVariableModal from './legacy_ci_variable_modal.vue'; -import LegacyCiVariableTable from './legacy_ci_variable_table.vue'; - -export default { - components: { - LegacyCiVariableModal, - LegacyCiVariableTable, - }, - computed: { - ...mapState(['isGroup', 'isProject']), - }, - mounted() { - if (this.isProject) { - this.fetchEnvironments(); - } - }, - methods: { - ...mapActions(['fetchEnvironments']), - }, -}; -</script> - -<template> - <div class="row"> - <div class="col-lg-12"> - <legacy-ci-variable-table /> - <legacy-ci-variable-modal /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue deleted file mode 100644 index f3a84e22316..00000000000 --- a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue +++ /dev/null @@ -1,209 +0,0 @@ -<script> -import { GlTable, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { s__, __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; - -export default { - modalId: ADD_CI_VARIABLE_MODAL_ID, - fields: [ - { - key: 'variable_type', - label: s__('CiVariables|Type'), - thClass: 'gl-w-10p', - }, - { - key: 'key', - label: s__('CiVariables|Key'), - tdClass: 'text-plain', - sortable: true, - }, - { - key: 'value', - label: s__('CiVariables|Value'), - thClass: 'gl-w-15p', - }, - { - key: 'options', - label: s__('CiVariables|Options'), - thClass: 'gl-w-10p', - }, - { - key: 'environment_scope', - label: s__('CiVariables|Environments'), - }, - { - key: 'actions', - label: '', - tdClass: 'text-right', - thClass: 'gl-w-5p', - }, - ], - components: { - GlButton, - GlTable, - }, - directives: { - GlModalDirective, - GlTooltip: GlTooltipDirective, - }, - mixins: [glFeatureFlagsMixin()], - computed: { - ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']), - valuesButtonText() { - return this.valuesHidden ? __('Reveal values') : __('Hide values'); - }, - isTableEmpty() { - return !this.variables || this.variables.length === 0; - }, - fields() { - return this.$options.fields; - }, - variablesWithOptions() { - return this.variables?.map((item, index) => ({ - ...item, - options: this.getOptions(item), - index, - })); - }, - }, - mounted() { - this.fetchVariables(); - }, - methods: { - ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']), - getOptions(item) { - const options = []; - if (item.protected) { - options.push(s__('CiVariables|Protected')); - } - if (item.masked) { - options.push(s__('CiVariables|Masked')); - } - return options.join(', '); - }, - editVariableClicked(index = -1) { - this.editVariable(this.variables[index] ?? null); - }, - }, -}; -</script> - -<template> - <div class="ci-variable-table" data-testid="ci-variable-table"> - <gl-table - :fields="fields" - :items="variablesWithOptions" - tbody-tr-class="js-ci-variable-row" - data-qa-selector="ci_variable_table_content" - sort-by="key" - sort-direction="asc" - stacked="lg" - table-class="text-secondary" - fixed - show-empty - sort-icon-left - no-sort-reset - > - <template #table-colgroup="scope"> - <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> - </template> - <template #cell(key)="{ item }"> - <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" - > - <span - :id="`ci-variable-key-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-word-break-word" - >{{ item.key }}</span - > - <gl-button - v-gl-tooltip - category="tertiary" - icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" - :title="__('Copy key')" - :data-clipboard-text="item.key" - :aria-label="__('Copy to clipboard')" - /> - </div> - </template> - <template #cell(value)="{ item }"> - <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" - > - <span v-if="valuesHidden">*****</span> - <span - v-else - :id="`ci-variable-value-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - >{{ item.value }}</span - > - <gl-button - v-gl-tooltip - category="tertiary" - icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" - :title="__('Copy value')" - :data-clipboard-text="item.value" - :aria-label="__('Copy to clipboard')" - /> - </div> - </template> - <template #cell(options)="{ item }"> - <span>{{ item.options }}</span> - </template> - <template #cell(environment_scope)="{ item }"> - <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" - > - <span - :id="`ci-variable-env-${item.id}`" - class="gl-display-inline-block gl-max-w-full gl-word-break-word" - >{{ item.environment_scope }}</span - > - <gl-button - v-gl-tooltip - category="tertiary" - icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" - :title="__('Copy environment')" - :data-clipboard-text="item.environment_scope" - :aria-label="__('Copy to clipboard')" - /> - </div> - </template> - <template #cell(actions)="{ item }"> - <gl-button - v-gl-modal-directive="$options.modalId" - icon="pencil" - :aria-label="__('Edit')" - data-qa-selector="edit_ci_variable_button" - @click="editVariableClicked(item.index)" - /> - </template> - <template #empty> - <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0"> - {{ __('There are no variables yet.') }} - </p> - </template> - </gl-table> - <div class="ci-variable-actions gl-display-flex gl-mt-5"> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mr-3" - data-qa-selector="add_ci_variable_button" - variant="confirm" - category="primary" - >{{ __('Add variable') }}</gl-button - > - <gl-button - v-if="!isTableEmpty" - data-qa-selector="reveal_ci_variable_value_button" - @click="toggleValues(!valuesHidden)" - >{{ valuesButtonText }}</gl-button - > - </div> - </div> -</template> diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 1b69da9e086..174a59aba42 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -5,9 +5,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import CiAdminVariables from './components/ci_admin_variables.vue'; import CiGroupVariables from './components/ci_group_variables.vue'; import CiProjectVariables from './components/ci_project_variables.vue'; -import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue'; import { cacheConfig, resolvers } from './graphql/settings'; -import createStore from './store'; const mountCiVariableListApp = (containerEl) => { const { @@ -76,62 +74,10 @@ const mountCiVariableListApp = (containerEl) => { }); }; -const mountLegacyCiVariableListApp = (containerEl) => { - const { - endpoint, - projectId, - isGroup, - isProject, - maskableRegex, - protectedByDefault, - awsLogoSvgPath, - awsTipDeployLink, - awsTipCommandsLink, - awsTipLearnLink, - containsVariableReferenceLink, - protectedEnvironmentVariablesLink, - maskedEnvironmentVariablesLink, - environmentScopeLink, - } = containerEl.dataset; - - const parsedIsProject = parseBoolean(isProject); - const parsedIsGroup = parseBoolean(isGroup); - const isProtectedByDefault = parseBoolean(protectedByDefault); - - const store = createStore({ - endpoint, - projectId, - isGroup: parsedIsGroup, - isProject: parsedIsProject, - maskableRegex, - isProtectedByDefault, - awsLogoSvgPath, - awsTipDeployLink, - awsTipCommandsLink, - awsTipLearnLink, - containsVariableReferenceLink, - protectedEnvironmentVariablesLink, - maskedEnvironmentVariablesLink, - environmentScopeLink, - }); - - return new Vue({ - el: containerEl, - store, - render(createElement) { - return createElement(LegacyCiVariableSettings); - }, - }); -}; - -export default (containerId = 'js-ci-project-variables') => { +export default (containerId = 'js-ci-variables') => { const el = document.getElementById(containerId); - if (el) { - if (gon.features?.ciVariableSettingsGraphql) { - mountCiVariableListApp(el); - } else { - mountLegacyCiVariableListApp(el); - } - } + if (!el) return; + + mountCiVariableListApp(el); }; diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js deleted file mode 100644 index ac31e845b0d..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ /dev/null @@ -1,208 +0,0 @@ -import Api from '~/api'; -import { createAlert } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import * as types from './mutation_types'; -import { prepareDataForApi, prepareDataForDisplay, prepareEnvironments } from './utils'; - -export const toggleValues = ({ commit }, valueState) => { - commit(types.TOGGLE_VALUES, valueState); -}; - -export const clearModal = ({ commit }) => { - commit(types.CLEAR_MODAL); -}; - -export const resetEditing = ({ commit, dispatch }) => { - // fetch variables again if modal is being edited and then hidden - // without saving changes, to cover use case of reactivity in the table - dispatch('fetchVariables'); - commit(types.RESET_EDITING); -}; - -export const setVariableProtected = ({ commit }) => { - commit(types.SET_VARIABLE_PROTECTED); -}; - -export const requestAddVariable = ({ commit }) => { - commit(types.REQUEST_ADD_VARIABLE); -}; - -export const receiveAddVariableSuccess = ({ commit }) => { - commit(types.RECEIVE_ADD_VARIABLE_SUCCESS); -}; - -export const receiveAddVariableError = ({ commit }, error) => { - commit(types.RECEIVE_ADD_VARIABLE_ERROR, error); -}; - -export const addVariable = ({ state, dispatch }) => { - dispatch('requestAddVariable'); - - return axios - .patch(state.endpoint, { - variables_attributes: [prepareDataForApi(state.variable)], - }) - .then(() => { - dispatch('receiveAddVariableSuccess'); - dispatch('fetchVariables'); - }) - .catch((error) => { - createAlert({ - message: error.response.data[0], - }); - dispatch('receiveAddVariableError', error); - }); -}; - -export const requestUpdateVariable = ({ commit }) => { - commit(types.REQUEST_UPDATE_VARIABLE); -}; - -export const receiveUpdateVariableSuccess = ({ commit }) => { - commit(types.RECEIVE_UPDATE_VARIABLE_SUCCESS); -}; - -export const receiveUpdateVariableError = ({ commit }, error) => { - commit(types.RECEIVE_UPDATE_VARIABLE_ERROR, error); -}; - -export const updateVariable = ({ state, dispatch }) => { - dispatch('requestUpdateVariable'); - - const updatedVariable = prepareDataForApi(state.variable); - updatedVariable.secrect_value = updateVariable.value; - - return axios - .patch(state.endpoint, { variables_attributes: [updatedVariable] }) - .then(() => { - dispatch('receiveUpdateVariableSuccess'); - dispatch('fetchVariables'); - }) - .catch((error) => { - createAlert({ - message: error.response.data[0], - }); - dispatch('receiveUpdateVariableError', error); - }); -}; - -export const editVariable = ({ commit }, variable) => { - const variableToEdit = variable; - variableToEdit.secret_value = variableToEdit.value; - commit(types.VARIABLE_BEING_EDITED, variableToEdit); -}; - -export const requestVariables = ({ commit }) => { - commit(types.REQUEST_VARIABLES); -}; -export const receiveVariablesSuccess = ({ commit }, variables) => { - commit(types.RECEIVE_VARIABLES_SUCCESS, variables); -}; - -export const fetchVariables = ({ dispatch, state }) => { - dispatch('requestVariables'); - - return axios - .get(state.endpoint) - .then(({ data }) => { - dispatch('receiveVariablesSuccess', prepareDataForDisplay(data.variables)); - }) - .catch(() => { - createAlert({ - message: __('There was an error fetching the variables.'), - }); - }); -}; - -export const requestDeleteVariable = ({ commit }) => { - commit(types.REQUEST_DELETE_VARIABLE); -}; - -export const receiveDeleteVariableSuccess = ({ commit }) => { - commit(types.RECEIVE_DELETE_VARIABLE_SUCCESS); -}; - -export const receiveDeleteVariableError = ({ commit }, error) => { - commit(types.RECEIVE_DELETE_VARIABLE_ERROR, error); -}; - -export const deleteVariable = ({ dispatch, state }) => { - dispatch('requestDeleteVariable'); - - const destroy = true; - - return axios - .patch(state.endpoint, { variables_attributes: [prepareDataForApi(state.variable, destroy)] }) - .then(() => { - dispatch('receiveDeleteVariableSuccess'); - dispatch('fetchVariables'); - }) - .catch((error) => { - createAlert({ - message: error.response.data[0], - }); - dispatch('receiveDeleteVariableError', error); - }); -}; - -export const requestEnvironments = ({ commit }) => { - commit(types.REQUEST_ENVIRONMENTS); -}; - -export const receiveEnvironmentsSuccess = ({ commit }, environments) => { - commit(types.RECEIVE_ENVIRONMENTS_SUCCESS, environments); -}; - -export const fetchEnvironments = ({ dispatch, state }) => { - dispatch('requestEnvironments'); - - return Api.environments(state.projectId) - .then((res) => { - dispatch('receiveEnvironmentsSuccess', prepareEnvironments(res.data)); - }) - .catch(() => { - createAlert({ - message: __('There was an error fetching the environments information.'), - }); - }); -}; - -export const setEnvironmentScope = ({ commit, dispatch }, environment) => { - commit(types.SET_ENVIRONMENT_SCOPE, environment); - dispatch('setSelectedEnvironment', environment); -}; - -export const addWildCardScope = ({ commit, dispatch }, environment) => { - commit(types.ADD_WILD_CARD_SCOPE, environment); - commit(types.SET_ENVIRONMENT_SCOPE, environment); - dispatch('setSelectedEnvironment', environment); -}; - -export const resetSelectedEnvironment = ({ commit }) => { - commit(types.RESET_SELECTED_ENVIRONMENT); -}; - -export const setSelectedEnvironment = ({ commit }, environment) => { - commit(types.SET_SELECTED_ENVIRONMENT, environment); -}; - -export const updateVariableKey = ({ commit }, { key }) => { - commit(types.UPDATE_VARIABLE_KEY, key); -}; - -export const updateVariableValue = ({ commit }, { secret_value }) => { - commit(types.UPDATE_VARIABLE_VALUE, secret_value); -}; - -export const updateVariableType = ({ commit }, { variable_type }) => { - commit(types.UPDATE_VARIABLE_TYPE, variable_type); -}; - -export const updateVariableProtected = ({ commit }, { protected_variable }) => { - commit(types.UPDATE_VARIABLE_PROTECTED, protected_variable); -}; - -export const updateVariableMasked = ({ commit }, { masked }) => { - commit(types.UPDATE_VARIABLE_MASKED, masked); -}; diff --git a/app/assets/javascripts/ci_variable_list/store/getters.js b/app/assets/javascripts/ci_variable_list/store/getters.js deleted file mode 100644 index 6570f455541..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/getters.js +++ /dev/null @@ -1,6 +0,0 @@ -import { uniq } from 'lodash'; - -export const joinedEnvironments = (state) => { - const scopesFromVariables = (state.variables || []).map((variable) => variable.environment_scope); - return uniq(state.environments.concat(scopesFromVariables)).sort(); -}; diff --git a/app/assets/javascripts/ci_variable_list/store/index.js b/app/assets/javascripts/ci_variable_list/store/index.js deleted file mode 100644 index 83802f6a36f..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export default (initialState = {}) => - new Vuex.Store({ - actions, - mutations, - getters, - state: { - ...state(), - ...initialState, - }, - }); diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js deleted file mode 100644 index 5db8f610192..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js +++ /dev/null @@ -1,33 +0,0 @@ -export const TOGGLE_VALUES = 'TOGGLE_VALUES'; -export const VARIABLE_BEING_EDITED = 'VARIABLE_BEING_EDITED'; -export const RESET_EDITING = 'RESET_EDITING'; -export const CLEAR_MODAL = 'CLEAR_MODAL'; -export const SET_VARIABLE_PROTECTED = 'SET_VARIABLE_PROTECTED'; - -export const REQUEST_VARIABLES = 'REQUEST_VARIABLES'; -export const RECEIVE_VARIABLES_SUCCESS = 'RECEIVE_VARIABLES_SUCCESS'; - -export const REQUEST_DELETE_VARIABLE = 'REQUEST_DELETE_VARIABLE'; -export const RECEIVE_DELETE_VARIABLE_SUCCESS = 'RECEIVE_DELETE_VARIABLE_SUCCESS'; -export const RECEIVE_DELETE_VARIABLE_ERROR = 'RECEIVE_DELETE_VARIABLE_ERROR'; - -export const REQUEST_ADD_VARIABLE = 'REQUEST_ADD_VARIABLE'; -export const RECEIVE_ADD_VARIABLE_SUCCESS = 'RECEIVE_ADD_VARIABLE_SUCCESS'; -export const RECEIVE_ADD_VARIABLE_ERROR = 'RECEIVE_ADD_VARIABLE_ERROR'; - -export const REQUEST_UPDATE_VARIABLE = 'REQUEST_UPDATE_VARIABLE'; -export const RECEIVE_UPDATE_VARIABLE_SUCCESS = 'RECEIVE_UPDATE_VARIABLE_SUCCESS'; -export const RECEIVE_UPDATE_VARIABLE_ERROR = 'RECEIVE_UPDATE_VARIABLE_ERROR'; - -export const REQUEST_ENVIRONMENTS = 'REQUEST_ENVIRONMENTS'; -export const RECEIVE_ENVIRONMENTS_SUCCESS = 'RECEIVE_ENVIRONMENTS_SUCCESS'; -export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE'; -export const ADD_WILD_CARD_SCOPE = 'ADD_WILD_CARD_SCOPE'; -export const RESET_SELECTED_ENVIRONMENT = 'RESET_SELECTED_ENVIRONMENT'; -export const SET_SELECTED_ENVIRONMENT = 'SET_SELECTED_ENVIRONMENT'; - -export const UPDATE_VARIABLE_KEY = 'UPDATE_VARIABLE_KEY'; -export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; -export const UPDATE_VARIABLE_TYPE = 'UPDATE_VARIABLE_TYPE'; -export const UPDATE_VARIABLE_PROTECTED = 'UPDATE_VARIABLE_PROTECTED'; -export const UPDATE_VARIABLE_MASKED = 'UPDATE_VARIABLE_MASKED'; diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js deleted file mode 100644 index 0e7c61cecb8..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ /dev/null @@ -1,128 +0,0 @@ -import { displayText } from '../constants'; -import * as types from './mutation_types'; - -export default { - [types.REQUEST_VARIABLES](state) { - state.isLoading = true; - }, - - [types.RECEIVE_VARIABLES_SUCCESS](state, variables) { - state.isLoading = false; - state.variables = variables; - }, - - [types.REQUEST_DELETE_VARIABLE](state) { - state.isDeleting = true; - }, - - [types.RECEIVE_DELETE_VARIABLE_SUCCESS](state) { - state.isDeleting = false; - }, - - [types.RECEIVE_DELETE_VARIABLE_ERROR](state, error) { - state.isDeleting = false; - state.error = error; - }, - - [types.REQUEST_ADD_VARIABLE](state) { - state.isLoading = true; - }, - - [types.RECEIVE_ADD_VARIABLE_SUCCESS](state) { - state.isLoading = false; - }, - - [types.RECEIVE_ADD_VARIABLE_ERROR](state, error) { - state.isLoading = false; - state.error = error; - }, - - [types.REQUEST_UPDATE_VARIABLE](state) { - state.isLoading = true; - }, - - [types.RECEIVE_UPDATE_VARIABLE_SUCCESS](state) { - state.isLoading = false; - }, - - [types.RECEIVE_UPDATE_VARIABLE_ERROR](state, error) { - state.isLoading = false; - state.error = error; - }, - - [types.TOGGLE_VALUES](state, valueState) { - state.valuesHidden = valueState; - }, - - [types.REQUEST_ENVIRONMENTS](state) { - state.isLoading = true; - }, - - [types.RECEIVE_ENVIRONMENTS_SUCCESS](state, environments) { - state.isLoading = false; - state.environments = environments; - state.environments.unshift(displayText.allEnvironmentsText); - }, - - [types.VARIABLE_BEING_EDITED](state, variable) { - state.variableBeingEdited = true; - state.variable = variable; - }, - - [types.CLEAR_MODAL](state) { - state.variable = { - variable_type: displayText.variableText, - key: '', - secret_value: '', - protected_variable: false, - masked: false, - environment_scope: displayText.allEnvironmentsText, - }; - }, - - [types.RESET_EDITING](state) { - state.variableBeingEdited = false; - state.showInputValue = false; - }, - - [types.SET_ENVIRONMENT_SCOPE](state, environment) { - state.variable.environment_scope = environment; - }, - - [types.ADD_WILD_CARD_SCOPE](state, environment) { - state.environments.push(environment); - state.environments.sort(); - }, - - [types.RESET_SELECTED_ENVIRONMENT](state) { - state.selectedEnvironment = ''; - }, - - [types.SET_SELECTED_ENVIRONMENT](state, environment) { - state.selectedEnvironment = environment; - }, - - [types.SET_VARIABLE_PROTECTED](state) { - state.variable.protected_variable = true; - }, - - [types.UPDATE_VARIABLE_KEY](state, key) { - state.variable.key = key; - }, - - [types.UPDATE_VARIABLE_VALUE](state, value) { - state.variable.secret_value = value; - }, - - [types.UPDATE_VARIABLE_TYPE](state, type) { - state.variable.variable_type = type; - }, - - [types.UPDATE_VARIABLE_PROTECTED](state, bool) { - state.variable.protected_variable = bool; - }, - - [types.UPDATE_VARIABLE_MASKED](state, bool) { - state.variable.masked = bool; - }, -}; diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js deleted file mode 100644 index 96b27792664..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/state.js +++ /dev/null @@ -1,26 +0,0 @@ -import { displayText } from '../constants'; - -export default () => ({ - endpoint: null, - projectId: null, - isGroup: null, - maskableRegex: null, - isProtectedByDefault: null, - isLoading: false, - isDeleting: false, - variable: { - variable_type: displayText.variableText, - key: '', - secret_value: '', - protected_variable: false, - masked: false, - environment_scope: displayText.allEnvironmentsText, - }, - variables: null, - valuesHidden: true, - error: null, - environments: [], - typeOptions: [displayText.variableText, displayText.fileText], - variableBeingEdited: false, - selectedEnvironment: '', -}); diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js deleted file mode 100644 index f46a671ae7b..00000000000 --- a/app/assets/javascripts/ci_variable_list/store/utils.js +++ /dev/null @@ -1,45 +0,0 @@ -import { cloneDeep } from 'lodash'; -import { displayText, types, allEnvironments } from '../constants'; - -const variableTypeHandler = (type) => - type === displayText.variableText ? types.variableType : types.fileType; - -export const prepareDataForDisplay = (variables) => { - const variablesToDisplay = []; - variables.forEach((variable) => { - const variableCopy = variable; - if (variableCopy.variable_type === types.variableType) { - variableCopy.variable_type = displayText.variableText; - } else { - variableCopy.variable_type = displayText.fileText; - } - variableCopy.secret_value = variableCopy.value; - - if (variableCopy.environment_scope === allEnvironments.type) { - variableCopy.environment_scope = displayText.allEnvironmentsText; - } - variableCopy.protected_variable = variableCopy.protected; - variablesToDisplay.push(variableCopy); - }); - return variablesToDisplay; -}; - -export const prepareDataForApi = (variable, destroy = false) => { - const variableCopy = cloneDeep(variable); - variableCopy.protected = variableCopy.protected_variable.toString(); - delete variableCopy.protected_variable; - variableCopy.masked = variableCopy.masked.toString(); - variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type); - if (variableCopy.environment_scope === displayText.allEnvironmentsText) { - variableCopy.environment_scope = allEnvironments.type; - } - - if (destroy) { - // eslint-disable-next-line - variableCopy._destroy = destroy; - } - - return variableCopy; -}; - -export const prepareEnvironments = (environments) => environments.map((e) => e.name); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue new file mode 100644 index 00000000000..c7fddadab1b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/forwarding_settings.vue @@ -0,0 +1,91 @@ +<script> +import { GlFormCheckbox, GlFormGroup, GlSprintf } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import { + PACKAGE_FORWARDING_CHECKBOX_LABEL, + PACKAGE_FORWARDING_ENFORCE_LABEL, +} from '~/packages_and_registries/settings/group/constants'; + +export default { + name: 'ForwardingSettings', + i18n: { + PACKAGE_FORWARDING_CHECKBOX_LABEL, + PACKAGE_FORWARDING_ENFORCE_LABEL, + }, + components: { + GlFormCheckbox, + GlFormGroup, + GlSprintf, + }, + props: { + disabled: { + type: Boolean, + required: false, + default: true, + }, + forwarding: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: true, + }, + lockForwarding: { + type: Boolean, + required: false, + default: false, + }, + modelNames: { + type: Object, + required: true, + validator(value) { + return isEqual(Object.keys(value), ['forwarding', 'lockForwarding', 'isLocked']); + }, + }, + }, + computed: { + fields() { + return [ + { + testid: 'forwarding-checkbox', + label: PACKAGE_FORWARDING_CHECKBOX_LABEL, + updateField: this.modelNames.forwarding, + checked: this.forwarding, + }, + { + testid: 'lock-forwarding-checkbox', + label: PACKAGE_FORWARDING_ENFORCE_LABEL, + updateField: this.modelNames.lockForwarding, + checked: this.lockForwarding, + }, + ]; + }, + }, + methods: { + update(type, value) { + this.$emit('update', type, value); + }, + }, +}; +</script> + +<template> + <gl-form-group :label="label"> + <gl-form-checkbox + v-for="field in fields" + :key="field.testid" + :checked="field.checked" + :disabled="disabled" + :data-testid="field.testid" + @change="update(field.updateField, $event)" + > + <gl-sprintf :message="field.label"> + <template #packageType> + {{ label }} + </template> + </gl-sprintf> + </gl-form-checkbox> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index f285dfc0755..36eb65c623b 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui'; import { n__ } from '~/locale'; import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; +import PackagesForwardingSettings from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; @@ -11,6 +12,7 @@ export default { components: { GlAlert, PackagesSettings, + PackagesForwardingSettings, DependencyProxySettings, }, inject: ['groupPath'], @@ -82,6 +84,12 @@ export default { @error="handleError(2)" /> + <packages-forwarding-settings + :forward-settings="packageSettings" + @success="handleSuccess(2)" + @error="handleError(2)" + /> + <dependency-proxy-settings :dependency-proxy-settings="dependencyProxySettings" :dependency-proxy-image-ttl-policy="dependencyProxyImageTtlPolicy" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue new file mode 100644 index 00000000000..b7d7f0aaca7 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_forwarding_settings.vue @@ -0,0 +1,190 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import { + PACKAGE_FORWARDING_SETTINGS_HEADER, + PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + PACKAGE_FORWARDING_FORM_BUTTON, + PACKAGE_FORWARDING_FIELDS, + MAVEN_FORWARDING_FIELDS, +} from '~/packages_and_registries/settings/group/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; + +import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; +import ForwardingSettings from '~/packages_and_registries/settings/group/components/forwarding_settings.vue'; + +export default { + name: 'PackageForwardingSettings', + i18n: { + PACKAGE_FORWARDING_FORM_BUTTON, + PACKAGE_FORWARDING_SETTINGS_HEADER, + PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + }, + components: { + ForwardingSettings, + GlButton, + SettingsBlock, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['groupPath'], + props: { + forwardSettings: { + type: Object, + required: true, + }, + }, + data() { + return { + mutationLoading: false, + workingCopy: { ...this.forwardSettings }, + }; + }, + computed: { + packageForwardingFields() { + const fields = PACKAGE_FORWARDING_FIELDS; + + if (this.glFeatures.mavenCentralRequestForwarding) { + return fields.concat(MAVEN_FORWARDING_FIELDS); + } + + return fields; + }, + isEdited() { + return !isEqual(this.forwardSettings, this.workingCopy); + }, + isDisabled() { + return !this.isEdited || this.mutationLoading; + }, + npmMutation() { + if (this.workingCopy.npmPackageRequestsForwardingLocked) { + return {}; + } + + return { + npmPackageRequestsForwarding: this.workingCopy.npmPackageRequestsForwarding, + lockNpmPackageRequestsForwarding: this.workingCopy.lockNpmPackageRequestsForwarding, + }; + }, + pypiMutation() { + if (this.workingCopy.pypiPackageRequestsForwardingLocked) { + return {}; + } + + return { + pypiPackageRequestsForwarding: this.workingCopy.pypiPackageRequestsForwarding, + lockPypiPackageRequestsForwarding: this.workingCopy.lockPypiPackageRequestsForwarding, + }; + }, + mavenMutation() { + if (this.workingCopy.mavenPackageRequestsForwardingLocked) { + return {}; + } + + return { + mavenPackageRequestsForwarding: this.workingCopy.mavenPackageRequestsForwarding, + lockMavenPackageRequestsForwarding: this.workingCopy.lockMavenPackageRequestsForwarding, + }; + }, + mutationVariables() { + return { + ...this.npmMutation, + ...this.pypiMutation, + ...this.mavenMutation, + }; + }, + }, + watch: { + forwardSettings(newValue) { + this.workingCopy = { ...newValue }; + }, + }, + methods: { + isForwardingFieldsDisabled(fields) { + const isLocked = fields?.modelNames?.isLocked; + + return this.mutationLoading || this.workingCopy[isLocked]; + }, + forwardingFieldsForwarding(fields) { + const forwarding = fields?.modelNames?.forwarding; + + return this.workingCopy[forwarding]; + }, + forwardingFieldsLockForwarding(fields) { + const lockForwarding = fields?.modelNames?.lockForwarding; + + return this.workingCopy[lockForwarding]; + }, + async submit() { + this.mutationLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: updateNamespacePackageSettings, + variables: { + input: { + namespacePath: this.groupPath, + ...this.mutationVariables, + }, + }, + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({ + ...this.forwardSettings, + ...this.mutationVariables, + }), + }); + + if (data.updateNamespacePackageSettings?.errors?.length > 0) { + throw new Error(); + } else { + this.$emit('success'); + } + } catch { + this.$emit('error'); + } finally { + this.mutationLoading = false; + } + }, + updateWorkingCopy(type, value) { + this.$set(this.workingCopy, type, value); + }, + }, +}; +</script> + +<template> + <settings-block> + <template #title> {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_HEADER }}</template> + <template #description> + <span data-testid="description"> + {{ $options.i18n.PACKAGE_FORWARDING_SETTINGS_DESCRIPTION }} + </span> + </template> + <template #default> + <form @submit.prevent="submit"> + <forwarding-settings + v-for="forwardingFields in packageForwardingFields" + :key="forwardingFields.label" + :data-testid="forwardingFields.testid" + :disabled="isForwardingFieldsDisabled(forwardingFields)" + :forwarding="forwardingFieldsForwarding(forwardingFields)" + :label="forwardingFields.label" + :lock-forwarding="forwardingFieldsLockForwarding(forwardingFields)" + :model-names="forwardingFields.modelNames" + @update="updateWorkingCopy" + /> + <gl-button + type="submit" + :disabled="isDisabled" + :loading="mutationLoading" + category="primary" + variant="confirm" + class="js-no-auto-disable gl-mr-4" + > + {{ $options.i18n.PACKAGE_FORWARDING_FORM_BUTTON }} + </gl-button> + </form> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index 2dd6d3f76f6..c93cd7f7d78 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -7,6 +7,8 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__( ); export const PACKAGE_FORMATS_TABLE_HEADER = s__('PackageRegistry|Package formats'); export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven'); +export const NPM_PACKAGE_FORMAT = s__('PackageRegistry|npm'); +export const PYPI_PACKAGE_FORMAT = s__('PackageRegistry|PyPI'); export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic'); export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); @@ -15,11 +17,65 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Publish packages if their name or version matches this regex.', ); +export const PACKAGE_FORWARDING_SETTINGS_HEADER = s__('PackageRegistry|Package forwarding'); +export const PACKAGE_FORWARDING_SETTINGS_DESCRIPTION = s__( + 'PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry.', +); +export const PACKAGE_FORWARDING_CHECKBOX_LABEL = s__( + `PackageRegistry|Forward %{packageType} package requests`, +); +export const PACKAGE_FORWARDING_ENFORCE_LABEL = s__( + `PackageRegistry|Enforce %{packageType} setting for all subgroups`, +); + +const MAVEN_PACKAGE_REQUESTS_FORWARDING = 'mavenPackageRequestsForwarding'; +const LOCK_MAVEN_PACKAGE_REQUESTS_FORWARDING = 'lockMavenPackageRequestsForwarding'; +const MAVEN_PACKAGE_REQUESTS_FORWARDING_LOCKED = 'mavenPackageRequestsForwardingLocked'; +const NPM_PACKAGE_REQUESTS_FORWARDING = 'npmPackageRequestsForwarding'; +const LOCK_NPM_PACKAGE_REQUESTS_FORWARDING = 'lockNpmPackageRequestsForwarding'; +const NPM_PACKAGE_REQUESTS_FORWARDING_LOCKED = 'npmPackageRequestsForwardingLocked'; +const PYPI_PACKAGE_REQUESTS_FORWARDING = 'pypiPackageRequestsForwarding'; +const LOCK_PYPI_PACKAGE_REQUESTS_FORWARDING = 'lockPypiPackageRequestsForwarding'; +const PYPI_PACKAGE_REQUESTS_FORWARDING_LOCKED = 'pypiPackageRequestsForwardingLocked'; + +export const PACKAGE_FORWARDING_FORM_BUTTON = __('Save changes'); + export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy'); export const DEPENDENCY_PROXY_DESCRIPTION = s__( 'DependencyProxy|Enable the Dependency Proxy and settings for clearing the cache.', ); +export const PACKAGE_FORWARDING_FIELDS = [ + { + label: NPM_PACKAGE_FORMAT, + testid: 'npm', + modelNames: { + forwarding: NPM_PACKAGE_REQUESTS_FORWARDING, + lockForwarding: LOCK_NPM_PACKAGE_REQUESTS_FORWARDING, + isLocked: NPM_PACKAGE_REQUESTS_FORWARDING_LOCKED, + }, + }, + { + label: PYPI_PACKAGE_FORMAT, + testid: 'pypi', + modelNames: { + forwarding: PYPI_PACKAGE_REQUESTS_FORWARDING, + lockForwarding: LOCK_PYPI_PACKAGE_REQUESTS_FORWARDING, + isLocked: PYPI_PACKAGE_REQUESTS_FORWARDING_LOCKED, + }, + }, +]; + +export const MAVEN_FORWARDING_FIELDS = { + label: MAVEN_PACKAGE_FORMAT, + testid: 'maven', + modelNames: { + forwarding: MAVEN_PACKAGE_REQUESTS_FORWARDING, + lockForwarding: LOCK_MAVEN_PACKAGE_REQUESTS_FORWARDING, + isLocked: MAVEN_PACKAGE_REQUESTS_FORWARDING_LOCKED, + }, +}; + // Parameters export const PACKAGES_DOCS_PATH = helpPagePath('user/packages/index'); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql new file mode 100644 index 00000000000..267e40263f2 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql @@ -0,0 +1,15 @@ +fragment PackageSettingsFields on PackageSettings { + mavenDuplicatesAllowed + mavenDuplicateExceptionRegex + genericDuplicatesAllowed + genericDuplicateExceptionRegex + mavenPackageRequestsForwarding + lockMavenPackageRequestsForwarding + mavenPackageRequestsForwardingLocked + npmPackageRequestsForwarding + lockNpmPackageRequestsForwarding + npmPackageRequestsForwardingLocked + pypiPackageRequestsForwarding + lockPypiPackageRequestsForwarding + pypiPackageRequestsForwardingLocked +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql index 5c245ff9453..5558cb66f42 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql @@ -1,10 +1,9 @@ +#import "~/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql" + mutation updateNamespacePackageSettings($input: UpdateNamespacePackageSettingsInput!) { updateNamespacePackageSettings(input: $input) { packageSettings { - mavenDuplicatesAllowed - mavenDuplicateExceptionRegex - genericDuplicatesAllowed - genericDuplicateExceptionRegex + ...PackageSettingsFields } errors } diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql index 404d9d26d49..82a282d6d81 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql @@ -1,3 +1,5 @@ +#import "~/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql" + query getGroupPackagesSettings($fullPath: ID!) { group(fullPath: $fullPath) { id @@ -9,10 +11,7 @@ query getGroupPackagesSettings($fullPath: ID!) { enabled } packageSettings { - mavenDuplicatesAllowed - mavenDuplicateExceptionRegex - genericDuplicatesAllowed - genericDuplicateExceptionRegex + ...PackageSettingsFields } } } diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index f3530b46845..ac5e0b28dd1 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -3,6 +3,7 @@ import BranchSortDropdown from '~/branches/branch_sort_dropdown'; import initDiverganceGraph from '~/branches/divergence_graph'; import initDeleteBranchButton from '~/branches/init_delete_branch_button'; import initDeleteBranchModal from '~/branches/init_delete_branch_modal'; +import initDeleteMergedBranches from '~/branches/init_delete_merged_branches'; const { divergingCountsEndpoint, defaultBranch } = document.querySelector( '.js-branch-list', @@ -11,6 +12,7 @@ const { divergingCountsEndpoint, defaultBranch } = document.querySelector( initDiverganceGraph(divergingCountsEndpoint, defaultBranch); BranchSortDropdown(); initDeprecatedRemoveRowBehavior(); +initDeleteMergedBranches(); document .querySelectorAll('.js-delete-branch-button') diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index f2782f96da1..f5e1525090e 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -45,7 +45,7 @@ export default { </script> <template> - <nav class="search-filter"> + <nav data-testid="search-filter"> <gl-nav vertical pills> <gl-nav-item v-for="(item, scope, index) in navigation" diff --git a/app/assets/javascripts/sentry/constants.js b/app/assets/javascripts/sentry/constants.js new file mode 100644 index 00000000000..fd96da5faf6 --- /dev/null +++ b/app/assets/javascripts/sentry/constants.js @@ -0,0 +1,43 @@ +import { __ } from '~/locale'; + +export const IGNORE_ERRORS = [ + // Random plugins/extensions + 'top.GLOBALS', + // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html + 'originalCreateNotification', + 'canvas.contentDocument', + 'MyApp_RemoveAllHighlights', + 'http://tt.epicplay.com', + __("Can't find variable: ZiteReader"), + __('jigsaw is not defined'), + __('ComboSearch is not defined'), + 'http://loading.retry.widdit.com/', + 'atomicFindClose', + // Facebook borked + 'fb_xd_fragment', + // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to + // reduce this. (thanks @acdha) + 'bmi_SafeAddOnload', + 'EBCallBackMessageReceived', + // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx + 'conduitPage', +]; + +export const DENY_URLS = [ + // Facebook flakiness + /graph\.facebook\.com/i, + // Facebook blocked + /connect\.facebook\.net\/en_US\/all\.js/i, + // Woopra flakiness + /eatdifferent\.com\.woopra-ns\.com/i, + /static\.woopra\.com\/js\/woopra\.js/i, + // Chrome extensions + /extensions\//i, + /^chrome:\/\//i, + // Other plugins + /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb + /webappstoolbarba\.texthelp\.com\//i, + /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, +]; + +export const SAMPLE_RATE = 0.95; diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index 8f3c4c644bf..4c5b8dbad5a 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,52 +1,11 @@ import * as Sentry from '@sentry/browser'; import $ from 'jquery'; import { __ } from '~/locale'; - -const IGNORE_ERRORS = [ - // Random plugins/extensions - 'top.GLOBALS', - // See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html - 'originalCreateNotification', - 'canvas.contentDocument', - 'MyApp_RemoveAllHighlights', - 'http://tt.epicplay.com', - __("Can't find variable: ZiteReader"), - __('jigsaw is not defined'), - __('ComboSearch is not defined'), - 'http://loading.retry.widdit.com/', - 'atomicFindClose', - // Facebook borked - 'fb_xd_fragment', - // ISP "optimizing" proxy - `Cache-Control: no-transform` seems to - // reduce this. (thanks @acdha) - 'bmi_SafeAddOnload', - 'EBCallBackMessageReceived', - // See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx - 'conduitPage', -]; - -const BLACKLIST_URLS = [ - // Facebook flakiness - /graph\.facebook\.com/i, - // Facebook blocked - /connect\.facebook\.net\/en_US\/all\.js/i, - // Woopra flakiness - /eatdifferent\.com\.woopra-ns\.com/i, - /static\.woopra\.com\/js\/woopra\.js/i, - // Chrome extensions - /extensions\//i, - /^chrome:\/\//i, - // Other plugins - /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb - /webappstoolbarba\.texthelp\.com\//i, - /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, -]; - -const SAMPLE_RATE = 0.95; +import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants'; const SentryConfig = { IGNORE_ERRORS, - BLACKLIST_URLS, + BLACKLIST_URLS: DENY_URLS, SAMPLE_RATE, init(options = {}) { this.options = options; diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index a1195b572f8..ec9441c2b9b 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -13,10 +13,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action :disable_query_limiting, only: [:usage_data] - before_action do - push_frontend_feature_flag(:ci_variable_settings_graphql) - end - feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned :general, :reporting, :metrics_and_profiling, :network, :preferences, :update, :reset_health_check_token diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b47facf6c47..2c8b4888d5d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -26,6 +26,8 @@ class Admin::UsersController < Admin::ApplicationController end def show + @can_impersonate = can_impersonate_user + @impersonation_error_text = @can_impersonate ? nil : impersonation_error_text end # rubocop: disable CodeReuse/ActiveRecord @@ -47,7 +49,7 @@ class Admin::UsersController < Admin::ApplicationController end def impersonate - if can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress? + if can_impersonate_user session[:impersonator_id] = current_user.id warden.set_user(user, scope: :user) @@ -59,18 +61,7 @@ class Admin::UsersController < Admin::ApplicationController redirect_to root_path else - flash[:alert] = - if impersonation_in_progress? - _("You are already impersonating another user") - elsif user.blocked? - _("You cannot impersonate a blocked user") - elsif user.password_expired? - _("You cannot impersonate a user with an expired password") - elsif user.internal? - _("You cannot impersonate an internal user") - else - _("You cannot impersonate a user who cannot log in") - end + flash[:alert] = impersonation_error_text redirect_to admin_user_path(user) end @@ -380,6 +371,24 @@ class Admin::UsersController < Admin::ApplicationController def log_impersonation_event Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username }) end + + def can_impersonate_user + can?(user, :log_in) && !user.password_expired? && !impersonation_in_progress? + end + + def impersonation_error_text + if impersonation_in_progress? + _("You are already impersonating another user") + elsif user.blocked? + _("You cannot impersonate a blocked user") + elsif user.password_expired? + _("You cannot impersonate a user with an expired password") + elsif user.internal? + _("You cannot impersonate an internal user") + else + _("You cannot impersonate a user who cannot log in") + end + end end Admin::UsersController.prepend_mod_with('Admin::UsersController') diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index e164a834519..b1afac1f1c7 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -10,9 +10,6 @@ module Groups before_action :define_variables, only: [:show] before_action :push_licensed_features, only: [:show] before_action :assign_variables_to_gon, only: [:show] - before_action do - push_frontend_feature_flag(:ci_variable_settings_graphql, @group) - end feature_category :continuous_integration urgency :low diff --git a/app/controllers/groups/settings/packages_and_registries_controller.rb b/app/controllers/groups/settings/packages_and_registries_controller.rb index 411b8577c3f..ec4a0b312ee 100644 --- a/app/controllers/groups/settings/packages_and_registries_controller.rb +++ b/app/controllers/groups/settings/packages_and_registries_controller.rb @@ -7,6 +7,10 @@ module Groups before_action :authorize_admin_group! before_action :verify_packages_enabled! + before_action do + push_frontend_feature_flag(:maven_central_request_forwarding, group) + end + feature_category :package_registry urgency :low diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index bf231bf012d..8aef1c3d24d 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -11,9 +11,6 @@ module Projects before_action :authorize_admin_pipeline! before_action :check_builds_available! before_action :define_variables - before_action do - push_frontend_feature_flag(:ci_variable_settings_graphql, @project) - end helper_method :highlight_badge diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 9e42aeea9ce..963f0b7afc4 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -40,7 +40,7 @@ module FormHelper end def dropdown_max_select(data, feature_flag) - return data[:'max-select'] unless Feature.enabled?(feature_flag) + return data[:'max-select'] unless feature_flag.nil? || Feature.enabled?(feature_flag) if data[:'max-select'] && data[:'max-select'] < ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS data[:'max-select'] @@ -162,12 +162,7 @@ module FormHelper new_options[:title] = _('Select assignee(s)') new_options[:data][:'dropdown-header'] = 'Assignee(s)' - - if Feature.enabled?(:limit_assignees_per_issuable) - new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS - else - new_options[:data].delete(:'max-select') - end + new_options[:data][:'max-select'] = ::Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS new_options end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 7de0011e91b..b8ac2afa7d6 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -419,7 +419,7 @@ module SearchHelper result = { label: label, scope: scope_name, data: data, link: search_path(search_params), active: active_scope } if active_scope - result[:count] = !@timeout ? @search_results.formatted_count(scope_name) : 0 + result[:count] = !@timeout ? @search_results.formatted_count(scope_name) : "0" end result[:count_link] = search_count_path(search_params) unless active_scope diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index d75f81e2839..adbbddd635c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -678,6 +678,8 @@ class ApplicationSetting < ApplicationRecord attr_encrypted :arkose_labs_private_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) attr_encrypted :cube_api_key, encryption_options_base_32_aes_256_gcm attr_encrypted :jitsu_administrator_password, encryption_options_base_32_aes_256_gcm + attr_encrypted :telesign_customer_xid, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) + attr_encrypted :telesign_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false, encode_iv: false) validates :disable_feed_token, inclusion: { in: [true, false], message: N_('must be a boolean value') } diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 14cb6659d03..31b2a8d7cc1 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -240,7 +240,6 @@ module Issuable end def validate_assignee_size_length - return true unless Feature.enabled?(:limit_assignees_per_issuable) return true unless assignees.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS errors.add :assignees, diff --git a/app/models/namespace.rb b/app/models/namespace.rb index bef68586c66..51c39ad4ec3 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -91,6 +91,7 @@ class Namespace < ApplicationRecord validates :name, presence: true, length: { maximum: 255 } + validates :name, uniqueness: { scope: [:type, :parent_id] }, if: -> { parent_id.present? } validates :description, length: { maximum: 255 } diff --git a/app/models/user.rb b/app/models/user.rb index 1858c134484..24f947183a2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -425,10 +425,6 @@ class User < ApplicationRecord end # rubocop: disable CodeReuse/ServiceClass - # Ideally we should not call a service object here but user.block - # is also called by Users::MigrateToGhostUserService which references - # this state transition object in order to do a rollback. - # For this reason the tradeoff is to disable this cop. after_transition any => :blocked do |user| user.run_after_commit do Ci::DropPipelineService.new.execute_async_for_all(user.pipelines, :user_blocked, user) diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index 79a3e9f3c22..d45d55cbebc 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -19,16 +19,9 @@ module MergeRequests attrs = update_attrs.merge(assignee_ids: new_ids) - # We now have assignees validation on merge request - # If we use an update with bang, it will explode, - # instead we need to check if its valid then return if its not valid. - if Feature.enabled?(:limit_assignees_per_issuable) - merge_request.update(**attrs) - - return merge_request unless merge_request.valid? - else - merge_request.update!(**attrs) - end + merge_request.update(**attrs) + + return merge_request unless merge_request.valid? # Defer the more expensive operations (handle_assignee_changes) to the background MergeRequests::HandleAssigneesChangeService diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 1aaf7fb769a..555d60dc291 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -137,8 +137,6 @@ module Notes end def invalid_assignees?(update_params) - return false unless Feature.enabled?(:limit_assignees_per_issuable) - if update_params.key?(:assignee_ids) possible_assignees = update_params[:assignee_ids]&.uniq&.size diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb deleted file mode 100644 index 3eb220c0e40..00000000000 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -# When a user is destroyed, some of their associated records are -# moved to a "Ghost User", to prevent these associated records from -# being destroyed. -# -# For example, all the issues/MRs a user has created are _not_ destroyed -# when the user is destroyed. -module Users - class MigrateToGhostUserService - extend ActiveSupport::Concern - - attr_reader :ghost_user, :user, :hard_delete - - def initialize(user) - @user = user - @ghost_user = User.ghost - end - - # If an admin attempts to hard delete a user, in some cases associated - # records may have a NOT NULL constraint on the user ID that prevent that record - # from being destroyed. In such situations we must assign the record to the ghost user. - # Passing in `hard_delete: true` will ensure these records get assigned to - # the ghost user before the user is destroyed. Other associated records will be destroyed. - # letting the other associated records be destroyed. - def execute(hard_delete: false) - @hard_delete = hard_delete - transition = user.block_transition - - # Block the user before moving records to prevent a data race. - # For example, if the user creates an issue after `migrate_issues` - # runs and before the user is destroyed, the destroy will fail with - # an exception. - user.block - - begin - user.transaction do - migrate_records - end - rescue Exception # rubocop:disable Lint/RescueException - # Reverse the user block if record migration fails - if transition - transition.rollback - user.save! - end - - raise - end - - user.reset - end - - private - - def migrate_records - return if hard_delete - - migrate_issues - migrate_merge_requests - migrate_notes - migrate_abuse_reports - migrate_award_emoji - migrate_snippets - migrate_reviews - end - - # rubocop: disable CodeReuse/ActiveRecord - def migrate_issues - batched_migrate(Issue, :author_id) - batched_migrate(Issue, :last_edited_by_id) - end - # rubocop: enable CodeReuse/ActiveRecord - - # rubocop: disable CodeReuse/ActiveRecord - def migrate_merge_requests - batched_migrate(MergeRequest, :author_id) - batched_migrate(MergeRequest, :merge_user_id) - end - # rubocop: enable CodeReuse/ActiveRecord - - def migrate_notes - batched_migrate(Note, :author_id) - end - - def migrate_abuse_reports - user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) - end - - def migrate_award_emoji - user.award_emoji.update_all(user_id: ghost_user.id) - end - - def migrate_snippets - snippets = user.snippets.only_project_snippets - snippets.update_all(author_id: ghost_user.id) - end - - def migrate_reviews - batched_migrate(Review, :author_id) - end - - # rubocop:disable CodeReuse/ActiveRecord - def batched_migrate(base_scope, column, batch_size: 50) - loop do - update_count = base_scope.where(column => user.id).limit(batch_size).update_all(column => ghost_user.id) - break if update_count == 0 - end - end - # rubocop:enable CodeReuse/ActiveRecord - end -end - -Users::MigrateToGhostUserService.prepend_mod_with('Users::MigrateToGhostUserService') diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index 3506038ca68..66b04006beb 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -6,7 +6,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') %p - = _("Control how the GitLab Package Registry functions.") + = s_('PackageRegistry|Configure package forwarding and package file size limits.') = render_if_exists 'admin/application_settings/ee_package_registry' diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 0ceff211806..1fa7c9c8651 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -30,9 +30,11 @@ .gl-p-2 #js-admin-user-actions{ data: admin_user_actions_data_attributes(@user) } - if @user != current_user - - if impersonation_enabled? && @user.can?(:log_in) + - if impersonation_enabled? .gl-p-2 - = link_to _('Impersonate'), impersonate_admin_user_path(@user), method: :post, class: "btn btn-default gl-button", data: { qa_selector: 'impersonate_user_link' } + %span.btn-group{ class: !@can_impersonate ? 'has-tooltip' : nil, title: @impersonation_error_text } + = render Pajamas::ButtonComponent.new(disabled: !@can_impersonate, method: :post, href: impersonate_admin_user_path(@user), button_options: { data: { qa_selector: 'impersonate_user_link', testid: 'impersonate_user_link' } }) do + = _('Impersonate') - if can_force_email_confirmation?(@user) .gl-p-2 = render Pajamas::ButtonComponent.new(variant: :default, button_options: { class: 'js-confirm-modal-button', data: confirm_user_data(@user) }) do diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 9ca11b35064..08865abbe86 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -10,7 +10,7 @@ - is_group = !@group.nil? - is_project = !@project.nil? -#js-ci-project-variables{ data: { endpoint: save_endpoint, +#js-ci-variables{ data: { endpoint: save_endpoint, is_project: is_project.to_s, project_id: @project&.id || '', project_full_path: @project&.full_path || '', diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index 82276938d45..475bc9e1c20 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -13,16 +13,10 @@ #js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } } - if can? current_user, :push_code, @project - = link_to project_merged_branches_path(@project), - class: 'gl-button btn btn-danger btn-danger-secondary has-tooltip', - title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref }, - method: :delete, - aria: { label: s_('Branches|Delete merged branches') }, - data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'), - confirm_btn_variant: 'danger', - container: 'body', - qa_selector: 'delete_merged_branches_link' } do - = s_('Branches|Delete merged branches') + .js-delete-merged-branches{ data: { + default_branch: @project.repository.root_ref, + form_path: project_merged_branches_path(@project) } + } = link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do = s_('Branches|New branch') diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 54aa9aad8a5..c15afd7bd5b 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -5,7 +5,7 @@ .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= sprite_icon('chevron-lg-left', size: 12) .fade-right= sprite_icon('chevron-lg-right', size: 12) - = gl_tabs_nav({ class: 'search-filter scrolling-tabs nav-links'}) do + = gl_tabs_nav({ class: 'scrolling-tabs nav-links', data: { testid: 'search-filter' } }) do - if @project - if project_search_tabs?(:blobs) = search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' } diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index ea2ea92dfce..027ae6bf77c 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -7,7 +7,6 @@ .gl-w-full.gl-flex-grow-1.gl-overflow-x-hidden = render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty? = render partial: 'search/results_list' - - else = render partial: 'search/results_status', locals: { search_service: @search_service } unless @search_objects.to_a.empty? diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml index e6bb0c18b90..adea6b598f7 100644 --- a/app/views/search/_results_status.html.haml +++ b/app/views/search/_results_status.html.haml @@ -3,7 +3,6 @@ - return unless search_service.show_results_status? - if Feature.enabled?(:search_page_vertical_nav, current_user) - = render partial: 'search/results_status_vert_nav', locals: { search_service: @search_service } - + = render partial: 'search/results_status_vert_nav', locals: { search_service: search_service } - else - = render partial: 'search/results_status_horiz_nav', locals: { search_service: @search_service } + = render partial: 'search/results_status_horiz_nav', locals: { search_service: search_service } diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index b8a9a82d9b2..8ca30d7ca97 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -39,7 +39,7 @@ - data[:multi_select] = true - data['dropdown-title'] = title - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header'] - - data['max-select'] = dropdown_max_select(dropdown_options[:data], :limit_assignees_per_issuable) + - data['max-select'] = dropdown_max_select(dropdown_options[:data], nil) - options[:data].merge!(data) = render 'shared/issuable/sidebar_user_dropdown', diff --git a/config/feature_flags/development/ci_variable_settings_graphql.yml b/config/feature_flags/development/ci_variable_settings_graphql.yml deleted file mode 100644 index 0af109968ab..00000000000 --- a/config/feature_flags/development/ci_variable_settings_graphql.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: ci_variable_settings_graphql -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89332 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364423 -milestone: '15.1' -type: development -group: group::pipeline authoring -default_enabled: false diff --git a/config/feature_flags/development/enable_old_sentry_clientside_integration.yml b/config/feature_flags/development/enable_old_sentry_clientside_integration.yml deleted file mode 100644 index 0507d7cc559..00000000000 --- a/config/feature_flags/development/enable_old_sentry_clientside_integration.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: enable_old_sentry_clientside_integration -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102650 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/344832 -milestone: '15.6' -type: development -group: group::runner -default_enabled: true diff --git a/config/feature_flags/development/index_user_callback.yml b/config/feature_flags/development/index_user_callback.yml deleted file mode 100644 index c9a1f175547..00000000000 --- a/config/feature_flags/development/index_user_callback.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: index_user_callback -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/101326 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/378364 -milestone: '15.6' -type: development -group: group::global search -default_enabled: false diff --git a/config/feature_flags/development/limit_assignees_per_issuable.yml b/config/feature_flags/development/limit_assignees_per_issuable.yml deleted file mode 100644 index d950b8c2f09..00000000000 --- a/config/feature_flags/development/limit_assignees_per_issuable.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: limit_assignees_per_issuable -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95673 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373237 -milestone: '15.5' -type: development -group: group::code review -default_enabled: false diff --git a/config/open_api.yml b/config/open_api.yml index ddf3d23dfc9..6e767f51ef8 100644 --- a/config/open_api.yml +++ b/config/open_api.yml @@ -69,6 +69,8 @@ metadata: description: Operations related to group packages - name: integrations description: Operations related to integrations + - name: issue_links + description: Operations related to issue links - name: merge_requests description: Operations related to merge requests - name: metadata diff --git a/db/migrate/20221108015813_add_telesign_to_application_settings.rb b/db/migrate/20221108015813_add_telesign_to_application_settings.rb new file mode 100644 index 00000000000..f8e4fb5340b --- /dev/null +++ b/db/migrate/20221108015813_add_telesign_to_application_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddTelesignToApplicationSettings < Gitlab::Database::Migration[2.0] + def change + add_column :application_settings, :encrypted_telesign_customer_xid, :binary + add_column :application_settings, :encrypted_telesign_customer_xid_iv, :binary + + add_column :application_settings, :encrypted_telesign_api_key, :binary + add_column :application_settings, :encrypted_telesign_api_key_iv, :binary + end +end diff --git a/db/post_migrate/20221102090940_create_next_ci_partitions_record.rb b/db/post_migrate/20221102090940_create_next_ci_partitions_record.rb new file mode 100644 index 00000000000..4bd89a70daa --- /dev/null +++ b/db/post_migrate/20221102090940_create_next_ci_partitions_record.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class CreateNextCiPartitionsRecord < Gitlab::Database::Migration[2.0] + NEXT_PARTITION_ID = 101 + + disable_ddl_transaction! + restrict_gitlab_migration gitlab_schema: :gitlab_ci + + def up + return unless Gitlab.com? + + execute(<<~SQL) + INSERT INTO "ci_partitions" ("id", "created_at", "updated_at") + VALUES (#{NEXT_PARTITION_ID}, now(), now()) + ON CONFLICT DO NOTHING; + SQL + + reset_pk_sequence!('ci_partitions') + end + + def down + return unless Gitlab.com? + + execute(<<~SQL) + DELETE FROM "ci_partitions" + WHERE "ci_partitions"."id" = #{NEXT_PARTITION_ID}; + SQL + end +end diff --git a/db/post_migrate/20221102090943_create_second_partition_for_builds_metadata.rb b/db/post_migrate/20221102090943_create_second_partition_for_builds_metadata.rb new file mode 100644 index 00000000000..6923e6f6cba --- /dev/null +++ b/db/post_migrate/20221102090943_create_second_partition_for_builds_metadata.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class CreateSecondPartitionForBuildsMetadata < Gitlab::Database::Migration[2.0] + TABLE_NAME = 'p_ci_builds_metadata' + BUILDS_TABLE = 'ci_builds' + NEXT_PARTITION_ID = 101 + PARTITION_NAME = 'gitlab_partitions_dynamic.ci_builds_metadata_101' + + disable_ddl_transaction! + + def up + return unless Gitlab.com? + + with_lock_retries(**lock_args) do + connection.execute(<<~SQL) + LOCK TABLE #{BUILDS_TABLE} IN SHARE UPDATE EXCLUSIVE MODE; + LOCK TABLE ONLY #{TABLE_NAME} IN ACCESS EXCLUSIVE MODE; + SQL + + connection.execute(<<~SQL) + CREATE TABLE IF NOT EXISTS #{PARTITION_NAME} + PARTITION OF #{TABLE_NAME} + FOR VALUES IN (#{NEXT_PARTITION_ID}); + SQL + end + end + + def down + return unless Gitlab.com? + return unless table_exists?(PARTITION_NAME) + + with_lock_retries(**lock_args) do + connection.execute(<<~SQL) + LOCK TABLE #{BUILDS_TABLE}, #{TABLE_NAME}, #{PARTITION_NAME} IN ACCESS EXCLUSIVE MODE; + SQL + + connection.execute(<<~SQL) + ALTER TABLE #{TABLE_NAME} DETACH PARTITION #{PARTITION_NAME}; + SQL + + connection.execute(<<~SQL) + DROP TABLE IF EXISTS #{PARTITION_NAME} CASCADE; + SQL + end + end + + private + + def lock_args + { + raise_on_exhaustion: true, + timing_configuration: lock_timing_configuration + } + end + + def lock_timing_configuration + iterations = Gitlab::Database::WithLockRetries::DEFAULT_TIMING_CONFIGURATION + aggressive_iterations = Array.new(5) { [10.seconds, 1.minute] } + + iterations + aggressive_iterations + end +end diff --git a/db/schema_migrations/20221102090940 b/db/schema_migrations/20221102090940 new file mode 100644 index 00000000000..c0ef7881688 --- /dev/null +++ b/db/schema_migrations/20221102090940 @@ -0,0 +1 @@ +3be66e9f4239eb75f14118d1fd795f1a1bcd2d6bc4e34fe58a0c8422e33c893a
\ No newline at end of file diff --git a/db/schema_migrations/20221102090943 b/db/schema_migrations/20221102090943 new file mode 100644 index 00000000000..bc7ff679c6e --- /dev/null +++ b/db/schema_migrations/20221102090943 @@ -0,0 +1 @@ +8e907e086c4b23dd08163c4d946ec4a0202288f7da08eff565a159bccdd445f2
\ No newline at end of file diff --git a/db/schema_migrations/20221108015813 b/db/schema_migrations/20221108015813 new file mode 100644 index 00000000000..39263419da6 --- /dev/null +++ b/db/schema_migrations/20221108015813 @@ -0,0 +1 @@ +d6b24d6346bd9b32dd726d61048e7eea791d02016b9b4c3a8cb561b2430e1fdb
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d1901a84f5a..350ac2ad454 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11525,6 +11525,10 @@ CREATE TABLE application_settings ( disable_admin_oauth_scopes boolean DEFAULT false NOT NULL, default_preferred_language text DEFAULT 'en'::text NOT NULL, disable_download_button boolean DEFAULT false NOT NULL, + encrypted_telesign_customer_xid bytea, + encrypted_telesign_customer_xid_iv bytea, + encrypted_telesign_api_key bytea, + encrypted_telesign_api_key_iv bytea, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_container_registry_pre_import_tags_rate_positive CHECK ((container_registry_pre_import_tags_rate >= (0)::numeric)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), diff --git a/doc/administration/reference_architectures/3k_users.md b/doc/administration/reference_architectures/3k_users.md index 74074f0803b..4fc6af3f72e 100644 --- a/doc/administration/reference_architectures/3k_users.md +++ b/doc/administration/reference_architectures/3k_users.md @@ -2269,7 +2269,6 @@ but with smaller performance requirements, several modifications can be consider - PostgreSQL and PgBouncer: PgBouncer nodes could be removed and instead be enabled on PostgreSQL nodes with the Internal Load Balancer pointing to them. However, to enable [Database Load Balancing](../postgresql/database_load_balancing.md), a separate PgBouncer array is still required. - Reducing the node counts: Some node types do not need consensus and can run with fewer nodes (but more than one for redundancy). This will also lead to reduced performance. - GitLab Rails and Sidekiq: Stateless services don't have a minimum node count. Two are enough for redundancy. - - Gitaly and Praefect: A quorum is not strictly necessary. Two Gitaly nodes and two Praefect nodes are enough for redundancy. - PostgreSQL and PgBouncer: A quorum is not strictly necessary. Two PostgreSQL nodes and two PgBouncer nodes are enough for redundancy. - Running select components in reputable Cloud PaaS solutions: Select components of the GitLab setup can instead be run on Cloud Provider PaaS solutions. By doing this, additional dependent components can also be removed: - PostgreSQL: Can be run on reputable Cloud PaaS solutions such as Google Cloud SQL or Amazon RDS. In this setup, the PgBouncer and Consul nodes are no longer required: diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 529b0802991..5764c876e4d 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -906,9 +906,14 @@ end Table **has records** but **no foreign keys**: -- First release: Remove the application code related to the table, such as models, -controllers and services. -- Second release: Use the `drop_table` method in your migration. +- Remove the application code related to the table, such as models, + controllers and services. +- In a post-deployment migration, use `drop_table`. + +This can all be in a single migration if you're sure the code is not used. +If you want to reduce risk slightly, consider putting the migrations into a +second merge request after the application changes are merged. This approach +provides an opportunity to roll back. ```ruby def up @@ -922,12 +927,16 @@ end Table **has foreign keys**: -- First release: Remove the application code related to the table, such as models, -controllers, and services. -- Second release: Remove the foreign keys using the `with_lock_retries` -helper method. Use `drop_table` in another migration file. +- Remove the application code related to the table, such as models, + controllers, and services. +- In a post-deployment migration, remove the foreign keys using the + `with_lock_retries` helper method. In another subsequent post-deployment + migration, use `drop_table`. -**Migrations for the second release:** +This can all be in a single migration if you're sure the code is not used. +If you want to reduce risk slightly, consider putting the migrations into a +second merge request after the application changes are merged. This approach +provides an opportunity to roll back. Removing the foreign key on the `projects` table: diff --git a/doc/development/shell_commands.md b/doc/development/shell_commands.md index 3935e98199a..d78a005d76b 100644 --- a/doc/development/shell_commands.md +++ b/doc/development/shell_commands.md @@ -71,6 +71,8 @@ FileUtils.touch myfile This coding style could have prevented CVE-2013-4546. +See also <https://gitlab.com/gitlab-org/gitlab/-/merge_requests/93030>, and <https://starlabs.sg/blog/2022/07-gitlab-project-import-rce-analysis-cve-2022-2185/> for another example. + ## Separate options from arguments with -- Make the difference between options and arguments clear to the argument parsers of system commands with `--`. This is supported by many but not all Unix commands. diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index aa7344f8506..b6bf3c7805a 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -933,9 +933,7 @@ In most specs, the Rails cache is actually an in-memory store. This is replaced between specs, so calls to `Rails.cache.read` and `Rails.cache.write` are safe. However, if a spec makes direct Redis calls, it should mark itself with the `:clean_gitlab_redis_cache`, `:clean_gitlab_redis_shared_state` or -`:clean_gitlab_redis_queues` traits as appropriate. To avoid triggering rate -limiting in specs, mark the spec with the `:clean_gitlab_redis_rate_limiting` -trait. +`:clean_gitlab_redis_queues` traits as appropriate. #### Background jobs / Sidekiq @@ -969,6 +967,14 @@ it "really connects to Prometheus", :permit_dns do And if you need more specific control, the DNS blocking is implemented in `spec/support/helpers/dns_helpers.rb` and these methods can be called elsewhere. +#### Rate Limiting + +[Rate limiting](../../security/rate_limits.md) is enabled in the test suite. Rate limits +may be triggered in feature specs that use the `:js` trait. In most cases, triggering rate +limiting can be avoided by marking the spec with the `:clean_gitlab_redis_rate_limiting` +trait. This trait clears the rate limiting data stored in Redis cache between specs. If +a single test triggers the rate limit, the `:disable_rate_limit` can be used instead. + #### Stubbing File methods In the situations where you need to diff --git a/doc/update/index.md b/doc/update/index.md index a49ad5bc4ce..dbac4304897 100644 --- a/doc/update/index.md +++ b/doc/update/index.md @@ -475,11 +475,28 @@ and [Helm Chart deployments](https://docs.gitlab.com/charts/). They come with ap - Git 2.37.0 and later is required by Gitaly. For installations from source, we recommend you use the [Git version provided by Gitaly](../install/installation.md#git). +### 15.5.0 + +- GitLab 15.4.0 introduced a default [Sidekiq routing rule](../administration/sidekiq/extra_sidekiq_routing.md) that routes all jobs to the `default` queue. For instances using [queue selectors](../administration/sidekiq/extra_sidekiq_processes.md#queue-selector), this will cause [performance problems](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1991) as some Sidekiq processes will be idle. + - The default routing rule has been reverted in 15.5.4, so upgrading to that version or later will return to the previous behavior. + - If a GitLab instance now listens only to the `default` queue (which is not currently recommended), it will be required to add this routing rule back in `/etc/gitlab/gitlab.rb`: + + ```ruby + sidekiq['routing_rules'] = [['*', 'default']] + ``` + ### 15.4.0 - GitLab 15.4.0 includes a [batched background migration](#batched-background-migrations) to [remove incorrect values from `expire_at` in `ci_job_artifacts` table](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89318). This migration might take hours or days to complete on larger GitLab instances. - By default, Gitaly and Praefect nodes use the time server at `pool.ntp.org`. If your instance can not connect to `pool.ntp.org`, [configure the `NTP_HOST` variable](../administration/gitaly/praefect.md#customize-time-server-setting). +- GitLab 15.4.0 introduced a default [Sidekiq routing rule](../administration/sidekiq/extra_sidekiq_routing.md) that routes all jobs to the `default` queue. For instances using [queue selectors](../administration/sidekiq/extra_sidekiq_processes.md#queue-selector), this will cause [performance problems](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1991) as some Sidekiq processes will be idle. + - The default routing rule has been reverted in 15.4.5, so upgrading to that version or later will return to the previous behavior. + - If a GitLab instance now listens only to the `default` queue (which is not currently recommended), it will be required to add this routing rule back in `/etc/gitlab/gitlab.rb`: + + ```ruby + sidekiq['routing_rules'] = [['*', 'default']] + ``` ### 15.3.3 diff --git a/doc/user/analytics/dora_metrics.md b/doc/user/analytics/dora_metrics.md index a85cd25f712..b5f37203817 100644 --- a/doc/user/analytics/dora_metrics.md +++ b/doc/user/analytics/dora_metrics.md @@ -108,6 +108,13 @@ Custom charts to visualize DORA data with Insights YAML-based reports. With this new visualization, software leaders can track metrics improvements, understand patterns in their metrics trends, and compare performance between groups and projects. +### Measure DORA metrics without using GitLab CI/CD pipelines + +Deployment frequency is calculated based on the deployments record, which is created for typical push-based deployments. +These deployment records are not created for pull-based deployments, for example when Container Images are connected to GitLab with an agent. + +To track DORA metrics in these cases, you can [create a deployment record](../../api/deployments.md#create-a-deployment) using the Deployments API. + ### Supported DORA metrics in GitLab | Metric | Level | API | UI chart | Comments | diff --git a/doc/user/application_security/policies/scan-result-policies.md b/doc/user/application_security/policies/scan-result-policies.md index 6d6c8a03d55..7482df18cc3 100644 --- a/doc/user/application_security/policies/scan-result-policies.md +++ b/doc/user/application_security/policies/scan-result-policies.md @@ -22,7 +22,8 @@ job is fully executed. The following video gives you an overview of GitLab scan ## Scan result policy editor -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77814) in GitLab 14.8. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77814) in GitLab 14.8. +> - [Enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/369473) in GitLab 15.6. NOTE: Only project Owners have the [permissions](../../permissions.md#project-members-permissions) diff --git a/doc/user/markdown.md b/doc/user/markdown.md index c44047d74b3..b6f3ba1cfdd 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -1160,17 +1160,15 @@ These details <em>remain</em> <strong>hidden</strong> until expanded. Markdown inside these tags is also supported. -NOTE: -If your Markdown isn't rendering correctly, try adding -`{::options parse_block_html="true" /}` to the top of the page, and add -`markdown="span"` to the opening summary tag like this: `<summary markdown="span">`. - -Remember to leave a blank line after the `</summary>` tag and before the `</details>` tag, -as shown in the example: +Remember to leave a blank line before and after any Markdown sections, as shown in the example: ````html <details> -<summary>Click this to collapse/fold.</summary> +<summary> + +Click this to _collapse/fold._ + +</summary> These details _remain_ **hidden** until expanded. @@ -1187,7 +1185,7 @@ works correctly in GitLab. --> <details> -<summary>Click this to collapse/fold.</summary> +<summary>Click this to <em>collapse/fold.</em></summary> These details <em>remain</em> <b>hidden</b> until expanded. diff --git a/doc/user/project/merge_requests/img/conflict_ui_v15_6.png b/doc/user/project/merge_requests/img/conflict_ui_v15_6.png Binary files differindex baa1cda3104..d5d5ad14edb 100644 --- a/doc/user/project/merge_requests/img/conflict_ui_v15_6.png +++ b/doc/user/project/merge_requests/img/conflict_ui_v15_6.png diff --git a/lib/api/api.rb b/lib/api/api.rb index 94dfb7f598c..ffb0cdf8991 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -218,6 +218,7 @@ module API mount ::API::ImportGithub mount ::API::Integrations mount ::API::Invitations + mount ::API::IssueLinks mount ::API::Keys mount ::API::Lint mount ::API::Markdown @@ -291,7 +292,6 @@ module API mount ::API::Groups mount ::API::HelmPackages mount ::API::Integrations::JiraConnect::Subscriptions - mount ::API::IssueLinks mount ::API::Issues mount ::API::Labels mount ::API::MavenPackages diff --git a/lib/api/branches.rb b/lib/api/branches.rb index a7418deb88a..845e42c2ed8 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -14,7 +14,7 @@ module API before do require_repository_enabled! - authorize! :download_code, user_project + authorize! :read_code, user_project end rescue_from Gitlab::Git::Repository::NoRepository do diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 3b122fb23a2..63a13b83a9b 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -9,7 +9,7 @@ module API before do require_repository_enabled! - authorize! :download_code, user_project + authorize! :read_code, user_project verify_pagination_params! end diff --git a/lib/api/entities/basic_project_details.rb b/lib/api/entities/basic_project_details.rb index f8f5b59cdc1..2585b2d0b6d 100644 --- a/lib/api/entities/basic_project_details.rb +++ b/lib/api/entities/basic_project_details.rb @@ -6,7 +6,7 @@ module API include ::API::ProjectsRelationBuilder include Gitlab::Utils::StrongMemoize - expose :default_branch_or_main, documentation: { type: 'string', example: 'main' }, as: :default_branch, if: -> (project, options) { Ability.allowed?(options[:current_user], :download_code, project) } + expose :default_branch_or_main, documentation: { type: 'string', example: 'main' }, as: :default_branch, if: -> (project, options) { Ability.allowed?(options[:current_user], :read_code, project) } # Avoids an N+1 query: https://github.com/mbleigh/acts-as-taggable-on/issues/91#issuecomment-168273770 expose :topic_names, as: :tag_list, documentation: { type: 'string', is_array: true, example: 'tag' } diff --git a/lib/api/entities/issuable_entity.rb b/lib/api/entities/issuable_entity.rb index e2c674c0b8b..4e70f945a48 100644 --- a/lib/api/entities/issuable_entity.rb +++ b/lib/api/entities/issuable_entity.rb @@ -3,10 +3,16 @@ module API module Entities class IssuableEntity < Grape::Entity - expose :id, :iid - expose(:project_id) { |entity| entity&.project.try(:id) } - expose :title, :description - expose :state, :created_at, :updated_at + expose :id, documentation: { type: 'integer', example: 84 } + expose :iid, documentation: { type: 'integer', example: 14 } + expose :project_id, documentation: { type: 'integer', example: 4 } do |entity| + entity&.project.try(:id) + end + expose :title, documentation: { type: 'string', example: 'Impedit et ut et dolores vero provident ullam est' } + expose :description, documentation: { type: 'string', example: 'Repellendus impedit et vel velit dignissimos.' } + expose :state, documentation: { type: 'string', example: 'closed' } + expose :created_at, documentation: { type: 'dateTime', example: '2022-08-17T12:46:35.053Z' } + expose :updated_at, documentation: { type: 'dateTime', example: '2022-11-14T17:22:01.470Z' } def presented lazy_issuable_metadata diff --git a/lib/api/entities/issue_basic.rb b/lib/api/entities/issue_basic.rb index 20f66c026e6..89fb8bbe1c0 100644 --- a/lib/api/entities/issue_basic.rb +++ b/lib/api/entities/issue_basic.rb @@ -7,10 +7,10 @@ module API item.upcase if item.respond_to?(:upcase) end - expose :closed_at + expose :closed_at, documentation: { type: 'dateTime', example: '2022-11-15T08:30:55.232Z' } expose :closed_by, using: Entities::UserBasic - expose :labels do |issue, options| + expose :labels, documentation: { type: 'string', is_array: true, example: 'bug' } do |issue, options| if options[:with_labels_details] ::API::Entities::LabelBasic.represent(issue.labels.sort_by(&:title)) else @@ -23,7 +23,7 @@ module API expose :issue_type, as: :type, format_with: :upcase, - documentation: { type: "String", desc: "One of #{::WorkItems::Type.allowed_types_for_issues.map(&:upcase)}" } + documentation: { type: 'String', example: 'ISSUE', desc: "One of #{::WorkItems::Type.allowed_types_for_issues.map(&:upcase)}" } expose :assignee, using: ::API::Entities::UserBasic do |issue| issue.assignees.first @@ -33,12 +33,12 @@ module API expose(:merge_requests_count) { |issue, options| issuable_metadata.merge_requests_count } expose(:upvotes) { |issue, options| issuable_metadata.upvotes } expose(:downvotes) { |issue, options| issuable_metadata.downvotes } - expose :due_date - expose :confidential - expose :discussion_locked - expose :issue_type + expose :due_date, documentation: { type: 'date', example: '2022-11-20' } + expose :confidential, documentation: { type: 'boolean' } + expose :discussion_locked, documentation: { type: 'boolean' } + expose :issue_type, documentation: { type: 'string', example: 'issue' } - expose :web_url do |issue| + expose :web_url, documentation: { type: 'string', example: 'http://example.com/example/example/issues/14' } do |issue| Gitlab::UrlBuilder.build(issue) end diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb index 947b0a3c0c1..1c1bafbf161 100644 --- a/lib/api/entities/project.rb +++ b/lib/api/entities/project.rb @@ -114,7 +114,7 @@ module API end expose :build_timeout, documentation: { type: 'integer', example: 3600 } expose :auto_cancel_pending_pipelines, documentation: { type: 'string', example: 'enabled' } - expose :ci_config_path, documentation: { type: 'string', example: '' }, if: -> (project, options) { Ability.allowed?(options[:current_user], :download_code, project) } + expose :ci_config_path, documentation: { type: 'string', example: '' }, if: -> (project, options) { Ability.allowed?(options[:current_user], :read_code, project) } expose :shared_with_groups, documentation: { is_array: true } do |project, options| user = options[:current_user] diff --git a/lib/api/entities/release.rb b/lib/api/entities/release.rb index 5feb1edbff6..c1a48a46d64 100644 --- a/lib/api/entities/release.rb +++ b/lib/api/entities/release.rb @@ -9,7 +9,7 @@ module API MarkupHelper.markdown_field(entity, :description, current_user: options[:current_user]) end expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? } - expose :commit, using: Entities::Commit, if: ->(_, _) { can_download_code? } + expose :commit, using: Entities::Commit, if: ->(_, _) { can_read_code? } expose :milestones, using: Entities::MilestoneWithStats, if: -> (release, _) { release.milestones.present? && can_read_milestone? } do |release, _| @@ -23,10 +23,10 @@ module API expose :assets do expose :assets_count, documentation: { type: 'integer', example: 2 }, as: :count - expose :sources, using: Entities::Releases::Source, if: ->(_, _) { can_download_code? } + expose :sources, using: Entities::Releases::Source, if: ->(_, _) { can_read_code? } expose :sorted_links, as: :links, using: Entities::Releases::Link end - expose :evidences, using: Entities::Releases::Evidence, expose_nil: false, if: ->(_, _) { can_download_code? } + expose :evidences, using: Entities::Releases::Evidence, expose_nil: false, if: ->(_, _) { can_read_code? } expose :_links do expose :self_url, as: :self, expose_nil: false expose :edit_url, expose_nil: false @@ -34,8 +34,8 @@ module API private - def can_download_code? - Ability.allowed?(options[:current_user], :download_code, object.project) + def can_read_code? + Ability.allowed?(options[:current_user], :read_code, object.project) end def can_read_milestone? diff --git a/lib/api/files.rb b/lib/api/files.rb index 68dd2647703..fa749299b9a 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -30,7 +30,7 @@ module API end def assign_file_vars! - authorize! :download_code, user_project + authorize! :read_code, user_project @commit = user_project.commit(params[:ref]) not_found!('Commit') unless @commit diff --git a/lib/api/issue_links.rb b/lib/api/issue_links.rb index 0f92f7aeb91..020b02248a0 100644 --- a/lib/api/issue_links.rb +++ b/lib/api/issue_links.rb @@ -6,16 +6,27 @@ module API before { authenticate! } + ISSUE_LINKS_TAGS = %w[issue_links].freeze + feature_category :team_planning urgency :low params do - requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' - requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' + requires :id, types: [String, Integer], + desc: 'The ID or URL-encoded path of the project owned by the authenticated user' + requires :issue_iid, type: Integer, desc: 'The internal ID of a project’s issue' end resource :projects, requirements: { id: %r{[^/]+} } do - desc 'Get related issues' do + desc 'List issue relations' do + detail 'Get a list of a given issue’s linked issues, sorted by the relationship creation datetime (ascending).'\ + 'Issues are filtered according to the user authorizations.' success Entities::RelatedIssue + is_array true + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + tags ISSUE_LINKS_TAGS end get ':id/issues/:issue_iid/links' do source_issue = find_project_issue(params[:issue_iid]) @@ -30,14 +41,23 @@ module API include_subscribed: false end - desc 'Relate issues' do + desc 'Create an issue link' do + detail 'Creates a two-way relation between two issues.'\ + 'The user must be allowed to update both issues to succeed.' success Entities::IssueLink + failure [ + { code: 400, message: 'Bad Request' }, + { code: 401, message: 'Unauthorized' } + ] + tags ISSUE_LINKS_TAGS end params do - requires :target_project_id, type: String, desc: 'The ID of the target project' - requires :target_issue_iid, type: Integer, desc: 'The IID of the target issue' + requires :target_project_id, types: [String, Integer], + desc: 'The ID or URL-encoded path of a target project' + requires :target_issue_iid, types: [String, Integer], desc: 'The internal ID of a target project’s issue' optional :link_type, type: String, values: IssueLink.link_types.keys, - desc: 'The type of the relation' + desc: 'The type of the relation (“relates_to”, “blocks”, “is_blocked_by”),'\ + 'defaults to “relates_to”)' end # rubocop: disable CodeReuse/ActiveRecord post ':id/issues/:issue_iid/links' do @@ -61,12 +81,17 @@ module API end # rubocop: enable CodeReuse/ActiveRecord - desc 'Get issues relation' do - detail 'This feature was introduced in GitLab 15.1.' + desc 'Get an issue link' do + detail 'Gets details about an issue link. This feature was introduced in GitLab 15.1.' success Entities::IssueLink + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + tags ISSUE_LINKS_TAGS end params do - requires :issue_link_id, type: Integer, desc: 'The ID of an issue link' + requires :issue_link_id, types: [String, Integer], desc: 'ID of an issue relationship' end get ':id/issues/:issue_iid/links/:issue_link_id' do issue = find_project_issue(params[:issue_iid]) @@ -77,11 +102,17 @@ module API present issue_link, with: Entities::IssueLink end - desc 'Remove issues relation' do + desc 'Delete an issue link' do + detail 'Deletes an issue link, thus removes the two-way relationship.' success Entities::IssueLink + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + tags ISSUE_LINKS_TAGS end params do - requires :issue_link_id, type: Integer, desc: 'The ID of an issue link' + requires :issue_link_id, types: [String, Integer], desc: 'The ID of an issue relationship' end delete ':id/issues/:issue_iid/links/:issue_link_id' do issue = find_project_issue(params[:issue_iid]) diff --git a/lib/api/lint.rb b/lib/api/lint.rb index 1d19d653d8b..89787ba00c2 100644 --- a/lib/api/lint.rb +++ b/lib/api/lint.rb @@ -56,7 +56,7 @@ module API end get ':id/ci/lint', urgency: :low do - authorize! :download_code, user_project + authorize! :read_code, user_project if user_project.commit.present? content = user_project.repository.gitlab_ci_yml_for(user_project.commit.id, user_project.ci_config_path_or_default) diff --git a/lib/api/releases.rb b/lib/api/releases.rb index ec9907b18f9..e6884e66200 100644 --- a/lib/api/releases.rb +++ b/lib/api/releases.rb @@ -131,7 +131,7 @@ module API end route_setting :authentication, job_token_allowed: true get ':id/releases/:tag_name', requirements: RELEASE_ENDPOINT_REQUIREMENTS do - authorize_download_code! + authorize_read_code! not_found! unless release @@ -157,7 +157,7 @@ module API end route_setting :authentication, job_token_allowed: true get ':id/releases/:tag_name/downloads/*file_path', format: false, requirements: RELEASE_ENDPOINT_REQUIREMENTS do - authorize_download_code! + authorize_read_code! not_found! unless release @@ -185,7 +185,7 @@ module API end route_setting :authentication, job_token_allowed: true get ':id/releases/permalink/latest(/)(*suffix_path)', format: false, requirements: RELEASE_ENDPOINT_REQUIREMENTS do - authorize_download_code! + authorize_read_code! # Try to find the latest release latest_release = find_latest_release @@ -373,6 +373,10 @@ module API authorize! :download_code, user_project end + def authorize_read_code! + authorize! :read_code, user_project + end + def authorize_create_evidence! # extended in EE end diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index beba2842316..70535496b12 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -41,7 +41,7 @@ module API end end - before { authorize! :download_code, user_project } + before { authorize! :read_code, user_project } feature_category :source_code_management @@ -63,7 +63,7 @@ module API end def assign_blob_vars!(limit:) - authorize! :download_code, user_project + authorize! :read_code, user_project @repo = user_project.repository diff --git a/lib/api/tags.rb b/lib/api/tags.rb index 0022b51bd92..b412a17bc6f 100644 --- a/lib/api/tags.rb +++ b/lib/api/tags.rb @@ -7,7 +7,7 @@ module API TAG_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) before do - authorize! :download_code, user_project + authorize! :read_code, user_project not_found! unless user_project.repo_exists? end diff --git a/lib/gitlab/ci/parsers/sbom/cyclonedx.rb b/lib/gitlab/ci/parsers/sbom/cyclonedx.rb index f1a07af1bf9..bc62fbe55ec 100644 --- a/lib/gitlab/ci/parsers/sbom/cyclonedx.rb +++ b/lib/gitlab/ci/parsers/sbom/cyclonedx.rb @@ -70,7 +70,7 @@ module Gitlab ) report.add_component(component) if component.ingestible? - rescue ::Sbom::PackageUrl::InvalidPackageURL + rescue ::Sbom::PackageUrl::InvalidPackageUrl report.add_error("/components/#{index}/purl is invalid") end end diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index c68c096bea1..735c7fcf80c 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -371,8 +371,6 @@ module Gitlab end def self.expected_server_version - return ENV[SERVER_VERSION_FILE] if ENV[SERVER_VERSION_FILE] - path = Rails.root.join(SERVER_VERSION_FILE) path.read.chomp end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index a0daa03bbed..ecb57bfc1a2 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -17,14 +17,15 @@ module Gitlab gon.markdown_surround_selection = current_user&.markdown_surround_selection gon.markdown_automatic_lists = current_user&.markdown_automatic_lists - # Support for Sentry setup via configuration will be removed in 16.0 - # in favor of Gitlab::CurrentSettings. - if Feature.enabled?(:enable_old_sentry_clientside_integration) && Gitlab.config.sentry.enabled - gon.sentry_dsn = Gitlab.config.sentry.clientside_dsn - gon.sentry_environment = Gitlab.config.sentry.environment + if Gitlab.config.sentry.enabled + gon.sentry_dsn = Gitlab.config.sentry.clientside_dsn + gon.sentry_environment = Gitlab.config.sentry.environment end - if Feature.enabled?(:enable_new_sentry_clientside_integration) && Gitlab::CurrentSettings.sentry_enabled + # Support for Sentry setup via configuration files will be removed in 16.0 + # in favor of Gitlab::CurrentSettings. + if Feature.enabled?(:enable_new_sentry_clientside_integration, + current_user) && Gitlab::CurrentSettings.sentry_enabled gon.sentry_dsn = Gitlab::CurrentSettings.sentry_clientside_dsn gon.sentry_environment = Gitlab::CurrentSettings.sentry_environment end diff --git a/lib/sbom/package_url.rb b/lib/sbom/package_url.rb index 3b545ebebf2..d8f4e876b82 100644 --- a/lib/sbom/package_url.rb +++ b/lib/sbom/package_url.rb @@ -44,7 +44,7 @@ module Sbom class PackageUrl # Raised when attempting to parse an invalid package URL string. # @see #parse - InvalidPackageURL = Class.new(ArgumentError) + InvalidPackageUrl = Class.new(ArgumentError) # The URL scheme, which has a constant value of `"pkg"`. def scheme @@ -79,20 +79,19 @@ module Sbom # @param qualifiers [Hash] Extra qualifying data for a package, specific to the type of package. # @param subpath [String] An extra subpath within a package, relative to the package root. def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil) - raise ArgumentError, 'type is required' unless type.present? - raise ArgumentError, 'name is required' unless name.present? - - @type = type.downcase + @type = type&.downcase @namespace = namespace @name = name @version = version @qualifiers = qualifiers @subpath = subpath + + ArgumentValidator.new(self).validate! end # Creates a new PackageUrl from a string. # @param [String] string The package URL string. - # @raise [InvalidPackageURL] If the string is not a valid package URL. + # @raise [InvalidPackageUrl] If the string is not a valid package URL. # @return [PackageUrl] def self.parse(string) Decoder.new(string).decode! diff --git a/lib/sbom/package_url/argument_validator.rb b/lib/sbom/package_url/argument_validator.rb new file mode 100644 index 00000000000..639ee9f89b6 --- /dev/null +++ b/lib/sbom/package_url/argument_validator.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Sbom + class PackageUrl + class ArgumentValidator + QUALIFIER_KEY_REGEXP = /^[A-Za-z\d._-]+$/.freeze + START_WITH_NUMBER_REGEXP = /^\d/.freeze + + def initialize(package) + @type = package.type + @namespace = package.namespace + @name = package.name + @version = package.version + @qualifiers = package.qualifiers + @errors = [] + end + + def validate! + validate_type + validate_name + validate_qualifiers + validate_by_type + + raise ArgumentError, formatted_errors if invalid? + end + + private + + def invalid? + errors.present? + end + + attr_reader :type, :namespace, :name, :version, :qualifiers, :errors + + def formatted_errors + errors.join(', ') + end + + def validate_type + errors.push('Type is required') if type.blank? + end + + def validate_name + errors.push('Name is required') if name.blank? + end + + def validate_qualifiers + return if qualifiers.nil? + + keys = qualifiers.keys + errors.push('Qualifier keys must be unique') unless keys.uniq.size == keys.size + + keys.each do |key| + errors.push(key_error(key, 'contains illegal characters')) unless key.match?(QUALIFIER_KEY_REGEXP) + errors.push(key_error(key, 'may not start with a number')) if key.match?(START_WITH_NUMBER_REGEXP) + end + end + + def key_error(key, text) + "Qualifier key `#{key}` #{text}" + end + + def validate_by_type + case type + when 'conan' + validate_conan + when 'cran' + validate_cran + when 'swift' + validate_swift + end + end + + def validate_conan + return unless namespace.blank? ^ (qualifiers.nil? || qualifiers.exclude?('channel')) + + errors.push('Conan packages require the channel be present if published in a namespace and vice-versa') + end + + def validate_cran + errors.push('Cran packages require a version') if version.blank? + end + + def validate_swift + errors.push('Swift packages require a namespace') if namespace.blank? + errors.push('Swift packages require a version') if version.blank? + end + end + end +end diff --git a/lib/sbom/package_url/decoder.rb b/lib/sbom/package_url/decoder.rb index 5a31343995d..ceadc36660c 100644 --- a/lib/sbom/package_url/decoder.rb +++ b/lib/sbom/package_url/decoder.rb @@ -43,14 +43,18 @@ module Sbom decode_name! decode_namespace! - PackageUrl.new( - type: @type, - name: @name, - namespace: @namespace, - version: @version, - qualifiers: @qualifiers, - subpath: @subpath - ) + begin + PackageUrl.new( + type: @type, + name: @name, + namespace: @namespace, + version: @version, + qualifiers: @qualifiers, + subpath: @subpath + ) + rescue ArgumentError => e + raise InvalidPackageUrl, e.message + end end private @@ -84,7 +88,7 @@ module Sbom # - The left side lowercased is the scheme: `scheme` # - The right side is the remainder: `type/namespace/name@version` @scheme, @string = partition(@string, ':', from: :left) - raise InvalidPackageURL, 'invalid or missing "pkg:" URL scheme' unless @scheme == 'pkg' + raise InvalidPackageUrl, 'invalid or missing "pkg:" URL scheme' unless @scheme == 'pkg' end def decode_type! @@ -94,8 +98,7 @@ module Sbom # Given the string: `type/namespace/name@version` # - The left side lowercased is the type: `type` # - The right side is the remainder: `namespace/name@version` - @type, @string = partition(@string, '/', from: :left) - raise InvalidPackageURL, 'invalid or missing package type' if @type.blank? + @type, @string = partition(@string, '/', from: :left, &:downcase) end def decode_version! @@ -116,20 +119,24 @@ module Sbom # - The right size is the name: `name` # - The name must be URI decoded @name, @string = partition(@string, '/', from: :right, require_separator: false) do |name| - URI.decode_www_form_component(name) + decoded_name = URI.decode_www_form_component(name) + Normalizer.new(type: @type, text: decoded_name).normalize_name end end def decode_namespace! # If there is anything remaining, this is the namespace. # The namespace may contain multiple segments delimited by `/`. - @namespace = decode_segments(@string, &:empty?) if @string.present? + return if @string.blank? + + @namespace = decode_segments(@string, &:empty?) + @namespace = Normalizer.new(type: @type, text: @namespace).normalize_namespace end def decode_segment(segment) decoded = URI.decode_www_form_component(segment) - raise InvalidPackageURL, 'slash-separated segments may not contain `/`' if decoded.include?('/') + raise InvalidPackageUrl, 'slash-separated segments may not contain `/`' if decoded.include?('/') decoded end diff --git a/lib/sbom/package_url/encoder.rb b/lib/sbom/package_url/encoder.rb index 1412824b76f..9cf05095571 100644 --- a/lib/sbom/package_url/encoder.rb +++ b/lib/sbom/package_url/encoder.rb @@ -84,11 +84,11 @@ module Sbom # - UTF-8-encode the name if needed in your programming language # - Append the percent-encoded name to the purl if @namespace.nil? - io.write(URI.encode_www_form_component(@name)) + io.write(URI.encode_www_form_component(@name, Encoding::UTF_8)) else io.write(encode_segments(@namespace, &:empty?)) io.write('/') - io.write(URI.encode_www_form_component(strip(@name, '/'))) + io.write(URI.encode_www_form_component(strip(@name, '/'), Encoding::UTF_8)) end end @@ -99,7 +99,7 @@ module Sbom # - UTF-8-encode the version if needed in your programming language # - Append the percent-encoded version to the purl io.write('@') - io.write(URI.encode_www_form_component(@version)) + io.write(URI.encode_www_form_component(@version, Encoding::UTF_8)) end def encode_qualifiers! @@ -115,7 +115,7 @@ module Sbom next "#{key.downcase}=#{value.join(',')}" if key == 'checksums' && value.is_a?(::Array) - "#{key.downcase}=#{URI.encode_www_form_component(value)}" + "#{key.downcase}=#{URI.encode_www_form_component(value, Encoding::UTF_8)}" end.sort.join('&') end diff --git a/lib/sbom/package_url/normalizer.rb b/lib/sbom/package_url/normalizer.rb new file mode 100644 index 00000000000..663df6f72a5 --- /dev/null +++ b/lib/sbom/package_url/normalizer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Sbom + class PackageUrl + class Normalizer + def initialize(type:, text:) + @type = type + @text = text + end + + def normalize_namespace + return if text.nil? + + normalize + end + + def normalize_name + raise ArgumentError, 'Name is required' if text.nil? + + normalize + end + + private + + def normalize + case type + when 'bitbucket', 'github' + downcase + when 'pypi' + normalize_pypi + else + text + end + end + + attr_reader :type, :text + + def downcase + text.downcase + end + + def normalize_pypi + downcase.tr('_', '-') + end + end + end +end diff --git a/lib/sbom/package_url/string_utils.rb b/lib/sbom/package_url/string_utils.rb index 7b476292c72..c1ea8de95b2 100644 --- a/lib/sbom/package_url/string_utils.rb +++ b/lib/sbom/package_url/string_utils.rb @@ -29,7 +29,9 @@ module Sbom private def strip(string, char) - string.delete_prefix(char).delete_suffix(char) + string = string.delete_prefix(char) while string.start_with?(char) + string = string.delete_suffix(char) while string.end_with?(char) + string end def split_segments(string) @@ -66,7 +68,7 @@ module Sbom return [nil, value] if separator.empty? && require_separator - value = yield(value, remainder) if block_given? + value = yield(value) if block_given? [value, remainder] end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3fa779062d7..64f787b32f3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4018,6 +4018,9 @@ msgstr "" msgid "All users with matching cards" msgstr "" +msgid "Allow %{strongOpen}%{group_name}%{strongClose} to sign you in?" +msgstr "" + msgid "Allow access to members of the following group" msgstr "" @@ -7041,6 +7044,9 @@ msgstr "" msgid "Branches: %{source_branch} → %{target_branch}" msgstr "" +msgid "Branches|A branch won't be deleted if it is protected or associated with an open merge request." +msgstr "" + msgid "Branches|Active" msgstr "" @@ -7062,7 +7068,10 @@ msgstr "" msgid "Branches|Compare" msgstr "" -msgid "Branches|Delete all branches that are merged into '%{default_branch}'" +msgid "Branches|Delete all branches that are merged into '%{defaultBranch}'" +msgstr "" + +msgid "Branches|Delete all merged branches?" msgstr "" msgid "Branches|Delete branch" @@ -7083,9 +7092,6 @@ msgstr "" msgid "Branches|Deleting the %{strongStart}%{branchName}%{strongEnd} branch cannot be undone. Are you sure?" msgstr "" -msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?" -msgstr "" - msgid "Branches|Filter by branch name" msgstr "" @@ -7107,6 +7113,9 @@ msgstr "" msgid "Branches|Please type the following to confirm:" msgstr "" +msgid "Branches|Plese type the following to confirm: %{codeStart}delete%{codeEnd}." +msgstr "" + msgid "Branches|Show active branches" msgstr "" @@ -7140,6 +7149,12 @@ msgstr "" msgid "Branches|This branch hasn't been merged into %{defaultBranchName}. To avoid data loss, consider merging this branch before deleting it." msgstr "" +msgid "Branches|This bulk action is %{strongStart}permanent and cannot be undone or recovered%{strongEnd}." +msgstr "" + +msgid "Branches|This may include merged branches that are not visible on the current screen." +msgstr "" + msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above." msgstr "" @@ -7152,6 +7167,9 @@ msgstr "" msgid "Branches|Yes, delete protected branch" msgstr "" +msgid "Branches|You are about to %{strongStart}delete all branches%{strongEnd} that were merged into %{codeStart}%{defaultBranch}%{codeEnd}." +msgstr "" + msgid "Branches|You're about to permanently delete the branch %{branchName}." msgstr "" @@ -10857,9 +10875,6 @@ msgstr "" msgid "Control how the CI_JOB_TOKEN CI/CD variable is used for API access between projects." msgstr "" -msgid "Control how the GitLab Package Registry functions." -msgstr "" - msgid "Control whether to display customer experience improvement content and third-party offers in GitLab." msgstr "" @@ -17382,9 +17397,6 @@ msgstr "" msgid "Format: %{dateFormat}" msgstr "" -msgid "Forward %{package_type} package requests to the %{registry_type} Registry if the packages are not found in the GitLab Package Registry" -msgstr "" - msgid "Found errors in your %{gitlab_ci_yml}:" msgstr "" @@ -28871,6 +28883,9 @@ msgstr "" msgid "PackageRegistry|Conan Command" msgstr "" +msgid "PackageRegistry|Configure package forwarding and package file size limits." +msgstr "" + msgid "PackageRegistry|Copy .pypirc content" msgstr "" @@ -28978,6 +28993,12 @@ msgstr "" msgid "PackageRegistry|Duplicate packages" msgstr "" +msgid "PackageRegistry|Enforce %{packageType} setting for all subgroups" +msgstr "" + +msgid "PackageRegistry|Enforce %{package_type} setting for all subgroups" +msgstr "" + msgid "PackageRegistry|Error publishing" msgstr "" @@ -29002,6 +29023,18 @@ msgstr "" msgid "PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." msgstr "" +msgid "PackageRegistry|Forward %{packageType} package requests" +msgstr "" + +msgid "PackageRegistry|Forward %{package_type} package requests" +msgstr "" + +msgid "PackageRegistry|Forward package requests" +msgstr "" + +msgid "PackageRegistry|Forward package requests to a public registry if the packages are not found in the GitLab package registry." +msgstr "" + msgid "PackageRegistry|Generic" msgstr "" @@ -29089,6 +29122,9 @@ msgstr "" msgid "PackageRegistry|Package formats" msgstr "" +msgid "PackageRegistry|Package forwarding" +msgstr "" + msgid "PackageRegistry|Package has %{updatesCount} archived update" msgid_plural "PackageRegistry|Package has %{updatesCount} archived updates" msgstr[0] "" @@ -29623,6 +29659,24 @@ msgstr "" msgid "Phone" msgstr "" +msgid "PhoneVerification|Enter a valid code." +msgstr "" + +msgid "PhoneVerification|Something went wrong. Please try again." +msgstr "" + +msgid "PhoneVerification|The code has expired. Request a new code and try again." +msgstr "" + +msgid "PhoneVerification|There was a problem with the phone number you entered. Enter a different phone number and try again." +msgstr "" + +msgid "PhoneVerification|There was a problem with the phone number you entered. Enter a valid phone number." +msgstr "" + +msgid "PhoneVerification|You've reached the maximum number of tries. Request a new code and try again." +msgstr "" + msgid "Pick a name" msgstr "" @@ -35590,19 +35644,16 @@ msgstr "" msgid "SAML single sign-on for %{group_name}" msgstr "" -msgid "SAML|Allow %{groupName} to sign you in?" -msgstr "" - msgid "SAML|Sign in to GitLab to connect your organization's account" msgstr "" -msgid "SAML|The %{groupName} group allows you to sign in using single sign-on." +msgid "SAML|The %{strongOpen}%{group_path}%{strongClose} group allows you to sign in using single sign-on." msgstr "" msgid "SAML|To access %{strongOpen}%{group_name}%{strongClose}, you must sign in using single sign-on through an external sign-in page." msgstr "" -msgid "SAML|To allow %{groupName} to manage your GitLab account %{username} after you sign in successfully using single sign-on, select %{strongStart}Authorize%{strongEnd}." +msgid "SAML|To allow %{strongOpen}%{group_name}%{strongClose} to manage your GitLab account %{strongOpen}%{username}%{strongClose} (%{email}) after you sign in successfully using single sign-on, select %{strongOpen}Authorize%{strongClose}." msgstr "" msgid "SAML|Your organization's SSO has been connected to your GitLab account" diff --git a/qa/qa/page/project/branches/show.rb b/qa/qa/page/project/branches/show.rb index 22b960b47ce..7163bc7464d 100644 --- a/qa/qa/page/project/branches/show.rb +++ b/qa/qa/page/project/branches/show.rb @@ -5,8 +5,6 @@ module QA module Project module Branches class Show < Page::Base - include Page::Component::ConfirmModal - view 'app/assets/javascripts/branches/components/delete_branch_button.vue' do element :delete_branch_button end @@ -25,8 +23,10 @@ module QA element :all_branches_container end - view 'app/views/projects/branches/index.html.haml' do - element :delete_merged_branches_link + view 'app/assets/javascripts/branches/components/delete_merged_branches.vue' do + element :delete_merged_branches_button + element :delete_merged_branches_input + element :delete_merged_branches_confirmation_button end def delete_branch(branch_name) @@ -53,9 +53,11 @@ module QA end end - def delete_merged_branches - click_element(:delete_merged_branches_link) - click_confirmation_ok_button + def delete_merged_branches(branches_length) + click_element(:delete_merged_branches_button) + fill_element(:delete_merged_branches_input, branches_length) + click_element(:delete_merged_branches_confirmation_button) + finished_loading? end end end diff --git a/qa/qa/page/project/settings/ci_variables.rb b/qa/qa/page/project/settings/ci_variables.rb index 7ee015ceb98..316920ffa90 100644 --- a/qa/qa/page/project/settings/ci_variables.rb +++ b/qa/qa/page/project/settings/ci_variables.rb @@ -14,13 +14,6 @@ module QA element :ci_variable_delete_button end - view 'app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue' do - element :ci_variable_table_content - element :add_ci_variable_button - element :edit_ci_variable_button - element :reveal_ci_variable_value_button - end - def fill_variable(key, value, masked = false) within_element(:ci_variable_key_field) { find('input').set key } fill_element :ci_variable_value_field, value diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb index 849022f5a93..866c6a146de 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb @@ -76,7 +76,7 @@ module QA expect(branches_page).to have_no_branch(third_branch) - branches_page.delete_merged_branches + branches_page.delete_merged_branches('delete') expect(branches_page).to have_content( 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.' diff --git a/scripts/build_qa_image b/scripts/build_qa_image index 4b7eb73e784..477bec29ba7 100755 --- a/scripts/build_qa_image +++ b/scripts/build_qa_image @@ -1,8 +1,8 @@ -#!/bin/sh +#!/bin/bash QA_IMAGE_NAME="gitlab-ee-qa" -if [ "${CI_PROJECT_NAME}" == "gitlabhq" ] || [ "${CI_PROJECT_NAME}" == "gitlab-foss" ]; then +if [[ "${CI_PROJECT_NAME}" == "gitlabhq" || "${CI_PROJECT_NAME}" == "gitlab-foss" ]]; then QA_IMAGE_NAME="gitlab-ce-qa" fi @@ -15,24 +15,29 @@ IMAGE_TAG=${CI_COMMIT_TAG#v} IMAGE_TAG=${IMAGE_TAG:-$CI_COMMIT_REF_SLUG} QA_IMAGE_BRANCH="${CI_REGISTRY}/${CI_PROJECT_PATH}/${QA_IMAGE_NAME}:${IMAGE_TAG}" +QA_IMAGE_MASTER="${CI_REGISTRY}/${CI_PROJECT_PATH}/${QA_IMAGE_NAME}:master" -DESTINATIONS="--destination=${QA_IMAGE} --destination=${QA_IMAGE_BRANCH}" +DESTINATIONS="--tag ${QA_IMAGE} --tag ${QA_IMAGE_BRANCH}" # Auto-deploy tag format uses first 12 letters of commit SHA. Tag with that # reference also for EE images. if [ "${QA_IMAGE_NAME}" == "gitlab-ee-qa" ]; then QA_IMAGE_FOR_AUTO_DEPLOY="${CI_REGISTRY}/${CI_PROJECT_PATH}/${QA_IMAGE_NAME}:${CI_COMMIT_SHA:0:11}" - DESTINATIONS="${DESTINATIONS} --destination=$QA_IMAGE_FOR_AUTO_DEPLOY" + DESTINATIONS="${DESTINATIONS} --tag $QA_IMAGE_FOR_AUTO_DEPLOY" fi echo "Building QA image for destinations: ${DESTINATIONS}" -/kaniko/executor \ - --context="${CI_PROJECT_DIR}" \ - --dockerfile="${CI_PROJECT_DIR}/qa/Dockerfile" \ +docker buildx build \ + --cache-to=type=inline \ + --cache-from="$QA_IMAGE_BRANCH" \ + --cache-from="$QA_IMAGE_MASTER" \ + --platform=${ARCH:-amd64} \ --build-arg=CHROME_VERSION="${CHROME_VERSION}" \ --build-arg=DOCKER_VERSION="${DOCKER_VERSION}" \ --build-arg=RUBY_VERSION="${RUBY_VERSION}" \ --build-arg=QA_BUILD_TARGET="${QA_BUILD_TARGET:-qa}" \ - --cache=true \ - ${DESTINATIONS} + --file="${CI_PROJECT_DIR}/qa/Dockerfile" \ + --push \ + ${DESTINATIONS} \ + ${CI_PROJECT_DIR} diff --git a/scripts/review_apps/base-config.yaml b/scripts/review_apps/base-config.yaml index 43dc562c58a..f845dd04e8f 100644 --- a/scripts/review_apps/base-config.yaml +++ b/scripts/review_apps/base-config.yaml @@ -50,7 +50,8 @@ gitlab: minReplicas: 1 maxReplicas: 1 hpa: - targetAverageValue: 500m + cpu: + targetAverageValue: 500m deployment: livenessProbe: timeoutSeconds: 5 @@ -80,7 +81,8 @@ gitlab: cpu: 1282m memory: 2890Mi hpa: - targetAverageValue: 650m + cpu: + targetAverageValue: 650m toolbox: resources: diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 18e0cf539f7..eecb803fb1a 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -63,6 +63,114 @@ RSpec.describe Admin::UsersController do expect(response).to be_redirect expect(response.location).to end_with(user.username) end + + describe 'impersonation_error_text' do + context 'when user can be impersonated' do + it 'sets impersonation_error_text to nil' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:impersonation_error_text)).to eq(nil) + end + end + + context 'when impersonation is already in progress' do + let(:admin2) { create(:admin) } + + before do + post :impersonate, params: { id: admin2.username } + end + + it 'sets impersonation_error_text' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:impersonation_error_text)).to eq(_("You are already impersonating another user")) + end + end + + context 'when user is blocked' do + before do + user.block + end + + it 'sets impersonation_error_text' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:impersonation_error_text)).to eq(_("You cannot impersonate a blocked user")) + end + end + + context "when the user's password is expired" do + before do + user.update!(password_expires_at: 1.day.ago) + end + + it 'sets impersonation_error_text' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:impersonation_error_text)).to eq(_("You cannot impersonate a user with an expired password")) + end + end + + context "when the user is internal" do + before do + user.update!(user_type: :migration_bot) + end + + it 'sets impersonation_error_text' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:impersonation_error_text)).to eq(_("You cannot impersonate an internal user")) + end + end + + context "when the user is a project bot" do + before do + user.update!(user_type: :project_bot) + end + + it 'sets impersonation_error_text' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:impersonation_error_text)).to eq(_("You cannot impersonate a user who cannot log in")) + end + end + end + + describe 'can_impersonate' do + context 'when user can be impersonated' do + it 'sets can_impersonate to true' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:can_impersonate)).to eq(true) + end + end + + context 'when impersonation is already in progress' do + let(:admin2) { create(:admin) } + + before do + post :impersonate, params: { id: admin2.username } + end + + it 'sets can_impersonate to false' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:can_impersonate)).to eq(false) + end + end + + context 'when user cannot log in' do + before do + user.update!(user_type: :project_bot) + end + + it 'sets can_impersonate to false' do + get :show, params: { id: user.username.downcase } + + expect(assigns(:can_impersonate)).to eq(false) + end + end + end end describe 'DELETE #destroy', :sidekiq_might_not_need_inline do diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 2e7c6116fe6..2b53a469841 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -27,6 +27,10 @@ FactoryBot.define do after(:build) { |user, _| user.block! } end + trait :locked do + after(:build) { |user, _| user.lock_access! } + end + trait :disallowed_password do password { User::DISALLOWED_PASSWORDS.first } end diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb index 6b8517e2ce2..35b5c755b66 100644 --- a/spec/features/admin/users/user_spec.rb +++ b/spec/features/admin/users/user_spec.rb @@ -150,13 +150,32 @@ RSpec.describe 'Admin::Users::User' do context 'before impersonating' do subject { visit admin_user_path(user_to_visit) } - let(:user_to_visit) { another_user } + let_it_be(:user_to_visit) { another_user } + + shared_examples "user that cannot be impersonated" do + it 'disables impersonate button' do + subject + + impersonate_btn = find('[data-testid="impersonate_user_link"]') + + expect(impersonate_btn).not_to be_nil + expect(impersonate_btn['disabled']).not_to be_nil + end + + it "shows tooltip with correct error message" do + subject + + expect(find("span[title='#{impersonation_error_msg}']")).not_to be_nil + end + end context 'for other users' do it 'shows impersonate button for other users' do subject expect(page).to have_content('Impersonate') + impersonate_btn = find('[data-testid="impersonate_user_link"]') + expect(impersonate_btn['disabled']).to be_nil end end @@ -171,15 +190,51 @@ RSpec.describe 'Admin::Users::User' do end context 'for blocked user' do - before do - another_user.block + let_it_be(:blocked_user) { create(:user, :blocked) } + let(:user_to_visit) { blocked_user } + let(:impersonation_error_msg) { _('You cannot impersonate a blocked user') } + + it_behaves_like "user that cannot be impersonated" + end + + context 'for user with expired password' do + let(:user_to_visit) do + another_user.update!(password_expires_at: Time.zone.now - 5.minutes) + another_user end - it 'does not show impersonate button for blocked user' do - subject + let(:impersonation_error_msg) { _("You cannot impersonate a user with an expired password") } - expect(page).not_to have_content('Impersonate') + it_behaves_like "user that cannot be impersonated" + end + + context 'for internal user' do + let_it_be(:internal_user) { create(:user, :bot) } + let(:user_to_visit) { internal_user } + let(:impersonation_error_msg) { _("You cannot impersonate an internal user") } + + it_behaves_like "user that cannot be impersonated" + end + + context 'for locked user' do + let_it_be(:locked_user) { create(:user, :locked) } + let(:user_to_visit) { locked_user } + let(:impersonation_error_msg) { _("You cannot impersonate a user who cannot log in") } + + it_behaves_like "user that cannot be impersonated" + end + + context 'when already impersonating another user' do + let_it_be(:admin_user) { create(:user, :admin) } + let(:impersonation_error_msg) { _("You are already impersonating another user") } + + subject do + visit admin_user_path(admin_user) + click_link 'Impersonate' + visit admin_user_path(another_user) end + + it_behaves_like "user that cannot be impersonated" end context 'when impersonation is disabled' do diff --git a/spec/features/admin_variables_spec.rb b/spec/features/admin_variables_spec.rb index 174d4567520..9ec22bbe948 100644 --- a/spec/features/admin_variables_spec.rb +++ b/spec/features/admin_variables_spec.rb @@ -12,23 +12,9 @@ RSpec.describe 'Instance variables', :js do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) + visit page_path wait_for_requests end - context 'with disabled ff `ci_variable_settings_graphql' do - before do - stub_feature_flags(ci_variable_settings_graphql: false) - visit page_path - end - - it_behaves_like 'variable list', isAdmin: true - end - - context 'with enabled ff `ci_variable_settings_graphql' do - before do - visit page_path - end - - it_behaves_like 'variable list', isAdmin: true - end + it_behaves_like 'variable list', isAdmin: true end diff --git a/spec/features/group_variables_spec.rb b/spec/features/group_variables_spec.rb index ab24162ad5a..e2c659d7dfe 100644 --- a/spec/features/group_variables_spec.rb +++ b/spec/features/group_variables_spec.rb @@ -11,23 +11,9 @@ RSpec.describe 'Group variables', :js do before do group.add_owner(user) gitlab_sign_in(user) + visit page_path wait_for_requests end - context 'with disabled ff `ci_variable_settings_graphql' do - before do - stub_feature_flags(ci_variable_settings_graphql: false) - visit page_path - end - - it_behaves_like 'variable list' - end - - context 'with enabled ff `ci_variable_settings_graphql' do - before do - visit page_path - end - - it_behaves_like 'variable list' - end + it_behaves_like 'variable list' end diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb index d3bedbf3a75..33b4af3b5aa 100644 --- a/spec/features/project_variables_spec.rb +++ b/spec/features/project_variables_spec.rb @@ -12,62 +12,29 @@ RSpec.describe 'Project variables', :js do sign_in(user) project.add_maintainer(user) project.variables << variable + visit page_path + wait_for_requests end - context 'with disabled ff `ci_variable_settings_graphql' do - before do - stub_feature_flags(ci_variable_settings_graphql: false) - visit page_path - end - - it_behaves_like 'variable list' - - it 'adds a new variable with an environment scope' do - click_button('Add variable') - - page.within('#add-ci-variable') do - fill_in 'Key', with: 'akey' - find('#ci-variable-value').set('akey_value') - find('[data-testid="environment-scope"]').click - find('[data-testid="ci-environment-search"]').set('review/*') - find('[data-testid="create-wildcard-button"]').click - - click_button('Add variable') - end - - wait_for_requests + it_behaves_like 'variable list' - page.within('[data-testid="ci-variable-table"]') do - expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*') - end - end - end - - context 'with enabled ff `ci_variable_settings_graphql' do - before do - visit page_path - end + it 'adds a new variable with an environment scope' do + click_button('Add variable') - it_behaves_like 'variable list' + page.within('#add-ci-variable') do + fill_in 'Key', with: 'akey' + find('#ci-variable-value').set('akey_value') + find('[data-testid="environment-scope"]').click + find('[data-testid="ci-environment-search"]').set('review/*') + find('[data-testid="create-wildcard-button"]').click - it 'adds a new variable with an environment scope' do click_button('Add variable') + end - page.within('#add-ci-variable') do - fill_in 'Key', with: 'akey' - find('#ci-variable-value').set('akey_value') - find('[data-testid="environment-scope"]').click - find('[data-testid="ci-environment-search"]').set('review/*') - find('[data-testid="create-wildcard-button"]').click - - click_button('Add variable') - end - - wait_for_requests + wait_for_requests - page.within('[data-testid="ci-variable-table"]') do - expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*') - end + page.within('[data-testid="ci-variable-table"]') do + expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*') end end end diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb index 50e6eb66466..ee74ac84a73 100644 --- a/spec/features/search/user_searches_for_code_spec.rb +++ b/spec/features/search/user_searches_for_code_spec.rb @@ -2,228 +2,237 @@ require 'spec_helper' -RSpec.describe 'User searches for code' do - let(:user) { create(:user) } - let(:project) { create(:project, :repository, namespace: user.namespace) } - - context 'when signed in' do - before do - stub_feature_flags(search_page_vertical_nav: false) - project.add_maintainer(user) - sign_in(user) - end - - it 'finds a file' do - visit(project_path(project)) +RSpec.describe 'User searches for code', :js, :disable_rate_limiter do + using RSpec::Parameterized::TableSyntax - submit_search('application.js') - select_search_scope('Code') + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:project) { create(:project, :repository, namespace: user.namespace) } - expect(page).to have_selector('.results', text: 'application.js') - expect(page).to have_selector('.file-content .code') - expect(page).to have_selector("span.line[lang='javascript']") - expect(page).to have_link('application.js', href: %r{master/files/js/application.js}) - expect(page).to have_button('Copy file path') - end - - context 'when on a project page', :js do + where(search_page_vertical_nav_enabled: [true, false]) + with_them do + context 'when signed in' do before do - visit(search_path) - find('[data-testid="project-filter"]').click - - wait_for_requests - - page.within('[data-testid="project-filter"]') do - click_on(project.name) - end + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) + project.add_maintainer(user) + sign_in(user) end - include_examples 'top right search form' - include_examples 'search timeouts', 'blobs' + it 'finds a file' do + visit(project_path(project)) - it 'finds code and links to blob' do - fill_in('dashboard_search', with: 'rspec') - find('.gl-search-box-by-click-search-button').click + submit_search('application.js') + select_search_scope('Code') - expect(page).to have_selector('.results', text: 'Update capybara, rspec-rails, poltergeist to recent versions') - - find("#blob-L3").click - expect(current_url).to match(%r{blob/master/.gitignore#L3}) + expect(page).to have_selector('.results', text: 'application.js') + expect(page).to have_selector('.file-content .code') + expect(page).to have_selector("span.line[lang='javascript']") + expect(page).to have_link('application.js', href: %r{master/files/js/application.js}) + expect(page).to have_button('Copy file path') end - it 'finds code and links to blame' do - fill_in('dashboard_search', with: 'rspec') - find('.gl-search-box-by-click-search-button').click + context 'when on a project page' do + before do + visit(search_path) + find('[data-testid="project-filter"]').click - expect(page).to have_selector('.results', text: 'Update capybara, rspec-rails, poltergeist to recent versions') + wait_for_requests - find("#blame-L3").click - expect(current_url).to match(%r{blame/master/.gitignore#L3}) - end + page.within('[data-testid="project-filter"]') do + click_on(project.name) + end + end - it 'search mutiple words with refs switching' do - expected_result = 'Use `snake_case` for naming files' - search = 'for naming files' + include_examples 'top right search form' + include_examples 'search timeouts', 'blobs' do + let(:additional_params) { { project_id: project.id } } + end - fill_in('dashboard_search', with: search) - find('.gl-search-box-by-click-search-button').click + it 'finds code and links to blob' do + expected_result = 'Update capybara, rspec-rails, poltergeist to recent versions' - expect(page).to have_selector('.results', text: expected_result) + fill_in('dashboard_search', with: 'rspec') + find('.gl-search-box-by-click-search-button').click - find('.ref-selector').click - wait_for_requests + expect(page).to have_selector('.results', text: expected_result) - page.within('.ref-selector') do - find('li', text: 'v1.0.0').click + find("#blob-L3").click + expect(current_url).to match(%r{blob/master/.gitignore#L3}) end - expect(page).to have_selector('.results', text: expected_result) + it 'finds code and links to blame' do + expected_result = 'Update capybara, rspec-rails, poltergeist to recent versions' - expect(find_field('dashboard_search').value).to eq(search) - expect(find("#blob-L1502")[:href]).to match(%r{blob/v1.0.0/files/markdown/ruby-style-guide.md#L1502}) - expect(find("#blame-L1502")[:href]).to match(%r{blame/v1.0.0/files/markdown/ruby-style-guide.md#L1502}) - end - end + fill_in('dashboard_search', with: 'rspec') + find('.gl-search-box-by-click-search-button').click - context 'when :new_header_search is true' do - context 'search code within refs', :js do - let(:ref_name) { 'v1.0.0' } + expect(page).to have_selector('.results', text: expected_result) - before do - # This feature is diabled by default in spec_helper.rb. - # We missed a feature breaking bug, so to prevent this regression, testing both scenarios for this spec. - # This can be removed as part of closing https://gitlab.com/gitlab-org/gitlab/-/issues/339348. - stub_feature_flags(new_header_search: true) - visit(project_tree_path(project, ref_name)) - - submit_search('gitlab-grack') - select_search_scope('Code') + find("#blame-L3").click + expect(current_url).to match(%r{blame/master/.gitignore#L3}) end - it 'shows ref switcher in code result summary' do - expect(find('.ref-selector')).to have_text(ref_name) - end + it 'search multiple words with refs switching' do + expected_result = 'Use `snake_case` for naming files' + search = 'for naming files' - it 'persists branch name across search' do + fill_in('dashboard_search', with: search) find('.gl-search-box-by-click-search-button').click - expect(find('.ref-selector')).to have_text(ref_name) - end - # this example is use to test the desgine that the refs is not - # only repersent the branch as well as the tags. - it 'ref swither list all the branchs and tags' do + expect(page).to have_selector('.results', text: expected_result) + find('.ref-selector').click wait_for_requests page.within('.ref-selector') do - expect(page).to have_selector('li', text: 'add-ipython-files') - expect(page).to have_selector('li', text: 'v1.0.0') + find('li', text: 'v1.0.0').click end + + expect(page).to have_selector('.results', text: expected_result) + + expect(find_field('dashboard_search').value).to eq(search) + expect(find("#blob-L1502")[:href]).to match(%r{blob/v1.0.0/files/markdown/ruby-style-guide.md#L1502}) + expect(find("#blame-L1502")[:href]).to match(%r{blame/v1.0.0/files/markdown/ruby-style-guide.md#L1502}) end + end - it 'search result changes when refs switched' do - ref = 'master' - expect(find('.results')).not_to have_content('path = gitlab-grack') + context 'when :new_header_search is true' do + context 'search code within refs' do + let(:ref_name) { 'v1.0.0' } - find('.ref-selector').click - wait_for_requests + before do + # This feature is disabled by default in spec_helper.rb. + # We missed a feature breaking bug, so to prevent this regression, testing both scenarios for this spec. + # This can be removed as part of closing https://gitlab.com/gitlab-org/gitlab/-/issues/339348. + stub_feature_flags(new_header_search: true) + visit(project_tree_path(project, ref_name)) - page.within('.ref-selector') do - fill_in _('Search by Git revision'), with: ref + submit_search('gitlab-grack') + select_search_scope('Code') + end + + it 'shows ref switcher in code result summary' do + expect(find('.ref-selector')).to have_text(ref_name) + end + + it 'persists branch name across search' do + find('.gl-search-box-by-click-search-button').click + expect(find('.ref-selector')).to have_text(ref_name) + end + + # this example is use to test the design that the refs is not + # only represent the branch as well as the tags. + it 'ref switcher list all the branches and tags' do + find('.ref-selector').click wait_for_requests - find('li', text: ref).click + page.within('.ref-selector') do + expect(page).to have_selector('li', text: 'add-ipython-files') + expect(page).to have_selector('li', text: 'v1.0.0') + end end - expect(page).to have_selector('.results', text: 'path = gitlab-grack') - end - end - end + it 'search result changes when refs switched' do + ref = 'master' + expect(find('.results')).not_to have_content('path = gitlab-grack') - context 'when :new_header_search is false' do - context 'search code within refs', :js do - let(:ref_name) { 'v1.0.0' } + find('.ref-selector').click + wait_for_requests - before do - # This feature is diabled by default in spec_helper.rb. - # We missed a feature breaking bug, so to prevent this regression, testing both scenarios for this spec. - # This can be removed as part of closing https://gitlab.com/gitlab-org/gitlab/-/issues/339348. - stub_feature_flags(new_header_search: false) - visit(project_tree_path(project, ref_name)) - - submit_search('gitlab-grack') - select_search_scope('Code') - end + page.within('.ref-selector') do + fill_in _('Search by Git revision'), with: ref + wait_for_requests - it 'shows ref switcher in code result summary' do - expect(find('.ref-selector')).to have_text(ref_name) - end + find('li', text: ref).click + end - it 'persists branch name across search' do - find('.gl-search-box-by-click-search-button').click - expect(find('.ref-selector')).to have_text(ref_name) + expect(page).to have_selector('.results', text: 'path = gitlab-grack') + end end + end - # this example is use to test the desgine that the refs is not - # only repersent the branch as well as the tags. - it 'ref swither list all the branchs and tags' do - find('.ref-selector').click - wait_for_requests + context 'when :new_header_search is false' do + context 'search code within refs' do + let(:ref_name) { 'v1.0.0' } - page.within('.ref-selector') do - expect(page).to have_selector('li', text: 'add-ipython-files') - expect(page).to have_selector('li', text: 'v1.0.0') + before do + # This feature is disabled by default in spec_helper.rb. + # We missed a feature breaking bug, so to prevent this regression, testing both scenarios for this spec. + # This can be removed as part of closing https://gitlab.com/gitlab-org/gitlab/-/issues/339348. + stub_feature_flags(new_header_search: false) + visit(project_tree_path(project, ref_name)) + + submit_search('gitlab-grack') + select_search_scope('Code') end - end - it 'search result changes when refs switched' do - ref = 'master' - expect(find('.results')).not_to have_content('path = gitlab-grack') + it 'shows ref switcher in code result summary' do + expect(find('.ref-selector')).to have_text(ref_name) + end - find('.ref-selector').click - wait_for_requests + it 'persists branch name across search' do + find('.gl-search-box-by-click-search-button').click + expect(find('.ref-selector')).to have_text(ref_name) + end - page.within('.ref-selector') do - fill_in _('Search by Git revision'), with: ref + # this example is use to test the design that the refs is not + # only represent the branch as well as the tags. + it 'ref switcher list all the branches and tags' do + find('.ref-selector').click wait_for_requests - find('li', text: ref).click + page.within('.ref-selector') do + expect(page).to have_selector('li', text: 'add-ipython-files') + expect(page).to have_selector('li', text: 'v1.0.0') + end end - expect(page).to have_selector('.results', text: 'path = gitlab-grack') + it 'search result changes when refs switched' do + ref = 'master' + expect(find('.results')).not_to have_content('path = gitlab-grack') + + find('.ref-selector').click + wait_for_requests + + page.within('.ref-selector') do + fill_in _('Search by Git revision'), with: ref + wait_for_requests + + find('li', text: ref).click + end + + expect(page).to have_selector('.results', text: 'path = gitlab-grack') + end end end - end - it 'no ref switcher shown in issue result summary', :js do - issue = create(:issue, title: 'test', project: project) - visit(project_tree_path(project)) + it 'no ref switcher shown in issue result summary' do + issue = create(:issue, title: 'test', project: project) + visit(project_tree_path(project)) - submit_search('test') - select_search_scope('Code') + submit_search('test') + select_search_scope('Code') - expect(page).to have_selector('.ref-selector') + expect(page).to have_selector('.ref-selector') - select_search_scope('Issues') + select_search_scope('Issues') - expect(find(:css, '.results')).to have_link(issue.title) - expect(page).not_to have_selector('.ref-selector') + expect(find(:css, '.results')).to have_link(issue.title) + expect(page).not_to have_selector('.ref-selector') + end end - end - context 'when signed out' do - let(:project) { create(:project, :public, :repository) } - - before do - stub_feature_flags(search_page_vertical_nav: false) - visit(project_path(project)) - end + context 'when signed out' do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) + end - it 'finds code' do - submit_search('rspec') - select_search_scope('Code') + context 'when block_anonymous_global_searches is enabled' do + it 'is redirected to login page' do + visit(search_path) - expect(page).to have_selector('.results', text: 'Update capybara, rspec-rails, poltergeist to recent versions') + expect(page).to have_content('You must be logged in to search across all of GitLab') + end + end end end end diff --git a/spec/features/search/user_searches_for_comments_spec.rb b/spec/features/search/user_searches_for_comments_spec.rb index a6793bc3aa7..3c39e9f41d4 100644 --- a/spec/features/search/user_searches_for_comments_spec.rb +++ b/spec/features/search/user_searches_for_comments_spec.rb @@ -2,45 +2,52 @@ require 'spec_helper' -RSpec.describe 'User searches for comments' do - let(:project) { create(:project, :repository) } - let(:user) { create(:user) } +RSpec.describe 'User searches for comments', :js, :disable_rate_limiter do + using RSpec::Parameterized::TableSyntax - before do - stub_feature_flags(search_page_vertical_nav: false) - project.add_reporter(user) - sign_in(user) + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } - visit(project_path(project)) - end + where(search_page_vertical_nav_enabled: [true, false]) + with_them do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) + project.add_reporter(user) + sign_in(user) - include_examples 'search timeouts', 'notes' + visit(project_path(project)) + end - context 'when a comment is in commits' do - context 'when comment belongs to an invalid commit' do - let(:comment) { create(:note_on_commit, author: user, project: project, commit_id: 12345678, note: 'Bug here') } + include_examples 'search timeouts', 'notes' do + let(:additional_params) { { project_id: project.id } } + end - it 'finds a commit' do - submit_search(comment.note) - select_search_scope('Comments') + context 'when a comment is in commits' do + context 'when comment belongs to an invalid commit' do + let(:comment) { create(:note_on_commit, author: user, project: project, commit_id: 12345678, note: 'Bug here') } - page.within('.results') do - expect(page).to have_content('Commit deleted') - expect(page).to have_content('12345678') + it 'finds a commit' do + submit_search(comment.note) + select_search_scope('Comments') + + page.within('.results') do + expect(page).to have_content('Commit deleted') + expect(page).to have_content('12345678') + end end end end - end - context 'when a comment is in a snippet' do - let(:snippet) { create(:project_snippet, :private, project: project, author: user, title: 'Some title') } - let(:comment) { create(:note, noteable: snippet, author: user, note: 'Supercalifragilisticexpialidocious', project: project) } + context 'when a comment is in a snippet' do + let(:snippet) { create(:project_snippet, :private, project: project, author: user, title: 'Some title') } + let(:comment) { create(:note, noteable: snippet, author: user, note: 'Supercalifragilisticexpialidocious', project: project) } - it 'finds a snippet' do - submit_search(comment.note) - select_search_scope('Comments') + it 'finds a snippet' do + submit_search(comment.note) + select_search_scope('Comments') - expect(page).to have_selector('.results', text: snippet.title) + expect(page).to have_selector('.results', text: snippet.title) + end end end end diff --git a/spec/features/search/user_searches_for_commits_spec.rb b/spec/features/search/user_searches_for_commits_spec.rb index 4ec2a9e6cff..e5d86c27942 100644 --- a/spec/features/search/user_searches_for_commits_spec.rb +++ b/spec/features/search/user_searches_for_commits_spec.rb @@ -2,54 +2,62 @@ require 'spec_helper' -RSpec.describe 'User searches for commits', :js do +RSpec.describe 'User searches for commits', :js, :clean_gitlab_redis_rate_limiting do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let(:project) { create(:project, :repository) } let(:sha) { '6d394385cf567f80a8fd85055db1ab4c5295806f' } - let(:user) { create(:user) } - before do - stub_feature_flags(search_page_vertical_nav: false) - project.add_reporter(user) - sign_in(user) + where(search_page_vertical_nav_enabled: [true, false]) + with_them do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) + project.add_reporter(user) + sign_in(user) - visit(search_path(project_id: project.id)) - end + visit(search_path(project_id: project.id)) + end - include_examples 'search timeouts', 'commits' + include_examples 'search timeouts', 'commits' do + let(:additional_params) { { project_id: project.id } } + end - context 'when searching by SHA' do - it 'finds a commit and redirects to its page' do - submit_search(sha) + context 'when searching by SHA' do + it 'finds a commit and redirects to its page' do + submit_search(sha) - expect(page).to have_current_path(project_commit_path(project, sha)) - end + expect(page).to have_current_path(project_commit_path(project, sha)) + end - it 'finds a commit in uppercase and redirects to its page' do - submit_search(sha.upcase) + it 'finds a commit in uppercase and redirects to its page' do + submit_search(sha.upcase) - expect(page).to have_current_path(project_commit_path(project, sha)) + expect(page).to have_current_path(project_commit_path(project, sha)) + end end - end - context 'when searching by message' do - it 'finds a commit and holds on /search page' do - project.repository.commit_files( - user, - message: 'Message referencing another sha: "deadbeef"', - branch_name: 'master', - actions: [{ action: :create, file_path: 'a/new.file', contents: 'new file' }] - ) + context 'when searching by message' do + it 'finds a commit and holds on /search page' do + project.repository.commit_files( + user, + message: 'Message referencing another sha: "deadbeef"', + branch_name: 'master', + actions: [{ action: :create, file_path: 'a/new.file', contents: 'new file' }] + ) - submit_search('deadbeef') + submit_search('deadbeef') - expect(page).to have_current_path('/search', ignore_query: true) - end + expect(page).to have_current_path('/search', ignore_query: true) + end - it 'finds multiple commits' do - submit_search('See merge request') - select_search_scope('Commits') + it 'finds multiple commits' do + submit_search('See merge request') + select_search_scope('Commits') - expect(page).to have_selector('.commit-row-description', visible: false, count: 9) + expect(page).to have_selector('.commit-row-description', visible: false, count: 9) + end end end end diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb index 51d2f355848..22d48bd38f2 100644 --- a/spec/features/search/user_searches_for_issues_spec.rb +++ b/spec/features/search/user_searches_for_issues_spec.rb @@ -2,9 +2,12 @@ require 'spec_helper' -RSpec.describe 'User searches for issues', :js do - let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } +RSpec.describe 'User searches for issues', :js, :clean_gitlab_redis_rate_limiting do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, namespace: user.namespace) } + let!(:issue1) { create(:issue, title: 'issue Foo', project: project, created_at: 1.hour.ago) } let!(:issue2) { create(:issue, :closed, :confidential, title: 'issue Bar', project: project) } @@ -14,127 +17,133 @@ RSpec.describe 'User searches for issues', :js do select_search_scope('Issues') end - context 'when signed in' do - before do - project.add_maintainer(user) - sign_in(user) - stub_feature_flags(search_page_vertical_nav: false) - - visit(search_path) - end + where(search_page_vertical_nav_enabled: [true, false]) - include_examples 'top right search form' - include_examples 'search timeouts', 'issues' + with_them do + context 'when signed in' do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) - it 'finds an issue' do - search_for_issue(issue1.title) + project.add_maintainer(user) + sign_in(user) - page.within('.results') do - expect(page).to have_link(issue1.title) - expect(page).not_to have_link(issue2.title) + visit(search_path) end - end - - it 'hides confidential icon for non-confidential issues' do - search_for_issue(issue1.title) - page.within('.results') do - expect(page).not_to have_css('[data-testid="eye-slash-icon"]') - end - end + include_examples 'top right search form' + include_examples 'search timeouts', 'issues' - it 'shows confidential icon for confidential issues' do - search_for_issue(issue2.title) + it 'finds an issue' do + search_for_issue(issue1.title) - page.within('.results') do - expect(page).to have_css('[data-testid="eye-slash-icon"]') + page.within('.results') do + expect(page).to have_link(issue1.title) + expect(page).not_to have_link(issue2.title) + end end - end - it 'shows correct badge for open issues' do - search_for_issue(issue1.title) + it 'hides confidential icon for non-confidential issues' do + search_for_issue(issue1.title) - page.within('.results') do - expect(page).to have_css('.badge-success') - expect(page).not_to have_css('.badge-info') + page.within('.results') do + expect(page).not_to have_css('[data-testid="eye-slash-icon"]') + end end - end - it 'shows correct badge for closed issues' do - search_for_issue(issue2.title) + it 'shows confidential icon for confidential issues' do + search_for_issue(issue2.title) - page.within('.results') do - expect(page).not_to have_css('.badge-success') - expect(page).to have_css('.badge-info') + page.within('.results') do + expect(page).to have_css('[data-testid="eye-slash-icon"]') + end end - end - it 'sorts by created date' do - search_for_issue('issue') + it 'shows correct badge for open issues' do + search_for_issue(issue1.title) - page.within('.results') do - expect(page.all('.search-result-row').first).to have_link(issue2.title) - expect(page.all('.search-result-row').last).to have_link(issue1.title) + page.within('.results') do + expect(page).to have_css('.badge-success') + expect(page).not_to have_css('.badge-info') + end end - find('[data-testid="sort-highest-icon"]').click + it 'shows correct badge for closed issues' do + search_for_issue(issue2.title) - page.within('.results') do - expect(page.all('.search-result-row').first).to have_link(issue1.title) - expect(page.all('.search-result-row').last).to have_link(issue2.title) + page.within('.results') do + expect(page).not_to have_css('.badge-success') + expect(page).to have_css('.badge-info') + end end - end - - context 'when on a project page' do - it 'finds an issue' do - find('[data-testid="project-filter"]').click - wait_for_requests + it 'sorts by created date' do + search_for_issue('issue') - page.within('[data-testid="project-filter"]') do - click_on(project.name) + page.within('.results') do + expect(page.all('.search-result-row').first).to have_link(issue2.title) + expect(page.all('.search-result-row').last).to have_link(issue1.title) end - search_for_issue(issue1.title) + find('[data-testid="sort-highest-icon"]').click page.within('.results') do - expect(page).to have_link(issue1.title) - expect(page).not_to have_link(issue2.title) + expect(page.all('.search-result-row').first).to have_link(issue1.title) + expect(page.all('.search-result-row').last).to have_link(issue2.title) end end - end - end - context 'when signed out' do - context 'when block_anonymous_global_searches is disabled' do - let(:project) { create(:project, :public) } + context 'when on a project page' do + it 'finds an issue' do + find('[data-testid="project-filter"]').click - before do - stub_feature_flags(block_anonymous_global_searches: false) - stub_feature_flags(search_page_vertical_nav: false) - visit(search_path) - end + wait_for_requests - include_examples 'top right search form' + page.within('[data-testid="project-filter"]') do + click_on(project.name) + end - it 'finds an issue' do - search_for_issue(issue1.title) + search_for_issue(issue1.title) - page.within('.results') do - expect(page).to have_link(issue1.title) - expect(page).not_to have_link(issue2.title) + page.within('.results') do + expect(page).to have_link(issue1.title) + expect(page).not_to have_link(issue2.title) + end end end end - context 'when block_anonymous_global_searches is enabled' do + context 'when signed out' do before do - stub_feature_flags(search_page_vertical_nav: false) - visit(search_path) + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) end - it 'is redirected to login page' do - expect(page).to have_content('You must be logged in to search across all of GitLab') + context 'when block_anonymous_global_searches is disabled' do + let_it_be(:project) { create(:project, :public) } + + before do + stub_feature_flags(block_anonymous_global_searches: false) + + visit(search_path) + end + + include_examples 'top right search form' + + it 'finds an issue' do + search_for_issue(issue1.title) + + page.within('.results') do + expect(page).to have_link(issue1.title) + expect(page).not_to have_link(issue2.title) + end + end + end + + context 'when block_anonymous_global_searches is enabled' do + it 'is redirected to login page' do + visit(search_path) + + expect(page).to have_content('You must be logged in to search across all of GitLab') + end end end end diff --git a/spec/features/search/user_searches_for_merge_requests_spec.rb b/spec/features/search/user_searches_for_merge_requests_spec.rb index a4fbe3a6e59..9bbf2cf16d8 100644 --- a/spec/features/search/user_searches_for_merge_requests_spec.rb +++ b/spec/features/search/user_searches_for_merge_requests_spec.rb @@ -2,7 +2,9 @@ require 'spec_helper' -RSpec.describe 'User searches for merge requests', :js do +RSpec.describe 'User searches for merge requests', :js, :clean_gitlab_redis_rate_limiting do + using RSpec::Parameterized::TableSyntax + let(:user) { create(:user) } let(:project) { create(:project, namespace: user.namespace) } let!(:merge_request1) { create(:merge_request, title: 'Merge Request Foo', source_project: project, target_project: project, created_at: 1.hour.ago) } @@ -14,62 +16,64 @@ RSpec.describe 'User searches for merge requests', :js do select_search_scope('Merge requests') end - before do - stub_feature_flags(search_page_vertical_nav: false) - project.add_maintainer(user) - sign_in(user) + where(search_page_vertical_nav_enabled: [true, false]) + with_them do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) + sign_in(user) - visit(search_path) - end + visit(search_path) + end - include_examples 'top right search form' - include_examples 'search timeouts', 'merge_requests' + include_examples 'top right search form' + include_examples 'search timeouts', 'merge_requests' - it 'finds a merge request' do - search_for_mr(merge_request1.title) + it 'finds a merge request' do + search_for_mr(merge_request1.title) - page.within('.results') do - expect(page).to have_link(merge_request1.title) - expect(page).not_to have_link(merge_request2.title) + page.within('.results') do + expect(page).to have_link(merge_request1.title) + expect(page).not_to have_link(merge_request2.title) - # Each result should have MR refs like `gitlab-org/gitlab!1` - page.all('.search-result-row').each do |e| - expect(e.text).to match(/!\d+/) + # Each result should have MR refs like `gitlab-org/gitlab!1` + page.all('.search-result-row').each do |e| + expect(e.text).to match(/!\d+/) + end end end - end - it 'sorts by created date' do - search_for_mr('Merge Request') + it 'sorts by created date' do + search_for_mr('Merge Request') - page.within('.results') do - expect(page.all('.search-result-row').first).to have_link(merge_request2.title) - expect(page.all('.search-result-row').last).to have_link(merge_request1.title) - end + page.within('.results') do + expect(page.all('.search-result-row').first).to have_link(merge_request2.title) + expect(page.all('.search-result-row').last).to have_link(merge_request1.title) + end - find('[data-testid="sort-highest-icon"]').click + find('[data-testid="sort-highest-icon"]').click - page.within('.results') do - expect(page.all('.search-result-row').first).to have_link(merge_request1.title) - expect(page.all('.search-result-row').last).to have_link(merge_request2.title) + page.within('.results') do + expect(page.all('.search-result-row').first).to have_link(merge_request1.title) + expect(page.all('.search-result-row').last).to have_link(merge_request2.title) + end end - end - context 'when on a project page' do - it 'finds a merge request' do - find('[data-testid="project-filter"]').click + context 'when on a project page' do + it 'finds a merge request' do + find('[data-testid="project-filter"]').click - wait_for_requests + wait_for_requests - page.within('[data-testid="project-filter"]') do - click_on(project.name) - end + page.within('[data-testid="project-filter"]') do + click_on(project.name) + end - search_for_mr(merge_request1.title) + search_for_mr(merge_request1.title) - page.within('.results') do - expect(page).to have_link(merge_request1.title) - expect(page).not_to have_link(merge_request2.title) + page.within('.results') do + expect(page).to have_link(merge_request1.title) + expect(page).not_to have_link(merge_request2.title) + end end end end diff --git a/spec/features/search/user_searches_for_milestones_spec.rb b/spec/features/search/user_searches_for_milestones_spec.rb index 6773059830c..702d4e60022 100644 --- a/spec/features/search/user_searches_for_milestones_spec.rb +++ b/spec/features/search/user_searches_for_milestones_spec.rb @@ -2,44 +2,30 @@ require 'spec_helper' -RSpec.describe 'User searches for milestones', :js do - let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } - let!(:milestone1) { create(:milestone, title: 'Foo', project: project) } - let!(:milestone2) { create(:milestone, title: 'Bar', project: project) } +RSpec.describe 'User searches for milestones', :js, :clean_gitlab_redis_rate_limiting do + using RSpec::Parameterized::TableSyntax - before do - project.add_maintainer(user) - sign_in(user) - stub_feature_flags(search_page_vertical_nav: false) + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, namespace: user.namespace) } - visit(search_path) - end + let!(:milestone1) { create(:milestone, title: 'Foo', project: project) } + let!(:milestone2) { create(:milestone, title: 'Bar', project: project) } - include_examples 'top right search form' - include_examples 'search timeouts', 'milestones' + where(search_page_vertical_nav_enabled: [true, false]) - it 'finds a milestone' do - fill_in('dashboard_search', with: milestone1.title) - find('.gl-search-box-by-click-search-button').click - select_search_scope('Milestones') + with_them do + before do + project.add_maintainer(user) + sign_in(user) + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) - page.within('.results') do - expect(page).to have_link(milestone1.title) - expect(page).not_to have_link(milestone2.title) + visit(search_path) end - end - - context 'when on a project page' do - it 'finds a milestone' do - find('[data-testid="project-filter"]').click - - wait_for_requests - page.within('[data-testid="project-filter"]') do - click_on(project.name) - end + include_examples 'top right search form' + include_examples 'search timeouts', 'milestones' + it 'finds a milestone' do fill_in('dashboard_search', with: milestone1.title) find('.gl-search-box-by-click-search-button').click select_search_scope('Milestones') @@ -49,5 +35,26 @@ RSpec.describe 'User searches for milestones', :js do expect(page).not_to have_link(milestone2.title) end end + + context 'when on a project page' do + it 'finds a milestone' do + find('[data-testid="project-filter"]').click + + wait_for_requests + + page.within('[data-testid="project-filter"]') do + click_on(project.name) + end + + fill_in('dashboard_search', with: milestone1.title) + find('.gl-search-box-by-click-search-button').click + select_search_scope('Milestones') + + page.within('.results') do + expect(page).to have_link(milestone1.title) + expect(page).not_to have_link(milestone2.title) + end + end + end end end diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb index 5902859d1f5..15c6224b61b 100644 --- a/spec/features/search/user_searches_for_projects_spec.rb +++ b/spec/features/search/user_searches_for_projects_spec.rb @@ -2,15 +2,12 @@ require 'spec_helper' -RSpec.describe 'User searches for projects', :js do +RSpec.describe 'User searches for projects', :js, :disable_rate_limiter do let!(:project) { create(:project, :public, name: 'Shop') } context 'when signed out' do context 'when block_anonymous_global_searches is disabled' do before do - stub_feature_flags(search_page_vertical_nav: false) - allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000) - allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000) stub_feature_flags(block_anonymous_global_searches: false) end diff --git a/spec/features/search/user_searches_for_users_spec.rb b/spec/features/search/user_searches_for_users_spec.rb index e21a66fed92..1d649b42c8c 100644 --- a/spec/features/search/user_searches_for_users_spec.rb +++ b/spec/features/search/user_searches_for_users_spec.rb @@ -2,84 +2,90 @@ require 'spec_helper' -RSpec.describe 'User searches for users' do - let(:user1) { create(:user, username: 'gob_bluth', name: 'Gob Bluth') } - let(:user2) { create(:user, username: 'michael_bluth', name: 'Michael Bluth') } - let(:user3) { create(:user, username: 'gob_2018', name: 'George Oscar Bluth') } - - before do - stub_feature_flags(search_page_vertical_nav: false) - sign_in(user1) - end - - include_examples 'search timeouts', 'users' +RSpec.describe 'User searches for users', :js, :clean_gitlab_redis_rate_limiting do + let_it_be(:user1) { create(:user, username: 'gob_bluth', name: 'Gob Bluth') } + let_it_be(:user2) { create(:user, username: 'michael_bluth', name: 'Michael Bluth') } + let_it_be(:user3) { create(:user, username: 'gob_2018', name: 'George Oscar Bluth') } - context 'when on the dashboard' do - it 'finds the user', :js do - visit dashboard_projects_path + where(search_page_vertical_nav_enabled: [true, false]) + with_them do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) - submit_search('gob') - select_search_scope('Users') + sign_in(user1) + end - page.within('.results') do - expect(page).to have_content('Gob Bluth') - expect(page).to have_content('@gob_bluth') + include_examples 'search timeouts', 'users' do + before do + visit(search_path) end end - end - context 'when on the project page' do - let(:project) { create(:project) } + context 'when on the dashboard' do + it 'finds the user' do + visit dashboard_projects_path - before do - create(:project_member, :developer, user: user1, project: project) - create(:project_member, :developer, user: user2, project: project) - user3 + submit_search('gob') + select_search_scope('Users') + + page.within('.results') do + expect(page).to have_content('Gob Bluth') + expect(page).to have_content('@gob_bluth') + end + end end - it 'finds the user belonging to the project' do - visit project_path(project) + context 'when on the project page' do + let_it_be_with_reload(:project) { create(:project) } - submit_search('gob') - select_search_scope('Users') + before do + project.add_developer(user1) + project.add_developer(user2) + end + + it 'finds the user belonging to the project' do + visit project_path(project) - page.within('.results') do - expect(page).to have_content('Gob Bluth') - expect(page).to have_content('@gob_bluth') + submit_search('gob') + select_search_scope('Users') - expect(page).not_to have_content('Michael Bluth') - expect(page).not_to have_content('@michael_bluth') + page.within('.results') do + expect(page).to have_content('Gob Bluth') + expect(page).to have_content('@gob_bluth') - expect(page).not_to have_content('George Oscar Bluth') - expect(page).not_to have_content('@gob_2018') + expect(page).not_to have_content('Michael Bluth') + expect(page).not_to have_content('@michael_bluth') + + expect(page).not_to have_content('George Oscar Bluth') + expect(page).not_to have_content('@gob_2018') + end end end - end - context 'when on the group page' do - let(:group) { create(:group) } + context 'when on the group page' do + let(:group) { create(:group) } - before do - create(:group_member, :developer, user: user1, group: group) - create(:group_member, :developer, user: user2, group: group) - user3 - end + before do + group.add_developer(user1) + group.add_developer(user2) + end - it 'finds the user belonging to the group' do - visit group_path(group) + it 'finds the user belonging to the group' do + visit group_path(group) - submit_search('gob') - select_search_scope('Users') + submit_search('gob') + select_search_scope('Users') - page.within('.results') do - expect(page).to have_content('Gob Bluth') - expect(page).to have_content('@gob_bluth') + page.within('.results') do + expect(page).to have_content('Gob Bluth') + expect(page).to have_content('@gob_bluth') - expect(page).not_to have_content('Michael Bluth') - expect(page).not_to have_content('@michael_bluth') + expect(page).not_to have_content('Michael Bluth') + expect(page).not_to have_content('@michael_bluth') - expect(page).not_to have_content('George Oscar Bluth') - expect(page).not_to have_content('@gob_2018') + expect(page).not_to have_content('George Oscar Bluth') + expect(page).not_to have_content('@gob_2018') + end end end end diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb index 2e390309022..0f20ad0aa07 100644 --- a/spec/features/search/user_searches_for_wiki_pages_spec.rb +++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb @@ -2,55 +2,59 @@ require 'spec_helper' -RSpec.describe 'User searches for wiki pages', :js do - let(:user) { create(:user) } +RSpec.describe 'User searches for wiki pages', :js, :clean_gitlab_redis_rate_limiting do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let(:project) { create(:project, :repository, :wiki_repo, namespace: user.namespace) } let!(:wiki_page) { create(:wiki_page, wiki: project.wiki, title: 'directory/title', content: 'Some Wiki content') } - before do - stub_feature_flags(search_page_vertical_nav: false) - project.add_maintainer(user) - sign_in(user) - - visit(search_path) - end + where(search_page_vertical_nav_enabled: [true, false]) + with_them do + before do + stub_feature_flags(search_page_vertical_nav: search_page_vertical_nav_enabled) + project.add_maintainer(user) + sign_in(user) - include_examples 'top right search form' - include_examples 'search timeouts', 'wiki_blobs' + visit(search_path) + end - shared_examples 'search wiki blobs' do - before do - stub_feature_flags(search_page_vertical_nav: false) + include_examples 'top right search form' + include_examples 'search timeouts', 'wiki_blobs' do + let(:additional_params) { { project_id: project.id } } end - it 'finds a page' do - find('[data-testid="project-filter"]').click + shared_examples 'search wiki blobs' do + it 'finds a page' do + find('[data-testid="project-filter"]').click - wait_for_requests + wait_for_requests - page.within('[data-testid="project-filter"]') do - click_on(project.name) - end + page.within('[data-testid="project-filter"]') do + click_on(project.name) + end - fill_in('dashboard_search', with: search_term) - find('.gl-search-box-by-click-search-button').click - select_search_scope('Wiki') + fill_in('dashboard_search', with: search_term) + find('.gl-search-box-by-click-search-button').click + select_search_scope('Wiki') - page.within('.results') do - expect(page).to have_link(wiki_page.title, href: project_wiki_path(project, wiki_page.slug)) + page.within('.results') do + expect(page).to have_link(wiki_page.title, href: project_wiki_path(project, wiki_page.slug)) + end end end - end - context 'when searching by content' do - it_behaves_like 'search wiki blobs' do - let(:search_term) { 'content' } + context 'when searching by content' do + it_behaves_like 'search wiki blobs' do + let(:search_term) { 'content' } + end end - end - context 'when searching by title' do - it_behaves_like 'search wiki blobs' do - let(:search_term) { 'title' } + context 'when searching by title' do + it_behaves_like 'search wiki blobs' do + let(:search_term) { 'title' } + end end end end diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb index 827e3984896..04f22cd2a31 100644 --- a/spec/features/search/user_uses_header_search_field_spec.rb +++ b/spec/features/search/user_uses_header_search_field_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'User uses header search field', :js do +RSpec.describe 'User uses header search field', :js, :disable_rate_limiter do include FilteredSearchHelpers let_it_be(:project) { create(:project, :repository) } @@ -17,10 +17,6 @@ RSpec.describe 'User uses header search field', :js do end before do - stub_feature_flags(search_page_vertical_nav: false) - allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).and_return(0) - allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000) - allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000) sign_in(user) end diff --git a/spec/fixtures/lib/sbom/package-url-test-cases.json b/spec/fixtures/lib/sbom/package-url-test-cases.json new file mode 100644 index 00000000000..448387397f6 --- /dev/null +++ b/spec/fixtures/lib/sbom/package-url-test-cases.json @@ -0,0 +1,502 @@ +[ + { + "description": "valid maven purl", + "purl": "pkg:maven/org.apache.commons/io@1.3.4", + "canonical_purl": "pkg:maven/org.apache.commons/io@1.3.4", + "type": "maven", + "namespace": "org.apache.commons", + "name": "io", + "version": "1.3.4", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "basic valid maven purl without version", + "purl": "pkg:maven/org.apache.commons/io", + "canonical_purl": "pkg:maven/org.apache.commons/io", + "type": "maven", + "namespace": "org.apache.commons", + "name": "io", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid go purl without version and with subpath", + "purl": "pkg:GOLANG/google.golang.org/genproto#/googleapis/api/annotations/", + "canonical_purl": "pkg:golang/google.golang.org/genproto#googleapis/api/annotations", + "type": "golang", + "namespace": "google.golang.org", + "name": "genproto", + "version": null, + "qualifiers": null, + "subpath": "googleapis/api/annotations", + "is_invalid": false + }, + { + "description": "valid go purl with version and subpath", + "purl": "pkg:GOLANG/google.golang.org/genproto@abcdedf#/googleapis/api/annotations/", + "canonical_purl": "pkg:golang/google.golang.org/genproto@abcdedf#googleapis/api/annotations", + "type": "golang", + "namespace": "google.golang.org", + "name": "genproto", + "version": "abcdedf", + "qualifiers": null, + "subpath": "googleapis/api/annotations", + "is_invalid": false + }, + { + "description": "bitbucket namespace and name should be lowercased", + "purl": "pkg:bitbucket/birKenfeld/pyGments-main@244fd47e07d1014f0aed9c", + "canonical_purl": "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c", + "type": "bitbucket", + "namespace": "birkenfeld", + "name": "pygments-main", + "version": "244fd47e07d1014f0aed9c", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "github namespace and name should be lowercased", + "purl": "pkg:github/Package-url/purl-Spec@244fd47e07d1004f0aed9c", + "canonical_purl": "pkg:github/package-url/purl-spec@244fd47e07d1004f0aed9c", + "type": "github", + "namespace": "package-url", + "name": "purl-spec", + "version": "244fd47e07d1004f0aed9c", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "debian can use qualifiers", + "purl": "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", + "canonical_purl": "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", + "type": "deb", + "namespace": "debian", + "name": "curl", + "version": "7.50.3-1", + "qualifiers": { + "arch": "i386", + "distro": "jessie" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "docker uses qualifiers and hash image id as versions", + "purl": "pkg:docker/customer/dockerimage@sha256:244fd47e07d1004f0aed9c?repository_url=gcr.io", + "canonical_purl": "pkg:docker/customer/dockerimage@sha256%3A244fd47e07d1004f0aed9c?repository_url=gcr.io", + "type": "docker", + "namespace": "customer", + "name": "dockerimage", + "version": "sha256:244fd47e07d1004f0aed9c", + "qualifiers": { + "repository_url": "gcr.io" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "Java gem can use a qualifier", + "purl": "pkg:gem/jruby-launcher@1.1.2?Platform=java", + "canonical_purl": "pkg:gem/jruby-launcher@1.1.2?platform=java", + "type": "gem", + "namespace": null, + "name": "jruby-launcher", + "version": "1.1.2", + "qualifiers": { + "platform": "java" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "maven often uses qualifiers", + "purl": "pkg:Maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources&repositorY_url=repo.spring.io/release", + "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources&repository_url=repo.spring.io%2Frelease", + "type": "maven", + "namespace": "org.apache.xmlgraphics", + "name": "batik-anim", + "version": "1.9.1", + "qualifiers": { + "classifier": "sources", + "repository_url": "repo.spring.io/release" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "maven pom reference", + "purl": "pkg:Maven/org.apache.xmlgraphics/batik-anim@1.9.1?extension=pom&repositorY_url=repo.spring.io/release", + "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?extension=pom&repository_url=repo.spring.io%2Frelease", + "type": "maven", + "namespace": "org.apache.xmlgraphics", + "name": "batik-anim", + "version": "1.9.1", + "qualifiers": { + "extension": "pom", + "repository_url": "repo.spring.io/release" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "maven can come with a type qualifier", + "purl": "pkg:Maven/net.sf.jacob-project/jacob@1.14.3?classifier=x86&type=dll", + "canonical_purl": "pkg:maven/net.sf.jacob-project/jacob@1.14.3?classifier=x86&type=dll", + "type": "maven", + "namespace": "net.sf.jacob-project", + "name": "jacob", + "version": "1.14.3", + "qualifiers": { + "classifier": "x86", + "type": "dll" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "npm can be scoped", + "purl": "pkg:npm/%40angular/animation@12.3.1", + "canonical_purl": "pkg:npm/%40angular/animation@12.3.1", + "type": "npm", + "namespace": "@angular", + "name": "animation", + "version": "12.3.1", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "nuget names are case sensitive", + "purl": "pkg:Nuget/EnterpriseLibrary.Common@6.0.1304", + "canonical_purl": "pkg:nuget/EnterpriseLibrary.Common@6.0.1304", + "type": "nuget", + "namespace": null, + "name": "EnterpriseLibrary.Common", + "version": "6.0.1304", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "pypi names have special rules and not case sensitive", + "purl": "pkg:PYPI/Django_package@1.11.1.dev1", + "canonical_purl": "pkg:pypi/django-package@1.11.1.dev1", + "type": "pypi", + "namespace": null, + "name": "django-package", + "version": "1.11.1.dev1", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "rpm often use qualifiers", + "purl": "pkg:Rpm/fedora/curl@7.50.3-1.fc25?Arch=i386&Distro=fedora-25", + "canonical_purl": "pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25", + "type": "rpm", + "namespace": "fedora", + "name": "curl", + "version": "7.50.3-1.fc25", + "qualifiers": { + "arch": "i386", + "distro": "fedora-25" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "a scheme is always required", + "purl": "EnterpriseLibrary.Common@6.0.1304", + "canonical_purl": "EnterpriseLibrary.Common@6.0.1304", + "type": null, + "namespace": null, + "name": "EnterpriseLibrary.Common", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "a type is always required", + "purl": "pkg:EnterpriseLibrary.Common@6.0.1304", + "canonical_purl": "pkg:EnterpriseLibrary.Common@6.0.1304", + "type": null, + "namespace": null, + "name": "EnterpriseLibrary.Common", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "a name is required", + "purl": "pkg:maven/@1.3.4", + "canonical_purl": "pkg:maven/@1.3.4", + "type": "maven", + "namespace": null, + "name": null, + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "slash / after scheme is not significant", + "purl": "pkg:/maven/org.apache.commons/io", + "canonical_purl": "pkg:maven/org.apache.commons/io", + "type": "maven", + "namespace": "org.apache.commons", + "name": "io", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "double slash // after scheme is not significant", + "purl": "pkg://maven/org.apache.commons/io", + "canonical_purl": "pkg:maven/org.apache.commons/io", + "type": "maven", + "namespace": "org.apache.commons", + "name": "io", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "slash /// after type is not significant", + "purl": "pkg:///maven/org.apache.commons/io", + "canonical_purl": "pkg:maven/org.apache.commons/io", + "type": "maven", + "namespace": "org.apache.commons", + "name": "io", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid maven purl with case sensitive namespace and name", + "purl": "pkg:maven/HTTPClient/HTTPClient@0.3-3", + "canonical_purl": "pkg:maven/HTTPClient/HTTPClient@0.3-3", + "type": "maven", + "namespace": "HTTPClient", + "name": "HTTPClient", + "version": "0.3-3", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid maven purl containing a space in the version and qualifier", + "purl": "pkg:maven/mygroup/myartifact@1.0.0%20Final?mykey=my%20value", + "canonical_purl": "pkg:maven/mygroup/myartifact@1.0.0+Final?mykey=my+value", + "type": "maven", + "namespace": "mygroup", + "name": "myartifact", + "version": "1.0.0 Final", + "qualifiers": { + "mykey": "my value" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "checks for invalid qualifier keys", + "purl": "pkg:npm/myartifact@1.0.0?in%20production=true", + "canonical_purl": null, + "type": "npm", + "namespace": null, + "name": "myartifact", + "version": "1.0.0", + "qualifiers": { + "in production": "true" + }, + "subpath": null, + "is_invalid": true + }, + { + "description": "valid conan purl", + "purl": "pkg:conan/cctz@2.3", + "canonical_purl": "pkg:conan/cctz@2.3", + "type": "conan", + "namespace": null, + "name": "cctz", + "version": "2.3", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid conan purl with namespace and qualifier channel", + "purl": "pkg:conan/bincrafters/cctz@2.3?channel=stable", + "canonical_purl": "pkg:conan/bincrafters/cctz@2.3?channel=stable", + "type": "conan", + "namespace": "bincrafters", + "name": "cctz", + "version": "2.3", + "qualifiers": { + "channel": "stable" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "invalid conan purl only namespace", + "purl": "pkg:conan/bincrafters/cctz@2.3", + "canonical_purl": "pkg:conan/bincrafters/cctz@2.3", + "type": "conan", + "namespace": "bincrafters", + "name": "cctz", + "version": "2.3", + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "invalid conan purl only channel qualifier", + "purl": "pkg:conan/cctz@2.3?channel=stable", + "canonical_purl": "pkg:conan/cctz@2.3?channel=stable", + "type": "conan", + "namespace": null, + "name": "cctz", + "version": "2.3", + "qualifiers": { + "channel": "stable" + }, + "subpath": null, + "is_invalid": true + }, + { + "description": "valid conda purl with qualifiers", + "purl": "pkg:conda/absl-py@0.4.1?build=py36h06a4308_0&channel=main&subdir=linux-64&type=tar.bz2", + "canonical_purl": "pkg:conda/absl-py@0.4.1?build=py36h06a4308_0&channel=main&subdir=linux-64&type=tar.bz2", + "type": "conda", + "namespace": null, + "name": "absl-py", + "version": "0.4.1", + "qualifiers": { + "build": "py36h06a4308_0", + "channel": "main", + "subdir": "linux-64", + "type": "tar.bz2" + }, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid cran purl", + "purl": "pkg:cran/A3@0.9.1", + "canonical_purl": "pkg:cran/A3@0.9.1", + "type": "cran", + "namespace": null, + "name": "A3", + "version": "0.9.1", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "invalid cran purl without name", + "purl": "pkg:cran/@0.9.1", + "canonical_purl": "pkg:cran/@0.9.1", + "type": "cran", + "namespace": null, + "name": null, + "version": "0.9.1", + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "invalid cran purl without version", + "purl": "pkg:cran/A3", + "canonical_purl": "pkg:cran/A3", + "type": "cran", + "namespace": null, + "name": "A3", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "valid swift purl", + "purl": "pkg:swift/github.com/Alamofire/Alamofire@5.4.3", + "canonical_purl": "pkg:swift/github.com/Alamofire/Alamofire@5.4.3", + "type": "swift", + "namespace": "github.com/Alamofire", + "name": "Alamofire", + "version": "5.4.3", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "invalid swift purl without namespace", + "purl": "pkg:swift/Alamofire@5.4.3", + "canonical_purl": "pkg:swift/Alamofire@5.4.3", + "type": "swift", + "namespace": null, + "name": "Alamofire", + "version": "5.4.3", + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "invalid swift purl without name", + "purl": "pkg:swift/github.com/Alamofire/@5.4.3", + "canonical_purl": "pkg:swift/github.com/Alamofire/@5.4.3", + "type": "swift", + "namespace": "github.com/Alamofire", + "name": null, + "version": "5.4.3", + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "invalid swift purl without version", + "purl": "pkg:swift/github.com/Alamofire/Alamofire", + "canonical_purl": "pkg:swift/github.com/Alamofire/Alamofire", + "type": "swift", + "namespace": "github.com/Alamofire", + "name": "Alamofire", + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + }, + { + "description": "valid hackage purl", + "purl": "pkg:hackage/AC-HalfInteger@1.2.1", + "canonical_purl": "pkg:hackage/AC-HalfInteger@1.2.1", + "type": "hackage", + "namespace": null, + "name": "AC-HalfInteger", + "version": "1.2.1", + "qualifiers": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "name and version are always required", + "purl": "pkg:hackage", + "canonical_purl": "pkg:hackage", + "type": "hackage", + "namespace": null, + "name": null, + "version": null, + "qualifiers": null, + "subpath": null, + "is_invalid": true + } +]
\ No newline at end of file diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap new file mode 100644 index 00000000000..6aab3b51806 --- /dev/null +++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Delete merged branches component Delete merged branches confirmation modal matches snapshot 1`] = ` +<div> + <b-button-stub + class="gl-mr-3 gl-button btn-danger-secondary" + data-qa-selector="delete_merged_branches_button" + size="md" + tag="button" + type="button" + variant="danger" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + Delete merged branches + + </span> + </b-button-stub> + + <div> + <form + action="/namespace/project/-/merged_branches" + method="post" + > + <p> + You are about to + <strong> + delete all branches + </strong> + that were merged into + <code> + master + </code> + . + </p> + + <p> + + This may include merged branches that are not visible on the current screen. + + </p> + + <p> + + A branch won't be deleted if it is protected or associated with an open merge request. + + </p> + + <p> + This bulk action is + <strong> + permanent and cannot be undone or recovered + </strong> + . + </p> + + <p> + Plese type the following to confirm: + <code> + delete + </code> + . + <b-form-input-stub + aria-labelledby="input-label" + autocomplete="off" + class="gl-form-input gl-mt-2 gl-form-input-sm" + data-qa-selector="delete_merged_branches_input" + debounce="0" + formatter="[Function]" + type="text" + value="" + /> + </p> + + <input + name="_method" + type="hidden" + value="delete" + /> + + <input + name="authenticity_token" + type="hidden" + value="mock-csrf-token" + /> + </form> + <div + class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0 gl-mr-3" + > + <b-button-stub + class="gl-button" + data-testid="delete-merged-branches-cancel-button" + size="md" + tag="button" + type="button" + variant="default" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Cancel + + </span> + </b-button-stub> + + <b-button-stub + class="gl-button" + data-qa-selector="delete_merged_branches_confirmation_button" + data-testid="delete-merged-branches-confirmation-button" + disabled="true" + size="md" + tag="button" + type="button" + variant="danger" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + Delete merged branches + </span> + </b-button-stub> + </div> + </div> +</div> +`; diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js new file mode 100644 index 00000000000..4f1e772f4a4 --- /dev/null +++ b/spec/frontend/branches/components/delete_merged_branches_spec.js @@ -0,0 +1,143 @@ +import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import DeleteMergedBranches, { i18n } from '~/branches/components/delete_merged_branches.vue'; +import { formPath, propsDataMock } from '../mock_data'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +let wrapper; + +const stubsData = { + GlModal: stubComponent(GlModal, { + template: + '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', + }), + GlButton, + GlFormInput, + GlSprintf, +}; + +const createComponent = (mountFn = shallowMountExtended, stubs = {}) => { + wrapper = mountFn(DeleteMergedBranches, { + propsData: { + ...propsDataMock, + }, + directives: { + GlTooltip: createMockDirective(), + }, + stubs, + }); +}; + +const findDeleteButton = () => wrapper.findComponent(GlButton); +const findModal = () => wrapper.findComponent(GlModal); +const findConfirmationButton = () => + wrapper.findByTestId('delete-merged-branches-confirmation-button'); +const findCancelButton = () => wrapper.findByTestId('delete-merged-branches-cancel-button'); +const findFormInput = () => wrapper.findComponent(GlFormInput); +const findForm = () => wrapper.find('form'); +const submitFormSpy = () => jest.spyOn(wrapper.vm.$refs.form, 'submit'); + +describe('Delete merged branches component', () => { + beforeEach(() => { + createComponent(); + }); + + describe('Delete merged branches button', () => { + it('has correct attributes, text and tooltip', () => { + expect(findDeleteButton().attributes()).toMatchObject({ + category: 'secondary', + variant: 'danger', + }); + + expect(findDeleteButton().text()).toBe(i18n.deleteButtonText); + }); + + it('displays a tooltip', () => { + const tooltip = getBinding(findDeleteButton().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe(wrapper.vm.buttonTooltipText); + }); + + it('opens modal when clicked', () => { + createComponent(mount); + jest.spyOn(wrapper.vm.$refs.modal, 'show'); + findDeleteButton().trigger('click'); + + expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled(); + }); + }); + + describe('Delete merged branches confirmation modal', () => { + beforeEach(() => { + createComponent(shallowMountExtended, stubsData); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correct modal title and text', () => { + const modalText = findModal().text(); + expect(findModal().props('title')).toBe(i18n.modalTitle); + expect(modalText).toContain(i18n.notVisibleBranchesWarning); + expect(modalText).toContain(i18n.protectedBranchWarning); + }); + + it('renders confirm and cancel buttons with correct text', () => { + expect(findConfirmationButton().text()).toContain(i18n.deleteButtonText); + expect(findCancelButton().text()).toContain(i18n.cancelButtonText); + }); + + it('renders form with correct attributes and hiden inputs', () => { + const form = findForm(); + expect(form.attributes()).toEqual({ + action: formPath, + method: 'post', + }); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has a disabled confirm button by default', () => { + expect(findConfirmationButton().props('disabled')).toBe(true); + }); + + it('keeps disabled state when wrong input is provided', async () => { + findFormInput().vm.$emit('input', 'hello'); + await waitForPromises(); + expect(findConfirmationButton().props('disabled')).toBe(true); + findConfirmationButton().trigger('click'); + + expect(submitFormSpy()).not.toHaveBeenCalled(); + findFormInput().trigger('keyup.enter'); + + expect(submitFormSpy()).not.toHaveBeenCalled(); + }); + + it('submits form when correct amount is provided and the confirm button is clicked', async () => { + findFormInput().vm.$emit('input', 'delete'); + await waitForPromises(); + expect(findDeleteButton().props('disabled')).not.toBe(true); + findConfirmationButton().trigger('click'); + expect(submitFormSpy()).toHaveBeenCalled(); + }); + + it('calls hide on the modal when cancel button is clicked', () => { + const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide'); + findCancelButton().trigger('click'); + expect(closeModalSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/branches/mock_data.js b/spec/frontend/branches/mock_data.js new file mode 100644 index 00000000000..9e8839d8ce9 --- /dev/null +++ b/spec/frontend/branches/mock_data.js @@ -0,0 +1,7 @@ +export const formPath = '/namespace/project/-/merged_branches'; +const defaultBranch = 'master'; + +export const propsDataMock = { + formPath, + defaultBranch, +}; diff --git a/spec/frontend/ci_variable_list/components/legacy_ci_environments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_environments_dropdown_spec.js deleted file mode 100644 index b3e23ba4201..00000000000 --- a/spec/frontend/ci_variable_list/components/legacy_ci_environments_dropdown_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import LegacyCiEnvironmentsDropdown from '~/ci_variable_list/components/legacy_ci_environments_dropdown.vue'; - -Vue.use(Vuex); - -describe('Ci environments dropdown', () => { - let wrapper; - let store; - - const enterSearchTerm = (value) => - wrapper.find('[data-testid="ci-environment-search"]').setValue(value); - - const createComponent = (term) => { - store = new Vuex.Store({ - getters: { - joinedEnvironments: () => ['dev', 'prod', 'staging'], - }, - }); - - wrapper = mount(LegacyCiEnvironmentsDropdown, { - store, - propsData: { - value: term, - }, - }); - enterSearchTerm(term); - }; - - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findDropdownItemByIndex = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); - const findActiveIconByIndex = (index) => findDropdownItemByIndex(index).findComponent(GlIcon); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('No environments found', () => { - beforeEach(() => { - createComponent('stable'); - }); - - it('renders create button with search term if environments do not contain search term', () => { - expect(findAllDropdownItems()).toHaveLength(2); - expect(findDropdownItemByIndex(1).text()).toBe('Create wildcard: stable'); - }); - - it('renders empty results message', () => { - expect(findDropdownItemByIndex(0).text()).toBe('No matching results'); - }); - }); - - describe('Search term is empty', () => { - beforeEach(() => { - createComponent(''); - }); - - it('renders all environments when search term is empty', () => { - expect(findAllDropdownItems()).toHaveLength(3); - expect(findDropdownItemByIndex(0).text()).toBe('dev'); - expect(findDropdownItemByIndex(1).text()).toBe('prod'); - expect(findDropdownItemByIndex(2).text()).toBe('staging'); - }); - - it('should not display active checkmark on the inactive stage', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); - }); - }); - - describe('Environments found', () => { - beforeEach(async () => { - createComponent('prod'); - await nextTick(); - }); - - it('renders only the environment searched for', () => { - expect(findAllDropdownItems()).toHaveLength(1); - expect(findDropdownItemByIndex(0).text()).toBe('prod'); - }); - - it('should not display create button', () => { - const environments = findAllDropdownItems().filter((env) => env.text().startsWith('Create')); - expect(environments).toHaveLength(0); - expect(findAllDropdownItems()).toHaveLength(1); - }); - - it('should not display empty results message', () => { - expect(wrapper.findComponent({ ref: 'noMatchingResults' }).exists()).toBe(false); - }); - - it('should display active checkmark if active', () => { - expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(false); - }); - - it('should clear the search term when showing the dropdown', () => { - wrapper.findComponent(GlDropdown).trigger('click'); - - expect(wrapper.find('[data-testid="ci-environment-search"]').text()).toBe(''); - }); - - describe('Custom events', () => { - it('should emit selectEnvironment if an environment is clicked', () => { - findDropdownItemByIndex(0).vm.$emit('click'); - expect(wrapper.emitted('selectEnvironment')).toEqual([['prod']]); - }); - - it('should emit createClicked if an environment is clicked', async () => { - createComponent('newscope'); - - await nextTick(); - findDropdownItemByIndex(1).vm.$emit('click'); - expect(wrapper.emitted('createClicked')).toEqual([['newscope']]); - }); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/components/legacy_ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_modal_spec.js deleted file mode 100644 index b607232907b..00000000000 --- a/spec/frontend/ci_variable_list/components/legacy_ci_variable_modal_spec.js +++ /dev/null @@ -1,323 +0,0 @@ -import { GlButton, GlFormInput } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { mockTracking } from 'helpers/tracking_helper'; -import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; -import LegacyCiVariableModal from '~/ci_variable_list/components/legacy_ci_variable_modal.vue'; -import { - AWS_ACCESS_KEY_ID, - EVENT_LABEL, - EVENT_ACTION, - ENVIRONMENT_SCOPE_LINK_TITLE, -} from '~/ci_variable_list/constants'; -import createStore from '~/ci_variable_list/store'; -import mockData from '../services/mock_data'; -import ModalStub from '../stubs'; - -Vue.use(Vuex); - -describe('Ci variable modal', () => { - let wrapper; - let store; - let trackingSpy; - - const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; - - const createComponent = (method, options = {}) => { - store = createStore({ - maskableRegex, - isGroup: options.isGroup, - environmentScopeLink: '/help/environments', - }); - wrapper = method(LegacyCiVariableModal, { - attachTo: document.body, - stubs: { - GlModal: ModalStub, - }, - store, - ...options, - }); - }; - - const findCiEnvironmentsDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); - const findModal = () => wrapper.findComponent(ModalStub); - const findAddorUpdateButton = () => findModal().find('[data-testid="ciUpdateOrAddVariableBtn"]'); - const deleteVariableButton = () => - findModal() - .findAllComponents(GlButton) - .wrappers.find((button) => button.props('variant') === 'danger'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('Basic interactions', () => { - beforeEach(() => { - createComponent(shallowMount); - }); - - it('button is disabled when no key/value pair are present', () => { - expect(findAddorUpdateButton().attributes('disabled')).toBe('true'); - }); - }); - - describe('Adding a new variable', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - createComponent(shallowMount); - jest.spyOn(store, 'dispatch').mockImplementation(); - store.state.variable = variable; - }); - - it('button is enabled when key/value pair are present', () => { - expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); - }); - - it('Add variable button dispatches addVariable action', () => { - findAddorUpdateButton().vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('addVariable'); - }); - - it('Clears the modal state once modal is hidden', () => { - findModal().vm.$emit('hidden'); - expect(store.dispatch).toHaveBeenCalledWith('clearModal'); - }); - - it('should dispatch setVariableProtected when admin settings are configured to protect variables', () => { - store.state.isProtectedByDefault = true; - findModal().vm.$emit('shown'); - - expect(store.dispatch).toHaveBeenCalledWith('setVariableProtected'); - }); - }); - - describe('Adding a new non-AWS variable', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidKeyVariable = { - ...variable, - key: 'key', - value: 'value', - secret_value: 'secret_value', - }; - createComponent(mount); - store.state.variable = invalidKeyVariable; - }); - - it('does not show AWS guidance tip', () => { - const tip = wrapper.find(`div[data-testid='aws-guidance-tip']`); - expect(tip.exists()).toBe(true); - expect(tip.isVisible()).toBe(false); - }); - }); - - describe('Adding a new AWS variable', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidKeyVariable = { - ...variable, - key: AWS_ACCESS_KEY_ID, - value: 'AKIAIOSFODNN7EXAMPLEjdhy', - secret_value: 'AKIAIOSFODNN7EXAMPLEjdhy', - }; - createComponent(mount); - store.state.variable = invalidKeyVariable; - }); - - it('shows AWS guidance tip', () => { - const tip = wrapper.find(`[data-testid='aws-guidance-tip']`); - expect(tip.exists()).toBe(true); - expect(tip.isVisible()).toBe(true); - }); - }); - - describe.each` - value | secret | rendered - ${'value'} | ${'secret_value'} | ${false} - ${'dollar$ign'} | ${'dollar$ign'} | ${true} - `('Adding a new variable', ({ value, secret, rendered }) => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidKeyVariable = { - ...variable, - key: 'key', - value, - secret_value: secret, - }; - createComponent(mount); - store.state.variable = invalidKeyVariable; - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it(`${rendered ? 'renders' : 'does not render'} the variable reference warning`, () => { - const warning = wrapper.find(`[data-testid='contains-variable-reference']`); - expect(warning.exists()).toBe(rendered); - }); - }); - - describe('Editing a variable', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - createComponent(shallowMount); - jest.spyOn(store, 'dispatch').mockImplementation(); - store.state.variableBeingEdited = variable; - }); - - it('button text is Update variable when updating', () => { - expect(findAddorUpdateButton().text()).toBe('Update variable'); - }); - - it('Update variable button dispatches updateVariable with correct variable', () => { - findAddorUpdateButton().vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('updateVariable'); - }); - - it('Resets the editing state once modal is hidden', () => { - findModal().vm.$emit('hidden'); - expect(store.dispatch).toHaveBeenCalledWith('resetEditing'); - }); - - it('dispatches deleteVariable with correct variable to delete', () => { - deleteVariableButton().vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('deleteVariable'); - }); - }); - - describe('Environment scope', () => { - describe('group level variables', () => { - it('renders the environment dropdown', () => { - createComponent(shallowMount, { - isGroup: true, - provide: { - glFeatures: { - groupScopedCiVariables: true, - }, - }, - }); - - expect(findCiEnvironmentsDropdown().exists()).toBe(true); - expect(findCiEnvironmentsDropdown().isVisible()).toBe(true); - }); - - describe('licensed feature is not available', () => { - it('disables the dropdown', () => { - createComponent(mount, { - isGroup: true, - provide: { - glFeatures: { - groupScopedCiVariables: false, - }, - }, - }); - - const environmentScopeInput = wrapper - .find('[data-testid="environment-scope"]') - .findComponent(GlFormInput); - expect(findCiEnvironmentsDropdown().exists()).toBe(false); - expect(environmentScopeInput.attributes('readonly')).toBe('readonly'); - }); - }); - }); - - it('renders a link to documentation on scopes', () => { - createComponent(mount); - - const link = wrapper.find('[data-testid="environment-scope-link"]'); - - expect(link.attributes('title')).toBe(ENVIRONMENT_SCOPE_LINK_TITLE); - expect(link.attributes('href')).toBe('/help/environments'); - }); - }); - - describe('Validations', () => { - const maskError = 'This variable can not be masked.'; - - describe('when the mask state is invalid', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidMaskVariable = { - ...variable, - key: 'qs', - value: 'd:;', - secret_value: 'd:;', - masked: true, - }; - createComponent(mount); - store.state.variable = invalidMaskVariable; - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it('disables the submit button', () => { - expect(findAddorUpdateButton().attributes('disabled')).toBe('disabled'); - }); - - it('shows the correct error text', () => { - expect(findModal().text()).toContain(maskError); - }); - - it('sends the correct tracking event', () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { - label: EVENT_LABEL, - property: ';', - }); - }); - }); - - describe.each` - value | secret | masked | eventSent | trackingErrorProperty - ${'value'} | ${'secretValue'} | ${false} | ${0} | ${null} - ${'shortMasked'} | ${'short'} | ${true} | ${0} | ${null} - ${'withDollar$Sign'} | ${'dollar$ign'} | ${false} | ${1} | ${'$'} - ${'withDollar$Sign'} | ${'dollar$ign'} | ${true} | ${1} | ${'$'} - ${'unsupported'} | ${'unsupported|char'} | ${true} | ${1} | ${'|'} - ${'unsupportedMasked'} | ${'unsupported|char'} | ${false} | ${0} | ${null} - `('Adding a new variable', ({ value, secret, masked, eventSent, trackingErrorProperty }) => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const invalidKeyVariable = { - ...variable, - key: 'key', - value, - secret_value: secret, - masked, - }; - createComponent(mount); - store.state.variable = invalidKeyVariable; - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it(`${ - eventSent > 0 ? 'sends the correct' : 'does not send the' - } variable validation tracking event`, () => { - expect(trackingSpy).toHaveBeenCalledTimes(eventSent); - - if (eventSent > 0) { - expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { - label: EVENT_LABEL, - property: trackingErrorProperty, - }); - } - }); - }); - - describe('when both states are valid', () => { - beforeEach(() => { - const [variable] = mockData.mockVariables; - const validMaskandKeyVariable = { - ...variable, - key: AWS_ACCESS_KEY_ID, - value: '12345678', - secret_value: '87654321', - masked: true, - }; - createComponent(mount); - store.state.variable = validMaskandKeyVariable; - }); - - it('does not disable the submit button', () => { - expect(findAddorUpdateButton().attributes('disabled')).toBeUndefined(); - }); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js deleted file mode 100644 index 7def4dd4f29..00000000000 --- a/spec/frontend/ci_variable_list/components/legacy_ci_variable_settings_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import LegacyCiVariableSettings from '~/ci_variable_list/components/legacy_ci_variable_settings.vue'; -import createStore from '~/ci_variable_list/store'; - -Vue.use(Vuex); - -describe('Ci variable table', () => { - let wrapper; - let store; - let isProject; - - const createComponent = (projectState) => { - store = createStore(); - store.state.isProject = projectState; - jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper = shallowMount(LegacyCiVariableSettings, { - store, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('dispatches fetchEnvironments when mounted', () => { - isProject = true; - createComponent(isProject); - expect(store.dispatch).toHaveBeenCalledWith('fetchEnvironments'); - }); - - it('does not dispatch fetchenvironments when in group context', () => { - isProject = false; - createComponent(isProject); - expect(store.dispatch).not.toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/ci_variable_list/components/legacy_ci_variable_table_spec.js b/spec/frontend/ci_variable_list/components/legacy_ci_variable_table_spec.js deleted file mode 100644 index 310afc8003a..00000000000 --- a/spec/frontend/ci_variable_list/components/legacy_ci_variable_table_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import LegacyCiVariableTable from '~/ci_variable_list/components/legacy_ci_variable_table.vue'; -import createStore from '~/ci_variable_list/store'; -import mockData from '../services/mock_data'; - -Vue.use(Vuex); - -describe('Ci variable table', () => { - let wrapper; - let store; - - const createComponent = () => { - store = createStore(); - jest.spyOn(store, 'dispatch').mockImplementation(); - wrapper = mountExtended(LegacyCiVariableTable, { - attachTo: document.body, - store, - }); - }; - - const findRevealButton = () => wrapper.findByText('Reveal values'); - const findEditButton = () => wrapper.findByLabelText('Edit'); - const findEmptyVariablesPlaceholder = () => wrapper.findByText('There are no variables yet.'); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('dispatches fetchVariables when mounted', () => { - expect(store.dispatch).toHaveBeenCalledWith('fetchVariables'); - }); - - describe('When table is empty', () => { - beforeEach(() => { - store.state.variables = []; - }); - - it('displays empty message', () => { - expect(findEmptyVariablesPlaceholder().exists()).toBe(true); - }); - - it('hides the reveal button', () => { - expect(findRevealButton().exists()).toBe(false); - }); - }); - - describe('When table has variables', () => { - beforeEach(() => { - store.state.variables = mockData.mockVariables; - }); - - it('does not display the empty message', () => { - expect(findEmptyVariablesPlaceholder().exists()).toBe(false); - }); - - it('displays the reveal button', () => { - expect(findRevealButton().exists()).toBe(true); - }); - - it('displays the correct amount of variables', async () => { - expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(1); - }); - }); - - describe('Table click actions', () => { - beforeEach(() => { - store.state.variables = mockData.mockVariables; - }); - - it('reveals secret values when button is clicked', () => { - findRevealButton().trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('toggleValues', false); - }); - - it('dispatches editVariable with correct variable to edit', () => { - findEditButton().trigger('click'); - expect(store.dispatch).toHaveBeenCalledWith('editVariable', mockData.mockVariables[0]); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js deleted file mode 100644 index e8c81a53a55..00000000000 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ /dev/null @@ -1,319 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testAction from 'helpers/vuex_action_helper'; -import Api from '~/api'; -import * as actions from '~/ci_variable_list/store/actions'; -import * as types from '~/ci_variable_list/store/mutation_types'; -import getInitialState from '~/ci_variable_list/store/state'; -import { prepareDataForDisplay, prepareEnvironments } from '~/ci_variable_list/store/utils'; -import { createAlert } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import mockData from '../services/mock_data'; - -jest.mock('~/api.js'); -jest.mock('~/flash.js'); - -describe('CI variable list store actions', () => { - let mock; - let state; - const mockVariable = { - environment_scope: '*', - id: 63, - key: 'test_var', - masked: false, - protected: false, - value: 'test_val', - variable_type: 'env_var', - _destory: true, - }; - const payloadError = new Error('Request failed with status code 500'); - - beforeEach(() => { - mock = new MockAdapter(axios); - state = getInitialState(); - state.endpoint = '/variables'; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('toggleValues', () => { - const valuesHidden = false; - it('commits TOGGLE_VALUES mutation', () => { - testAction(actions.toggleValues, valuesHidden, {}, [ - { - type: types.TOGGLE_VALUES, - payload: valuesHidden, - }, - ]); - }); - }); - - describe('clearModal', () => { - it('commits CLEAR_MODAL mutation', () => { - testAction(actions.clearModal, {}, {}, [ - { - type: types.CLEAR_MODAL, - }, - ]); - }); - }); - - describe('resetEditing', () => { - it('commits RESET_EDITING mutation', () => { - testAction( - actions.resetEditing, - {}, - {}, - [ - { - type: types.RESET_EDITING, - }, - ], - [{ type: 'fetchVariables' }], - ); - }); - }); - - describe('setVariableProtected', () => { - it('commits SET_VARIABLE_PROTECTED mutation', () => { - testAction(actions.setVariableProtected, {}, {}, [ - { - type: types.SET_VARIABLE_PROTECTED, - }, - ]); - }); - }); - - describe('deleteVariable', () => { - it('dispatch correct actions on successful deleted variable', () => { - mock.onPatch(state.endpoint).reply(200); - - return testAction( - actions.deleteVariable, - {}, - state, - [], - [ - { type: 'requestDeleteVariable' }, - { type: 'receiveDeleteVariableSuccess' }, - { type: 'fetchVariables' }, - ], - ); - }); - - it('should show flash error and set error in state on delete failure', async () => { - mock.onPatch(state.endpoint).reply(500, ''); - - await testAction( - actions.deleteVariable, - {}, - state, - [], - [ - { type: 'requestDeleteVariable' }, - { - type: 'receiveDeleteVariableError', - payload: payloadError, - }, - ], - ); - expect(createAlert).toHaveBeenCalled(); - }); - }); - - describe('updateVariable', () => { - it('dispatch correct actions on successful updated variable', () => { - mock.onPatch(state.endpoint).reply(200); - - return testAction( - actions.updateVariable, - {}, - state, - [], - [ - { type: 'requestUpdateVariable' }, - { type: 'receiveUpdateVariableSuccess' }, - { type: 'fetchVariables' }, - ], - ); - }); - - it('should show flash error and set error in state on update failure', async () => { - mock.onPatch(state.endpoint).reply(500, ''); - - await testAction( - actions.updateVariable, - mockVariable, - state, - [], - [ - { type: 'requestUpdateVariable' }, - { - type: 'receiveUpdateVariableError', - payload: payloadError, - }, - ], - ); - expect(createAlert).toHaveBeenCalled(); - }); - }); - - describe('addVariable', () => { - it('dispatch correct actions on successful added variable', () => { - mock.onPatch(state.endpoint).reply(200); - - return testAction( - actions.addVariable, - {}, - state, - [], - [ - { type: 'requestAddVariable' }, - { type: 'receiveAddVariableSuccess' }, - { type: 'fetchVariables' }, - ], - ); - }); - - it('should show flash error and set error in state on add failure', async () => { - mock.onPatch(state.endpoint).reply(500, ''); - - await testAction( - actions.addVariable, - {}, - state, - [], - [ - { type: 'requestAddVariable' }, - { - type: 'receiveAddVariableError', - payload: payloadError, - }, - ], - ); - expect(createAlert).toHaveBeenCalled(); - }); - }); - - describe('fetchVariables', () => { - it('dispatch correct actions on fetchVariables', () => { - mock.onGet(state.endpoint).reply(200, { variables: mockData.mockVariables }); - - return testAction( - actions.fetchVariables, - {}, - state, - [], - [ - { type: 'requestVariables' }, - { - type: 'receiveVariablesSuccess', - payload: prepareDataForDisplay(mockData.mockVariables), - }, - ], - ); - }); - - it('should show flash error and set error in state on fetch variables failure', async () => { - mock.onGet(state.endpoint).reply(500); - - await testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }]); - expect(createAlert).toHaveBeenCalledWith({ - message: 'There was an error fetching the variables.', - }); - }); - }); - - describe('fetchEnvironments', () => { - it('dispatch correct actions on fetchEnvironments', () => { - Api.environments = jest.fn().mockResolvedValue({ data: mockData.mockEnvironments }); - - return testAction( - actions.fetchEnvironments, - {}, - state, - [], - [ - { type: 'requestEnvironments' }, - { - type: 'receiveEnvironmentsSuccess', - payload: prepareEnvironments(mockData.mockEnvironments), - }, - ], - ); - }); - - it('should show flash error and set error in state on fetch environments failure', async () => { - Api.environments = jest.fn().mockRejectedValue(); - - await testAction(actions.fetchEnvironments, {}, state, [], [{ type: 'requestEnvironments' }]); - - expect(createAlert).toHaveBeenCalledWith({ - message: 'There was an error fetching the environments information.', - }); - }); - }); - - describe('Update variable values', () => { - it('updateVariableKey', () => { - testAction( - actions.updateVariableKey, - { key: mockVariable.key }, - {}, - [ - { - type: types.UPDATE_VARIABLE_KEY, - payload: mockVariable.key, - }, - ], - [], - ); - }); - - it('updateVariableValue', () => { - testAction( - actions.updateVariableValue, - { secret_value: mockVariable.value }, - {}, - [ - { - type: types.UPDATE_VARIABLE_VALUE, - payload: mockVariable.value, - }, - ], - [], - ); - }); - - it('updateVariableType', () => { - testAction( - actions.updateVariableType, - { variable_type: mockVariable.variable_type }, - {}, - [{ type: types.UPDATE_VARIABLE_TYPE, payload: mockVariable.variable_type }], - [], - ); - }); - - it('updateVariableProtected', () => { - testAction( - actions.updateVariableProtected, - { protected_variable: mockVariable.protected }, - {}, - [{ type: types.UPDATE_VARIABLE_PROTECTED, payload: mockVariable.protected }], - [], - ); - }); - - it('updateVariableMasked', () => { - testAction( - actions.updateVariableMasked, - { masked: mockVariable.masked }, - {}, - [{ type: types.UPDATE_VARIABLE_MASKED, payload: mockVariable.masked }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/store/getters_spec.js b/spec/frontend/ci_variable_list/store/getters_spec.js deleted file mode 100644 index 92f22b18763..00000000000 --- a/spec/frontend/ci_variable_list/store/getters_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import * as getters from '~/ci_variable_list/store/getters'; -import mockData from '../services/mock_data'; - -describe('Ci variable getters', () => { - describe('joinedEnvironments', () => { - it('should join fetched environments with variable environment scopes', () => { - const state = { - environments: ['All (default)', 'staging', 'deployment', 'prod'], - variables: mockData.mockVariableScopes, - }; - - expect(getters.joinedEnvironments(state)).toEqual([ - 'All (default)', - 'deployment', - 'prod', - 'production', - 'staging', - ]); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js deleted file mode 100644 index c7d07ead09b..00000000000 --- a/spec/frontend/ci_variable_list/store/mutations_spec.js +++ /dev/null @@ -1,136 +0,0 @@ -import * as types from '~/ci_variable_list/store/mutation_types'; -import mutations from '~/ci_variable_list/store/mutations'; -import state from '~/ci_variable_list/store/state'; - -describe('CI variable list mutations', () => { - let stateCopy; - - beforeEach(() => { - stateCopy = state(); - }); - - describe('TOGGLE_VALUES', () => { - it('should toggle state', () => { - const valuesHidden = false; - - mutations[types.TOGGLE_VALUES](stateCopy, valuesHidden); - - expect(stateCopy.valuesHidden).toEqual(valuesHidden); - }); - }); - - describe('VARIABLE_BEING_EDITED', () => { - it('should set the variable that is being edited', () => { - mutations[types.VARIABLE_BEING_EDITED](stateCopy); - - expect(stateCopy.variableBeingEdited).toBe(true); - }); - }); - - describe('RESET_EDITING', () => { - it('should reset variableBeingEdited to false', () => { - mutations[types.RESET_EDITING](stateCopy); - - expect(stateCopy.variableBeingEdited).toBe(false); - }); - }); - - describe('CLEAR_MODAL', () => { - it('should clear modal state', () => { - const modalState = { - variable_type: 'Variable', - key: '', - secret_value: '', - protected_variable: false, - masked: false, - environment_scope: 'All (default)', - }; - - mutations[types.CLEAR_MODAL](stateCopy); - - expect(stateCopy.variable).toEqual(modalState); - }); - }); - - describe('RECEIVE_ENVIRONMENTS_SUCCESS', () => { - it('should set environments', () => { - const environments = ['env1', 'env2']; - - mutations[types.RECEIVE_ENVIRONMENTS_SUCCESS](stateCopy, environments); - - expect(stateCopy.environments).toEqual(['All (default)', 'env1', 'env2']); - }); - }); - - describe('SET_ENVIRONMENT_SCOPE', () => { - const environment = 'production'; - - it('should set environment scope on variable', () => { - mutations[types.SET_ENVIRONMENT_SCOPE](stateCopy, environment); - - expect(stateCopy.variable.environment_scope).toBe('production'); - }); - }); - - describe('ADD_WILD_CARD_SCOPE', () => { - it('should add wild card scope to environments array and sort', () => { - stateCopy.environments = ['dev', 'staging']; - mutations[types.ADD_WILD_CARD_SCOPE](stateCopy, 'production'); - - expect(stateCopy.environments).toEqual(['dev', 'production', 'staging']); - }); - }); - - describe('SET_VARIABLE_PROTECTED', () => { - it('should set protected value to true', () => { - mutations[types.SET_VARIABLE_PROTECTED](stateCopy); - - expect(stateCopy.variable.protected_variable).toBe(true); - }); - }); - - describe('UPDATE_VARIABLE_KEY', () => { - it('should update variable key value', () => { - const key = 'new_var'; - mutations[types.UPDATE_VARIABLE_KEY](stateCopy, key); - - expect(stateCopy.variable.key).toBe(key); - }); - }); - - describe('UPDATE_VARIABLE_VALUE', () => { - it('should update variable value', () => { - const value = 'variable_value'; - mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, value); - - expect(stateCopy.variable.secret_value).toBe(value); - }); - }); - - describe('UPDATE_VARIABLE_TYPE', () => { - it('should update variable type value', () => { - const type = 'File'; - mutations[types.UPDATE_VARIABLE_TYPE](stateCopy, type); - - expect(stateCopy.variable.variable_type).toBe(type); - }); - }); - - describe('UPDATE_VARIABLE_PROTECTED', () => { - it('should update variable protected value', () => { - const protectedValue = true; - mutations[types.UPDATE_VARIABLE_PROTECTED](stateCopy, protectedValue); - - expect(stateCopy.variable.protected_variable).toBe(protectedValue); - }); - }); - - describe('UPDATE_VARIABLE_MASKED', () => { - it('should update variable masked value', () => { - const masked = true; - mutations[types.UPDATE_VARIABLE_MASKED](stateCopy, masked); - - expect(stateCopy.variable.masked).toBe(masked); - }); - }); -}); diff --git a/spec/frontend/ci_variable_list/store/utils_spec.js b/spec/frontend/ci_variable_list/store/utils_spec.js deleted file mode 100644 index 5b10370324a..00000000000 --- a/spec/frontend/ci_variable_list/store/utils_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import { - prepareDataForDisplay, - prepareEnvironments, - prepareDataForApi, -} from '~/ci_variable_list/store/utils'; -import mockData from '../services/mock_data'; - -describe('CI variables store utils', () => { - it('prepares ci variables for display', () => { - expect(prepareDataForDisplay(mockData.mockVariablesApi)).toStrictEqual( - mockData.mockVariablesDisplay, - ); - }); - - it('prepares single ci variable for api', () => { - expect(prepareDataForApi(mockData.mockVariablesDisplay[0])).toStrictEqual({ - environment_scope: '*', - id: 113, - key: 'test_var', - masked: 'false', - protected: 'false', - secret_value: 'test_val', - value: 'test_val', - variable_type: 'env_var', - }); - - expect(prepareDataForApi(mockData.mockVariablesDisplay[1])).toStrictEqual({ - environment_scope: '*', - id: 114, - key: 'test_var_2', - masked: 'false', - protected: 'false', - secret_value: 'test_val_2', - value: 'test_val_2', - variable_type: 'file', - }); - }); - - it('prepares single ci variable for delete', () => { - expect(prepareDataForApi(mockData.mockVariablesDisplay[0], true)).toHaveProperty( - '_destroy', - true, - ); - }); - - it('prepares environments for display', () => { - expect(prepareEnvironments(mockData.mockEnvironments)).toStrictEqual(['staging', 'production']); - }); -}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js new file mode 100644 index 00000000000..8f229182fe5 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/forwarding_settings_spec.js @@ -0,0 +1,78 @@ +import { GlFormGroup, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import component from '~/packages_and_registries/settings/group/components/forwarding_settings.vue'; + +describe('Forwarding Settings', () => { + let wrapper; + + const defaultProps = { + disabled: false, + forwarding: false, + label: 'label', + lockForwarding: false, + modelNames: { + forwarding: 'forwardField', + lockForwarding: 'lockForwardingField', + isLocked: 'lockedField', + }, + }; + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMountExtended(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findForwardingCheckbox = () => wrapper.findByTestId('forwarding-checkbox'); + const findLockForwardingCheckbox = () => wrapper.findByTestId('lock-forwarding-checkbox'); + + it('has a form group', () => { + mountComponent(); + + expect(findFormGroup().exists()).toBe(true); + expect(findFormGroup().attributes()).toMatchObject({ + label: defaultProps.label, + }); + }); + + describe.each` + name | finder | label | extraProps | field + ${'forwarding'} | ${findForwardingCheckbox} | ${'Forward label package requests'} | ${{ forwarding: true }} | ${defaultProps.modelNames.forwarding} + ${'lock forwarding'} | ${findLockForwardingCheckbox} | ${'Enforce label setting for all subgroups'} | ${{ lockForwarding: true }} | ${defaultProps.modelNames.lockForwarding} + `('$name checkbox', ({ name, finder, label, extraProps, field }) => { + it('is rendered', () => { + mountComponent(); + expect(finder().exists()).toBe(true); + expect(finder().text()).toMatchInterpolatedText(label); + expect(finder().attributes('disabled')).toBeUndefined(); + expect(finder().attributes('checked')).toBeUndefined(); + }); + + it(`is checked when ${name} set`, () => { + mountComponent({ ...defaultProps, ...extraProps }); + + expect(finder().attributes('checked')).toBe('true'); + }); + + it(`emits an update event with field ${field} set`, () => { + mountComponent(); + + finder().vm.$emit('change', true); + + expect(wrapper.emitted('update')).toStrictEqual([[field, true]]); + }); + }); + + describe('disabled', () => { + it('disables both checkboxes', () => { + mountComponent({ ...defaultProps, disabled: true }); + + expect(findForwardingCheckbox().attributes('disabled')).toEqual('true'); + expect(findLockForwardingCheckbox().attributes('disabled')).toEqual('true'); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index 31fc3ad419c..7edc321867c 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -7,6 +7,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; +import PackagesForwardingSettings from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; @@ -60,6 +61,7 @@ describe('Group Settings App', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findPackageSettings = () => wrapper.findComponent(PackagesSettings); + const findPackageForwardingSettings = () => wrapper.findComponent(PackagesForwardingSettings); const findDependencyProxySettings = () => wrapper.findComponent(DependencyProxySettings); const waitForApolloQueryAndRender = async () => { @@ -67,16 +69,18 @@ describe('Group Settings App', () => { await nextTick(); }; - const packageSettingsProps = { packageSettings: packageSettings() }; + const packageSettingsProps = { packageSettings }; + const packageForwardingSettingsProps = { forwardSettings: { ...packageSettings } }; const dependencyProxyProps = { dependencyProxySettings: dependencyProxySettings(), dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), }; describe.each` - finder | entitySpecificProps | successMessage | errorMessage - ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} - ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} + finder | entitySpecificProps | successMessage | errorMessage + ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findPackageForwardingSettings} | ${packageForwardingSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} `('settings blocks', ({ finder, entitySpecificProps, successMessage, errorMessage }) => { beforeEach(() => { mountComponent(); @@ -88,10 +92,7 @@ describe('Group Settings App', () => { }); it('binds the correctProps', () => { - expect(finder().props()).toMatchObject({ - isLoading: false, - ...entitySpecificProps, - }); + expect(finder().props()).toMatchObject(entitySpecificProps); }); describe('success event', () => { diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js index 13eba39ec8c..807f332f4d3 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -48,7 +48,7 @@ describe('Packages Settings', () => { apolloProvider, provide: defaultProvide, propsData: { - packageSettings: packageSettings(), + packageSettings, }, stubs: { SettingsBlock, @@ -83,7 +83,7 @@ describe('Packages Settings', () => { }; const emitMavenSettingsUpdate = (override) => { - findGenericDuplicatedSettingsExceptionsInput().vm.$emit('update', { + findMavenDuplicatedSettingsExceptionsInput().vm.$emit('update', { mavenDuplicateExceptionRegex: ')', ...override, }); @@ -117,7 +117,7 @@ describe('Packages Settings', () => { it('renders toggle', () => { mountComponent({ mountFn: mountExtended }); - const { mavenDuplicatesAllowed } = packageSettings(); + const { mavenDuplicatesAllowed } = packageSettings; expect(findMavenDuplicatedSettingsToggle().exists()).toBe(true); @@ -132,7 +132,7 @@ describe('Packages Settings', () => { it('renders ExceptionsInput and assigns duplication allowness and exception props', () => { mountComponent({ mountFn: mountExtended }); - const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings(); + const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings; expect(findMavenDuplicatedSettingsExceptionsInput().exists()).toBe(true); @@ -170,7 +170,7 @@ describe('Packages Settings', () => { it('renders toggle', () => { mountComponent({ mountFn: mountExtended }); - const { genericDuplicatesAllowed } = packageSettings(); + const { genericDuplicatesAllowed } = packageSettings; expect(findGenericDuplicatedSettingsToggle().exists()).toBe(true); expect(findGenericDuplicatedSettingsToggle().props()).toMatchObject({ @@ -184,7 +184,7 @@ describe('Packages Settings', () => { it('renders ExceptionsInput and assigns duplication allowness and exception props', async () => { mountComponent({ mountFn: mountExtended }); - const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings(); + const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings; expect(findGenericDuplicatedSettingsExceptionsInput().props()).toMatchObject({ duplicatesAllowed: genericDuplicatesAllowed, @@ -239,7 +239,7 @@ describe('Packages Settings', () => { emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex }); expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ - ...packageSettings(), + ...packageSettings, mavenDuplicateExceptionRegex, }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js new file mode 100644 index 00000000000..a0b257a9496 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/packages_forwarding_settings_spec.js @@ -0,0 +1,280 @@ +import Vue from 'vue'; +import { GlButton } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import component from '~/packages_and_registries/settings/group/components/packages_forwarding_settings.vue'; +import { + PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + PACKAGE_FORWARDING_SETTINGS_HEADER, +} from '~/packages_and_registries/settings/group/constants'; + +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + packageSettings, + packageForwardingSettings, + groupPackageSettingsMock, + groupPackageForwardSettingsMutationMock, + mutationErrorMock, + npmProps, + pypiProps, + mavenProps, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +describe('Packages Forwarding Settings', () => { + let wrapper; + let apolloProvider; + const mutationResolverFn = jest.fn().mockResolvedValue(groupPackageForwardSettingsMutationMock()); + + const defaultProvide = { + groupPath: 'foo_group_path', + }; + + const mountComponent = ({ + forwardSettings = { ...packageSettings }, + features = {}, + mutationResolver = mutationResolverFn, + } = {}) => { + Vue.use(VueApollo); + + const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(component, { + apolloProvider, + provide: { + ...defaultProvide, + glFeatures: { + ...features, + }, + }, + propsData: { + forwardSettings, + }, + stubs: { + SettingsBlock, + }, + }); + }; + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findForm = () => wrapper.find('form'); + const findSubmitButton = () => findForm().findComponent(GlButton); + const findDescription = () => wrapper.findByTestId('description'); + const findMavenForwardingSettings = () => wrapper.findByTestId('maven'); + const findNpmForwardingSettings = () => wrapper.findByTestId('npm'); + const findPyPiForwardingSettings = () => wrapper.findByTestId('pypi'); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const updateNpmSettings = () => { + findNpmForwardingSettings().vm.$emit('update', 'npmPackageRequestsForwarding', false); + }; + + const submitForm = () => { + findForm().trigger('submit'); + return waitForPromises(); + }; + + afterEach(() => { + apolloProvider = null; + }); + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(PACKAGE_FORWARDING_SETTINGS_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText( + PACKAGE_FORWARDING_SETTINGS_DESCRIPTION, + ); + }); + + it('watches changes to props', async () => { + mountComponent(); + + expect(findNpmForwardingSettings().props()).toMatchObject(npmProps); + + await wrapper.setProps({ + forwardSettings: { + ...packageSettings, + npmPackageRequestsForwardingLocked: true, + }, + }); + + expect(findNpmForwardingSettings().props()).toMatchObject({ ...npmProps, disabled: true }); + }); + + it('submit button is disabled', () => { + mountComponent(); + + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + describe.each` + type | finder | props | field + ${'npm'} | ${findNpmForwardingSettings} | ${npmProps} | ${'npmPackageRequestsForwarding'} + ${'pypi'} | ${findPyPiForwardingSettings} | ${pypiProps} | ${'pypiPackageRequestsForwarding'} + ${'maven'} | ${findMavenForwardingSettings} | ${mavenProps} | ${'mavenPackageRequestsForwarding'} + `('$type settings', ({ finder, props, field }) => { + beforeEach(() => { + mountComponent({ features: { mavenCentralRequestForwarding: true } }); + }); + + it('assigns forwarding settings props', () => { + expect(finder().props()).toMatchObject(props); + }); + + it('on update event enables submit button', async () => { + finder().vm.$emit('update', field, false); + + await waitForPromises(); + + expect(findSubmitButton().props('disabled')).toBe(false); + }); + }); + + describe('maven settings', () => { + describe('with feature turned off', () => { + it('does not exist', () => { + mountComponent(); + + expect(findMavenForwardingSettings().exists()).toBe(false); + }); + }); + }); + + describe('settings update', () => { + describe('success state', () => { + it('calls the mutation with the right variables', async () => { + const { + mavenPackageRequestsForwardingLocked, + npmPackageRequestsForwardingLocked, + pypiPackageRequestsForwardingLocked, + ...packageSettingsInput + } = packageForwardingSettings; + + mountComponent(); + + fillApolloCache(); + updateNpmSettings(); + + await submitForm(); + + expect(mutationResolverFn).toHaveBeenCalledWith({ + input: { + namespacePath: defaultProvide.groupPath, + ...packageSettingsInput, + npmPackageRequestsForwarding: false, + }, + }); + }); + + it('when field are locked calls the mutation with the right variables', async () => { + mountComponent({ + forwardSettings: { + ...packageSettings, + mavenPackageRequestsForwardingLocked: true, + pypiPackageRequestsForwardingLocked: true, + }, + }); + + fillApolloCache(); + updateNpmSettings(); + + await submitForm(); + + expect(mutationResolverFn).toHaveBeenCalledWith({ + input: { + namespacePath: defaultProvide.groupPath, + lockNpmPackageRequestsForwarding: false, + npmPackageRequestsForwarding: false, + }, + }); + }); + + it('emits a success event', async () => { + mountComponent(); + fillApolloCache(); + updateNpmSettings(); + + await submitForm(); + + expect(wrapper.emitted('success')).toHaveLength(1); + }); + + it('has an optimistic response', async () => { + const npmPackageRequestsForwarding = false; + mountComponent(); + + fillApolloCache(); + + expect(findNpmForwardingSettings().props('forwarding')).toBe(true); + + updateNpmSettings(); + await submitForm(); + + expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ + ...packageSettings, + npmPackageRequestsForwarding, + }); + expect(findNpmForwardingSettings().props('forwarding')).toBe(npmPackageRequestsForwarding); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + const mutationResolver = jest.fn().mockResolvedValue(mutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + updateNpmSettings(); + await submitForm(); + + expect(wrapper.emitted('error')).toHaveLength(1); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(groupPackageForwardSettingsMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + + updateNpmSettings(); + await submitForm(); + + expect(wrapper.emitted('error')).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index d53446de910..1ca9dc6daeb 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -1,9 +1,26 @@ -export const packageSettings = () => ({ +const packageDuplicateSettings = { mavenDuplicatesAllowed: true, mavenDuplicateExceptionRegex: '', genericDuplicatesAllowed: true, genericDuplicateExceptionRegex: '', -}); +}; + +export const packageForwardingSettings = { + mavenPackageRequestsForwarding: true, + lockMavenPackageRequestsForwarding: false, + npmPackageRequestsForwarding: true, + lockNpmPackageRequestsForwarding: false, + pypiPackageRequestsForwarding: true, + lockPypiPackageRequestsForwarding: false, + mavenPackageRequestsForwardingLocked: false, + npmPackageRequestsForwardingLocked: false, + pypiPackageRequestsForwardingLocked: false, +}; + +export const packageSettings = { + ...packageDuplicateSettings, + ...packageForwardingSettings, +}; export const dependencyProxySettings = (extend) => ({ enabled: true, @@ -21,13 +38,52 @@ export const groupPackageSettingsMock = { group: { id: '1', fullPath: 'foo_group_path', - packageSettings: packageSettings(), + packageSettings: { + ...packageSettings, + __typename: 'PackageSettings', + }, dependencyProxySetting: dependencyProxySettings(), dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(), }, }, }; +export const npmProps = { + forwarding: packageForwardingSettings.npmPackageRequestsForwarding, + lockForwarding: packageForwardingSettings.lockNpmPackageRequestsForwarding, + label: 'npm', + disabled: false, + modelNames: { + forwarding: 'npmPackageRequestsForwarding', + lockForwarding: 'lockNpmPackageRequestsForwarding', + isLocked: 'npmPackageRequestsForwardingLocked', + }, +}; + +export const pypiProps = { + forwarding: packageForwardingSettings.pypiPackageRequestsForwarding, + lockForwarding: packageForwardingSettings.lockPypiPackageRequestsForwarding, + label: 'PyPI', + disabled: false, + modelNames: { + forwarding: 'pypiPackageRequestsForwarding', + lockForwarding: 'lockPypiPackageRequestsForwarding', + isLocked: 'pypiPackageRequestsForwardingLocked', + }, +}; + +export const mavenProps = { + forwarding: packageForwardingSettings.mavenPackageRequestsForwarding, + lockForwarding: packageForwardingSettings.lockMavenPackageRequestsForwarding, + label: 'Maven', + disabled: false, + modelNames: { + forwarding: 'mavenPackageRequestsForwarding', + lockForwarding: 'lockMavenPackageRequestsForwarding', + isLocked: 'mavenPackageRequestsForwardingLocked', + }, +}; + export const groupPackageSettingsMutationMock = (override) => ({ data: { updateNamespacePackageSettings: { @@ -43,6 +99,19 @@ export const groupPackageSettingsMutationMock = (override) => ({ }, }); +export const groupPackageForwardSettingsMutationMock = (override) => ({ + data: { + updateNamespacePackageSettings: { + packageSettings: { + npmPackageRequestsForwarding: true, + lockNpmPackageRequestsForwarding: false, + }, + errors: [], + ...override, + }, + }, +}); + export const dependencyProxySettingMutationMock = (override) => ({ data: { updateDependencyProxySettings: { diff --git a/spec/helpers/form_helper_spec.rb b/spec/helpers/form_helper_spec.rb index 14ff5d97057..1797b0e32cd 100644 --- a/spec/helpers/form_helper_spec.rb +++ b/spec/helpers/form_helper_spec.rb @@ -44,43 +44,19 @@ RSpec.describe FormHelper do describe '#assignees_dropdown_options' do let(:merge_request) { build(:merge_request) } - context "with the :limit_assignees_per_issuable feature flag on" do - context "with multiple assignees" do - it 'correctly returns the max amount of assignees to allow' do - allow(helper).to receive(:merge_request_supports_multiple_assignees?).and_return(true) + context "with multiple assignees" do + it 'correctly returns the max amount of assignees to allow' do + allow(helper).to receive(:merge_request_supports_multiple_assignees?).and_return(true) - expect(helper.assignees_dropdown_options(:merge_request)[:data][:'max-select']) - .to eq(Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS) - end - end - - context "with only 1 assignee" do - it 'correctly returns the max amount of assignees to allow' do - expect(helper.assignees_dropdown_options(:merge_request)[:data][:'max-select']) - .to eq(1) - end + expect(helper.assignees_dropdown_options(:merge_request)[:data][:'max-select']) + .to eq(Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS) end end - context "with the :limit_assignees_per_issuable feature flag off" do - before do - stub_feature_flags(limit_assignees_per_issuable: false) - end - - context "with multiple assignees" do - it 'correctly returns the max amount of assignees to allow' do - allow(helper).to receive(:merge_request_supports_multiple_assignees?).and_return(true) - - expect(helper.assignees_dropdown_options(:merge_request)[:data][:'max-select']) - .to eq(nil) - end - end - - context "with only 1 assignee" do - it 'correctly returns the max amount of assignees to allow' do - expect(helper.assignees_dropdown_options(:merge_request)[:data][:'max-select']) - .to eq(1) - end + context "with only 1 assignee" do + it 'correctly returns the max amount of assignees to allow' do + expect(helper.assignees_dropdown_options(:merge_request)[:data][:'max-select']) + .to eq(1) end end end diff --git a/spec/lib/api/entities/release_spec.rb b/spec/lib/api/entities/release_spec.rb index aa2c5126bb9..d1e5f191614 100644 --- a/spec/lib/api/entities/release_spec.rb +++ b/spec/lib/api/entities/release_spec.rb @@ -16,13 +16,13 @@ RSpec.describe API::Entities::Release do end describe 'evidences' do - context 'when the current user can download code' do + context 'when the current user can read code' do let(:entity_evidence) { entity[:evidences].first } it 'exposes the evidence sha and the json path' do allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?) - .with(user, :download_code, project).and_return(true) + .with(user, :read_code, project).and_return(true) expect(entity_evidence[:sha]).to eq(evidence.summary_sha) expect(entity_evidence[:collected_at]).to eq(evidence.collected_at) @@ -36,11 +36,11 @@ RSpec.describe API::Entities::Release do end end - context 'when the current user cannot download code' do + context 'when the current user cannot read code' do it 'does not expose any evidence data' do allow(Ability).to receive(:allowed?).and_call_original allow(Ability).to receive(:allowed?) - .with(user, :download_code, project).and_return(false) + .with(user, :read_code, project).and_return(false) expect(entity.keys).not_to include(:evidences) end diff --git a/spec/lib/gitlab/gon_helper_spec.rb b/spec/lib/gitlab/gon_helper_spec.rb index 94192a9257c..5a1fcc5e2dc 100644 --- a/spec/lib/gitlab/gon_helper_spec.rb +++ b/spec/lib/gitlab/gon_helper_spec.rb @@ -41,67 +41,53 @@ RSpec.describe Gitlab::GonHelper do end describe 'sentry configuration' do - let(:legacy_clientside_dsn) { 'https://xxx@sentry-legacy.example.com/1' } let(:clientside_dsn) { 'https://xxx@sentry.example.com/1' } - let(:environment) { 'production' } + let(:environment) { 'staging' } - context 'with enable_old_sentry_clientside_integration enabled' do + describe 'sentry integration' do before do - stub_feature_flags( - enable_old_sentry_clientside_integration: true, - enable_new_sentry_clientside_integration: false - ) - - stub_config(sentry: { enabled: true, clientside_dsn: legacy_clientside_dsn, environment: environment }) + stub_config(sentry: { enabled: true, clientside_dsn: clientside_dsn, environment: environment }) end it 'sets sentry dsn and environment from config' do - expect(gon).to receive(:sentry_dsn=).with(legacy_clientside_dsn) + expect(gon).to receive(:sentry_dsn=).with(clientside_dsn) expect(gon).to receive(:sentry_environment=).with(environment) helper.add_gon_variables end end - context 'with enable_new_sentry_clientside_integration enabled' do + describe 'new sentry integration' do before do - stub_feature_flags( - enable_old_sentry_clientside_integration: false, - enable_new_sentry_clientside_integration: true - ) - stub_application_setting(sentry_enabled: true) stub_application_setting(sentry_clientside_dsn: clientside_dsn) stub_application_setting(sentry_environment: environment) end - it 'sets sentry dsn and environment from application settings' do - expect(gon).to receive(:sentry_dsn=).with(clientside_dsn) - expect(gon).to receive(:sentry_environment=).with(environment) - - helper.add_gon_variables - end - end - - context 'with enable_old_sentry_clientside_integration and enable_new_sentry_clientside_integration enabled' do - before do - stub_feature_flags( - enable_old_sentry_clientside_integration: true, - enable_new_sentry_clientside_integration: true - ) + context 'when enable_new_sentry_clientside_integration is disabled' do + before do + stub_feature_flags(enable_new_sentry_clientside_integration: false) + end - stub_config(sentry: { enabled: true, clientside_dsn: legacy_clientside_dsn, environment: environment }) + it 'does not set sentry dsn and environment from config' do + expect(gon).not_to receive(:sentry_dsn=).with(clientside_dsn) + expect(gon).not_to receive(:sentry_environment=).with(environment) - stub_application_setting(sentry_enabled: true) - stub_application_setting(sentry_clientside_dsn: clientside_dsn) - stub_application_setting(sentry_environment: environment) + helper.add_gon_variables + end end - it 'sets sentry dsn and environment from application settings' do - expect(gon).to receive(:sentry_dsn=).with(clientside_dsn) - expect(gon).to receive(:sentry_environment=).with(environment) + context 'when enable_new_sentry_clientside_integration is enabled' do + before do + stub_feature_flags(enable_new_sentry_clientside_integration: true) + end - helper.add_gon_variables + it 'sets sentry dsn and environment from config' do + expect(gon).to receive(:sentry_dsn=).with(clientside_dsn) + expect(gon).to receive(:sentry_environment=).with(environment) + + helper.add_gon_variables + end end end end diff --git a/spec/lib/sbom/package_url/argument_validator_spec.rb b/spec/lib/sbom/package_url/argument_validator_spec.rb new file mode 100644 index 00000000000..246da1c0bda --- /dev/null +++ b/spec/lib/sbom/package_url/argument_validator_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +require_relative '../../../support/shared_contexts/lib/sbom/package_url_shared_contexts' + +RSpec.describe Sbom::PackageUrl::ArgumentValidator do + let(:mock_package_url) { Struct.new(:type, :namespace, :name, :version, :qualifiers, keyword_init: true) } + let(:package) do + mock_package_url.new( + type: type, + namespace: namespace, + name: name, + version: version, + qualifiers: qualifiers + ) + end + + subject(:validate) { described_class.new(package).validate! } + + context 'with valid arguments' do + include_context 'with valid purl examples' + + with_them do + it 'does not raise error' do + expect { validate }.not_to raise_error + end + end + end + + context 'with invalid arguments' do + include_context 'with invalid purl examples' + + with_them do + it 'raises an ArgumentError' do + expect { validate }.to raise_error(ArgumentError) + end + end + end + + context 'with multiple errors' do + let(:type) { nil } + let(:name) { nil } + let(:package) { mock_package_url.new(type: type, name: name) } + + it 'reports all errors' do + expect { validate }.to raise_error(ArgumentError, 'Type is required, Name is required') + end + end +end diff --git a/spec/lib/sbom/package_url/decoder_spec.rb b/spec/lib/sbom/package_url/decoder_spec.rb index 1da3c35f403..5b480475b7c 100644 --- a/spec/lib/sbom/package_url/decoder_spec.rb +++ b/spec/lib/sbom/package_url/decoder_spec.rb @@ -7,9 +7,9 @@ require_relative '../../../support/shared_contexts/lib/sbom/package_url_shared_c RSpec.describe Sbom::PackageUrl::Decoder do describe '#decode' do - subject(:decode) { described_class.new(url).decode! } + subject(:decode) { described_class.new(purl).decode! } - include_context 'with purl matrix' + include_context 'with valid purl examples' with_them do it do @@ -25,7 +25,7 @@ RSpec.describe Sbom::PackageUrl::Decoder do end context 'when no argument is passed' do - let(:url) { nil } + let(:purl) { nil } it 'raises an error' do expect { decode }.to raise_error(ArgumentError) @@ -33,17 +33,17 @@ RSpec.describe Sbom::PackageUrl::Decoder do end context 'when an invalid package URL string is passed' do - where(:url) { ['invalid', 'pkg:nil'] } + include_context 'with invalid purl examples' with_them do it 'raises an error' do - expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageURL) + expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageUrl) end end end context 'when namespace or subpath contains an encoded slash' do - where(:url) do + where(:purl) do [ 'pkg:golang/google.org/golang/genproto#googleapis%2fapi%2fannotations', 'pkg:golang/google.org%2fgolang/genproto#googleapis/api/annotations' @@ -51,12 +51,12 @@ RSpec.describe Sbom::PackageUrl::Decoder do end with_them do - it { expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageURL) } + it { expect { decode }.to raise_error(Sbom::PackageUrl::InvalidPackageUrl) } end end context 'when name contains an encoded slash' do - let(:url) { 'pkg:golang/google.org/golang%2fgenproto#googleapis/api/annotations' } + let(:purl) { 'pkg:golang/google.org/golang%2fgenproto#googleapis/api/annotations' } it do is_expected.to have_attributes( @@ -71,7 +71,7 @@ RSpec.describe Sbom::PackageUrl::Decoder do end context 'with URL encoded segments' do - let(:url) do + let(:purl) do 'pkg:golang/namespace%21/google.golang.org%20genproto@version%21?k=v%21#googleapis%20api%20annotations' end @@ -88,7 +88,7 @@ RSpec.describe Sbom::PackageUrl::Decoder do end context 'when segments contain empty values' do - let(:url) { 'pkg:golang/google.golang.org//.././genproto#googleapis/..//./api/annotations' } + let(:purl) { 'pkg:golang/google.golang.org//.././genproto#googleapis/..//./api/annotations' } it 'removes them from the segments' do is_expected.to have_attributes( @@ -103,7 +103,7 @@ RSpec.describe Sbom::PackageUrl::Decoder do end context 'when qualifiers have no value' do - let(:url) { 'pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25&foo=&bar=' } + let(:purl) { 'pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25&foo=&bar=' } it 'they are ignored' do is_expected.to have_attributes( diff --git a/spec/lib/sbom/package_url/encoder_spec.rb b/spec/lib/sbom/package_url/encoder_spec.rb index ff672170050..bdbd61636b5 100644 --- a/spec/lib/sbom/package_url/encoder_spec.rb +++ b/spec/lib/sbom/package_url/encoder_spec.rb @@ -20,10 +20,10 @@ RSpec.describe Sbom::PackageUrl::Encoder do subject(:encode) { described_class.new(package).encode } - include_context 'with purl matrix' + include_context 'with valid purl examples' with_them do - it { is_expected.to eq(url) } + it { is_expected.to eq(canonical_purl) } end end end diff --git a/spec/lib/sbom/package_url/normalizer_spec.rb b/spec/lib/sbom/package_url/normalizer_spec.rb new file mode 100644 index 00000000000..bbc2bd3ca13 --- /dev/null +++ b/spec/lib/sbom/package_url/normalizer_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +require_relative '../../../support/shared_contexts/lib/sbom/package_url_shared_contexts' + +RSpec.describe Sbom::PackageUrl::Normalizer do + shared_examples 'name normalization' do + context 'with bitbucket url' do + let(:type) { 'bitbucket' } + let(:text) { 'Purl_Spec' } + + it 'downcases text' do + is_expected.to eq('purl_spec') + end + end + + context 'with github url' do + let(:type) { 'github' } + let(:text) { 'Purl_Spec' } + + it 'downcases text' do + is_expected.to eq('purl_spec') + end + end + + context 'with pypi url' do + let(:type) { 'pypi' } + let(:text) { 'Purl_Spec' } + + it 'downcases text and replaces underscores' do + is_expected.to eq('purl-spec') + end + end + + context 'with other urls' do + let(:type) { 'npm' } + let(:text) { 'Purl_Spec' } + + it 'does not change the text' do + is_expected.to eq(text) + end + end + end + + describe '#normalize_name' do + subject(:normalize_name) { described_class.new(type: type, text: text).normalize_name } + + it_behaves_like 'name normalization' + + context 'when text is nil' do + let(:type) { 'npm' } + let(:text) { nil } + + it 'raises an error' do + expect { normalize_name }.to raise_error(ArgumentError, 'Name is required') + end + end + end + + describe '#normalize_namespace' do + subject(:normalize_namespace) { described_class.new(type: type, text: text).normalize_namespace } + + it_behaves_like 'name normalization' + + context 'when text is nil' do + let(:type) { 'npm' } + let(:text) { nil } + + it 'allows nil values' do + expect(normalize_namespace).to be_nil + end + end + end +end diff --git a/spec/lib/sbom/package_url_spec.rb b/spec/lib/sbom/package_url_spec.rb index 72090c5bd29..6760b0a68e5 100644 --- a/spec/lib/sbom/package_url_spec.rb +++ b/spec/lib/sbom/package_url_spec.rb @@ -32,37 +32,46 @@ require_relative '../../support/shared_contexts/lib/sbom/package_url_shared_cont RSpec.describe Sbom::PackageUrl do include NextInstanceOf - let(:args) do - { - type: 'example', - namespace: 'test', - name: 'test', - version: '1.0.0', - qualifiers: { 'arch' => 'x86_64' }, - subpath: 'path/to/package' - } - end - describe '#initialize' do - subject { described_class.new(**args) } + subject do + described_class.new( + type: type, + namespace: namespace, + name: name, + version: version, + qualifiers: qualifiers, + subpath: subpath + ) + end context 'with well-formed arguments' do - it { is_expected.to have_attributes(**args) } + include_context 'with valid purl examples' + + with_them do + it do + is_expected.to have_attributes( + type: type, + namespace: namespace, + name: name, + version: version, + qualifiers: qualifiers, + subpath: subpath + ) + end + end end context 'when no arguments are given' do it { expect { described_class.new }.to raise_error(ArgumentError) } end - context 'when required parameters are missing' do - where(:param) { %i[type name] } - - before do - args[param] = nil - end + context 'when parameters are invalid' do + include_context 'with invalid purl examples' with_them do - it { expect { subject }.to raise_error(ArgumentError) } + it 'raises an ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end end end @@ -98,7 +107,7 @@ RSpec.describe Sbom::PackageUrl do end describe '#to_h' do - let(:purl) do + let(:package) do described_class.new( type: type, namespace: namespace, @@ -109,9 +118,9 @@ RSpec.describe Sbom::PackageUrl do ) end - subject(:to_h) { purl.to_h } + subject(:to_h) { package.to_h } - include_context 'with purl matrix' + include_context 'with valid purl examples' with_them do it do @@ -131,7 +140,16 @@ RSpec.describe Sbom::PackageUrl do end describe '#to_s' do - let(:package) { described_class.new(**args) } + let(:package) do + described_class.new( + type: 'npm', + namespace: nil, + name: 'lodash', + version: nil, + qualifiers: nil, + subpath: nil + ) + end it 'delegates to_s to the encoder' do expect_next_instance_of(described_class::Encoder, package) do |encoder| diff --git a/spec/migrations/20221102090940_create_next_ci_partitions_record_spec.rb b/spec/migrations/20221102090940_create_next_ci_partitions_record_spec.rb new file mode 100644 index 00000000000..c55e4bcfba7 --- /dev/null +++ b/spec/migrations/20221102090940_create_next_ci_partitions_record_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CreateNextCiPartitionsRecord, migration: :gitlab_ci do + let(:migration) { described_class.new } + let(:partitions) { table(:ci_partitions) } + + describe '#up' do + context 'when on sass' do + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + it 'creates next partitions record and resets the sequence' do + expect { migrate! } + .to change { partitions.where(id: 101).any? } + .from(false).to(true) + + expect { partitions.create! }.not_to raise_error + end + end + + context 'when self-managed' do + before do + allow(Gitlab).to receive(:com?).and_return(false) + end + + it 'does not create records' do + expect { migrate! }.not_to change(partitions, :count) + end + end + end + + describe '#down' do + context 'when on sass' do + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + it 'removes the record' do + migrate! + + expect { migration.down } + .to change { partitions.where(id: 101).any? } + .from(true).to(false) + end + end + + context 'when self-managed' do + before do + allow(Gitlab).to receive(:com?).and_return(true, false) + end + + it 'does not remove the record' do + expect { migrate! }.to change(partitions, :count).by(1) + + expect { migration.down }.not_to change(partitions, :count) + end + end + end +end diff --git a/spec/migrations/20221102090943_create_second_partition_for_builds_metadata_spec.rb b/spec/migrations/20221102090943_create_second_partition_for_builds_metadata_spec.rb new file mode 100644 index 00000000000..99754d609ed --- /dev/null +++ b/spec/migrations/20221102090943_create_second_partition_for_builds_metadata_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CreateSecondPartitionForBuildsMetadata, :migration do + let(:migration) { described_class.new } + let(:partitions) { table(:ci_partitions) } + + describe '#up' do + context 'when on sass' do + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + it 'creates a new partition' do + expect { migrate! }.to change { partitions_count }.by(1) + end + end + + context 'when self-managed' do + before do + allow(Gitlab).to receive(:com?).and_return(false) + end + + it 'does not create the partition' do + expect { migrate! }.not_to change { partitions_count } + end + end + end + + describe '#down' do + context 'when on sass' do + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + it 'removes the partition' do + migrate! + + expect { migration.down }.to change { partitions_count }.by(-1) + end + end + + context 'when self-managed' do + before do + allow(Gitlab).to receive(:com?).and_return(false) + end + + it 'does not change the partitions count' do + migrate! + + expect { migration.down }.not_to change { partitions_count } + end + end + end + + def partitions_count + Gitlab::Database::PostgresPartition.for_parent_table(:p_ci_builds_metadata).size + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index c03fcdb6f9c..6ba450b6d57 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -99,6 +99,15 @@ RSpec.describe Group do expect(group).to be_valid end + + it 'does not allow a subgroup to have the same name as an existing subgroup' do + sub_group1 = create(:group, parent: group, name: "SG", path: 'api') + sub_group2 = described_class.new(parent: group, name: "SG", path: 'api2') + + expect(sub_group1).to be_valid + expect(sub_group2).not_to be_valid + expect(sub_group2.errors.full_messages.to_sentence).to eq('Name has already been taken') + end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 184500f3209..e76cd22d342 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -598,7 +598,7 @@ RSpec.describe Project, factory_default: :keep do end it 'contains errors related to the project being deleted' do - expect(new_project.errors.full_messages.first).to eq(_('The project is still being deleted. Please try again later.')) + expect(new_project.errors.full_messages).to include(_('The project is still being deleted. Please try again later.')) end end diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb index 401db766589..05fe55b06a1 100644 --- a/spec/requests/api/project_import_spec.rb +++ b/spec/requests/api/project_import_spec.rb @@ -47,7 +47,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do it 'executes a limited number of queries' do control_count = ActiveRecord::QueryRecorder.new { subject }.count - expect(control_count).to be <= 110 + expect(control_count).to be <= 111 end it 'schedules an import using a namespace' do @@ -215,7 +215,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do subject expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to eq('Name has already been taken') + expect(json_response['message']).to eq('Project namespace name has already been taken') end context 'when param overwrite is true' do diff --git a/spec/scripts/trigger-build_spec.rb b/spec/scripts/trigger-build_spec.rb index 9032ba85b9f..ac8e3c7797c 100644 --- a/spec/scripts/trigger-build_spec.rb +++ b/spec/scripts/trigger-build_spec.rb @@ -229,7 +229,6 @@ RSpec.describe Trigger do context "when set in a file" do before do - stub_env(version_file) allow(File).to receive(:read).and_call_original end diff --git a/spec/services/users/migrate_to_ghost_user_service_spec.rb b/spec/services/users/migrate_to_ghost_user_service_spec.rb deleted file mode 100644 index 073ebaae5b0..00000000000 --- a/spec/services/users/migrate_to_ghost_user_service_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Users::MigrateToGhostUserService do - let!(:user) { create(:user) } - let!(:project) { create(:project, :repository) } - let(:service) { described_class.new(user) } - let(:always_ghost) { false } - - context "migrating a user's associated records to the ghost user" do - context 'issues' do - context 'deleted user is present as both author and edited_user' do - include_examples "migrating a deleted user's associated records to the ghost user", Issue, [:author, :last_edited_by] do - let(:created_record) do - create(:issue, project: project, author: user, last_edited_by: user) - end - end - end - - context 'deleted user is present only as edited_user' do - include_examples "migrating a deleted user's associated records to the ghost user", Issue, [:last_edited_by] do - let(:created_record) { create(:issue, project: project, author: create(:user), last_edited_by: user) } - end - end - end - - context 'merge requests' do - context 'deleted user is present as both author and merge_user' do - include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest, [:author, :merge_user] do - let(:created_record) { create(:merge_request, source_project: project, author: user, merge_user: user, target_branch: "first") } - end - end - - context 'deleted user is present only as both merge_user' do - include_examples "migrating a deleted user's associated records to the ghost user", MergeRequest, [:merge_user] do - let(:created_record) { create(:merge_request, source_project: project, merge_user: user, target_branch: "first") } - end - end - end - - context 'notes' do - include_examples "migrating a deleted user's associated records to the ghost user", Note do - let(:created_record) { create(:note, project: project, author: user) } - end - end - - context 'abuse reports' do - include_examples "migrating a deleted user's associated records to the ghost user", AbuseReport do - let(:created_record) { create(:abuse_report, reporter: user, user: create(:user)) } - end - end - - context 'award emoji' do - include_examples "migrating a deleted user's associated records to the ghost user", AwardEmoji, [:user] do - let(:created_record) { create(:award_emoji, user: user) } - - context "when the awardable already has an award emoji of the same name assigned to the ghost user" do - let(:awardable) { create(:issue) } - let!(:existing_award_emoji) { create(:award_emoji, user: User.ghost, name: "thumbsup", awardable: awardable) } - let!(:award_emoji) { create(:award_emoji, user: user, name: "thumbsup", awardable: awardable) } - - it "migrates the award emoji regardless" do - service.execute - - migrated_record = AwardEmoji.find_by_id(award_emoji.id) - - expect(migrated_record.user).to eq(User.ghost) - end - - it "does not leave the migrated award emoji in an invalid state" do - service.execute - - migrated_record = AwardEmoji.find_by_id(award_emoji.id) - - expect(migrated_record).to be_valid - end - end - end - end - - context 'snippets' do - include_examples "migrating a deleted user's associated records to the ghost user", Snippet do - let(:created_record) { create(:snippet, project: project, author: user) } - end - end - - context 'reviews' do - let!(:user) { create(:user) } - let(:service) { described_class.new(user) } - - include_examples "migrating a deleted user's associated records to the ghost user", Review, [:author] do - let(:created_record) { create(:review, author: user) } - end - end - end -end diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb index 581ef07752e..7d0f8c09933 100644 --- a/spec/support/helpers/search_helpers.rb +++ b/spec/support/helpers/search_helpers.rb @@ -33,13 +33,13 @@ module SearchHelpers end def select_search_scope(scope) - page.within '.search-filter' do + page.within '[data-testid="search-filter"]' do click_link scope end end def has_search_scope?(scope) - page.within '.search-filter' do + page.within '[data-testid="search-filter"]' do has_link?(scope) end end diff --git a/spec/support/rate_limiter.rb b/spec/support/rate_limiter.rb new file mode 100644 index 00000000000..525d593c293 --- /dev/null +++ b/spec/support/rate_limiter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.before(:each, :disable_rate_limiter) do + allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(false) + end +end diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index f9d61117ef6..67b7023f1ff 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -997,7 +997,6 @@ - './ee/spec/graphql/types/vulnerability_severity_enum_spec.rb' - './ee/spec/graphql/types/vulnerability_sort_enum_spec.rb' - './ee/spec/graphql/types/vulnerability_state_enum_spec.rb' -- './ee/spec/graphql/types/vulnerability_type_spec.rb' - './ee/spec/graphql/types/vulnerable_dependency_type_spec.rb' - './ee/spec/graphql/types/vulnerable_kubernetes_resource_type_spec.rb' - './ee/spec/graphql/types/vulnerable_package_type_spec.rb' diff --git a/spec/support/shared_contexts/lib/sbom/package_url_shared_contexts.rb b/spec/support/shared_contexts/lib/sbom/package_url_shared_contexts.rb index b5c9e9cc7b0..263cf9f5e19 100644 --- a/spec/support/shared_contexts/lib/sbom/package_url_shared_contexts.rb +++ b/spec/support/shared_contexts/lib/sbom/package_url_shared_contexts.rb @@ -1,98 +1,26 @@ # frozen_string_literal: true -RSpec.shared_context 'with purl matrix' do +require 'oj' + +def parameterized_test_matrix(invalid: false) + test_cases_path = File.join( + File.expand_path(__dir__), '..', '..', '..', '..', 'fixtures', 'lib', 'sbom', 'package-url-test-cases.json') + test_cases = Gitlab::Json.parse(File.read(test_cases_path)) + + test_cases.filter { _1.delete('is_invalid') == invalid }.each_with_object({}) do |test_case, memo| + description = test_case.delete('description') + memo[description] = test_case.symbolize_keys + end +end + +RSpec.shared_context 'with valid purl examples' do + where do + parameterized_test_matrix(invalid: false) + end +end + +RSpec.shared_context 'with invalid purl examples' do where do - { - 'valid RubyGems package URL' => { - url: 'pkg:gem/ruby-advisory-db-check@0.12.4', - type: 'gem', - namespace: nil, - name: 'ruby-advisory-db-check', - version: '0.12.4', - qualifiers: nil, - subpath: nil - }, - 'valid BitBucket package URL' => { - url: 'pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c', - type: 'bitbucket', - namespace: 'birkenfeld', - name: 'pygments-main', - version: '244fd47e07d1014f0aed9c', - qualifiers: nil, - subpath: nil - }, - 'valid GitHub package URL' => { - url: 'pkg:github/package-url/purl-spec@244fd47e07d1004f0aed9c', - type: 'github', - namespace: 'package-url', - name: 'purl-spec', - version: '244fd47e07d1004f0aed9c', - qualifiers: nil, - subpath: nil - }, - 'valid Go module URL' => { - url: 'pkg:golang/google.golang.org/genproto#googleapis/api/annotations', - type: 'golang', - namespace: 'google.golang.org', - name: 'genproto', - version: nil, - qualifiers: nil, - subpath: 'googleapis/api/annotations' - }, - 'valid Maven package URL' => { - url: 'pkg:maven/org.apache.commons/io@1.3.4', - type: 'maven', - namespace: 'org.apache.commons', - name: 'io', - version: '1.3.4', - qualifiers: nil, - subpath: nil - }, - 'valid NPM package URL' => { - url: 'pkg:npm/foobar@12.3.1', - type: 'npm', - namespace: nil, - name: 'foobar', - version: '12.3.1', - qualifiers: nil, - subpath: nil - }, - 'valid NuGet package URL' => { - url: 'pkg:nuget/EnterpriseLibrary.Common@6.0.1304', - type: 'nuget', - namespace: nil, - name: 'EnterpriseLibrary.Common', - version: '6.0.1304', - qualifiers: nil, - subpath: nil - }, - 'valid PyPI package URL' => { - url: 'pkg:pypi/django@1.11.1', - type: 'pypi', - namespace: nil, - name: 'django', - version: '1.11.1', - qualifiers: nil, - subpath: nil - }, - 'valid RPM package URL' => { - url: 'pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25', - type: 'rpm', - namespace: 'fedora', - name: 'curl', - version: '7.50.3-1.fc25', - qualifiers: { 'arch' => 'i386', 'distro' => 'fedora-25' }, - subpath: nil - }, - 'package URL with checksums' => { - url: 'pkg:rpm/name?checksums=a,b,c', - type: 'rpm', - namespace: nil, - name: 'name', - version: nil, - qualifiers: { 'checksums' => %w[a b c] }, - subpath: nil - } - } + parameterized_test_matrix(invalid: true) end end diff --git a/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb b/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb index 84dc2b20ddc..cc74c977064 100644 --- a/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb +++ b/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb @@ -1,23 +1,23 @@ # frozen_string_literal: true RSpec.shared_examples 'search timeouts' do |scope| + let(:additional_params) { {} } + context 'when search times out' do before do - stub_feature_flags(search_page_vertical_nav: false) allow_next_instance_of(SearchService) do |service| allow(service).to receive(:search_objects).and_raise(ActiveRecord::QueryCanceled) end - visit(search_path(search: 'test', scope: scope)) + visit(search_path(search: 'test', scope: scope, **additional_params)) end it 'renders timeout information' do - # expect(page).to have_content('This endpoint has been requested too many times.') expect(page).to have_content('Your search timed out') end it 'sets tab count to 0' do - expect(page.find('.search-filter .active')).to have_text('0') + expect(page.find('[data-testid="search-filter"] .active')).to have_text('0') end end end diff --git a/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb b/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb index e725de8ad31..f5431b29ee2 100644 --- a/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb +++ b/spec/support/shared_examples/quick_actions/issuable/max_issuable_examples.rb @@ -12,49 +12,60 @@ RSpec.shared_examples 'does not exceed the issuable size limit' do project.add_maintainer(user3) end - context 'when feature flag is turned on' do - context "when the number of users of issuable does exceed the limit" do - before do - stub_const("Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS", 2) + context "when the number of users of issuable does exceed the limit" do + before do + stub_const("Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS", 2) + end + + it 'will not add more than the allowed number of users' do + allow_next_instance_of(update_service) do |service| + expect(service).not_to receive(:execute) end - it 'will not add more than the allowed number of users' do - allow_next_instance_of(update_service) do |service| - expect(service).not_to receive(:execute) - end + note = described_class.new(project, user, opts.merge( + note: note_text, + noteable_type: noteable_type, + noteable_id: issuable.id, + confidential: false + )).execute - note = described_class.new(project, user, opts.merge( - note: note_text, - noteable_type: noteable_type, - noteable_id: issuable.id, - confidential: false - )).execute + expect(note.errors[:validation]).to match_array([validation_message]) + end + end - expect(note.errors[:validation]).to match_array([validation_message]) - end + context "when the number of users does not exceed the limit" do + before do + stub_const("Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS", 6) end - context "when the number of users does not exceed the limit" do - before do - stub_const("Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS", 6) + it 'calls execute and does not return an error' do + allow_next_instance_of(update_service) do |service| + expect(service).to receive(:execute).and_call_original end - it 'calls execute and does not return an error' do - allow_next_instance_of(update_service) do |service| - expect(service).to receive(:execute).and_call_original - end - - note = described_class.new(project, user, opts.merge( - note: note_text, - noteable_type: noteable_type, - noteable_id: issuable.id, - confidential: false - )).execute + note = described_class.new(project, user, opts.merge( + note: note_text, + noteable_type: noteable_type, + noteable_id: issuable.id, + confidential: false + )).execute - expect(note.errors[:validation]).to be_empty - end + expect(note.errors[:validation]).to be_empty end end +end + +RSpec.shared_examples 'does not exceed the issuable size limit with ff off' do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:user3) { create(:user) } + + before do + project.add_maintainer(user) + project.add_maintainer(user1) + project.add_maintainer(user2) + project.add_maintainer(user3) + end context 'when feature flag is off' do before do diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index 37df90fff22..d2f4fa0b8ef 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'gitlab:gitaly namespace rake task', :silence_stdout do let(:repo) { 'https://gitlab.com/gitlab-org/gitaly.git' } let(:clone_path) { Rails.root.join('tmp/tests/gitaly').to_s } let(:storage_path) { Rails.root.join('tmp/tests/repositories').to_s } - let(:version) { Gitlab::GitalyClient.expected_server_version } + let(:version) { File.read(Rails.root.join(Gitlab::GitalyClient::SERVER_VERSION_FILE)).chomp } describe 'clone' do subject { run_rake_task('gitlab:gitaly:clone', clone_path, storage_path) } |