diff options
128 files changed, 6334 insertions, 289 deletions
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 062225a75ff..1d730d42ac0 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -154,8 +154,6 @@ Performance/Count: - 'app/helpers/groups_helper.rb' - 'app/services/merge_requests/add_context_service.rb' - 'ee/lib/gitlab/graphql/aggregations/epics/epic_node.rb' - - 'ee/spec/controllers/projects/feature_flags_controller_spec.rb' - - 'ee/spec/requests/api/feature_flags_spec.rb' - 'lib/gitlab/sidekiq_status.rb' - 'spec/lib/gitlab/conflict/file_spec.rb' - 'spec/lib/gitlab/git/tree_spec.rb' @@ -167,7 +165,6 @@ Performance/Count: Performance/Detect: Exclude: - 'ee/spec/controllers/projects/dependencies_controller_spec.rb' - - 'ee/spec/controllers/projects/feature_flags_controller_spec.rb' - 'spec/lib/gitlab/git/tree_spec.rb' - 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb' - 'spec/models/event_spec.rb' diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 340a93e4e66..c8168afbcb0 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -3,9 +3,10 @@ import commitPipelinesTable from './pipelines_table.vue'; /** * Used in: - * - Commit details View > Pipelines Tab > Pipelines Table. - * - Merge Request details View > Pipelines Tab > Pipelines Table. - * - New Merge Request View > Pipelines Tab > Pipelines Table. + * - Project Pipelines List (projects:pipelines:index) + * - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines) + * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show) + * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new) */ const CommitPipelinesTable = Vue.extend(commitPipelinesTable); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 188d958ba86..fe32868e6d8 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -193,7 +193,7 @@ export default { " /> - <div v-else-if="shouldRenderTable" class="table-holder"> + <div v-else-if="shouldRenderTable"> <gl-button v-if="canRenderPipelineButton" block diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 6c64f05c973..cf6fba95115 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -1,6 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ -import { groupBy } from 'lodash'; +import { differenceBy } from 'lodash'; import produce from 'immer'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; @@ -132,10 +132,13 @@ const addNewDesignToStore = (store, designManagementUpload, query) => { const data = produce(sourceData, draftData => { const currentDesigns = extractDesigns(draftData); - const existingDesigns = groupBy(currentDesigns, 'filename'); - const newDesigns = currentDesigns.concat( - designManagementUpload.designs.filter(d => !existingDesigns[d.filename]), - ); + const difference = differenceBy(designManagementUpload.designs, currentDesigns, 'filename'); + + const newDesigns = currentDesigns + .map(design => { + return designManagementUpload.designs[design.filename] || design; + }) + .concat(difference); let newVersionNode; const findNewVersions = designManagementUpload.designs.find(design => design.versions); diff --git a/app/assets/javascripts/ide/lib/languages/hcl.js b/app/assets/javascripts/ide/lib/languages/hcl.js new file mode 100644 index 00000000000..4539719b1f2 --- /dev/null +++ b/app/assets/javascripts/ide/lib/languages/hcl.js @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-useless-escape */ +/* eslint-disable @gitlab/require-i18n-strings */ + +const conf = { + comments: { + lineComment: '//', + blockComment: ['/*', '*/'], + }, + brackets: [['{', '}'], ['[', ']'], ['(', ')']], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string'] }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + ], +}; + +const language = { + defaultToken: '', + tokenPostfix: '.hcl', + + keywords: [ + 'var', + 'local', + 'path', + 'for_each', + 'any', + 'string', + 'number', + 'bool', + 'true', + 'false', + 'null', + 'if ', + 'else ', + 'endif ', + 'for ', + 'in', + 'endfor', + ], + + operators: [ + '=', + '>=', + '<=', + '==', + '!=', + '+', + '-', + '*', + '/', + '%', + '&&', + '||', + '!', + '<', + '>', + '?', + '...', + ':', + ], + + symbols: /[=><!~?:&|+\-*\/\^%]+/, + escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/, + terraformFunctions: /(abs|ceil|floor|log|max|min|pow|signum|chomp|format|formatlist|indent|join|lower|regex|regexall|replace|split|strrev|substr|title|trimspace|upper|chunklist|coalesce|coalescelist|compact|concat|contains|distinct|element|flatten|index|keys|length|list|lookup|map|matchkeys|merge|range|reverse|setintersection|setproduct|setunion|slice|sort|transpose|values|zipmap|base64decode|base64encode|base64gzip|csvdecode|jsondecode|jsonencode|urlencode|yamldecode|yamlencode|abspath|dirname|pathexpand|basename|file|fileexists|fileset|filebase64|templatefile|formatdate|timeadd|timestamp|base64sha256|base64sha512|bcrypt|filebase64sha256|filebase64sha512|filemd5|filemd1|filesha256|filesha512|md5|rsadecrypt|sha1|sha256|sha512|uuid|uuidv5|cidrhost|cidrnetmask|cidrsubnet|tobool|tolist|tomap|tonumber|toset|tostring)/, + terraformMainBlocks: /(module|data|terraform|resource|provider|variable|output|locals)/, + tokenizer: { + root: [ + // highlight main blocks + [ + /^@terraformMainBlocks([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/, + ['type', '', 'string', '', 'string', '', '@brackets'], + ], + // highlight all the remaining blocks + [ + /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/, + ['identifier', '', 'string', '', 'string', '', '@brackets'], + ], + // highlight block + [ + /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)(=)(\{)/, + ['identifier', '', 'string', '', 'operator', '', '@brackets'], + ], + // terraform general highlight - shared with expressions + { include: '@terraform' }, + ], + terraform: [ + // highlight terraform functions + [/@terraformFunctions(\()/, ['type', '@brackets']], + // all other words are variables or keywords + [ + /[a-zA-Z_]\w*-*/, // must work with variables such as foo-bar and also with negative numbers + { + cases: { + '@keywords': { token: 'keyword.$0' }, + '@default': 'variable', + }, + }, + ], + { include: '@whitespace' }, + { include: '@heredoc' }, + // delimiters and operators + [/[{}()\[\]]/, '@brackets'], + [/[<>](?!@symbols)/, '@brackets'], + [ + /@symbols/, + { + cases: { + '@operators': 'operator', + '@default': '', + }, + }, + ], + // numbers + [/\d*\d+[eE]([\-+]?\d+)?/, 'number.float'], + [/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'], + [/\d[\d']*/, 'number'], + [/\d/, 'number'], + [/[;,.]/, 'delimiter'], // delimiter: after number because of .\d floats + // strings + [/"/, 'string', '@string'], // this will include expressions + [/'/, 'invalid'], + ], + heredoc: [ + [ + /<<[-]*\s*["]?([\w\-]+)["]?/, + { token: 'string.heredoc.delimiter', next: '@heredocBody.$1' }, + ], + ], + heredocBody: [ + [ + /^([\w\-]+)$/, + { + cases: { + '$1==$S2': [ + { + token: 'string.heredoc.delimiter', + next: '@popall', + }, + ], + '@default': 'string.heredoc', + }, + }, + ], + [/./, 'string.heredoc'], + ], + whitespace: [ + [/[ \t\r\n]+/, ''], + [/\/\*/, 'comment', '@comment'], + [/\/\/.*$/, 'comment'], + [/#.*$/, 'comment'], + ], + comment: [[/[^\/*]+/, 'comment'], [/\*\//, 'comment', '@pop'], [/[\/*]/, 'comment']], + string: [ + [/\$\{/, { token: 'delimiter', next: '@stringExpression' }], + [/[^\\"\$]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@popall'], + ], + stringInsideExpression: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'], + ], + stringExpression: [ + [/\}/, { token: 'delimiter', next: '@pop' }], + [/"/, 'string', '@stringInsideExpression'], + { include: '@terraform' }, + ], + }, +}; + +export default { + id: 'hcl', + extensions: ['.tf', '.tfvars', '.hcl'], + aliases: ['Terraform', 'tf', 'HCL', 'hcl'], + conf, + language, +}; diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js index 0c85a1104fc..580ad820bf9 100644 --- a/app/assets/javascripts/ide/lib/languages/index.js +++ b/app/assets/javascripts/ide/lib/languages/index.js @@ -1,5 +1,6 @@ import vue from './vue'; +import hcl from './hcl'; -const languages = [vue]; +const languages = [vue, hcl]; export default languages; diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 3d6c9e6b297..878a748e99a 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -55,7 +55,7 @@ export default { <div class="discussion-with-resolve-btn clearfix"> <reply-placeholder data-qa-selector="discussion_reply_tab" - :button-text="s__('MergeRequests|Reply')" + :button-text="s__('MergeRequests|Reply...')" @onClick="$emit('showReplyForm')" /> diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue index ab00ccdc09b..0204169214b 100644 --- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -1,11 +1,6 @@ <script> -import { GlButton } from '@gitlab/ui'; - export default { name: 'ReplyPlaceholder', - components: { - GlButton, - }, props: { buttonText: { type: String, @@ -16,13 +11,13 @@ export default { </script> <template> - <gl-button - category="primary" - variant="success" - class="js-vue-discussion-reply" + <button + ref="button" + type="button" + class="js-vue-discussion-reply btn btn-text-field" :title="s__('MergeRequests|Add a reply')" @click="$emit('onClick')" > {{ buttonText }} - </gl-button> + </button> </template> diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue index 76e0976ac05..4e99099b0a1 100644 --- a/app/assets/javascripts/packages/details/components/additional_metadata.vue +++ b/app/assets/javascripts/packages/details/components/additional_metadata.vue @@ -2,7 +2,6 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -import { generateConanRecipe } from '../utils'; import { PackageType } from '../../shared/constants'; export default { @@ -25,9 +24,6 @@ export default { }, }, computed: { - conanRecipe() { - return generateConanRecipe(this.packageEntity); - }, showMetadata() { const visibilityConditions = { [PackageType.NUGET]: this.packageEntity.nuget_metadatum, @@ -73,7 +69,7 @@ export default { data-testid="conan-recipe" > <gl-sprintf :message="$options.i18n.recipeText"> - <template #recipe>{{ conanRecipe }}</template> + <template #recipe>{{ packageEntity.name }}</template> </gl-sprintf> </details-row> diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js index 04f75fc8333..bb0ae3e9ab7 100644 --- a/app/assets/javascripts/packages/details/store/getters.js +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -1,4 +1,3 @@ -import { generateConanRecipe } from '../utils'; import { PackageType } from '../../shared/constants'; import { getPackageTypeLabel } from '../../shared/utils'; import { NpmManager } from '../constants'; @@ -20,10 +19,8 @@ export const packageIcon = ({ packageEntity }) => { }; export const conanInstallationCommand = ({ packageEntity }) => { - const recipe = generateConanRecipe(packageEntity); - // eslint-disable-next-line @gitlab/require-i18n-strings - return `conan install ${recipe} --remote=gitlab`; + return `conan install ${packageEntity.name} --remote=gitlab`; }; export const conanSetupCommand = ({ conanPath }) => diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js index 454c83c9ccd..27cc95566d3 100644 --- a/app/assets/javascripts/packages/details/utils.js +++ b/app/assets/javascripts/packages/details/utils.js @@ -8,16 +8,3 @@ export const trackInstallationTabChange = { }, }, }; - -export function generateConanRecipe(packageEntity = {}) { - const { - name = '', - version = '', - conan_metadatum: { - package_username: packageUsername = '', - package_channel: packageChannel = '', - } = {}, - } = packageEntity; - - return `${name}/${version}@${packageUsername}/${packageChannel}`; -} diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js index 4836900aa28..c94782fdf1b 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -1,7 +1,3 @@ import initPackageList from '~/packages/list/packages_list_app_bundle'; -document.addEventListener('DOMContentLoaded', () => { - if (document.getElementById('js-vue-packages-list')) { - initPackageList(); - } -}); +initPackageList(); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 1bdb7d18f04..423bf3b32bd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -321,7 +321,11 @@ export default { </div> </div> - <pipelines-timeago :duration="pipelineDuration" :finished-time="pipelineFinishedAt" /> + <pipelines-timeago + class="gl-text-right" + :duration="pipelineDuration" + :finished-time="pipelineFinishedAt" + /> <div v-if="displayPipelineActions" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 8de18aef639..d43b3f93aef 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -50,7 +50,7 @@ export default { }; </script> <template> - <div class="table-section section-15 pipelines-time-ago"> + <div class="table-section section-15"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div> <div class="table-mobile-content"> <p v-if="hasDuration" class="duration"> diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 94d0f7c999f..a8cc685d880 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -244,15 +244,20 @@ } &.btn-text-field { - color: $gray-500; - justify-content: start; width: 100%; text-align: left; + padding: 6px 16px; + border-color: $border-color; + color: $gray-darkest; + background-color: $white; &:hover, &:active, &:focus { cursor: text; + box-shadow: none; + border-color: lighten($blue-300, 20%); + color: $gray-darkest; } } diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss new file mode 100644 index 00000000000..8fcfde6b32b --- /dev/null +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -0,0 +1,66 @@ +@import 'mixins_and_variables_and_functions'; + +/** + * Pipelines Bundle + * + * Styles of pipeline lists + * + * Should affect pipelines table components rendered by: + * app/assets/javascripts/commit/pipelines/pipelines_bundle.js + */ + +.pipelines { + .badge { + margin-bottom: 3px; + } + + .pipeline-actions { + min-width: 170px; //Guarantees buttons don't break in several lines. + + .btn-default { + color: $gl-text-color-secondary; + } + + .btn.btn-retry:hover, + .btn.btn-retry:focus { + border-color: $dropdown-toggle-active-border-color; + background-color: $white-normal; + } + + svg path { + fill: $gl-text-color-secondary; + } + + .dropdown-menu { + max-height: $dropdown-max-height; + overflow-y: auto; + } + + .dropdown-toggle, + .dropdown-menu { + color: $gl-text-color-secondary; + + .fa { + color: $gl-text-color-secondary; + font-size: 14px; + } + } + + .btn-group.open .btn-default { + background-color: $white-normal; + border-color: $border-white-normal; + } + + .btn .text-center { + display: inline; + } + + .tooltip { + white-space: nowrap; + } + } + + .pipeline-tags .label-container { + white-space: normal; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 48d37ead8e2..0f68c393187 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,92 +1,3 @@ -.pipelines { - .stage { - max-width: 90px; - width: 90px; - text-align: center; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .table-holder { - overflow: unset; - width: 100%; - } - - .commit-title { - margin: 0; - white-space: normal; - - @include media-breakpoint-down(sm) { - justify-content: flex-end; - } - } - - .ci-table { - .badge { - margin-bottom: 3px; - } - - .pipeline-id { - color: $black; - } - - .pipelines-time-ago { - text-align: right; - } - - .pipeline-actions { - min-width: 170px; //Guarantees buttons don't break in several lines. - - .btn-default { - color: $gl-text-color-secondary; - } - - .btn.btn-retry:hover, - .btn.btn-retry:focus { - border-color: $dropdown-toggle-active-border-color; - background-color: $white-normal; - } - - svg path { - fill: $gl-text-color-secondary; - } - - .dropdown-menu { - max-height: $dropdown-max-height; - overflow-y: auto; - } - - .dropdown-toggle, - .dropdown-menu { - color: $gl-text-color-secondary; - - .fa { - color: $gl-text-color-secondary; - font-size: 14px; - } - } - - .btn-group.open .btn-default { - background-color: $white-normal; - border-color: $border-white-normal; - } - - .btn .text-center { - display: inline; - } - - .tooltip { - white-space: nowrap; - } - } - - .pipeline-tags .label-container { - white-space: normal; - } - } -} - @include media-breakpoint-down(md) { .content-list { &.builds-content-list { @@ -246,11 +157,6 @@ } } -// Pipeline visualization -.pipeline-actions { - border-bottom: 0; -} - .ci-build-text, .ci-status-text { font-weight: 200; diff --git a/app/controllers/projects/feature_flags_clients_controller.rb b/app/controllers/projects/feature_flags_clients_controller.rb new file mode 100644 index 00000000000..02c9d9ab8fb --- /dev/null +++ b/app/controllers/projects/feature_flags_clients_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Projects::FeatureFlagsClientsController < Projects::ApplicationController + before_action :authorize_admin_feature_flags_client! + before_action :feature_flags_client + + def reset_token + feature_flags_client.reset_token! + + respond_to do |format| + format.json do + render json: feature_flags_client_token_json, status: :ok + end + end + end + + private + + def feature_flags_client + project.operations_feature_flags_client || not_found + end + + def feature_flags_client_token_json + FeatureFlagsClientSerializer.new + .represent_token(feature_flags_client) + end +end diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb new file mode 100644 index 00000000000..4452b61508b --- /dev/null +++ b/app/controllers/projects/feature_flags_controller.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +class Projects::FeatureFlagsController < Projects::ApplicationController + respond_to :html + + before_action :authorize_read_feature_flag! + before_action :authorize_create_feature_flag!, only: [:new, :create] + before_action :authorize_update_feature_flag!, only: [:edit, :update] + before_action :authorize_destroy_feature_flag!, only: [:destroy] + + before_action :feature_flag, only: [:edit, :update, :destroy] + + before_action :ensure_legacy_flags_writable!, only: [:update] + + before_action do + push_frontend_feature_flag(:feature_flag_permissions) + push_frontend_feature_flag(:feature_flags_new_version, project, default_enabled: true) + push_frontend_feature_flag(:feature_flags_legacy_read_only, project, default_enabled: true) + push_frontend_feature_flag(:feature_flags_legacy_read_only_override, project) + end + + def index + @feature_flags = FeatureFlagsFinder + .new(project, current_user, scope: params[:scope]) + .execute + .page(params[:page]) + .per(30) + + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + render json: { feature_flags: feature_flags_json }.merge(summary_json) + end + end + end + + def new + end + + def show + respond_to do |format| + format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + render_success_json(feature_flag) + end + end + end + + def create + result = FeatureFlags::CreateService.new(project, current_user, create_params).execute + + if result[:status] == :success + respond_to do |format| + format.json { render_success_json(result[:feature_flag]) } + end + else + respond_to do |format| + format.json { render_error_json(result[:message]) } + end + end + end + + def edit + end + + def update + result = FeatureFlags::UpdateService.new(project, current_user, update_params).execute(feature_flag) + + if result[:status] == :success + respond_to do |format| + format.json { render_success_json(result[:feature_flag]) } + end + else + respond_to do |format| + format.json { render_error_json(result[:message]) } + end + end + end + + def destroy + result = FeatureFlags::DestroyService.new(project, current_user).execute(feature_flag) + + if result[:status] == :success + respond_to do |format| + format.html { redirect_to_index(notice: _('Feature flag was successfully removed.')) } + format.json { render_success_json(feature_flag) } + end + else + respond_to do |format| + format.html { redirect_to_index(alert: _('Feature flag was not removed.')) } + format.json { render_error_json(result[:message]) } + end + end + end + + protected + + def feature_flag + @feature_flag ||= @noteable = if new_version_feature_flags_enabled? + project.operations_feature_flags.find_by_iid!(params[:iid]) + else + project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid]) + end + end + + def new_version_feature_flags_enabled? + ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true) + end + + def ensure_legacy_flags_writable! + if ::Feature.enabled?(:feature_flags_legacy_read_only, project, default_enabled: true) && + ::Feature.disabled?(:feature_flags_legacy_read_only_override, project) && + feature_flag.legacy_flag? + render_error_json(['Legacy feature flags are read-only']) + end + end + + def create_params + params.require(:operations_feature_flag) + .permit(:name, :description, :active, :version, + scopes_attributes: [:environment_scope, :active, + strategies: [:name, parameters: [:groupId, :percentage, :userIds]]], + strategies_attributes: [:name, :user_list_id, + parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness], + scopes_attributes: [:environment_scope]]) + end + + def update_params + params.require(:operations_feature_flag) + .permit(:name, :description, :active, + scopes_attributes: [:id, :environment_scope, :active, :_destroy, + strategies: [:name, parameters: [:groupId, :percentage, :userIds]]], + strategies_attributes: [:id, :name, :user_list_id, :_destroy, + parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness], + scopes_attributes: [:id, :environment_scope, :_destroy]]) + end + + def feature_flag_json(feature_flag) + FeatureFlagSerializer + .new(project: @project, current_user: @current_user) + .represent(feature_flag) + end + + def feature_flags_json + FeatureFlagSerializer + .new(project: @project, current_user: @current_user) + .with_pagination(request, response) + .represent(@feature_flags) + end + + def summary_json + FeatureFlagSummarySerializer + .new(project: @project, current_user: @current_user) + .represent(@project) + end + + def redirect_to_index(**args) + redirect_to project_feature_flags_path(@project), status: :found, **args + end + + def render_success_json(feature_flag) + render json: feature_flag_json(feature_flag), status: :ok + end + + def render_error_json(messages) + render json: { message: messages }, + status: :bad_request + end +end diff --git a/app/controllers/projects/feature_flags_user_lists_controller.rb b/app/controllers/projects/feature_flags_user_lists_controller.rb new file mode 100644 index 00000000000..5427a892bff --- /dev/null +++ b/app/controllers/projects/feature_flags_user_lists_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Projects::FeatureFlagsUserListsController < Projects::ApplicationController + before_action :authorize_admin_feature_flags_user_lists! + before_action :user_list, only: [:edit, :show] + + def new + end + + def edit + end + + def show + end + + private + + def user_list + @user_list = project.operations_feature_flags_user_lists.find_by_iid!(params[:iid]) + end +end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index b0cfda67ad4..0cdf53d6174 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -9,6 +9,7 @@ module UserCalloutsHelper TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' WEBHOOKS_MOVED = 'webhooks_moved' CUSTOMIZE_HOMEPAGE = 'customize_homepage' + FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version' def show_admin_integrations_moved? !user_dismissed?(ADMIN_INTEGRATIONS_MOVED) @@ -50,6 +51,10 @@ module UserCalloutsHelper customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE) end + def show_feature_flags_new_version? + !user_dismissed?(FEATURE_FLAGS_NEW_VERSION) + end + private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb index f525180048e..c899ac514d3 100644 --- a/app/models/blob_viewer/markup.rb +++ b/app/models/blob_viewer/markup.rb @@ -9,5 +9,15 @@ module BlobViewer self.extensions = Gitlab::MarkupHelper::EXTENSIONS self.file_types = %i(readme) self.binary = false + + def banzai_render_context + {}.tap do |h| + h[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup) + + if Feature.enabled?(:cached_markdown_blob, blob.project) + h[:cache_key] = ['blob', blob.id, 'commit', blob.commit_id] + end + end + end end end diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb index bdb2e34854e..e8223d6498b 100644 --- a/app/presenters/packages/detail/package_presenter.rb +++ b/app/presenters/packages/detail/package_presenter.rb @@ -8,10 +8,13 @@ module Packages end def detail_view + name = @package.name + name = @package.conan_recipe if @package.conan? + package_detail = { id: @package.id, created_at: @package.created_at, - name: @package.name, + name: name, package_files: @package.package_files.map { |pf| build_package_file_view(pf) }, package_type: @package.package_type, project_id: @package.project_id, @@ -20,6 +23,7 @@ module Packages version: @package.version } + package_detail[:conan_package_name] = @package.name if @package.conan? package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum package_detail[:composer_metadatum] = @package.composer_metadatum if @package.composer_metadatum diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index 0394cfb6119..809c478b8c7 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -33,7 +33,7 @@ module Ci pipeline_params.fetch(:target_revision)) downstream_pipeline = service.execute( - pipeline_params.fetch(:source), pipeline_params[:execute_params]) do |pipeline| + pipeline_params.fetch(:source), **pipeline_params[:execute_params]) do |pipeline| pipeline.variables.build(@bridge.downstream_variables) end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 3f1a2d1350d..e7ede98fea4 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -70,7 +70,7 @@ module Ci push_options: params[:push_options] || {}, chat_data: params[:chat_data], bridge: bridge, - **extra_options(options)) + **extra_options(**options)) # Ensure we never persist the pipeline when dry_run: true @pipeline.readonly! if command.dry_run? diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb index 0fe0d26d7b2..040ecc29d3a 100644 --- a/app/services/notification_recipients/build_service.rb +++ b/app/services/notification_recipients/build_service.rb @@ -13,8 +13,8 @@ module NotificationRecipients NotificationRecipient.new(user, *args).notifiable? end - def self.build_recipients(*args) - ::NotificationRecipients::Builder::Default.new(*args).notification_recipients + def self.build_recipients(target, current_user, **args) + ::NotificationRecipients::Builder::Default.new(target, current_user, **args).notification_recipients end def self.build_new_note_recipients(*args) @@ -25,8 +25,8 @@ module NotificationRecipients ::NotificationRecipients::Builder::MergeRequestUnmergeable.new(*args).notification_recipients end - def self.build_project_maintainers_recipients(*args) - ::NotificationRecipients::Builder::ProjectMaintainers.new(*args).notification_recipients + def self.build_project_maintainers_recipients(target, **args) + ::NotificationRecipients::Builder::ProjectMaintainers.new(target, **args).notification_recipients end def self.build_new_release_recipients(*args) diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml index 8134adcbc32..703ffa8896e 100644 --- a/app/views/projects/blob/viewers/_markup.html.haml +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -1,4 +1,3 @@ - blob = viewer.blob -- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {} .file-content.md - = markup(blob.name, blob.data, context) + = markup(blob.name, blob.data, viewer.banzai_render_context) diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index f8c27f4c026..0dbd6e53212 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -1,4 +1,5 @@ - page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits') +- add_page_specific_style 'page_bundles/pipelines' = render 'commit_box' = render 'ci_menu' diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml index 67b1a8398d3..028595aba0b 100644 --- a/app/views/projects/feature_flags/edit.html.haml +++ b/app/views/projects/feature_flags/edit.html.haml @@ -9,7 +9,7 @@ feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json), user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERISION, + user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'), diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml index a7388361da5..3bad1d9773c 100644 --- a/app/views/projects/feature_flags/new.html.haml +++ b/app/views/projects/feature_flags/new.html.haml @@ -7,7 +7,7 @@ feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json), user_callouts_path: user_callouts_path, - user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERISION, + user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'), diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml index ad4980fa57f..4c968c8e8eb 100644 --- a/app/views/projects/merge_requests/creations/new.html.haml +++ b/app/views/projects/merge_requests/creations/new.html.haml @@ -1,6 +1,7 @@ - add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project) - breadcrumb_title _("New") - page_title _("New Merge Request") +- add_page_specific_style 'page_bundles/pipelines' - if @merge_request.can_be_created && !params[:change_branches] = render 'new_submit' diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 84b108d69ad..06513d56221 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -8,6 +8,7 @@ - suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') - number_of_pipelines = @pipelines.size - mr_action = j(params[:tab].presence || 'show') +- add_page_specific_style 'page_bundles/pipelines' .merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } = render "projects/merge_requests/mr_title" diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 05f8a126a02..ca07f33136b 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -1,4 +1,5 @@ - page_title _('Pipelines') +- add_page_specific_style 'page_bundles/pipelines' = render_if_exists "shared/shared_runners_minutes_limit_flash_message" diff --git a/changelogs/unreleased/229727-drop-type-on-audit-events.yml b/changelogs/unreleased/229727-drop-type-on-audit-events.yml new file mode 100644 index 00000000000..0abfc00444b --- /dev/null +++ b/changelogs/unreleased/229727-drop-type-on-audit-events.yml @@ -0,0 +1,5 @@ +--- +title: Remove type column on audit_events table +merge_request: 43703 +author: +type: other diff --git a/changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml b/changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml new file mode 100644 index 00000000000..0737cec3d89 --- /dev/null +++ b/changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml @@ -0,0 +1,5 @@ +--- +title: Restore snippet repositories from backups +merge_request: 43696 +author: +type: changed diff --git a/changelogs/unreleased/239130-package-presenter-conan.yml b/changelogs/unreleased/239130-package-presenter-conan.yml new file mode 100644 index 00000000000..8f8ad152966 --- /dev/null +++ b/changelogs/unreleased/239130-package-presenter-conan.yml @@ -0,0 +1,5 @@ +--- +title: Display conan recipe as package name on package detail page +merge_request: 44294 +author: +type: changed diff --git a/changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml b/changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml deleted file mode 100644 index cb99207fffe..00000000000 --- a/changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Replacing deprecated Bootstrap button with GlButton and updating btn-text-field - class to align with styles -merge_request: 41430 -author: -type: other diff --git a/changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml b/changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml new file mode 100644 index 00000000000..b6e597bfc97 --- /dev/null +++ b/changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml @@ -0,0 +1,5 @@ +--- +title: Update Design thumbnail after uploading an image with the same filename +merge_request: 44305 +author: +type: fixed diff --git a/changelogs/unreleased/42782-move-beforescript-into-script.yml b/changelogs/unreleased/42782-move-beforescript-into-script.yml new file mode 100644 index 00000000000..5d649838a8c --- /dev/null +++ b/changelogs/unreleased/42782-move-beforescript-into-script.yml @@ -0,0 +1,5 @@ +--- +title: Move before_script into script for CQ template +merge_request: 42782 +author: Vicken Simonian @vicken.papaya +type: fixed diff --git a/changelogs/unreleased/adding-hcl.yml b/changelogs/unreleased/adding-hcl.yml new file mode 100644 index 00000000000..4b0d750474f --- /dev/null +++ b/changelogs/unreleased/adding-hcl.yml @@ -0,0 +1,5 @@ +--- +title: IDE editor - Adding syntax highlighting for terraform / hcl +merge_request: 44056 +author: +type: added diff --git a/changelogs/unreleased/id-required-sections.yml b/changelogs/unreleased/id-required-sections.yml new file mode 100644 index 00000000000..b24baa719c0 --- /dev/null +++ b/changelogs/unreleased/id-required-sections.yml @@ -0,0 +1,5 @@ +--- +title: Introduce required_code_owners_sections table +merge_request: 43573 +author: +type: added diff --git a/config/application.rb b/config/application.rb index 411c136e7d4..798deada81b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -182,6 +182,7 @@ module Gitlab config.assets.precompile << "page_bundles/merge_conflicts.css" config.assets.precompile << "page_bundles/milestone.css" config.assets.precompile << "page_bundles/pipeline.css" + config.assets.precompile << "page_bundles/pipelines.css" config.assets.precompile << "page_bundles/todos.css" config.assets.precompile << "page_bundles/xterm.css" config.assets.precompile << "lazy_bundles/cropper.css" diff --git a/config/feature_flags/development/cached_markdown_blob.yml b/config/feature_flags/development/cached_markdown_blob.yml new file mode 100644 index 00000000000..f125f598698 --- /dev/null +++ b/config/feature_flags/development/cached_markdown_blob.yml @@ -0,0 +1,7 @@ +--- +name: cached_markdown_blob +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44300 +rollout_issue_url: +type: development +group: group::source code +default_enabled: false diff --git a/config/feature_flags/development/ci_dynamic_child_pipeline.yml b/config/feature_flags/development/ci_dynamic_child_pipeline.yml index ac2afe77743..c568e9392b2 100644 --- a/config/feature_flags/development/ci_dynamic_child_pipeline.yml +++ b/config/feature_flags/development/ci_dynamic_child_pipeline.yml @@ -1,7 +1,7 @@ --- name: ci_dynamic_child_pipeline -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23790 +rollout_issue_url: +group: group::continuous integration type: development default_enabled: true diff --git a/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml b/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml index 8abb52486b6..5f23d038998 100644 --- a/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml +++ b/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml @@ -1,7 +1,7 @@ --- name: ci_lint_creates_pipeline_with_dry_run -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37828 +rollout_issue_url: +group: group::continuous integration type: development default_enabled: true diff --git a/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml b/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml index d2e25e7bf11..c2cd1d62734 100644 --- a/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml +++ b/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml @@ -1,7 +1,7 @@ --- name: ci_raise_job_rules_without_workflow_rules_warning -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38387 +rollout_issue_url: +group: group::continuous integration type: development default_enabled: true diff --git a/config/feature_flags/development/ci_store_pipeline_messages.yml b/config/feature_flags/development/ci_store_pipeline_messages.yml index c7235ab2196..35cbfad0efa 100644 --- a/config/feature_flags/development/ci_store_pipeline_messages.yml +++ b/config/feature_flags/development/ci_store_pipeline_messages.yml @@ -1,7 +1,7 @@ --- name: ci_store_pipeline_messages -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: +rollout_issue_url: +group: group::continuous integration type: development default_enabled: true diff --git a/config/feature_flags/development/ci_yaml_limit_size.yml b/config/feature_flags/development/ci_yaml_limit_size.yml index 06229c08af5..0ebd29d0ba5 100644 --- a/config/feature_flags/development/ci_yaml_limit_size.yml +++ b/config/feature_flags/development/ci_yaml_limit_size.yml @@ -1,7 +1,7 @@ --- name: ci_yaml_limit_size -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: +rollout_issue_url: +group: group::continuous integration type: development default_enabled: true diff --git a/config/feature_flags/development/efficient_counter_attribute.yml b/config/feature_flags/development/efficient_counter_attribute.yml index a1b16be7ce8..1b12c166c53 100644 --- a/config/feature_flags/development/efficient_counter_attribute.yml +++ b/config/feature_flags/development/efficient_counter_attribute.yml @@ -1,7 +1,7 @@ --- name: efficient_counter_attribute -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35878 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238535 +group: group::continuous integration type: development default_enabled: false diff --git a/config/feature_flags/development/feature_flag_api.yml b/config/feature_flags/development/feature_flag_api.yml new file mode 100644 index 00000000000..326cfa83433 --- /dev/null +++ b/config/feature_flags/development/feature_flag_api.yml @@ -0,0 +1,7 @@ +--- +name: feature_flag_api +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18198 +rollout_issue_url: +group: group::progressive delivery +type: development +default_enabled: false diff --git a/config/feature_flags/development/feature_flag_permissions.yml b/config/feature_flags/development/feature_flag_permissions.yml new file mode 100644 index 00000000000..2eb5b513743 --- /dev/null +++ b/config/feature_flags/development/feature_flag_permissions.yml @@ -0,0 +1,7 @@ +--- +name: feature_flag_permissions +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/10096 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254981 +group: group::progressive delivery +type: development +default_enabled: false diff --git a/config/feature_flags/development/feature_flags_legacy_read_only.yml b/config/feature_flags/development/feature_flags_legacy_read_only.yml new file mode 100644 index 00000000000..b790e466093 --- /dev/null +++ b/config/feature_flags/development/feature_flags_legacy_read_only.yml @@ -0,0 +1,7 @@ +--- +name: feature_flags_legacy_read_only +introduced_by_url: +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/240985 +group: group::progressive delivery +type: development +default_enabled: true diff --git a/config/feature_flags/development/feature_flags_legacy_read_only_override.yml b/config/feature_flags/development/feature_flags_legacy_read_only_override.yml new file mode 100644 index 00000000000..14acde1b8fc --- /dev/null +++ b/config/feature_flags/development/feature_flags_legacy_read_only_override.yml @@ -0,0 +1,7 @@ +--- +name: feature_flags_legacy_read_only_override +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40431 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/240985 +group: group::progressive delivery +type: development +default_enabled: false diff --git a/config/routes/project.rb b/config/routes/project.rb index c8fd5dc7e9e..2c681b3cbe7 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -373,9 +373,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :feature_flags, param: :iid do - resources :feature_flag_issues, only: [:index, :create, :destroy], as: 'issues', path: 'issues' - end + resources :feature_flags, param: :iid resource :feature_flags_client, only: [] do post :reset_token end diff --git a/db/migrate/20200928131934_create_required_code_owners_sections.rb b/db/migrate/20200928131934_create_required_code_owners_sections.rb new file mode 100644 index 00000000000..f2dfd4007e5 --- /dev/null +++ b/db/migrate/20200928131934_create_required_code_owners_sections.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CreateRequiredCodeOwnersSections < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + with_lock_retries do + create_table :required_code_owners_sections, if_not_exists: true do |t| + t.references :protected_branch, null: false, foreign_key: { on_delete: :cascade } + t.text :name, null: false + end + end + + add_text_limit :required_code_owners_sections, :name, 1024 + end + + def down + with_lock_retries do + drop_table :required_code_owners_sections, if_exists: true + end + end +end diff --git a/db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb b/db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb new file mode 100644 index 00000000000..7bfae7377d7 --- /dev/null +++ b/db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddIndexOnVulnerabilitiesStateCase < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_vulnerabilities_on_state_case_id' + STATE_ORDER_ARRAY_POSITION = 'ARRAY_POSITION(ARRAY[1, 4, 3, 2]::smallint[], state)' + + disable_ddl_transaction! + + def up + add_concurrent_index :vulnerabilities, "#{STATE_ORDER_ARRAY_POSITION}, id DESC", name: INDEX_NAME + add_concurrent_index :vulnerabilities, "#{STATE_ORDER_ARRAY_POSITION} DESC, id DESC", name: "#{INDEX_NAME}_desc" + end + + def down + remove_concurrent_index_by_name :vulnerabilities, "#{INDEX_NAME}_desc" + remove_concurrent_index_by_name :vulnerabilities, INDEX_NAME + end +end diff --git a/db/post_migrate/20200929113254_remove_type_from_audit_events.rb b/db/post_migrate/20200929113254_remove_type_from_audit_events.rb new file mode 100644 index 00000000000..000dc0d2865 --- /dev/null +++ b/db/post_migrate/20200929113254_remove_type_from_audit_events.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +class RemoveTypeFromAuditEvents < ActiveRecord::Migration[6.0] + include Gitlab::Database::SchemaHelpers + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + SOURCE_TABLE_NAME = 'audit_events' + PARTITIONED_TABLE_NAME = 'audit_events_part_5fc467ac26' + TRIGGER_FUNCTION_NAME = 'table_sync_function_2be879775d' + + def up + with_lock_retries do + remove_column SOURCE_TABLE_NAME, :type + + create_trigger_function(TRIGGER_FUNCTION_NAME, replace: true) do + <<~SQL + IF (TG_OP = 'DELETE') THEN + DELETE FROM #{PARTITIONED_TABLE_NAME} where id = OLD.id; + ELSIF (TG_OP = 'UPDATE') THEN + UPDATE #{PARTITIONED_TABLE_NAME} + SET author_id = NEW.author_id, + entity_id = NEW.entity_id, + entity_type = NEW.entity_type, + details = NEW.details, + ip_address = NEW.ip_address, + author_name = NEW.author_name, + entity_path = NEW.entity_path, + target_details = NEW.target_details, + target_type = NEW.target_type, + target_id = NEW.target_id, + created_at = NEW.created_at + WHERE #{PARTITIONED_TABLE_NAME}.id = NEW.id; + ELSIF (TG_OP = 'INSERT') THEN + INSERT INTO #{PARTITIONED_TABLE_NAME} (id, + author_id, + entity_id, + entity_type, + details, + ip_address, + author_name, + entity_path, + target_details, + target_type, + target_id, + created_at) + VALUES (NEW.id, + NEW.author_id, + NEW.entity_id, + NEW.entity_type, + NEW.details, + NEW.ip_address, + NEW.author_name, + NEW.entity_path, + NEW.target_details, + NEW.target_type, + NEW.target_id, + NEW.created_at); + END IF; + RETURN NULL; + SQL + end + + remove_column PARTITIONED_TABLE_NAME, :type + end + end + + def down + with_lock_retries do + add_column SOURCE_TABLE_NAME, :type, :string + add_column PARTITIONED_TABLE_NAME, :type, :string + + create_trigger_function(TRIGGER_FUNCTION_NAME, replace: true) do + <<~SQL + IF (TG_OP = 'DELETE') THEN + DELETE FROM #{PARTITIONED_TABLE_NAME} where id = OLD.id; + ELSIF (TG_OP = 'UPDATE') THEN + UPDATE #{PARTITIONED_TABLE_NAME} + SET author_id = NEW.author_id, + type = NEW.type, + entity_id = NEW.entity_id, + entity_type = NEW.entity_type, + details = NEW.details, + ip_address = NEW.ip_address, + author_name = NEW.author_name, + entity_path = NEW.entity_path, + target_details = NEW.target_details, + target_type = NEW.target_type, + target_id = NEW.target_id, + created_at = NEW.created_at + WHERE #{PARTITIONED_TABLE_NAME}.id = NEW.id; + ELSIF (TG_OP = 'INSERT') THEN + INSERT INTO #{PARTITIONED_TABLE_NAME} (id, + author_id, + type, + entity_id, + entity_type, + details, + ip_address, + author_name, + entity_path, + target_details, + target_type, + target_id, + created_at) + VALUES (NEW.id, + NEW.author_id, + NEW.type, + NEW.entity_id, + NEW.entity_type, + NEW.details, + NEW.ip_address, + NEW.author_name, + NEW.entity_path, + NEW.target_details, + NEW.target_type, + NEW.target_id, + NEW.created_at); + END IF; + RETURN NULL; + SQL + end + end + end +end diff --git a/db/schema_migrations/20200928131934 b/db/schema_migrations/20200928131934 new file mode 100644 index 00000000000..952e2121d35 --- /dev/null +++ b/db/schema_migrations/20200928131934 @@ -0,0 +1 @@ +106757b0f30d3c89fcafa13be92271090fa107831fd538ee087d7ce212842492
\ No newline at end of file diff --git a/db/schema_migrations/20200928164807 b/db/schema_migrations/20200928164807 new file mode 100644 index 00000000000..3efd3c56402 --- /dev/null +++ b/db/schema_migrations/20200928164807 @@ -0,0 +1 @@ +346d0e913212d6e84528d47228ba7e6d0cf4a396e7fc921f7c684acfaaeeedb8
\ No newline at end of file diff --git a/db/schema_migrations/20200929113254 b/db/schema_migrations/20200929113254 new file mode 100644 index 00000000000..172a6eabd66 --- /dev/null +++ b/db/schema_migrations/20200929113254 @@ -0,0 +1 @@ +260f392c3ff257960dc7b198473056e7bf9b9a668403d2f05391d2b7989cf83c
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 038fcd4120e..f027deb56bc 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -19,7 +19,6 @@ IF (TG_OP = 'DELETE') THEN ELSIF (TG_OP = 'UPDATE') THEN UPDATE audit_events_part_5fc467ac26 SET author_id = NEW.author_id, - type = NEW.type, entity_id = NEW.entity_id, entity_type = NEW.entity_type, details = NEW.details, @@ -34,7 +33,6 @@ ELSIF (TG_OP = 'UPDATE') THEN ELSIF (TG_OP = 'INSERT') THEN INSERT INTO audit_events_part_5fc467ac26 (id, author_id, - type, entity_id, entity_type, details, @@ -47,7 +45,6 @@ ELSIF (TG_OP = 'INSERT') THEN created_at) VALUES (NEW.id, NEW.author_id, - NEW.type, NEW.entity_id, NEW.entity_type, NEW.details, @@ -69,7 +66,6 @@ COMMENT ON FUNCTION table_sync_function_2be879775d() IS 'Partitioning migration: CREATE TABLE audit_events_part_5fc467ac26 ( id bigint NOT NULL, author_id integer NOT NULL, - type character varying, entity_id integer NOT NULL, entity_type character varying NOT NULL, details text, @@ -9541,7 +9537,6 @@ ALTER SEQUENCE atlassian_identities_user_id_seq OWNED BY atlassian_identities.us CREATE TABLE audit_events ( id integer NOT NULL, author_id integer NOT NULL, - type character varying, entity_id integer NOT NULL, entity_type character varying NOT NULL, details text, @@ -15451,6 +15446,22 @@ CREATE TABLE repository_languages ( share double precision NOT NULL ); +CREATE TABLE required_code_owners_sections ( + id bigint NOT NULL, + protected_branch_id bigint NOT NULL, + name text NOT NULL, + CONSTRAINT check_e58d53741e CHECK ((char_length(name) <= 1024)) +); + +CREATE SEQUENCE required_code_owners_sections_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE required_code_owners_sections_id_seq OWNED BY required_code_owners_sections.id; + CREATE TABLE requirements ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -17749,6 +17760,8 @@ ALTER TABLE ONLY releases ALTER COLUMN id SET DEFAULT nextval('releases_id_seq': ALTER TABLE ONLY remote_mirrors ALTER COLUMN id SET DEFAULT nextval('remote_mirrors_id_seq'::regclass); +ALTER TABLE ONLY required_code_owners_sections ALTER COLUMN id SET DEFAULT nextval('required_code_owners_sections_id_seq'::regclass); + ALTER TABLE ONLY requirements ALTER COLUMN id SET DEFAULT nextval('requirements_id_seq'::regclass); ALTER TABLE ONLY requirements_management_test_reports ALTER COLUMN id SET DEFAULT nextval('requirements_management_test_reports_id_seq'::regclass); @@ -19030,6 +19043,9 @@ ALTER TABLE ONLY releases ALTER TABLE ONLY remote_mirrors ADD CONSTRAINT remote_mirrors_pkey PRIMARY KEY (id); +ALTER TABLE ONLY required_code_owners_sections + ADD CONSTRAINT required_code_owners_sections_pkey PRIMARY KEY (id); + ALTER TABLE ONLY requirements_management_test_reports ADD CONSTRAINT requirements_management_test_reports_pkey PRIMARY KEY (id); @@ -21207,6 +21223,8 @@ CREATE INDEX index_remote_mirrors_on_project_id ON remote_mirrors USING btree (p CREATE UNIQUE INDEX index_repository_languages_on_project_and_languages_id ON repository_languages USING btree (project_id, programming_language_id); +CREATE INDEX index_required_code_owners_sections_on_protected_branch_id ON required_code_owners_sections USING btree (protected_branch_id); + CREATE INDEX index_requirements_management_test_reports_on_author_id ON requirements_management_test_reports USING btree (author_id); CREATE INDEX index_requirements_management_test_reports_on_build_id ON requirements_management_test_reports USING btree (build_id); @@ -21603,6 +21621,10 @@ CREATE INDEX index_vulnerabilities_on_resolved_by_id ON vulnerabilities USING bt CREATE INDEX index_vulnerabilities_on_start_date_sourcing_milestone_id ON vulnerabilities USING btree (start_date_sourcing_milestone_id); +CREATE INDEX index_vulnerabilities_on_state_case_id ON vulnerabilities USING btree (array_position(ARRAY[(1)::smallint, (4)::smallint, (3)::smallint, (2)::smallint], state), id DESC); + +CREATE INDEX index_vulnerabilities_on_state_case_id_desc ON vulnerabilities USING btree (array_position(ARRAY[(1)::smallint, (4)::smallint, (3)::smallint, (2)::smallint], state) DESC, id DESC); + CREATE INDEX index_vulnerabilities_on_updated_by_id ON vulnerabilities USING btree (updated_by_id); CREATE INDEX index_vulnerability_exports_on_author_id ON vulnerability_exports USING btree (author_id); @@ -23310,6 +23332,9 @@ ALTER TABLE ONLY clusters_kubernetes_namespaces ALTER TABLE ONLY approval_merge_request_rules_users ADD CONSTRAINT fk_rails_80e6801803 FOREIGN KEY (approval_merge_request_rule_id) REFERENCES approval_merge_request_rules(id) ON DELETE CASCADE; +ALTER TABLE ONLY required_code_owners_sections + ADD CONSTRAINT fk_rails_817708cf2d FOREIGN KEY (protected_branch_id) REFERENCES protected_branches(id) ON DELETE CASCADE; + ALTER TABLE ONLY dast_site_profiles ADD CONSTRAINT fk_rails_83e309d69e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/administration/geo/disaster_recovery/index.md b/doc/administration/geo/disaster_recovery/index.md index dc46c0756db..c2a59ade7e4 100644 --- a/doc/administration/geo/disaster_recovery/index.md +++ b/doc/administration/geo/disaster_recovery/index.md @@ -142,7 +142,7 @@ In GitLab 13.2 and later versions, promoting a secondary node to a primary while If you have already run the [preflight checks](planned_failover.md#preflight-checks) separately or don't want to run them, you can skip preflight checks with: ```shell - gitlab-ctl promote-to-primary-node --skip-preflight-check + gitlab-ctl promote-to-primary-node --skip-preflight-checks ``` You can also promote the secondary node to primary **without any further confirmation**, even when preflight checks fail: diff --git a/doc/administration/raketasks/check.md b/doc/administration/raketasks/check.md index d13e6328b2f..f4c8bc9e989 100644 --- a/doc/administration/raketasks/check.md +++ b/doc/administration/raketasks/check.md @@ -171,9 +171,7 @@ Checking integrity of Uploads Done! ``` -To delete these references to remote uploads that were deleted externally, open the [GitLab Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session) -and run: -[Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session): +To delete these references to remote uploads that were deleted externally, open the [GitLab Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session) and run: ```ruby uploads_deleted=0 diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 4866cd9f4a0..4fcd608af5c 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -15889,6 +15889,11 @@ type Requirement { iid: ID! """ + Indicates if latest test report was created by user + """ + lastTestReportManuallyCreated: Boolean + + """ Latest requirement test report state """ lastTestReportState: TestReportState @@ -20153,7 +20158,7 @@ type Vulnerability implements Noteable { severity: VulnerabilitySeverity """ - State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED) + State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED) """ state: VulnerabilityState @@ -20816,6 +20821,16 @@ enum VulnerabilitySort { severity_desc """ + State in ascending order + """ + state_asc + + """ + State in descending order + """ + state_desc + + """ Title in ascending order """ title_asc diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index ec247a09c97..7c96df4ad19 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -45945,6 +45945,20 @@ "deprecationReason": null }, { + "name": "lastTestReportManuallyCreated", + "description": "Indicates if latest test report was created by user", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "lastTestReportState", "description": "Latest requirement test report state", "args": [ @@ -58489,7 +58503,7 @@ }, { "name": "state", - "description": "State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED)", + "description": "State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED)", "args": [ ], @@ -60468,6 +60482,18 @@ "description": "Report Type in ascending order", "isDeprecated": false, "deprecationReason": null + }, + { + "name": "state_desc", + "description": "State in descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state_asc", + "description": "State in ascending order", + "isDeprecated": false, + "deprecationReason": null } ], "possibleTypes": null @@ -60487,7 +60513,7 @@ "deprecationReason": null }, { - "name": "DISMISSED", + "name": "CONFIRMED", "description": null, "isDeprecated": false, "deprecationReason": null @@ -60499,7 +60525,7 @@ "deprecationReason": null }, { - "name": "CONFIRMED", + "name": "DISMISSED", "description": null, "isDeprecated": false, "deprecationReason": null diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 77387a180b2..0f7769c44ec 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2135,6 +2135,7 @@ Represents a requirement. | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `id` | ID! | ID of the requirement | | `iid` | ID! | Internal ID of the requirement | +| `lastTestReportManuallyCreated` | Boolean | Indicates if latest test report was created by user | | `lastTestReportState` | TestReportState | Latest requirement test report state | | `project` | Project! | Project to which the requirement belongs | | `state` | RequirementState! | State of the requirement | @@ -2820,7 +2821,7 @@ Represents a vulnerability. | `resolvedOnDefaultBranch` | Boolean! | Indicates whether the vulnerability is fixed on the default branch or not | | `scanner` | VulnerabilityScanner | Scanner metadata for the vulnerability. | | `severity` | VulnerabilitySeverity | Severity of the vulnerability (INFO, UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL) | -| `state` | VulnerabilityState | State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED) | +| `state` | VulnerabilityState | State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED) | | `title` | String | Title of the vulnerability | | `userNotesCount` | Int! | Number of user notes attached to the vulnerability | | `userPermissions` | VulnerabilityPermissions! | Permissions for the current user on the resource | @@ -3766,6 +3767,8 @@ Vulnerability sort values. | `report_type_desc` | Report Type in descending order | | `severity_asc` | Severity in ascending order | | `severity_desc` | Severity in descending order | +| `state_asc` | State in ascending order | +| `state_desc` | State in descending order | | `title_asc` | Title in ascending order | | `title_desc` | Title in descending order | diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 268770193f9..ae01571ae4b 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -364,17 +364,7 @@ standard Rails migration helper methods. Calling more than one migration helper is not a problem if they're executed on the same table. Using the `with_lock_retries` helper method is advised when a database -migration involves one of the high-traffic tables: - -- `users` -- `projects` -- `namespaces` -- `gitlab_subscriptions` -- `issues` -- `merge_requests` -- `ci_pipelines` -- `ci_builds` -- `notes` +migration involves one of the [high-traffic tables](https://gitlab.com/gitlab-org/gitlab/-/blob/master/rubocop/rubocop-migrations.yml#L3). Example changes: diff --git a/doc/gitlab-basics/add-file.md b/doc/gitlab-basics/add-file.md index c5b57d4623d..659cab299aa 100644 --- a/doc/gitlab-basics/add-file.md +++ b/doc/gitlab-basics/add-file.md @@ -29,16 +29,16 @@ to the desired destination: cd <destination folder> ``` -[Create a branch](create-branch.md) to add your file to, before it is added to the master -(main) branch of the project. It is not strictly necessary, but working directly in -the `master` branch is not recommended unless your project is very small, and you are +[Create a branch](create-branch.md) to add your file to, before it's added to the master +(main) branch of the project. It's not strictly necessary, but working directly in +the `master` branch is not recommended unless your project is very small, and you're the only person working on it. You can [switch to an existing branch](start-using-git.md#work-on-an-existing-branch), -if you have one already. +if you've one already. Using your standard tool for copying files (for example, Finder in macOS, or File Explorer in Windows), put the file into a directory within the GitLab project. -Check if your file is actually present in the directory (if you are in Windows, +Check if your file is actually present in the directory (if you're in Windows, use `dir` instead): ```shell @@ -79,7 +79,7 @@ Now you can push (send) your changes (in the branch `<branch-name>`) to GitLab git push origin <branch-name> ``` -Your image will be added to your branch in your repository in GitLab. +Your image is added to your branch in your repository in GitLab. <!-- ## Troubleshooting diff --git a/lib/api/api.rb b/lib/api/api.rb index 546d726243e..417d4d66aca 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -153,6 +153,9 @@ module API mount ::API::Environments mount ::API::ErrorTracking mount ::API::Events + mount ::API::FeatureFlags + mount ::API::FeatureFlagScopes + mount ::API::FeatureFlagsUserLists mount ::API::Features mount ::API::Files mount ::API::FreezePeriods diff --git a/lib/api/feature_flag_scopes.rb b/lib/api/feature_flag_scopes.rb new file mode 100644 index 00000000000..4a42dbc1aea --- /dev/null +++ b/lib/api/feature_flag_scopes.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module API + class FeatureFlagScopes < Grape::API::Instance + include PaginationParams + + ENVIRONMENT_SCOPE_ENDPOINT_REQUIREMENTS = FeatureFlags::FEATURE_FLAG_ENDPOINT_REQUIREMENTS + .merge(environment_scope: API::NO_SLASH_URL_PART_REGEX) + + before do + authorize_read_feature_flags! + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + resource :feature_flag_scopes do + desc 'Get all effective feature flags under the environment' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag::DetailedLegacyScope + end + params do + requires :environment, type: String, desc: 'The environment name' + end + get do + present scopes_for_environment, with: ::API::Entities::FeatureFlag::DetailedLegacyScope + end + end + + params do + requires :name, type: String, desc: 'The name of the feature flag' + end + resource 'feature_flags/:name', requirements: FeatureFlags::FEATURE_FLAG_ENDPOINT_REQUIREMENTS do + resource :scopes do + desc 'Get all scopes of a feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag::LegacyScope + end + params do + use :pagination + end + get do + present paginate(feature_flag.scopes), with: ::API::Entities::FeatureFlag::LegacyScope + end + + desc 'Create a scope of a feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag::LegacyScope + end + params do + requires :environment_scope, type: String, desc: 'The environment scope of the scope' + requires :active, type: Boolean, desc: 'Whether the scope is active' + requires :strategies, type: JSON, desc: 'The strategies of the scope' + end + post do + authorize_update_feature_flag! + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, scopes_attributes: [declared_params]) + .execute(feature_flag) + + if result[:status] == :success + present scope, with: ::API::Entities::FeatureFlag::LegacyScope + else + render_api_error!(result[:message], result[:http_status]) + end + end + + params do + requires :environment_scope, type: String, desc: 'URL-encoded environment scope' + end + resource ':environment_scope', requirements: ENVIRONMENT_SCOPE_ENDPOINT_REQUIREMENTS do + desc 'Get a scope of a feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag::LegacyScope + end + get do + present scope, with: ::API::Entities::FeatureFlag::LegacyScope + end + + desc 'Update a scope of a feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag::LegacyScope + end + params do + optional :active, type: Boolean, desc: 'Whether the scope is active' + optional :strategies, type: JSON, desc: 'The strategies of the scope' + end + put do + authorize_update_feature_flag! + + scope_attributes = declared_params.merge(id: scope.id) + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, scopes_attributes: [scope_attributes]) + .execute(feature_flag) + + if result[:status] == :success + updated_scope = result[:feature_flag].scopes + .find { |scope| scope.environment_scope == params[:environment_scope] } + + present updated_scope, with: ::API::Entities::FeatureFlag::LegacyScope + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Delete a scope from a feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag::LegacyScope + end + delete do + authorize_update_feature_flag! + + param = { scopes_attributes: [{ id: scope.id, _destroy: true }] } + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, param) + .execute(feature_flag) + + if result[:status] == :success + status :no_content + else + render_api_error!(result[:message], result[:http_status]) + end + end + end + end + end + end + + helpers do + def authorize_read_feature_flags! + authorize! :read_feature_flag, user_project + end + + def authorize_update_feature_flag! + authorize! :update_feature_flag, feature_flag + end + + def feature_flag + @feature_flag ||= user_project.operations_feature_flags + .find_by_name!(params[:name]) + end + + def scope + @scope ||= feature_flag.scopes + .find_by_environment_scope!(CGI.unescape(params[:environment_scope])) + end + + def scopes_for_environment + Operations::FeatureFlagScope + .for_unleash_client(user_project, params[:environment]) + end + end + end +end diff --git a/lib/api/feature_flags.rb b/lib/api/feature_flags.rb new file mode 100644 index 00000000000..9e2be67f0de --- /dev/null +++ b/lib/api/feature_flags.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +module API + class FeatureFlags < Grape::API::Instance + include PaginationParams + + FEATURE_FLAG_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS + .merge(name: API::NO_SLASH_URL_PART_REGEX) + + before do + authorize_read_feature_flags! + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + resource :feature_flags do + desc 'Get all feature flags of a project' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag + end + params do + optional :scope, type: String, desc: 'The scope of feature flags', + values: %w[enabled disabled] + use :pagination + end + get do + feature_flags = ::FeatureFlagsFinder + .new(user_project, current_user, declared_params(include_missing: false)) + .execute + + present_entity(paginate(feature_flags)) + end + + desc 'Create a new feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag + end + params do + requires :name, type: String, desc: 'The name of feature flag' + optional :description, type: String, desc: 'The description of the feature flag' + optional :active, type: Boolean, desc: 'Active/inactive value of the flag' + optional :version, type: String, desc: 'The version of the feature flag' + optional :scopes, type: Array do + requires :environment_scope, type: String, desc: 'The environment scope of the scope' + requires :active, type: Boolean, desc: 'Active/inactive of the scope' + requires :strategies, type: JSON, desc: 'The strategies of the scope' + end + optional :strategies, type: Array do + requires :name, type: String, desc: 'The strategy name' + requires :parameters, type: JSON, desc: 'The strategy parameters' + optional :scopes, type: Array do + requires :environment_scope, type: String, desc: 'The environment scope of the scope' + end + end + end + post do + authorize_create_feature_flag! + + attrs = declared_params(include_missing: false) + + ensure_post_version_2_flags_enabled! if attrs[:version] == 'new_version_flag' + + rename_key(attrs, :scopes, :scopes_attributes) + rename_key(attrs, :strategies, :strategies_attributes) + update_value(attrs, :strategies_attributes) do |strategies| + strategies.map { |s| rename_key(s, :scopes, :scopes_attributes) } + end + + result = ::FeatureFlags::CreateService + .new(user_project, current_user, attrs) + .execute + + if result[:status] == :success + present_entity(result[:feature_flag]) + else + render_api_error!(result[:message], result[:http_status]) + end + end + end + + params do + requires :feature_flag_name, type: String, desc: 'The name of the feature flag' + end + resource 'feature_flags/:feature_flag_name', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMENTS do + desc 'Get a feature flag of a project' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag + end + get do + authorize_read_feature_flag! + + present_entity(feature_flag) + end + + desc 'Enable a strategy for a feature flag on an environment' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag + end + params do + requires :environment_scope, type: String, desc: 'The environment scope of the feature flag' + requires :strategy, type: JSON, desc: 'The strategy to be enabled on the scope' + end + post :enable do + not_found! unless Feature.enabled?(:feature_flag_api, user_project) + render_api_error!('Version 2 flags not supported', :unprocessable_entity) if new_version_flag_present? + + result = ::FeatureFlags::EnableService + .new(user_project, current_user, params).execute + + if result[:status] == :success + status :ok + present_entity(result[:feature_flag]) + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Disable a strategy for a feature flag on an environment' do + detail 'This feature is going to be introduced in GitLab 12.5 if `feature_flag_api` feature flag is removed' + success ::API::Entities::FeatureFlag + end + params do + requires :environment_scope, type: String, desc: 'The environment scope of the feature flag' + requires :strategy, type: JSON, desc: 'The strategy to be disabled on the scope' + end + post :disable do + not_found! unless Feature.enabled?(:feature_flag_api, user_project) + render_api_error!('Version 2 flags not supported', :unprocessable_entity) if feature_flag.new_version_flag? + + result = ::FeatureFlags::DisableService + .new(user_project, current_user, params).execute + + if result[:status] == :success + status :ok + present_entity(result[:feature_flag]) + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Update a feature flag' do + detail 'This feature will be introduced in GitLab 13.1 if feature_flags_new_version feature flag is removed' + success ::API::Entities::FeatureFlag + end + params do + optional :name, type: String, desc: 'The name of the feature flag' + optional :description, type: String, desc: 'The description of the feature flag' + optional :active, type: Boolean, desc: 'Active/inactive value of the flag' + optional :strategies, type: Array do + optional :id, type: Integer, desc: 'The strategy id' + optional :name, type: String, desc: 'The strategy type' + optional :parameters, type: JSON, desc: 'The strategy parameters' + optional :_destroy, type: Boolean, desc: 'Delete the strategy when true' + optional :scopes, type: Array do + optional :id, type: Integer, desc: 'The environment scope id' + optional :environment_scope, type: String, desc: 'The environment scope of the scope' + optional :_destroy, type: Boolean, desc: 'Delete the scope when true' + end + end + end + put do + not_found! unless feature_flags_new_version_enabled? + authorize_update_feature_flag! + render_api_error!('PUT operations are not supported for legacy feature flags', :unprocessable_entity) if feature_flag.legacy_flag? + + attrs = declared_params(include_missing: false) + + rename_key(attrs, :strategies, :strategies_attributes) + update_value(attrs, :strategies_attributes) do |strategies| + strategies.map { |s| rename_key(s, :scopes, :scopes_attributes) } + end + + result = ::FeatureFlags::UpdateService + .new(user_project, current_user, attrs) + .execute(feature_flag) + + if result[:status] == :success + present_entity(result[:feature_flag]) + else + render_api_error!(result[:message], result[:http_status]) + end + end + + desc 'Delete a feature flag' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::FeatureFlag + end + delete do + authorize_destroy_feature_flag! + + result = ::FeatureFlags::DestroyService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute(feature_flag) + + if result[:status] == :success + present_entity(result[:feature_flag]) + else + render_api_error!(result[:message], result[:http_status]) + end + end + end + end + + helpers do + def authorize_read_feature_flags! + authorize! :read_feature_flag, user_project + end + + def authorize_read_feature_flag! + authorize! :read_feature_flag, feature_flag + end + + def authorize_create_feature_flag! + authorize! :create_feature_flag, user_project + end + + def authorize_update_feature_flag! + authorize! :update_feature_flag, feature_flag + end + + def authorize_destroy_feature_flag! + authorize! :destroy_feature_flag, feature_flag + end + + def present_entity(result) + present result, + with: ::API::Entities::FeatureFlag, + feature_flags_new_version_enabled: feature_flags_new_version_enabled? + end + + def ensure_post_version_2_flags_enabled! + unless feature_flags_new_version_enabled? + render_api_error!('Version 2 flags are not enabled for this project', :unprocessable_entity) + end + end + + def feature_flag + @feature_flag ||= if feature_flags_new_version_enabled? + user_project.operations_feature_flags.find_by_name!(params[:feature_flag_name]) + else + user_project.operations_feature_flags.legacy_flag.find_by_name!(params[:feature_flag_name]) + end + end + + def new_version_flag_present? + user_project.operations_feature_flags.new_version_flag.find_by_name(params[:name]).present? + end + + def feature_flags_new_version_enabled? + Feature.enabled?(:feature_flags_new_version, user_project, default_enabled: true) + end + + def rename_key(hash, old_key, new_key) + hash[new_key] = hash.delete(old_key) if hash.key?(old_key) + hash + end + + def update_value(hash, key) + hash[key] = yield(hash[key]) if hash.key?(key) + hash + end + end + end +end diff --git a/lib/api/feature_flags_user_lists.rb b/lib/api/feature_flags_user_lists.rb new file mode 100644 index 00000000000..67aee3993f1 --- /dev/null +++ b/lib/api/feature_flags_user_lists.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module API + class FeatureFlagsUserLists < Grape::API::Instance + include PaginationParams + + error_formatter :json, -> (message, _backtrace, _options, _env, _original_exception) { + message.is_a?(String) ? { message: message }.to_json : message.to_json + } + + before do + authorize_admin_feature_flags_user_lists! + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + resource :feature_flags_user_lists do + desc 'Get all feature flags user lists of a project' do + detail 'This feature was introduced in GitLab 12.10' + success ::API::Entities::FeatureFlag::UserList + end + params do + use :pagination + end + get do + present paginate(user_project.operations_feature_flags_user_lists), + with: ::API::Entities::FeatureFlag::UserList + end + + desc 'Create a feature flags user list for a project' do + detail 'This feature was introduced in GitLab 12.10' + success ::API::Entities::FeatureFlag::UserList + end + params do + requires :name, type: String, desc: 'The name of the list' + requires :user_xids, type: String, desc: 'A comma separated list of external user ids' + end + post do + list = user_project.operations_feature_flags_user_lists.create(declared_params) + + if list.save + present list, with: ::API::Entities::FeatureFlag::UserList + else + render_api_error!(list.errors.full_messages, :bad_request) + end + end + end + + params do + requires :iid, type: String, desc: 'The internal id of the user list' + end + resource 'feature_flags_user_lists/:iid' do + desc 'Get a single feature flag user list belonging to a project' do + detail 'This feature was introduced in GitLab 12.10' + success ::API::Entities::FeatureFlag::UserList + end + get do + present user_project.operations_feature_flags_user_lists.find_by_iid!(params[:iid]), + with: ::API::Entities::FeatureFlag::UserList + end + + desc 'Update a feature flag user list' do + detail 'This feature was introduced in GitLab 12.10' + success ::API::Entities::FeatureFlag::UserList + end + params do + optional :name, type: String, desc: 'The name of the list' + optional :user_xids, type: String, desc: 'A comma separated list of external user ids' + end + put do + list = user_project.operations_feature_flags_user_lists.find_by_iid!(params[:iid]) + + if list.update(declared_params(include_missing: false)) + present list, with: ::API::Entities::FeatureFlag::UserList + else + render_api_error!(list.errors.full_messages, :bad_request) + end + end + + desc 'Delete a feature flag user list' do + detail 'This feature was introduced in GitLab 12.10' + end + delete do + list = user_project.operations_feature_flags_user_lists.find_by_iid!(params[:iid]) + unless list.destroy + render_api_error!(list.errors.full_messages, :conflict) + end + end + end + end + + helpers do + def authorize_admin_feature_flags_user_lists! + authorize! :admin_feature_flags_user_lists, user_project + end + end + end +end diff --git a/lib/backup/repositories.rb b/lib/backup/repositories.rb index fc5b5e59e07..c8268a14bfe 100644 --- a/lib/backup/repositories.rb +++ b/lib/backup/repositories.rb @@ -46,6 +46,10 @@ module Backup restore_repository(project, Gitlab::GlRepository::DESIGN) end + Snippet.find_each(batch_size: 1000) do |snippet| + restore_repository(snippet, Gitlab::GlRepository::SNIPPET) + end + restore_object_pools end diff --git a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml index 568ceceeaa2..ec33020205b 100644 --- a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml @@ -9,9 +9,8 @@ code_quality: DOCKER_TLS_CERTDIR: "" CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/ci-cd/codequality:0.85.10-gitlab.1" needs: [] - before_script: - - export SOURCE_CODE=$PWD script: + - export SOURCE_CODE=$PWD - | if ! docker info &>/dev/null; then if [ -z "$DOCKER_HOST" -a "$KUBERNETES_PORT" ]; then diff --git a/lib/gitlab/graphql/pagination/keyset/order_info.rb b/lib/gitlab/graphql/pagination/keyset/order_info.rb index 577f59911f5..f3ce3a10703 100644 --- a/lib/gitlab/graphql/pagination/keyset/order_info.rb +++ b/lib/gitlab/graphql/pagination/keyset/order_info.rb @@ -95,7 +95,9 @@ module Gitlab elsif ordering_by_similarity?(order_value) ['similarity', order_value.direction, order_value.expr] elsif ordering_by_case?(order_value) - [order_value.expr.case.name.to_s, order_value.direction, order_value.expr] + ['case_order_value', order_value.direction, order_value.expr] + elsif ordering_by_array_position?(order_value) + ['array_position', order_value.direction, order_value.expr] else [order_value.expr.name, order_value.direction, nil] end @@ -106,6 +108,11 @@ module Gitlab order_value.expr.is_a?(Arel::Nodes::NamedFunction) && order_value.expr&.name&.downcase == 'lower' end + # determine if ordering using ARRAY_POSITION, eg. "ORDER BY ARRAY_POSITION(Array[4,3,1,2]::smallint, state)" + def ordering_by_array_position?(order_value) + order_value.expr.is_a?(Arel::Nodes::NamedFunction) && order_value.expr&.name&.downcase == 'array_position' + end + # determine if ordering using SIMILARITY scoring based on Gitlab::Database::SimilarityScore def ordering_by_similarity?(order_value) Gitlab::Database::SimilarityScore.order_by_similarity?(order_value) diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb index 9668badc757..f16bd7c735b 100644 --- a/lib/google_api/cloud_platform/client.rb +++ b/lib/google_api/cloud_platform/client.rb @@ -66,7 +66,7 @@ module GoogleApi cluster_options = make_cluster_options(cluster_name, cluster_size, machine_type, legacy_abac, enable_addons) - request_body = Google::Apis::ContainerV1beta1::CreateClusterRequest.new(cluster_options) + request_body = Google::Apis::ContainerV1beta1::CreateClusterRequest.new(**cluster_options) service.create_cluster(project_id, zone, request_body, options: user_agent_header) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d2d692e5ecd..d9371be7d7f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8028,6 +8028,15 @@ msgstr "" msgid "Dashboard|Unable to add %{invalidProjects}. This dashboard is available for public projects, and private projects in groups with a Silver plan." msgstr "" +msgid "DastProfiles|AJAX spider" +msgstr "" + +msgid "DastProfiles|Active" +msgstr "" + +msgid "DastProfiles|Active scan will make active attacks against the target site while Passive scan will not" +msgstr "" + msgid "DastProfiles|Are you sure you want to delete this profile?" msgstr "" @@ -8067,6 +8076,9 @@ msgstr "" msgid "DastProfiles|Could not update the site profile. Please try again." msgstr "" +msgid "DastProfiles|Debug messages" +msgstr "" + msgid "DastProfiles|Do you want to discard this scanner profile?" msgstr "" @@ -8085,9 +8097,18 @@ msgstr "" msgid "DastProfiles|Edit site profile" msgstr "" +msgid "DastProfiles|Enable it to include the debug messages in DAST console output" +msgstr "" + +msgid "DastProfiles|Enable it to run the AJAX spider (in addition to the traditional spider) to crawl the target site" +msgstr "" + msgid "DastProfiles|Error Details" msgstr "" +msgid "DastProfiles|Hide debug messages" +msgstr "" + msgid "DastProfiles|Manage Profiles" msgstr "" @@ -8139,6 +8160,9 @@ msgstr "" msgid "DastProfiles|Scanner Profiles" msgstr "" +msgid "DastProfiles|Show debug messages" +msgstr "" + msgid "DastProfiles|Site Profile" msgstr "" @@ -8178,6 +8202,9 @@ msgstr "" msgid "DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request." msgstr "" +msgid "DastProfiles|Turn on AJAX spider" +msgstr "" + msgid "DastProfiles|Validate" msgstr "" @@ -16050,7 +16077,7 @@ msgstr "" msgid "MergeRequests|Jump to next unresolved thread" msgstr "" -msgid "MergeRequests|Reply" +msgid "MergeRequests|Reply..." msgstr "" msgid "MergeRequests|Resolve this thread in a new issue" @@ -17892,6 +17919,9 @@ msgstr "" msgid "Omnibus Protected Paths throttle is active, and takes priority over these settings. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}." msgstr "" +msgid "On" +msgstr "" + msgid "On track" msgstr "" @@ -28716,7 +28746,13 @@ msgstr "" msgid "Vulnerability|Comments" msgstr "" -msgid "Vulnerability|Crash Address" +msgid "Vulnerability|Crash address" +msgstr "" + +msgid "Vulnerability|Crash state" +msgstr "" + +msgid "Vulnerability|Crash type" msgstr "" msgid "Vulnerability|Description" diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb index c26ba88f269..e9533fb65b2 100644 --- a/rubocop/migration_helpers.rb +++ b/rubocop/migration_helpers.rb @@ -21,7 +21,7 @@ module RuboCop TABLE_METHODS = %i(create_table create_table_if_not_exists change_table).freeze def high_traffic_tables - @high_traffic_tables ||= rubocop_migrations_config.dig('Migration/UpdateLargeTable', 'DeniedTables') + @high_traffic_tables ||= rubocop_migrations_config.dig('Migration/UpdateLargeTable', 'HighTrafficTables') end # Returns true if the given node originated from the db/migrate directory. diff --git a/rubocop/rubocop-migrations.yml b/rubocop/rubocop-migrations.yml index a919d570ccc..454bed71833 100644 --- a/rubocop/rubocop-migrations.yml +++ b/rubocop/rubocop-migrations.yml @@ -1,6 +1,7 @@ +# Make sure to update the docs if this file moves. Docs URL: https://docs.gitlab.com/ce/development/migration_style_guide.html#when-to-use-the-helper-method Migration/UpdateLargeTable: Enabled: true - DeniedTables: &denied_tables # size in GB (>= 10 GB on GitLab.com as of 02/2020) and/or number of records + HighTrafficTables: &high_traffic_tables # size in GB (>= 10 GB on GitLab.com as of 02/2020) and/or number of records - :audit_events - :ci_build_trace_sections - :ci_builds diff --git a/spec/controllers/projects/feature_flags_clients_controller_spec.rb b/spec/controllers/projects/feature_flags_clients_controller_spec.rb new file mode 100644 index 00000000000..f527d2ba430 --- /dev/null +++ b/spec/controllers/projects/feature_flags_clients_controller_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::FeatureFlagsClientsController do + include Gitlab::Routing + + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + describe 'POST reset_token.json' do + subject(:reset_token) do + post :reset_token, + params: { namespace_id: project.namespace, project_id: project }, + format: :json + end + + before do + sign_in(user) + end + + context 'when user is a project maintainer' do + before do + project.add_maintainer(user) + end + + context 'and feature flags client exist' do + it 'regenerates feature flags client token' do + project.create_operations_feature_flags_client! + expect { reset_token }.to change { project.reload.feature_flags_client_token } + + expect(json_response['token']).to eq(project.feature_flags_client_token) + end + end + + context 'but feature flags client does not exist' do + it 'returns 404' do + reset_token + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when user is not a project maintainer' do + before do + project.add_developer(user) + end + + it 'returns 404' do + reset_token + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb new file mode 100644 index 00000000000..96eeb6f239f --- /dev/null +++ b/spec/controllers/projects/feature_flags_controller_spec.rb @@ -0,0 +1,1604 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::FeatureFlagsController do + include Gitlab::Routing + include FeatureFlagHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let(:user) { developer } + + before_all do + project.add_developer(developer) + project.add_reporter(reporter) + end + + before do + sign_in(user) + end + + describe 'GET index' do + render_views + + subject { get(:index, params: view_params) } + + context 'when there is no feature flags' do + it 'responds with success' do + is_expected.to have_gitlab_http_status(:ok) + end + end + + context 'for a list of feature flags' do + let!(:feature_flags) { create_list(:operations_feature_flag, 50, project: project) } + + it 'responds with success' do + is_expected.to have_gitlab_http_status(:ok) + end + end + + context 'when the user is a reporter' do + let(:user) { reporter } + + it 'responds with not found' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET #index.json' do + subject { get(:index, params: view_params, format: :json) } + + let!(:feature_flag_active) do + create(:operations_feature_flag, project: project, active: true, name: 'feature_flag_a') + end + + let!(:feature_flag_inactive) do + create(:operations_feature_flag, project: project, active: false, name: 'feature_flag_b') + end + + it 'returns all feature flags as json response' do + subject + + expect(json_response['feature_flags'].count).to eq(2) + expect(json_response['feature_flags'].first['name']).to eq(feature_flag_active.name) + expect(json_response['feature_flags'].second['name']).to eq(feature_flag_inactive.name) + end + + it 'returns CRUD paths' do + subject + + expected_edit_path = edit_project_feature_flag_path(project, feature_flag_active) + expected_update_path = project_feature_flag_path(project, feature_flag_active) + expected_destroy_path = project_feature_flag_path(project, feature_flag_active) + + feature_flag_json = json_response['feature_flags'].first + + expect(feature_flag_json['edit_path']).to eq(expected_edit_path) + expect(feature_flag_json['update_path']).to eq(expected_update_path) + expect(feature_flag_json['destroy_path']).to eq(expected_destroy_path) + end + + it 'returns the summary of feature flags' do + subject + + expect(json_response['count']['all']).to eq(2) + expect(json_response['count']['enabled']).to eq(1) + expect(json_response['count']['disabled']).to eq(1) + end + + it 'matches json schema' do + is_expected.to match_response_schema('feature_flags') + end + + it 'returns false for active when the feature flag is inactive even if it has an active scope' do + create(:operations_feature_flag_scope, + feature_flag: feature_flag_inactive, + environment_scope: 'production', + active: true) + + subject + + expect(response).to have_gitlab_http_status(:ok) + feature_flag_json = json_response['feature_flags'].second + + expect(feature_flag_json['active']).to eq(false) + end + + it 'returns the feature flag iid' do + subject + + feature_flag_json = json_response['feature_flags'].first + + expect(feature_flag_json['iid']).to eq(feature_flag_active.iid) + end + + context 'when scope is specified' do + let(:view_params) do + { namespace_id: project.namespace, project_id: project, scope: scope } + end + + context 'when all feature flags are requested' do + let(:scope) { 'all' } + + it 'returns all feature flags' do + subject + + expect(json_response['feature_flags'].count).to eq(2) + end + end + + context 'when enabled feature flags are requested' do + let(:scope) { 'enabled' } + + it 'returns enabled feature flags' do + subject + + expect(json_response['feature_flags'].count).to eq(1) + expect(json_response['feature_flags'].first['active']).to be_truthy + end + end + + context 'when disabled feature flags are requested' do + let(:scope) { 'disabled' } + + it 'returns disabled feature flags' do + subject + + expect(json_response['feature_flags'].count).to eq(1) + expect(json_response['feature_flags'].first['active']).to be_falsy + end + end + end + + context 'when feature flags have additional scopes' do + let!(:feature_flag_active_scope) do + create(:operations_feature_flag_scope, + feature_flag: feature_flag_active, + environment_scope: 'production', + active: false) + end + + let!(:feature_flag_inactive_scope) do + create(:operations_feature_flag_scope, + feature_flag: feature_flag_inactive, + environment_scope: 'staging', + active: false) + end + + it 'returns a correct summary' do + subject + + expect(json_response['count']['all']).to eq(2) + expect(json_response['count']['enabled']).to eq(1) + expect(json_response['count']['disabled']).to eq(1) + end + + it 'recognizes feature flag 1 as active' do + subject + + expect(json_response['feature_flags'].first['active']).to be_truthy + end + + it 'recognizes feature flag 2 as inactive' do + subject + + expect(json_response['feature_flags'].second['active']).to be_falsy + end + + it 'has ordered scopes' do + subject + + expect(json_response['feature_flags'][0]['scopes'][0]['id']) + .to be < json_response['feature_flags'][0]['scopes'][1]['id'] + expect(json_response['feature_flags'][1]['scopes'][0]['id']) + .to be < json_response['feature_flags'][1]['scopes'][1]['id'] + end + + it 'does not have N+1 problem' do + recorded = ActiveRecord::QueryRecorder.new { subject } + + related_count = recorded.log + .count { |query| query.include?('operations_feature_flag') } + + expect(related_count).to be_within(5).of(2) + end + end + + context 'with version 1 and 2 feature flags' do + let!(:new_version_feature_flag) do + create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature_flag_c') + end + + it 'returns all feature flags as json response' do + subject + + expect(json_response['feature_flags'].count).to eq(3) + end + + it 'returns only version 1 flags when new version flags are disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expected = [feature_flag_active.name, feature_flag_inactive.name].sort + expect(json_response['feature_flags'].map { |f| f['name'] }.sort).to eq(expected) + end + end + end + + describe 'GET new' do + render_views + + subject { get(:new, params: view_params) } + + it 'renders the form' do + is_expected.to have_gitlab_http_status(:ok) + end + end + + describe 'GET #show.json' do + subject { get(:show, params: params, format: :json) } + + let!(:feature_flag) do + create(:operations_feature_flag, project: project) + end + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid + } + end + + it 'returns the feature flag as json response' do + subject + + expect(json_response['name']).to eq(feature_flag.name) + expect(json_response['active']).to eq(feature_flag.active) + expect(json_response['version']).to eq('legacy_flag') + end + + it 'matches json schema' do + is_expected.to match_response_schema('feature_flag') + end + + it 'routes based on iid' do + other_project = create(:project) + other_project.add_developer(user) + other_feature_flag = create(:operations_feature_flag, project: other_project, + name: 'other_flag') + params = { + namespace_id: other_project.namespace, + project_id: other_project, + iid: other_feature_flag.iid + } + + get(:show, params: params, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq(other_feature_flag.name) + end + + it 'routes based on iid when new version flags are disabled' do + stub_feature_flags(feature_flags_new_version: false) + other_project = create(:project) + other_project.add_developer(user) + other_feature_flag = create(:operations_feature_flag, project: other_project, + name: 'other_flag') + params = { + namespace_id: other_project.namespace, + project_id: other_project, + iid: other_feature_flag.iid + } + + get(:show, params: params, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq(other_feature_flag.name) + end + + context 'when feature flag is not found' do + let!(:feature_flag) { } + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: 1 + } + end + + it 'returns 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context 'when user is reporter' do + let(:user) { reporter } + + it 'returns 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context 'when feature flags have additional scopes' do + context 'when there is at least one active scope' do + let!(:feature_flag) do + create(:operations_feature_flag, project: project, active: false) + end + + let!(:feature_flag_scope_production) do + create(:operations_feature_flag_scope, + feature_flag: feature_flag, + environment_scope: 'review/*', + active: true) + end + + it 'returns false for active' do + subject + + expect(json_response['active']).to eq(false) + end + end + + context 'when all scopes are inactive' do + let!(:feature_flag) do + create(:operations_feature_flag, project: project, active: false) + end + + let!(:feature_flag_scope_production) do + create(:operations_feature_flag_scope, + feature_flag: feature_flag, + environment_scope: 'production', + active: false) + end + + it 'recognizes the feature flag as inactive' do + subject + + expect(json_response['active']).to be_falsy + end + end + end + + context 'with a version 2 feature flag' do + let!(:new_version_feature_flag) do + create(:operations_feature_flag, :new_version_flag, project: project) + end + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: new_version_feature_flag.iid + } + end + + it 'returns the feature flag' do + subject + + expect(json_response['name']).to eq(new_version_feature_flag.name) + expect(json_response['active']).to eq(new_version_feature_flag.active) + expect(json_response['version']).to eq('new_version_flag') + end + + it 'returns a 404 when new version flags are disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns strategies ordered by id' do + first_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag) + second_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag) + + subject + + expect(json_response['strategies'].map { |s| s['id'] }).to eq([first_strategy.id, second_strategy.id]) + end + end + end + + describe 'POST create.json' do + subject { post(:create, params: params, format: :json) } + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true + } + } + end + + it 'returns 200' do + is_expected.to have_gitlab_http_status(:ok) + end + + it 'creates a new feature flag' do + subject + + expect(json_response['name']).to eq('my_feature_flag') + expect(json_response['active']).to be_truthy + end + + it 'creates a default scope' do + subject + + expect(json_response['scopes'].count).to eq(1) + expect(json_response['scopes'].first['environment_scope']).to eq('*') + expect(json_response['scopes'].first['active']).to be_truthy + end + + it 'matches json schema' do + is_expected.to match_response_schema('feature_flag') + end + + context 'when the same named feature flag has already existed' do + before do + create(:operations_feature_flag, name: 'my_feature_flag', project: project) + end + + it 'returns 400' do + is_expected.to have_gitlab_http_status(:bad_request) + end + + it 'returns an error message' do + subject + + expect(json_response['message']).to include('Name has already been taken') + end + end + + context 'without the active parameter' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag' + } + } + end + + it 'creates a flag with active set to true' do + expect { subject }.to change { Operations::FeatureFlag.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('feature_flag') + expect(json_response['active']).to eq(true) + expect(Operations::FeatureFlag.last.active).to eq(true) + end + end + + context 'when user is reporter' do + let(:user) { reporter } + + it 'returns 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context 'when creates additional scope' do + let(:params) do + view_params.merge({ + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + scopes_attributes: [{ environment_scope: '*', active: true }, + { environment_scope: 'production', active: false }] + } + }) + end + + it 'creates feature flag scopes successfully' do + expect { subject }.to change { Operations::FeatureFlagScope.count }.by(2) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'creates feature flag scopes in a correct order' do + subject + + expect(json_response['scopes'].first['environment_scope']).to eq('*') + expect(json_response['scopes'].second['environment_scope']).to eq('production') + end + + context 'when default scope is not placed first' do + let(:params) do + view_params.merge({ + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + scopes_attributes: [{ environment_scope: 'production', active: false }, + { environment_scope: '*', active: true }] + } + }) + end + + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']) + .to include('Default scope has to be the first element') + end + end + end + + context 'when creates additional scope with a percentage rollout' do + it 'creates a strategy for the scope' do + params = view_params.merge({ + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + scopes_attributes: [{ environment_scope: '*', active: true }, + { environment_scope: 'production', active: false, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '42' } }] }] + } + }) + + post(:create, params: params, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + production_strategies_json = json_response['scopes'].second['strategies'] + expect(production_strategies_json).to eq([{ + 'name' => 'gradualRolloutUserId', + 'parameters' => { "groupId" => "default", "percentage" => "42" } + }]) + end + end + + context 'when creates additional scope with a userWithId strategy' do + it 'creates a strategy for the scope' do + params = view_params.merge({ + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + scopes_attributes: [{ environment_scope: '*', active: true }, + { environment_scope: 'production', active: false, + strategies: [{ name: 'userWithId', + parameters: { userIds: '123,4,6722' } }] }] + } + }) + + post(:create, params: params, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + production_strategies_json = json_response['scopes'].second['strategies'] + expect(production_strategies_json).to eq([{ + 'name' => 'userWithId', + 'parameters' => { "userIds" => "123,4,6722" } + }]) + end + end + + context 'when creates an additional scope without a strategy' do + it 'creates a default strategy' do + params = view_params.merge({ + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + scopes_attributes: [{ environment_scope: '*', active: true }] + } + }) + + post(:create, params: params, format: :json) + + expect(response).to have_gitlab_http_status(:ok) + default_strategies_json = json_response['scopes'].first['strategies'] + expect(default_strategies_json).to eq([{ "name" => "default", "parameters" => {} }]) + end + end + + context 'when creating a version 2 feature flag' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'new_version_flag' + } + } + end + + it 'creates a new feature flag' do + subject + + expect(json_response['name']).to eq('my_feature_flag') + expect(json_response['active']).to be_truthy + expect(json_response['version']).to eq('new_version_flag') + end + end + + context 'when creating a version 2 feature flag with strategies and scopes' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'new_version_flag', + strategies_attributes: [{ + name: 'userWithId', + parameters: { userIds: 'user1' }, + scopes_attributes: [{ environment_scope: '*' }] + }] + } + } + end + + it 'creates a new feature flag with the strategies and scopes' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('my_feature_flag') + expect(json_response['active']).to eq(true) + expect(json_response['strategies'].count).to eq(1) + + strategy_json = json_response['strategies'].first + expect(strategy_json).to have_key('id') + expect(strategy_json['name']).to eq('userWithId') + expect(strategy_json['parameters']).to eq({ 'userIds' => 'user1' }) + expect(strategy_json['scopes'].count).to eq(1) + + scope_json = strategy_json['scopes'].first + expect(scope_json).to have_key('id') + expect(scope_json['environment_scope']).to eq('*') + end + end + + context 'when creating a version 2 feature flag with a gradualRolloutUserId strategy' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'new_version_flag', + strategies_attributes: [{ + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '15' }, + scopes_attributes: [{ environment_scope: 'production' }] + }] + } + } + end + + it 'creates the new strategy' do + subject + + expect(response).to have_gitlab_http_status(:ok) + + strategy_json = json_response['strategies'].first + expect(strategy_json['name']).to eq('gradualRolloutUserId') + expect(strategy_json['parameters']).to eq({ 'groupId' => 'default', 'percentage' => '15' }) + expect(strategy_json['scopes'].count).to eq(1) + + scope_json = strategy_json['scopes'].first + expect(scope_json['environment_scope']).to eq('production') + end + end + + context 'when creating a version 2 feature flag with a flexibleRollout strategy' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'new_version_flag', + strategies_attributes: [{ + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '15', stickiness: 'DEFAULT' }, + scopes_attributes: [{ environment_scope: 'production' }] + }] + } + } + end + + it 'creates the new strategy' do + subject + + expect(response).to have_gitlab_http_status(:ok) + + strategy_json = json_response['strategies'].first + expect(strategy_json['name']).to eq('flexibleRollout') + expect(strategy_json['parameters']).to eq({ 'groupId' => 'default', 'rollout' => '15', 'stickiness' => 'DEFAULT' }) + expect(strategy_json['scopes'].count).to eq(1) + + scope_json = strategy_json['scopes'].first + expect(scope_json['environment_scope']).to eq('production') + end + end + + context 'when creating a version 2 feature flag with a gitlabUserList strategy' do + let!(:user_list) do + create(:operations_feature_flag_user_list, project: project, + name: 'My List', user_xids: 'user1,user2') + end + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'new_version_flag', + strategies_attributes: [{ + name: 'gitlabUserList', + parameters: {}, + user_list_id: user_list.id, + scopes_attributes: [{ environment_scope: 'production' }] + }] + } + } + end + + it 'creates the new strategy' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to match([a_hash_including({ + 'name' => 'gitlabUserList', + 'parameters' => {}, + 'user_list' => { + 'id' => user_list.id, + 'iid' => user_list.iid, + 'name' => 'My List', + 'user_xids' => 'user1,user2' + }, + 'scopes' => [a_hash_including({ + 'environment_scope' => 'production' + })] + })]) + end + end + + context 'when version parameter is invalid' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'bad_version' + } + } + end + + it 'returns a 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => 'Version is invalid' }) + expect(Operations::FeatureFlag.count).to eq(0) + end + end + + context 'when version 2 flags are disabled' do + context 'and attempting to create a version 2 flag' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true, + version: 'new_version_flag' + } + } + end + + it 'returns a 400' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(Operations::FeatureFlag.count).to eq(0) + end + end + + context 'and attempting to create a version 1 flag' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + operations_feature_flag: { + name: 'my_feature_flag', + active: true + } + } + end + + it 'creates the flag' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(Operations::FeatureFlag.count).to eq(1) + expect(json_response['version']).to eq('legacy_flag') + end + end + end + end + + describe 'DELETE destroy.json' do + subject { delete(:destroy, params: params, format: :json) } + + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid + } + end + + it 'returns 200' do + is_expected.to have_gitlab_http_status(:ok) + end + + it 'deletes one feature flag' do + expect { subject }.to change { Operations::FeatureFlag.count }.by(-1) + end + + it 'destroys the default scope' do + expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-1) + end + + it 'matches json schema' do + is_expected.to match_response_schema('feature_flag') + end + + context 'when user is reporter' do + let(:user) { reporter } + + it 'returns 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context 'when the feature flag does not exist' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: 0 + } + end + + it 'returns not found' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context 'when there is an additional scope' do + let!(:scope) { create_scope(feature_flag, 'production', false) } + + it 'destroys the default scope and production scope' do + expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-2) + end + end + + context 'with a version 2 flag' do + let!(:new_version_flag) { create(:operations_feature_flag, :new_version_flag, project: project) } + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: new_version_flag.iid + } + end + + it 'deletes the flag' do + expect { subject }.to change { Operations::FeatureFlag.count }.by(-1) + end + + context 'when new version flags are disabled' do + it 'returns a 404' do + stub_feature_flags(feature_flags_new_version: false) + + expect { subject }.not_to change { Operations::FeatureFlag.count } + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end + + describe 'PUT update.json' do + def put_request(feature_flag, feature_flag_params) + params = { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: feature_flag_params + } + + put(:update, params: params, format: :json, as: :json) + end + + before do + stub_feature_flags( + feature_flags_legacy_read_only: false, + feature_flags_legacy_read_only_override: false + ) + end + + subject { put(:update, params: params, format: :json) } + + let!(:feature_flag) do + create(:operations_feature_flag, + :legacy_flag, + name: 'ci_live_trace', + active: true, + project: project) + end + + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + name: 'ci_new_live_trace' + } + } + end + + it 'returns 200' do + is_expected.to have_gitlab_http_status(:ok) + end + + it 'updates the name of the feature flag name' do + subject + + expect(json_response['name']).to eq('ci_new_live_trace') + end + + it 'matches json schema' do + is_expected.to match_response_schema('feature_flag') + end + + context 'when updates active' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + active: false + } + } + end + + it 'updates active from true to false' do + expect { subject } + .to change { feature_flag.reload.active }.from(true).to(false) + end + + it "does not change default scope's active" do + expect { subject } + .not_to change { feature_flag.default_scope.reload.active }.from(true) + end + + it 'updates active from false to true when an inactive feature flag has an active scope' do + feature_flag = create(:operations_feature_flag, project: project, name: 'my_flag', active: false) + create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production', active: true) + + put_request(feature_flag, { active: true }) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('feature_flag') + expect(json_response['active']).to eq(true) + expect(feature_flag.reload.active).to eq(true) + expect(feature_flag.default_scope.reload.active).to eq(false) + end + end + + context 'when user is reporter' do + let(:user) { reporter } + + it 'returns 404' do + is_expected.to have_gitlab_http_status(:not_found) + end + end + + context "when creates an additional scope for production environment" do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + scopes_attributes: [{ environment_scope: 'production', active: false }] + } + } + end + + it 'creates a production scope' do + expect { subject }.to change { feature_flag.reload.scopes.count }.by(1) + + expect(json_response['scopes'].last['environment_scope']).to eq('production') + expect(json_response['scopes'].last['active']).to be_falsy + end + end + + context "when creates a default scope" do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + scopes_attributes: [{ environment_scope: '*', active: false }] + } + } + end + + it 'returns 400' do + is_expected.to have_gitlab_http_status(:bad_request) + end + end + + context "when updates a default scope's active value" do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + scopes_attributes: [ + { + id: feature_flag.default_scope.id, + environment_scope: '*', + active: false + } + ] + } + } + end + + it "updates successfully" do + subject + + expect(json_response['scopes'].first['environment_scope']).to eq('*') + expect(json_response['scopes'].first['active']).to be_falsy + end + end + + context "when changes default scope's spec" do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + scopes_attributes: [ + { + id: feature_flag.default_scope.id, + environment_scope: 'review/*' + } + ] + } + } + end + + it 'returns 400' do + is_expected.to have_gitlab_http_status(:bad_request) + end + end + + context "when destroys the default scope" do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + scopes_attributes: [ + { + id: feature_flag.default_scope.id, + _destroy: 1 + } + ] + } + } + end + + it 'raises an error' do + expect { subject }.to raise_error(ActiveRecord::ReadOnlyRecord) + end + end + + context "when destroys a production scope" do + let!(:production_scope) { create_scope(feature_flag, 'production', true) } + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + iid: feature_flag.iid, + operations_feature_flag: { + scopes_attributes: [ + { + id: production_scope.id, + _destroy: 1 + } + ] + } + } + end + + it 'destroys successfully' do + subject + + scopes = json_response['scopes'] + expect(scopes.any? { |scope| scope['environment_scope'] == 'production' }) + .to be_falsy + end + end + + describe "updating the strategy" do + it 'creates a default strategy' do + scope = create_scope(feature_flag, 'production', true, []) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [{ name: 'default', parameters: {} }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([{ + "name" => "default", + "parameters" => {} + }]) + end + + it 'creates a gradualRolloutUserId strategy' do + scope = create_scope(feature_flag, 'production', true, []) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: "70" } }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([{ + "name" => "gradualRolloutUserId", + "parameters" => { + "groupId" => "default", + "percentage" => "70" + } + }]) + end + + it 'creates a userWithId strategy' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [{ name: 'userWithId', parameters: { userIds: 'sam,fred' } }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([{ + "name" => "userWithId", + "parameters" => { "userIds" => "sam,fred" } + }]) + end + + it 'updates an existing strategy' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [{ name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: "50" } }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([{ + "name" => "gradualRolloutUserId", + "parameters" => { + "groupId" => "default", + "percentage" => "50" + } + }]) + end + + it 'clears an existing strategy' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [] + }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([]) + end + + it 'accepts multiple strategies' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [ + { name: 'gradualRolloutUserId', parameters: { groupId: 'mygroup', percentage: '55' } }, + { name: 'userWithId', parameters: { userIds: 'joe' } } + ] + }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies'].length).to eq(2) + expect(scope_json['strategies']).to include({ + "name" => "gradualRolloutUserId", + "parameters" => { "groupId" => "mygroup", "percentage" => "55" } + }) + expect(scope_json['strategies']).to include({ + "name" => "userWithId", + "parameters" => { "userIds" => "joe" } + }) + end + + it 'does not modify strategies when there is no strategies key in the params' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) + + put_request(feature_flag, scopes_attributes: [{ id: scope.id }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([{ + "name" => "default", + "parameters" => {} + }]) + end + + it 'leaves an existing strategy when there are no strategies in the params' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '10' } }]) + + put_request(feature_flag, scopes_attributes: [{ id: scope.id }]) + + expect(response).to have_gitlab_http_status(:ok) + scope_json = json_response['scopes'].find do |s| + s['environment_scope'] == 'production' + end + expect(scope_json['strategies']).to eq([{ + "name" => "gradualRolloutUserId", + "parameters" => { "groupId" => "default", "percentage" => "10" } + }]) + end + + it 'does not accept extra parameters in the strategy params' do + scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }]) + + put_request(feature_flag, scopes_attributes: [{ + id: scope.id, + strategies: [{ name: 'userWithId', parameters: { userIds: 'joe', groupId: 'default' } }] + }]) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(["Scopes strategies parameters are invalid"]) + end + end + + context 'when legacy feature flags are set to be read only' do + it 'does not update the flag' do + stub_feature_flags(feature_flags_legacy_read_only: true) + + put_request(feature_flag, name: 'ci_new_live_trace') + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(["Legacy feature flags are read-only"]) + end + + it 'updates the flag if the legacy read-only override is enabled for a particular project' do + stub_feature_flags( + feature_flags_legacy_read_only: true, + feature_flags_legacy_read_only_override: project + ) + + put_request(feature_flag, name: 'ci_new_live_trace') + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('ci_new_live_trace') + end + end + + context 'with a version 2 feature flag' do + let!(:new_version_flag) do + create(:operations_feature_flag, + :new_version_flag, + name: 'new-feature', + active: true, + project: project) + end + + it 'creates a new strategy and scope' do + put_request(new_version_flag, strategies_attributes: [{ + name: 'userWithId', + parameters: { userIds: 'user1' }, + scopes_attributes: [{ + environment_scope: 'production' + }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies'].count).to eq(1) + strategy_json = json_response['strategies'].first + expect(strategy_json['name']).to eq('userWithId') + expect(strategy_json['parameters']).to eq({ + 'userIds' => 'user1' + }) + expect(strategy_json['scopes'].count).to eq(1) + scope_json = strategy_json['scopes'].first + expect(scope_json['environment_scope']).to eq('production') + end + + it 'creates a gradualRolloutUserId strategy' do + put_request(new_version_flag, strategies_attributes: [{ + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '30' } + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies'].count).to eq(1) + strategy_json = json_response['strategies'].first + expect(strategy_json['name']).to eq('gradualRolloutUserId') + expect(strategy_json['parameters']).to eq({ + 'groupId' => 'default', + 'percentage' => '30' + }) + expect(strategy_json['scopes']).to eq([]) + end + + it 'creates a flexibleRollout strategy' do + put_request(new_version_flag, strategies_attributes: [{ + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '30', stickiness: 'DEFAULT' } + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies'].count).to eq(1) + strategy_json = json_response['strategies'].first + expect(strategy_json['name']).to eq('flexibleRollout') + expect(strategy_json['parameters']).to eq({ + 'groupId' => 'default', + 'rollout' => '30', + 'stickiness' => 'DEFAULT' + }) + expect(strategy_json['scopes']).to eq([]) + end + + it 'creates a gitlabUserList strategy' do + user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2') + + put_request(new_version_flag, strategies_attributes: [{ + name: 'gitlabUserList', + parameters: {}, + user_list_id: user_list.id + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to match([a_hash_including({ + 'id' => an_instance_of(Integer), + 'name' => 'gitlabUserList', + 'parameters' => {}, + 'user_list' => { + 'id' => user_list.id, + 'iid' => user_list.iid, + 'name' => 'My List', + 'user_xids' => 'user1,user2' + }, + 'scopes' => [] + })]) + end + + it 'supports switching the associated user list for an existing gitlabUserList strategy' do + user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2') + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'gitlabUserList', parameters: {}, user_list: user_list) + other_user_list = create(:operations_feature_flag_user_list, project: project, name: 'Other List', user_xids: 'user3') + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + user_list_id: other_user_list.id + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to eq([{ + 'id' => strategy.id, + 'name' => 'gitlabUserList', + 'parameters' => {}, + 'user_list' => { + 'id' => other_user_list.id, + 'iid' => other_user_list.iid, + 'name' => 'Other List', + 'user_xids' => 'user3' + }, + 'scopes' => [] + }]) + end + + it 'automatically dissociates the user list when switching the type of an existing gitlabUserList strategy' do + user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2') + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'gitlabUserList', parameters: {}, user_list: user_list) + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + name: 'gradualRolloutUserId', + parameters: { + groupId: 'default', + percentage: '25' + } + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to eq([{ + 'id' => strategy.id, + 'name' => 'gradualRolloutUserId', + 'parameters' => { + 'groupId' => 'default', + 'percentage' => '25' + }, + 'scopes' => [] + }]) + end + + it 'does not delete a user list when deleting a gitlabUserList strategy' do + user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2') + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'gitlabUserList', parameters: {}, user_list: user_list) + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + _destroy: true + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to eq([]) + expect(::Operations::FeatureFlags::Strategy.count).to eq(0) + expect(::Operations::FeatureFlags::StrategyUserList.count).to eq(0) + expect(::Operations::FeatureFlags::UserList.first).to eq(user_list) + end + + it 'returns not found when trying to create a gitlabUserList strategy with an invalid user list id' do + put_request(new_version_flag, strategies_attributes: [{ + name: 'gitlabUserList', + parameters: {}, + user_list_id: 1 + }]) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'updates an existing strategy' do + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {}) + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + name: 'userWithId', + parameters: { userIds: 'user2,user3' } + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to eq([{ + 'id' => strategy.id, + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user2,user3' }, + 'scopes' => [] + }]) + end + + it 'updates an existing scope' do + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {}) + scope = create(:operations_scope, strategy: strategy, environment_scope: 'staging') + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + scopes_attributes: [{ + id: scope.id, + environment_scope: 'sandbox' + }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies'].first['scopes']).to eq([{ + 'id' => scope.id, + 'environment_scope' => 'sandbox' + }]) + end + + it 'deletes an existing strategy' do + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {}) + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + _destroy: true + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies']).to eq([]) + end + + it 'deletes an existing scope' do + strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {}) + scope = create(:operations_scope, strategy: strategy, environment_scope: 'staging') + + put_request(new_version_flag, strategies_attributes: [{ + id: strategy.id, + scopes_attributes: [{ + id: scope.id, + _destroy: true + }] + }]) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['strategies'].first['scopes']).to eq([]) + end + + it 'does not update the flag if version 2 flags are disabled' do + stub_feature_flags(feature_flags_new_version: false) + + put_request(new_version_flag, { name: 'some-other-name' }) + + expect(response).to have_gitlab_http_status(:not_found) + expect(new_version_flag.reload.name).to eq('new-feature') + end + + it 'updates the flag when legacy feature flags are set to be read only' do + stub_feature_flags(feature_flags_legacy_read_only: true) + + put_request(new_version_flag, name: 'some-other-name') + + expect(response).to have_gitlab_http_status(:ok) + expect(new_version_flag.reload.name).to eq('some-other-name') + end + end + end + + private + + def view_params + { namespace_id: project.namespace, project_id: project } + end +end diff --git a/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb b/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb new file mode 100644 index 00000000000..e0d1d3765b2 --- /dev/null +++ b/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::FeatureFlagsUserListsController do + let_it_be(:project) { create(:project) } + let_it_be(:reporter) { create(:user) } + let_it_be(:developer) { create(:user) } + + before_all do + project.add_reporter(reporter) + project.add_developer(developer) + end + + def request_params(extra_params = {}) + { namespace_id: project.namespace, project_id: project }.merge(extra_params) + end + + describe 'GET #new' do + it 'redirects when the user is unauthenticated' do + get(:new, params: request_params) + + expect(response).to redirect_to(new_user_session_path) + end + + it 'returns not found if the user does not belong to the project' do + user = create(:user) + sign_in(user) + + get(:new, params: request_params) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found for a reporter' do + sign_in(reporter) + + get(:new, params: request_params) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'renders the new page for a developer' do + sign_in(developer) + + get(:new, params: request_params) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + describe 'GET #edit' do + before do + sign_in(developer) + end + + it 'renders the edit page for a developer' do + list = create(:operations_feature_flag_user_list, project: project) + + get(:edit, params: request_params(iid: list.iid)) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns not found with an iid that does not exist' do + list = create(:operations_feature_flag_user_list, project: project) + + get(:edit, params: request_params(iid: list.iid + 1)) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found for a list belonging to a another project' do + other_project = create(:project) + list = create(:operations_feature_flag_user_list, project: other_project) + + get(:edit, params: request_params(iid: list.iid)) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe 'GET #show' do + before do + sign_in(developer) + end + + it 'renders the page for a developer' do + list = create(:operations_feature_flag_user_list, project: project) + + get(:show, params: request_params(iid: list.iid)) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns not found with an iid that does not exist' do + list = create(:operations_feature_flag_user_list, project: project) + + get(:show, params: request_params(iid: list.iid + 1)) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found for a list belonging to a another project' do + other_project = create(:project) + list = create(:operations_feature_flag_user_list, project: other_project) + + get(:show, params: request_params(iid: list.iid)) + + expect(response).to have_gitlab_http_status(:not_found) + end + end +end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 321e214df1c..ff78b9e608f 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -834,7 +834,7 @@ RSpec.describe 'GFM autocomplete', :js do end def start_and_cancel_discussion - click_button('Reply') + click_button('Reply...') fill_in('note_note', with: 'Whoops!') diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb index f1f04c47bd8..c8fc23bebf9 100644 --- a/spec/features/merge_request/batch_comments_spec.rb +++ b/spec/features/merge_request/batch_comments_spec.rb @@ -223,7 +223,7 @@ end def write_reply_to_discussion(button_text: 'Start a review', text: 'Line is wrong', resolve: false, unresolve: false) page.within(first('.diff-files-holder .discussion-reply-holder')) do - click_button('Reply') + click_button('Reply...') fill_in('note_note', with: text) diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb index d305359e022..9556142ecb8 100644 --- a/spec/features/merge_request/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb @@ -186,7 +186,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js do it 'adds as discussion' do should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'), asset_form_reset: false) expect(page).to have_css('.notes_holder .note.note-discussion', count: 1) - expect(page).to have_button('Reply') + expect(page).to have_button('Reply...') end end end diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index d546c602d96..cd06886169d 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -146,7 +146,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment' do page.within '.diff-content' do - click_button 'Reply' + click_button 'Reply...' find(".js-unresolve-checkbox").set false find('.js-note-text').set 'testing' @@ -176,7 +176,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment & unresolve thread' do page.within '.diff-content' do - click_button 'Reply' + click_button 'Reply...' find('.js-note-text').set 'testing' @@ -205,7 +205,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment & resolve thread' do page.within '.diff-content' do - click_button 'Reply' + click_button 'Reply...' find('.js-note-text').set 'testing' @@ -438,7 +438,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do it 'allows user to comment & resolve thread' do page.within '.diff-content' do - click_button 'Reply' + click_button 'Reply...' find('.js-note-text').set 'testing' @@ -457,7 +457,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do page.within '.diff-content' do click_button 'Resolve thread' - click_button 'Reply' + click_button 'Reply...' find('.js-note-text').set 'testing' diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb index 8d492708f2c..d15d5b3bc73 100644 --- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb +++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb @@ -37,7 +37,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do end it 'does not render avatars after commenting on discussion tab' do - click_button 'Reply' + click_button 'Reply...' page.within('.js-discussion-note-form') do find('.note-textarea').native.send_keys('Test comment') @@ -132,7 +132,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do end it 'adds avatar when commenting' do - click_button 'Reply' + click_button 'Reply...' page.within '.js-discussion-note-form' do find('.js-note-text').native.send_keys('Test') @@ -151,7 +151,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do it 'adds multiple comments' do 3.times do - click_button 'Reply' + click_button 'Reply...' page.within '.js-discussion-note-form' do find('.js-note-text').native.send_keys('Test') diff --git a/spec/features/merge_request/user_sees_discussions_spec.rb b/spec/features/merge_request/user_sees_discussions_spec.rb index 86e4b58b347..289c861739f 100644 --- a/spec/features/merge_request/user_sees_discussions_spec.rb +++ b/spec/features/merge_request/user_sees_discussions_spec.rb @@ -60,7 +60,7 @@ RSpec.describe 'Merge request > User sees threads', :js do it 'can be replied to' do within(".discussion[data-discussion-id='#{discussion_id}']") do - click_button 'Reply' + click_button 'Reply...' fill_in 'note[note]', with: 'Test!' click_button 'Comment' diff --git a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb index 44da911441a..20c45a1d652 100644 --- a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb +++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'Merge request > User sees notes from forked project', :js do expect(page).to have_content('A commit comment') page.within('.discussion-notes') do - find('.js-vue-discussion-reply').click + find('.btn-text-field').click scroll_to(page.find('#note_note', visible: false)) find('#note_note').send_keys('A reply comment') find('.js-comment-button').click diff --git a/spec/features/projects/commit/builds_spec.rb b/spec/features/projects/commit/builds_spec.rb index f97abc5bd8b..00ec9d49a10 100644 --- a/spec/features/projects/commit/builds_spec.rb +++ b/spec/features/projects/commit/builds_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'project commit pipelines', :js do context 'when no builds triggered yet' do it 'shows the ID of the first pipeline' do - page.within('.table-holder') do + page.within('.pipelines .ci-table') do expect(page).to have_content project.ci_pipelines[0].id # pipeline ids end end diff --git a/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb b/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb new file mode 100644 index 00000000000..2a81c706525 --- /dev/null +++ b/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User deletes feature flag user list', :js do + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user) } + + before do + project.add_developer(developer) + sign_in(developer) + end + + context 'with a list' do + before do + create(:operations_feature_flag_user_list, project: project, name: 'My List') + end + + it 'deletes the list' do + visit(project_feature_flags_path(project, scope: 'userLists')) + + delete_user_list_button.click + delete_user_list_modal_confirmation_button.click + + expect(page).to have_text('Lists 0') + end + end + + context 'with a list that is in use' do + before do + list = create(:operations_feature_flag_user_list, project: project, name: 'My List') + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project) + create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: list) + end + + it 'does not delete the list' do + visit(project_feature_flags_path(project, scope: 'userLists')) + + delete_user_list_button.click + delete_user_list_modal_confirmation_button.click + + expect(page).to have_text('User list is associated with a strategy') + expect(page).to have_text('Lists 1') + expect(page).to have_text('My List') + + alert_dismiss_button.click + + expect(page).not_to have_text('User list is associated with a strategy') + end + end + + def delete_user_list_button + find("button[data-testid='delete-user-list']") + end + + def delete_user_list_modal_confirmation_button + find("button[data-testid='modal-confirm']") + end + + def alert_dismiss_button + find("div[data-testid='serverErrors'] button") + end +end diff --git a/spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb b/spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb new file mode 100644 index 00000000000..b37c2780827 --- /dev/null +++ b/spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User edits feature flag user list', :js do + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user) } + + before do + project.add_developer(developer) + sign_in(developer) + end + + it 'prefills the edit form with the list name' do + list = create(:operations_feature_flag_user_list, project: project, name: 'My List Name') + + visit(edit_project_feature_flags_user_list_path(project, list)) + + expect(page).to have_field 'Name', with: 'My List Name' + end +end diff --git a/spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb b/spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb new file mode 100644 index 00000000000..dfebe6408bd --- /dev/null +++ b/spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User sees feature flag user list details', :js do + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user) } + + before do + project.add_developer(developer) + sign_in(developer) + end + + it 'displays the list name' do + list = create(:operations_feature_flag_user_list, project: project, name: 'My List') + + visit(project_feature_flags_user_list_path(project, list)) + + expect(page).to have_text('My List') + end +end diff --git a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb new file mode 100644 index 00000000000..830dda737b0 --- /dev/null +++ b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User creates feature flag', :js do + include FeatureFlagHelpers + + let(:user) { create(:user) } + let(:project) { create(:project, namespace: user.namespace) } + + before do + project.add_developer(user) + stub_feature_flags(feature_flag_permissions: false) + sign_in(user) + end + + it 'user creates a flag enabled for user ids' do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('test_feature', 'Test feature') + within_strategy_row(1) do + select 'User IDs', from: 'Type' + fill_in 'User IDs', with: 'user1, user2' + environment_plus_button.click + environment_search_input.set('production') + environment_search_results.first.click + end + click_button 'Create feature flag' + + expect_user_to_see_feature_flags_index_page + expect(page).to have_text('test_feature') + end + + it 'user creates a flag with default environment scopes' do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('test_flag', 'Test flag') + within_strategy_row(1) do + select 'All users', from: 'Type' + end + click_button 'Create feature flag' + + expect_user_to_see_feature_flags_index_page + expect(page).to have_text('test_flag') + + edit_feature_flag_button.click + + within_strategy_row(1) do + expect(page).to have_text('All users') + expect(page).to have_text('All environments') + end + end + + it 'removes the correct strategy when a strategy is deleted' do + visit(new_project_feature_flag_path(project)) + click_button 'Add strategy' + within_strategy_row(1) do + select 'All users', from: 'Type' + end + within_strategy_row(2) do + select 'Percent of users', from: 'Type' + end + within_strategy_row(1) do + delete_strategy_button.click + end + + within_strategy_row(1) do + expect(page).to have_select('Type', selected: 'Percent of users') + end + end + + context 'with new version flags disabled' do + before do + stub_feature_flags(feature_flags_new_version: false) + end + + context 'when creates without changing scopes' do + before do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('ci_live_trace', 'For live trace') + click_button 'Create feature flag' + expect(page).to have_current_path(project_feature_flags_path(project)) + end + + it 'shows the created feature flag' do + within_feature_flag_row(1) do + expect(page.find('.feature-flag-name')).to have_content('ci_live_trace') + expect_status_toggle_button_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*') + end + end + end + end + + context 'when creates with disabling the default scope' do + before do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('ci_live_trace', 'For live trace') + + within_scope_row(1) do + within_status { find('.project-feature-toggle').click } + end + + click_button 'Create feature flag' + end + + it 'shows the created feature flag' do + within_feature_flag_row(1) do + expect(page.find('.feature-flag-name')).to have_content('ci_live_trace') + expect_status_toggle_button_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*') + end + end + end + end + + context 'when creates with an additional scope' do + before do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('mr_train', '') + + within_scope_row(2) do + within_environment_spec do + find('.js-env-search > input').set("review/*") + find('.js-create-button').click + end + end + + within_scope_row(2) do + within_status { find('.project-feature-toggle').click } + end + + click_button 'Create feature flag' + end + + it 'shows the created feature flag' do + within_feature_flag_row(1) do + expect(page.find('.feature-flag-name')).to have_content('mr_train') + expect_status_toggle_button_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*') + expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(2)')).to have_content('review/*') + end + end + end + end + + context 'when searches an environment name for scope creation' do + let!(:environment) { create(:environment, name: 'production', project: project) } + + before do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('mr_train', '') + + within_scope_row(2) do + within_environment_spec do + find('.js-env-search > input').set('prod') + click_button 'production' + end + end + + click_button 'Create feature flag' + end + + it 'shows the created feature flag' do + within_feature_flag_row(1) do + expect(page.find('.feature-flag-name')).to have_content('mr_train') + expect_status_toggle_button_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*') + expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(2)')).to have_content('production') + end + end + end + end + end + + private + + def set_feature_flag_info(name, description) + fill_in 'Name', with: name + fill_in 'Description', with: description + end + + def environment_plus_button + find('.js-new-environments-dropdown') + end + + def environment_search_input + find('.js-new-environments-dropdown input') + end + + def environment_search_results + all('.js-new-environments-dropdown button.dropdown-item') + end +end diff --git a/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb new file mode 100644 index 00000000000..581709aacee --- /dev/null +++ b/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User deletes feature flag', :js do + include FeatureFlagHelpers + + let(:user) { create(:user) } + let(:project) { create(:project, namespace: user.namespace) } + + let!(:feature_flag) do + create_flag(project, 'ci_live_trace', false, + description: 'For live trace feature') + end + + before do + project.add_developer(user) + stub_feature_flags(feature_flag_permissions: false) + sign_in(user) + + visit(project_feature_flags_path(project)) + + find('.js-feature-flag-delete-button').click + click_button('Delete feature flag') + expect(page).to have_current_path(project_feature_flags_path(project)) + end + + it 'user does not see feature flag' do + expect(page).to have_no_content('ci_live_trace') + end +end diff --git a/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb new file mode 100644 index 00000000000..750f4dc5ef4 --- /dev/null +++ b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User sees feature flag list', :js do + include FeatureFlagHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, namespace: user.namespace) } + + before_all do + project.add_developer(user) + end + + before do + sign_in(user) + end + + context 'with legacy feature flags' do + before do + create_flag(project, 'ci_live_trace', false).tap do |feature_flag| + create_scope(feature_flag, 'review/*', true) + end + create_flag(project, 'drop_legacy_artifacts', false) + create_flag(project, 'mr_train', true).tap do |feature_flag| + create_scope(feature_flag, 'production', false) + end + stub_feature_flags(feature_flags_legacy_read_only_override: false) + end + + it 'user sees the first flag' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(1) do + expect(page.find('.js-feature-flag-id')).to have_content('^1') + expect(page.find('.feature-flag-name')).to have_content('ci_live_trace') + expect_status_toggle_button_not_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*') + expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(2)')).to have_content('review/*') + end + end + end + + it 'user sees the second flag' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(2) do + expect(page.find('.js-feature-flag-id')).to have_content('^2') + expect(page.find('.feature-flag-name')).to have_content('drop_legacy_artifacts') + expect_status_toggle_button_not_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*') + end + end + end + + it 'user sees the third flag' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(3) do + expect(page.find('.js-feature-flag-id')).to have_content('^3') + expect(page.find('.feature-flag-name')).to have_content('mr_train') + expect_status_toggle_button_to_be_checked + + within_feature_flag_scopes do + expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*') + expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(2)')).to have_content('production') + end + end + end + + it 'user sees the status toggle disabled' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(1) do + expect_status_toggle_button_to_be_disabled + end + end + + context 'when legacy feature flags are not read-only' do + before do + stub_feature_flags(feature_flags_legacy_read_only: false) + end + + it 'user updates the status toggle' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(1) do + status_toggle_button.click + + expect_status_toggle_button_to_be_checked + end + end + end + + context 'when legacy feature flags are read-only but the override is active for a project' do + before do + stub_feature_flags( + feature_flags_legacy_read_only: true, + feature_flags_legacy_read_only_override: project + ) + end + + it 'user updates the status toggle' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(1) do + status_toggle_button.click + + expect_status_toggle_button_to_be_checked + end + end + end + end + + context 'with new version flags' do + before do + create(:operations_feature_flag, :new_version_flag, project: project, + name: 'my_flag', active: false) + end + + it 'user updates the status toggle' do + visit(project_feature_flags_path(project)) + + within_feature_flag_row(1) do + status_toggle_button.click + + expect_status_toggle_button_to_be_checked + end + end + end + + context 'when there are no feature flags' do + before do + visit(project_feature_flags_path(project)) + end + + it 'shows empty page' do + expect(page).to have_text 'Get started with feature flags' + expect(page).to have_selector('.btn-success', text: 'New feature flag') + expect(page).to have_selector('[data-qa-selector="configure_feature_flags_button"]', text: 'Configure') + end + end +end diff --git a/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb new file mode 100644 index 00000000000..bc2d63e1953 --- /dev/null +++ b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'User updates feature flag', :js do + include FeatureFlagHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, namespace: user.namespace) } + + before_all do + project.add_developer(user) + end + + before do + stub_feature_flags( + feature_flag_permissions: false, + feature_flags_legacy_read_only_override: false + ) + sign_in(user) + end + + context 'with a new version feature flag' do + let!(:feature_flag) do + create_flag(project, 'test_flag', false, version: Operations::FeatureFlag.versions['new_version_flag'], + description: 'For testing') + end + + let!(:strategy) do + create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + end + + let!(:scope) do + create(:operations_scope, strategy: strategy, environment_scope: '*') + end + + it 'user adds a second strategy' do + visit(edit_project_feature_flag_path(project, feature_flag)) + + wait_for_requests + + click_button 'Add strategy' + within_strategy_row(2) do + select 'Percent of users', from: 'Type' + fill_in 'Percentage', with: '15' + end + click_button 'Save changes' + + edit_feature_flag_button.click + + within_strategy_row(1) do + expect(page).to have_text 'All users' + expect(page).to have_text 'All environments' + end + within_strategy_row(2) do + expect(page).to have_text 'Percent of users' + expect(page).to have_field 'Percentage', with: '15' + expect(page).to have_text 'All environments' + end + end + + it 'user toggles the flag on' do + visit(edit_project_feature_flag_path(project, feature_flag)) + status_toggle_button.click + click_button 'Save changes' + + within_feature_flag_row(1) do + expect_status_toggle_button_to_be_checked + end + end + end + + context 'with a legacy feature flag' do + let!(:feature_flag) do + create_flag(project, 'ci_live_trace', true, + description: 'For live trace feature') + end + + let!(:scope) { create_scope(feature_flag, 'review/*', true) } + + context 'when legacy flags are editable' do + before do + stub_feature_flags(feature_flags_legacy_read_only: false) + + visit(edit_project_feature_flag_path(project, feature_flag)) + end + + it 'user sees persisted default scope' do + within_scope_row(1) do + within_environment_spec do + expect(page).to have_content('* (All Environments)') + end + + within_status do + expect(find('.project-feature-toggle')['aria-label']) + .to eq('Toggle Status: ON') + end + end + end + + context 'when user updates the status of a scope' do + before do + within_scope_row(2) do + within_status { find('.project-feature-toggle').click } + end + + click_button 'Save changes' + expect(page).to have_current_path(project_feature_flags_path(project)) + end + + it 'shows the updated feature flag' do + within_feature_flag_row(1) do + expect(page.find('.feature-flag-name')).to have_content('ci_live_trace') + expect_status_toggle_button_to_be_checked + + within_feature_flag_scopes do + expect(page.find('.badge:nth-child(1)')).to have_content('*') + expect(page.find('.badge:nth-child(1)')['class']).to include('badge-info') + expect(page.find('.badge:nth-child(2)')).to have_content('review/*') + expect(page.find('.badge:nth-child(2)')['class']).to include('badge-muted') + end + end + end + end + + context 'when user adds a new scope' do + before do + within_scope_row(3) do + within_environment_spec do + find('.js-env-search > input').set('production') + find('.js-create-button').click + end + end + + click_button 'Save changes' + expect(page).to have_current_path(project_feature_flags_path(project)) + end + + it 'shows the newly created scope' do + within_feature_flag_row(1) do + within_feature_flag_scopes do + expect(page.find('.badge:nth-child(3)')).to have_content('production') + expect(page.find('.badge:nth-child(3)')['class']).to include('badge-muted') + end + end + end + end + + context 'when user deletes a scope' do + before do + within_scope_row(2) do + within_delete { find('.js-delete-scope').click } + end + + click_button 'Save changes' + expect(page).to have_current_path(project_feature_flags_path(project)) + end + + it 'shows the updated feature flag' do + within_feature_flag_row(1) do + within_feature_flag_scopes do + expect(page).to have_css('.badge:nth-child(1)') + expect(page).not_to have_css('.badge:nth-child(2)') + end + end + end + end + end + + context 'when legacy flags are read-only' do + it 'the user cannot edit the flag' do + visit(edit_project_feature_flag_path(project, feature_flag)) + + expect(page).to have_text 'This feature flag is read-only, and it will be removed in 14.0.' + expect(page).to have_css('button.js-ff-submit.disabled') + end + end + + context 'when legacy flags are read-only, but the override is active for one project' do + it 'the user can edit the flag' do + stub_feature_flags(feature_flags_legacy_read_only_override: project) + + visit(edit_project_feature_flag_path(project, feature_flag)) + status_toggle_button.click + click_button 'Save changes' + + expect(page).to have_current_path(project_feature_flags_path(project)) + within_feature_flag_row(1) do + expect_status_toggle_button_not_to_be_checked + end + end + end + end +end diff --git a/spec/features/projects/releases/user_views_edit_release_spec.rb b/spec/features/projects/releases/user_views_edit_release_spec.rb index 2028902f10f..9115a135aeb 100644 --- a/spec/features/projects/releases/user_views_edit_release_spec.rb +++ b/spec/features/projects/releases/user_views_edit_release_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'User edits Release', :js do it 'renders the edit Release form' do expect(page).to have_content('Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.') - expect(find_field('Tag name', { disabled: true }).value).to eq(release.tag) + expect(find_field('Tag name', disabled: true).value).to eq(release.tag) expect(find_field('Release title').value).to eq(release.name) expect(find_field('Release notes').value).to eq(release.description) diff --git a/spec/fixtures/api/schemas/feature_flag.json b/spec/fixtures/api/schemas/feature_flag.json new file mode 100644 index 00000000000..5f8cedc1132 --- /dev/null +++ b/spec/fixtures/api/schemas/feature_flag.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "required" : [ + "id", + "name" + ], + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": ["integer", "null"] }, + "version": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "name": { "type": "string" }, + "active": { "type": "boolean" }, + "description": { "type": ["string", "null"] }, + "edit_path": { "type": ["string", "null"] }, + "update_path": { "type": ["string", "null"] }, + "destroy_path": { "type": ["string", "null"] }, + "scopes": { "type": "array", "items": { "$ref": "feature_flag_scope.json" } }, + "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/feature_flag_scope.json b/spec/fixtures/api/schemas/feature_flag_scope.json new file mode 100644 index 00000000000..07c5eed532a --- /dev/null +++ b/spec/fixtures/api/schemas/feature_flag_scope.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required" : [ + "id", + "environment_scope", + "active" + ], + "properties" : { + "id": { "type": "integer" }, + "environment_scope": { "type": "string" }, + "active": { "type": "boolean" }, + "percentage": { "type": ["integer", "null"] }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/feature_flag_strategy.json b/spec/fixtures/api/schemas/feature_flag_strategy.json new file mode 100644 index 00000000000..5a2777dc8ea --- /dev/null +++ b/spec/fixtures/api/schemas/feature_flag_strategy.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { "type": "string" }, + "parameters": { + "type": "object" + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/feature_flags.json b/spec/fixtures/api/schemas/feature_flags.json new file mode 100644 index 00000000000..fc5e668c8b0 --- /dev/null +++ b/spec/fixtures/api/schemas/feature_flags.json @@ -0,0 +1,13 @@ +{ + "required": ["feature_flags", "count"], + "feature_flags": { "type": "array", "items": { "$ref": "feature_flag.json" } }, + "count": { + "type": "object", + "properties" : { + "all": { "type": "integer" }, + "enabled": { "type": "integer" }, + "disabled": { "type": "integer" } + }, + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag.json new file mode 100644 index 00000000000..0f304e9ee73 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "active": {"type": "boolean" }, + "version": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "scopes": { "type": "array", "items": { "$ref": "feature_flag_scope.json" } }, + "strategies": { "type": "array", "items": { "$ref": "operations/strategy.json" } } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json new file mode 100644 index 00000000000..a11ae5705cc --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json @@ -0,0 +1,22 @@ +{ + "type": "array", + "items": { + "type": "object", + "required" : [ + "name", + "id", + "environment_scope", + "active" + ], + "properties" : { + "name": { "type": "string" }, + "id": { "type": "integer" }, + "environment_scope": { "type": "string" }, + "active": { "type": "boolean" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } } + }, + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json new file mode 100644 index 00000000000..18402af482e --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "required": [ + "id", + "environment_scope", + "active" + ], + "properties": { + "id": { "type": "integer" }, + "environment_scope": { "type": "string" }, + "active": { "type": "boolean" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json new file mode 100644 index 00000000000..b1a7021db8b --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json @@ -0,0 +1,9 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "$ref": "./feature_flag_scope.json" + } + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json new file mode 100644 index 00000000000..5a2777dc8ea --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { "type": "string" }, + "parameters": { + "type": "object" + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flags.json b/spec/fixtures/api/schemas/public_api/v4/feature_flags.json new file mode 100644 index 00000000000..c19df0443d9 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/feature_flags.json @@ -0,0 +1,9 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "$ref": "./feature_flag.json" + } + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/operations/scope.json b/spec/fixtures/api/schemas/public_api/v4/operations/scope.json new file mode 100644 index 00000000000..e2b6d1ad6f1 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/operations/scope.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "required": ["environment_scope"], + "properties": { + "id": { "type": "integer" }, + "environment_scope": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json b/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json new file mode 100644 index 00000000000..f572b1a4f9b --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "required": [ + "name", + "parameters" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "parameters": { "type": "object" }, + "scopes": { "type": "array", "items": { "$ref": "scope.json" } } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/lib/backup/personal_snippet_repo.bundle b/spec/fixtures/lib/backup/personal_snippet_repo.bundle Binary files differnew file mode 100644 index 00000000000..452cf6a19fe --- /dev/null +++ b/spec/fixtures/lib/backup/personal_snippet_repo.bundle diff --git a/spec/fixtures/lib/backup/project_snippet_repo.bundle b/spec/fixtures/lib/backup/project_snippet_repo.bundle Binary files differnew file mode 100644 index 00000000000..c05f8ec9495 --- /dev/null +++ b/spec/fixtures/lib/backup/project_snippet_repo.bundle diff --git a/spec/frontend/ide/lib/languages/hcl_spec.js b/spec/frontend/ide/lib/languages/hcl_spec.js new file mode 100644 index 00000000000..a39673a3225 --- /dev/null +++ b/spec/frontend/ide/lib/languages/hcl_spec.js @@ -0,0 +1,290 @@ +import { editor } from 'monaco-editor'; +import { registerLanguages } from '~/ide/utils'; +import hcl from '~/ide/lib/languages/hcl'; + +describe('tokenization for .tf files', () => { + beforeEach(() => { + registerLanguages(hcl); + }); + + it.each([ + ['// Foo', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]], + ['/* Bar */', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]], + ['/*', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]], + [ + 'foo = "bar"', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'string.hcl' }, + ], + ], + ], + [ + 'variable "foo" {', + [ + [ + { language: 'hcl', offset: 0, type: 'type.hcl' }, + { language: 'hcl', offset: 8, type: '' }, + { language: 'hcl', offset: 9, type: 'string.hcl' }, + { language: 'hcl', offset: 14, type: '' }, + { language: 'hcl', offset: 15, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + // eslint-disable-next-line no-template-curly-in-string + ' api_key = "${var.foo}"', + [ + [ + { language: 'hcl', offset: 0, type: '' }, + { language: 'hcl', offset: 2, type: 'variable.hcl' }, + { language: 'hcl', offset: 9, type: '' }, + { language: 'hcl', offset: 10, type: 'operator.hcl' }, + { language: 'hcl', offset: 11, type: '' }, + { language: 'hcl', offset: 12, type: 'string.hcl' }, + { language: 'hcl', offset: 13, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 15, type: 'keyword.var.hcl' }, + { language: 'hcl', offset: 18, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 19, type: 'variable.hcl' }, + { language: 'hcl', offset: 22, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 23, type: 'string.hcl' }, + ], + ], + ], + [ + 'resource "aws_security_group" "firewall" {', + [ + [ + { language: 'hcl', offset: 0, type: 'type.hcl' }, + { language: 'hcl', offset: 8, type: '' }, + { language: 'hcl', offset: 9, type: 'string.hcl' }, + { language: 'hcl', offset: 29, type: '' }, + { language: 'hcl', offset: 30, type: 'string.hcl' }, + { language: 'hcl', offset: 40, type: '' }, + { language: 'hcl', offset: 41, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + ' network_interface {', + [ + [ + { language: 'hcl', offset: 0, type: '' }, + { language: 'hcl', offset: 2, type: 'identifier.hcl' }, + { language: 'hcl', offset: 20, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + 'foo = [1, 2, "foo"]', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'delimiter.square.hcl' }, + { language: 'hcl', offset: 7, type: 'number.hcl' }, + { language: 'hcl', offset: 8, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 9, type: '' }, + { language: 'hcl', offset: 10, type: 'number.hcl' }, + { language: 'hcl', offset: 11, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 12, type: '' }, + { language: 'hcl', offset: 13, type: 'string.hcl' }, + { language: 'hcl', offset: 18, type: 'delimiter.square.hcl' }, + ], + ], + ], + [ + 'resource "foo" "bar" {}', + [ + [ + { language: 'hcl', offset: 0, type: 'type.hcl' }, + { language: 'hcl', offset: 8, type: '' }, + { language: 'hcl', offset: 9, type: 'string.hcl' }, + { language: 'hcl', offset: 14, type: '' }, + { language: 'hcl', offset: 15, type: 'string.hcl' }, + { language: 'hcl', offset: 20, type: '' }, + { language: 'hcl', offset: 21, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + 'foo = "bar"', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'string.hcl' }, + ], + ], + ], + [ + 'bar = 7', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'number.hcl' }, + ], + ], + ], + [ + 'baz = [1,2,3]', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'delimiter.square.hcl' }, + { language: 'hcl', offset: 7, type: 'number.hcl' }, + { language: 'hcl', offset: 8, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 9, type: 'number.hcl' }, + { language: 'hcl', offset: 10, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 11, type: 'number.hcl' }, + { language: 'hcl', offset: 12, type: 'delimiter.square.hcl' }, + ], + ], + ], + [ + 'foo = -12', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'operator.hcl' }, + { language: 'hcl', offset: 7, type: 'number.hcl' }, + ], + ], + ], + [ + 'bar = 3.14159', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'foo = true', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'keyword.true.hcl' }, + ], + ], + ], + [ + 'foo = false', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'keyword.false.hcl' }, + ], + ], + ], + [ + // eslint-disable-next-line no-template-curly-in-string + 'bar = "${file("bing/bong.txt")}"', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'string.hcl' }, + { language: 'hcl', offset: 7, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 9, type: 'type.hcl' }, + { language: 'hcl', offset: 13, type: 'delimiter.parenthesis.hcl' }, + { language: 'hcl', offset: 14, type: 'string.hcl' }, + { language: 'hcl', offset: 29, type: 'delimiter.parenthesis.hcl' }, + { language: 'hcl', offset: 30, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 31, type: 'string.hcl' }, + ], + ], + ], + [ + 'a = 1e-10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'b = 1e+10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'c = 1e10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'd = 1.2e-10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'e = 1.2e+10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + ])('%s', (string, tokens) => { + expect(editor.tokenize(string, 'hcl')).toEqual(tokens); + }); +}); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index c9c33cf3af1..b7b7ec08867 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton } from '@gitlab/ui'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; const buttonText = 'Test Button Text'; @@ -7,7 +6,7 @@ const buttonText = 'Test Button Text'; describe('ReplyPlaceholder', () => { let wrapper; - const findButton = () => wrapper.find(GlButton); + const findButton = () => wrapper.find({ ref: 'button' }); beforeEach(() => { wrapper = shallowMount(ReplyPlaceholder, { @@ -21,8 +20,8 @@ describe('ReplyPlaceholder', () => { wrapper.destroy(); }); - it('should emit a onClick event on button click', () => { - findButton().vm.$emit('click'); + it('emits onClick event on button click', () => { + findButton().trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted()).toEqual({ diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index cb164a426c8..378d259ad3f 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -31,7 +31,6 @@ import { registryUrl, pypiSetupCommandStr, } from '../mock_data'; -import { generateConanRecipe } from '~/packages/details/utils'; import { NpmManager } from '~/packages/details/constants'; describe('Getters PackageDetails Store', () => { @@ -53,8 +52,7 @@ describe('Getters PackageDetails Store', () => { }; }; - const recipe = generateConanRecipe(conanPackage); - const conanInstallationCommandStr = `conan install ${recipe} --remote=gitlab`; + const conanInstallationCommandStr = `conan install ${conanPackage.name} --remote=gitlab`; const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`; const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum); diff --git a/spec/frontend/packages/details/utils_spec.js b/spec/frontend/packages/details/utils_spec.js deleted file mode 100644 index 087888016ee..00000000000 --- a/spec/frontend/packages/details/utils_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { generateConanRecipe } from '~/packages/details/utils'; -import { conanPackage } from '../mock_data'; - -describe('Package detail utils', () => { - describe('generateConanRecipe', () => { - it('correctly generates the conan recipe', () => { - const recipe = generateConanRecipe(conanPackage); - - expect(recipe).toEqual(conanPackage.recipe); - }); - - it('returns an empty recipe when no information is supplied', () => { - const recipe = generateConanRecipe({}); - - expect(recipe).toEqual('/@/'); - }); - - it('recipe returns empty strings for missing metadata', () => { - const recipe = generateConanRecipe({ name: 'foo', version: '0.0.1' }); - - expect(recipe).toBe('foo/0.0.1@/'); - }); - }); -}); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index b95d06428ff..d7494bf85d0 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -84,15 +84,15 @@ export const conanPackage = { package_channel: 'stable', package_username: 'conan+conan-package', }, + conan_package_name: 'conan-package', created_at: '2015-12-10', id: 3, - name: 'conan-package', + name: 'conan-package/1.0.0@conan+conan-package/stable', project_path: 'foo/bar/baz', projectPathName: 'foo/bar/baz', package_files: [], package_type: 'conan', project_id: 1, - recipe: 'conan-package/1.0.0@conan+conan-package/stable', updated_at: '2015-12-10', version: '1.0.0', _links, diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb index a42be3c87fb..bcb0b5c51e7 100644 --- a/spec/helpers/user_callouts_helper_spec.rb +++ b/spec/helpers/user_callouts_helper_spec.rb @@ -139,4 +139,26 @@ RSpec.describe UserCalloutsHelper do helper.render_flash_user_callout(:warning, 'foo', 'bar') end end + + describe '.show_feature_flags_new_version?' do + subject { helper.show_feature_flags_new_version? } + + let(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + end + + context 'when the feature flags new version info has not been dismissed' do + it { is_expected.to be_truthy } + end + + context 'when the feature flags new version has been dismissed' do + before do + create(:user_callout, user: user, feature_name: described_class::FEATURE_FLAGS_NEW_VERSION) + end + + it { is_expected.to be_falsy } + end + end end diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index 247fce683db..5f734f4b71b 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -159,12 +159,16 @@ RSpec.describe Backup::Repositories do describe '#restore' do let_it_be(:project) { create(:project) } + let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) } + let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) } it 'restores repositories from bundles', :aggregate_failures do next_path_to_bundle = [ Rails.root.join('spec/fixtures/lib/backup/project_repo.bundle'), Rails.root.join('spec/fixtures/lib/backup/wiki_repo.bundle'), - Rails.root.join('spec/fixtures/lib/backup/design_repo.bundle') + Rails.root.join('spec/fixtures/lib/backup/design_repo.bundle'), + Rails.root.join('spec/fixtures/lib/backup/personal_snippet_repo.bundle'), + Rails.root.join('spec/fixtures/lib/backup/project_snippet_repo.bundle') ].to_enum allow_next_instance_of(described_class::BackupRestore) do |backup_restore| @@ -178,6 +182,8 @@ RSpec.describe Backup::Repositories do expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec']) expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea']) expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d']) + expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e']) + expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1']) end describe 'command failure' do @@ -228,7 +234,9 @@ RSpec.describe Backup::Repositories do expect_next_instance_of(DesignManagement::Repository) do |repository| expect(repository).to receive(:remove) end - expect(Repository).to receive(:new).twice.and_wrap_original do |method, *original_args| + + # 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo + expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args| repository = method.call(*original_args) expect(repository).to receive(:remove) diff --git a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb index 77a8588e2cb..eb28e6c8c0a 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb @@ -69,11 +69,23 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::OrderInfo do it 'assigns the right attribute name, named function, and direction' do expect(order_list.count).to eq 1 - expect(order_list.first.attribute_name).to eq 'pending_delete' + expect(order_list.first.attribute_name).to eq 'case_order_value' expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::Case) expect(order_list.first.sort_direction).to eq :asc end end + + context 'when ordering by ARRAY_POSITION', :aggregate_failuers do + let(:array_position) { Arel::Nodes::NamedFunction.new('ARRAY_POSITION', [Arel.sql("ARRAY[1,0]::smallint[]"), Project.arel_table[:auto_cancel_pending_pipelines]]) } + let(:relation) { Project.order(array_position.asc) } + + it 'assigns the right attribute name, named function, and direction' do + expect(order_list.count).to eq 1 + expect(order_list.first.attribute_name).to eq 'array_position' + expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::NamedFunction) + expect(order_list.first.sort_direction).to eq :asc + end + end end describe '#validate_ordering' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 45f48cd8a57..2df15c8f400 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -304,6 +304,7 @@ protected_branches: - push_access_levels - unprotect_access_levels - approval_project_rules +- required_code_owners_sections protected_tags: - project - create_access_levels diff --git a/spec/models/blob_viewer/markup_spec.rb b/spec/models/blob_viewer/markup_spec.rb new file mode 100644 index 00000000000..13b040d62d0 --- /dev/null +++ b/spec/models/blob_viewer/markup_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BlobViewer::Markup do + include FakeBlobHelpers + + let(:project) { create(:project, :repository) } + let(:blob) { fake_blob(path: 'CHANGELOG.md') } + + subject { described_class.new(blob) } + + describe '#banzai_render_context' do + it 'returns context needed for banzai rendering' do + expect(subject.banzai_render_context.keys).to eq([:cache_key]) + end + + context 'when blob does respond to rendered_markup' do + before do + allow(blob).to receive(:rendered_markup).and_return("some rendered markup") + end + + it 'does sets rendered key' do + expect(subject.banzai_render_context.keys).to include(:rendered) + end + end + + context 'when cached_markdown_blob feature flag is disabled' do + before do + stub_feature_flags(cached_markdown_blob: false) + end + + it 'does not set cache_key key' do + expect(subject.banzai_render_context.keys).not_to include(:cache_key) + end + end + end +end diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb index 3a13aca6c7a..8ece27e9b5f 100644 --- a/spec/presenters/packages/detail/package_presenter_spec.rb +++ b/spec/presenters/packages/detail/package_presenter_spec.rb @@ -76,7 +76,7 @@ RSpec.describe ::Packages::Detail::PackagePresenter do context 'with conan metadata' do let(:package) { create(:conan_package, project: project) } - let(:expected_package_details) { super().merge(conan_metadatum: package.conan_metadatum) } + let(:expected_package_details) { super().merge(conan_metadatum: package.conan_metadatum, conan_package_name: package.name, name: package.conan_recipe) } it 'returns conan_metadatum' do expect(presenter.detail_view).to eq expected_package_details diff --git a/spec/requests/api/feature_flag_scopes_spec.rb b/spec/requests/api/feature_flag_scopes_spec.rb new file mode 100644 index 00000000000..da5b2cbb7ae --- /dev/null +++ b/spec/requests/api/feature_flag_scopes_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::FeatureFlagScopes do + include FeatureFlagHelpers + + let(:project) { create(:project, :repository) } + let(:developer) { create(:user) } + let(:reporter) { create(:user) } + let(:user) { developer } + + before do + project.add_developer(developer) + project.add_reporter(reporter) + end + + shared_examples_for 'check user permission' do + context 'when user is reporter' do + let(:user) { reporter } + + it 'forbids the request' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + shared_examples_for 'not found' do + it 'returns Not Found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe 'GET /projects/:id/feature_flag_scopes' do + subject do + get api("/projects/#{project.id}/feature_flag_scopes", user), + params: params + end + + let(:feature_flag_1) { create_flag(project, 'flag_1', true) } + let(:feature_flag_2) { create_flag(project, 'flag_2', true) } + + before do + create_scope(feature_flag_1, 'staging', false) + create_scope(feature_flag_1, 'production', true) + create_scope(feature_flag_2, 'review/*', false) + end + + context 'when environment is production' do + let(:params) { { environment: 'production' } } + + it_behaves_like 'check user permission' + + it 'returns all effective feature flags under the environment' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag_detailed_scopes') + expect(json_response.second).to include({ 'name' => 'flag_1', 'active' => true }) + expect(json_response.first).to include({ 'name' => 'flag_2', 'active' => true }) + end + end + + context 'when environment is staging' do + let(:params) { { environment: 'staging' } } + + it 'returns all effective feature flags under the environment' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.second).to include({ 'name' => 'flag_1', 'active' => false }) + expect(json_response.first).to include({ 'name' => 'flag_2', 'active' => true }) + end + end + + context 'when environment is review/feature X' do + let(:params) { { environment: 'review/feature X' } } + + it 'returns all effective feature flags under the environment' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.second).to include({ 'name' => 'flag_1', 'active' => true }) + expect(json_response.first).to include({ 'name' => 'flag_2', 'active' => false }) + end + end + end + + describe 'GET /projects/:id/feature_flags/:name/scopes' do + subject do + get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes", user) + end + + context 'when there are two scopes' do + let(:feature_flag) { create_flag(project, 'test') } + let!(:additional_scope) { create_scope(feature_flag, 'production', false) } + + it_behaves_like 'check user permission' + + it 'returns scopes of the feature flag' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag_scopes') + expect(json_response.count).to eq(2) + expect(json_response.first['environment_scope']).to eq(feature_flag.scopes[0].environment_scope) + expect(json_response.second['environment_scope']).to eq(feature_flag.scopes[1].environment_scope) + end + end + + context 'when there are no feature flags' do + let(:feature_flag) { double(:feature_flag, name: 'test') } + + it_behaves_like 'not found' + end + end + + describe 'POST /projects/:id/feature_flags/:name/scopes' do + subject do + post api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes", user), + params: params + end + + let(:params) do + { + environment_scope: 'staging', + active: true, + strategies: [{ name: 'userWithId', parameters: { 'userIds': 'a,b,c' } }].to_json + } + end + + context 'when there is a corresponding feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + + it_behaves_like 'check user permission' + + it 'creates a new scope' do + subject + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag_scope') + expect(json_response['environment_scope']).to eq(params[:environment_scope]) + expect(json_response['active']).to eq(params[:active]) + expect(json_response['strategies']).to eq(Gitlab::Json.parse(params[:strategies])) + end + + context 'when the scope already exists' do + before do + create_scope(feature_flag, params[:environment_scope]) + end + + it 'returns error' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to include('Scopes environment scope (staging) has already been taken') + end + end + end + + context 'when feature flag is not found' do + let(:feature_flag) { double(:feature_flag, name: 'test') } + + it_behaves_like 'not found' + end + end + + describe 'GET /projects/:id/feature_flags/:name/scopes/:environment_scope' do + subject do + get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{environment_scope}", + user) + end + + let(:environment_scope) { scope.environment_scope } + + shared_examples_for 'successful response' do + it 'returns a scope' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag_scope') + expect(json_response['id']).to eq(scope.id) + expect(json_response['active']).to eq(scope.active) + expect(json_response['environment_scope']).to eq(scope.environment_scope) + end + end + + context 'when there is a feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + let(:scope) { feature_flag.default_scope } + + it_behaves_like 'check user permission' + it_behaves_like 'successful response' + + context 'when environment scope includes slash' do + let!(:scope) { create_scope(feature_flag, 'review/*', false) } + + it_behaves_like 'not found' + + context 'when URL-encoding the environment scope parameter' do + let(:environment_scope) { CGI.escape(scope.environment_scope) } + + it_behaves_like 'successful response' + end + end + end + + context 'when there are no feature flags' do + let(:feature_flag) { double(:feature_flag, name: 'test') } + let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') } + + it_behaves_like 'not found' + end + end + + describe 'PUT /projects/:id/feature_flags/:name/scopes/:environment_scope' do + subject do + put api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{environment_scope}", + user), params: params + end + + let(:environment_scope) { scope.environment_scope } + + let(:params) do + { + active: true, + strategies: [{ name: 'userWithId', parameters: { 'userIds': 'a,b,c' } }].to_json + } + end + + context 'when there is a corresponding feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + let(:scope) { create_scope(feature_flag, 'staging', false, [{ name: "default", parameters: {} }]) } + + it_behaves_like 'check user permission' + + it 'returns the updated scope' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag_scope') + expect(json_response['id']).to eq(scope.id) + expect(json_response['active']).to eq(params[:active]) + expect(json_response['strategies']).to eq(Gitlab::Json.parse(params[:strategies])) + end + + context 'when there are no corresponding feature flag scopes' do + let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') } + + it_behaves_like 'not found' + end + end + + context 'when there are no corresponding feature flags' do + let(:feature_flag) { double(:feature_flag, name: 'test') } + let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') } + + it_behaves_like 'not found' + end + end + + describe 'DELETE /projects/:id/feature_flags/:name/scopes/:environment_scope' do + subject do + delete api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{environment_scope}", + user) + end + + let(:environment_scope) { scope.environment_scope } + + shared_examples_for 'successful response' do + it 'destroys the scope' do + expect { subject } + .to change { Operations::FeatureFlagScope.exists?(environment_scope: scope.environment_scope) } + .from(true).to(false) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + + context 'when there is a feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + + context 'when there is a targeted scope' do + let!(:scope) { create_scope(feature_flag, 'production', false) } + + it_behaves_like 'check user permission' + it_behaves_like 'successful response' + + context 'when environment scope includes slash' do + let!(:scope) { create_scope(feature_flag, 'review/*', false) } + + it_behaves_like 'not found' + + context 'when URL-encoding the environment scope parameter' do + let(:environment_scope) { CGI.escape(scope.environment_scope) } + + it_behaves_like 'successful response' + end + end + end + + context 'when there are no targeted scopes' do + let!(:scope) { double(:feature_flag_scope, environment_scope: 'production') } + + it_behaves_like 'not found' + end + end + + context 'when there are no feature flags' do + let(:feature_flag) { double(:feature_flag, name: 'test') } + let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') } + + it_behaves_like 'not found' + end + end +end diff --git a/spec/requests/api/feature_flags_spec.rb b/spec/requests/api/feature_flags_spec.rb new file mode 100644 index 00000000000..90d4a7b8b21 --- /dev/null +++ b/spec/requests/api/feature_flags_spec.rb @@ -0,0 +1,1130 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::FeatureFlags do + include FeatureFlagHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:non_project_member) { create(:user) } + let(:user) { developer } + + before_all do + project.add_developer(developer) + project.add_reporter(reporter) + end + + shared_examples_for 'check user permission' do + context 'when user is reporter' do + let(:user) { reporter } + + it 'forbids the request' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + shared_examples_for 'not found' do + it 'returns Not Found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + describe 'GET /projects/:id/feature_flags' do + subject { get api("/projects/#{project.id}/feature_flags", user) } + + context 'when there are two feature flags' do + let!(:feature_flag_1) do + create(:operations_feature_flag, project: project) + end + + let!(:feature_flag_2) do + create(:operations_feature_flag, project: project) + end + + it 'returns feature flags ordered by name' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response.count).to eq(2) + expect(json_response.first['name']).to eq(feature_flag_1.name) + expect(json_response.second['name']).to eq(feature_flag_2.name) + end + + it 'returns the legacy flag version' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response.map { |f| f['version'] }).to eq(%w[legacy_flag legacy_flag]) + end + + it 'does not return the legacy flag version when the feature flag is disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response.select { |f| f.key?('version') }).to eq([]) + end + + it 'does not return strategies if the new flag is disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response.select { |f| f.key?('strategies') }).to eq([]) + end + + it 'does not have N+1 problem' do + control_count = ActiveRecord::QueryRecorder.new { subject } + + create_list(:operations_feature_flag, 3, project: project) + + expect { get api("/projects/#{project.id}/feature_flags", user) } + .not_to exceed_query_limit(control_count) + end + + it_behaves_like 'check user permission' + end + + context 'with version 2 feature flags' do + let!(:feature_flag) do + create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1') + end + + let!(:strategy) do + create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + end + + let!(:scope) do + create(:operations_scope, strategy: strategy, environment_scope: 'production') + end + + it 'returns the feature flags' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response).to eq([{ + 'name' => 'feature1', + 'description' => nil, + 'active' => true, + 'version' => 'new_version_flag', + 'updated_at' => feature_flag.updated_at.as_json, + 'created_at' => feature_flag.created_at.as_json, + 'scopes' => [], + 'strategies' => [{ + 'id' => strategy.id, + 'name' => 'default', + 'parameters' => {}, + 'scopes' => [{ + 'id' => scope.id, + 'environment_scope' => 'production' + }] + }] + }]) + end + + it 'does not return a version 2 flag when the feature flag is disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response).to eq([]) + end + end + + context 'with version 1 and 2 feature flags' do + it 'returns both versions of flags ordered by name' do + create(:operations_feature_flag, project: project, name: 'legacy_flag') + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag') + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response.map { |f| f['name'] }).to eq(%w[legacy_flag new_version_flag]) + end + + it 'returns only version 1 flags when the feature flag is disabled' do + stub_feature_flags(feature_flags_new_version: false) + create(:operations_feature_flag, project: project, name: 'legacy_flag') + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag') + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flags') + expect(json_response.map { |f| f['name'] }).to eq(['legacy_flag']) + end + end + end + + describe 'GET /projects/:id/feature_flags/:name' do + subject { get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", user) } + + context 'when there is a feature flag' do + let!(:feature_flag) { create_flag(project, 'awesome-feature') } + + it 'returns a feature flag entry' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response['name']).to eq(feature_flag.name) + expect(json_response['description']).to eq(feature_flag.description) + expect(json_response['version']).to eq('legacy_flag') + end + + it_behaves_like 'check user permission' + end + + context 'with a version 2 feature_flag' do + it 'returns the feature flag' do + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1') + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + scope = create(:operations_scope, strategy: strategy, environment_scope: 'production') + + get api("/projects/#{project.id}/feature_flags/feature1", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response).to eq({ + 'name' => 'feature1', + 'description' => nil, + 'active' => true, + 'version' => 'new_version_flag', + 'updated_at' => feature_flag.updated_at.as_json, + 'created_at' => feature_flag.created_at.as_json, + 'scopes' => [], + 'strategies' => [{ + 'id' => strategy.id, + 'name' => 'default', + 'parameters' => {}, + 'scopes' => [{ + 'id' => scope.id, + 'environment_scope' => 'production' + }] + }] + }) + end + + it 'returns a 404 when the feature is disabled' do + stub_feature_flags(feature_flags_new_version: false) + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1') + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + get api("/projects/#{project.id}/feature_flags/feature1", user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq({ 'message' => '404 Not found' }) + end + end + end + + describe 'POST /projects/:id/feature_flags' do + def scope_default + { + environment_scope: '*', + active: false, + strategies: [{ name: 'default', parameters: {} }].to_json + } + end + + subject do + post api("/projects/#{project.id}/feature_flags", user), params: params + end + + let(:params) do + { + name: 'awesome-feature', + scopes: [scope_default] + } + end + + it 'creates a new feature flag' do + subject + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + expect(feature_flag.description).to eq(params[:description]) + end + + it 'defaults to a version 1 (legacy) feature flag' do + subject + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.version).to eq('legacy_flag') + end + + it_behaves_like 'check user permission' + + it 'returns version' do + subject + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response['version']).to eq('legacy_flag') + end + + it 'does not return version when new version flags are disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response.key?('version')).to eq(false) + end + + context 'with active set to false in the params for a legacy flag' do + let(:params) do + { + name: 'awesome-feature', + version: 'legacy_flag', + active: 'false', + scopes: [scope_default] + } + end + + it 'creates an inactive feature flag' do + subject + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response['active']).to eq(false) + end + end + + context 'when no scopes passed in parameters' do + let(:params) { { name: 'awesome-feature' } } + + it 'creates a new feature flag with active default scope' do + subject + + expect(response).to have_gitlab_http_status(:created) + feature_flag = project.operations_feature_flags.last + expect(feature_flag.default_scope).to be_active + end + end + + context 'when there is a feature flag with the same name already' do + before do + create_flag(project, 'awesome-feature') + end + + it 'fails to create a new feature flag' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'when create a feature flag with two scopes' do + let(:params) do + { + name: 'awesome-feature', + description: 'this is awesome', + scopes: [ + scope_default, + scope_with_user_with_id + ] + } + end + + let(:scope_with_user_with_id) do + { + environment_scope: 'production', + active: true, + strategies: [{ + name: 'userWithId', + parameters: { userIds: 'user:1' } + }].to_json + } + end + + it 'creates a new feature flag with two scopes' do + subject + + expect(response).to have_gitlab_http_status(:created) + + feature_flag = project.operations_feature_flags.last + feature_flag.scopes.ordered.each_with_index do |scope, index| + expect(scope.environment_scope).to eq(params[:scopes][index][:environment_scope]) + expect(scope.active).to eq(params[:scopes][index][:active]) + expect(scope.strategies).to eq(Gitlab::Json.parse(params[:scopes][index][:strategies])) + end + end + end + + context 'when creating a version 2 feature flag' do + it 'creates a new feature flag' do + params = { + name: 'new-feature', + version: 'new_version_flag' + } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response).to match(hash_including({ + 'name' => 'new-feature', + 'description' => nil, + 'active' => true, + 'version' => 'new_version_flag', + 'scopes' => [], + 'strategies' => [] + })) + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + expect(feature_flag.version).to eq('new_version_flag') + end + + it 'creates a new feature flag that is inactive' do + params = { + name: 'new-feature', + version: 'new_version_flag', + active: false + } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response['active']).to eq(false) + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.active).to eq(false) + end + + it 'creates a new feature flag with strategies' do + params = { + name: 'new-feature', + version: 'new_version_flag', + strategies: [{ + name: 'userWithId', + parameters: { 'userIds': 'user1' } + }] + } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + expect(feature_flag.version).to eq('new_version_flag') + expect(feature_flag.strategies.map { |s| s.slice(:name, :parameters).deep_symbolize_keys }).to eq([{ + name: 'userWithId', + parameters: { userIds: 'user1' } + }]) + end + + it 'creates a new feature flag with gradual rollout strategy with scopes' do + params = { + name: 'new-feature', + version: 'new_version_flag', + strategies: [{ + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '50' }, + scopes: [{ + environment_scope: 'staging' + }] + }] + } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + expect(feature_flag.version).to eq('new_version_flag') + expect(feature_flag.strategies.map { |s| s.slice(:name, :parameters).deep_symbolize_keys }).to eq([{ + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '50' } + }]) + expect(feature_flag.strategies.first.scopes.map { |s| s.slice(:environment_scope).deep_symbolize_keys }).to eq([{ + environment_scope: 'staging' + }]) + end + + it 'creates a new feature flag with flexible rollout strategy with scopes' do + params = { + name: 'new-feature', + version: 'new_version_flag', + strategies: [{ + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '50', stickiness: 'DEFAULT' }, + scopes: [{ + environment_scope: 'staging' + }] + }] + } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:created) + expect(response).to match_response_schema('public_api/v4/feature_flag') + + feature_flag = project.operations_feature_flags.last + expect(feature_flag.name).to eq(params[:name]) + expect(feature_flag.version).to eq('new_version_flag') + expect(feature_flag.strategies.map { |s| s.slice(:name, :parameters).deep_symbolize_keys }).to eq([{ + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '50', stickiness: 'DEFAULT' } + }]) + expect(feature_flag.strategies.first.scopes.map { |s| s.slice(:environment_scope).deep_symbolize_keys }).to eq([{ + environment_scope: 'staging' + }]) + end + + it 'returns a 422 when the feature flag is disabled' do + stub_feature_flags(feature_flags_new_version: false) + params = { + name: 'new-feature', + version: 'new_version_flag' + } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to eq({ 'message' => 'Version 2 flags are not enabled for this project' }) + expect(project.operations_feature_flags.count).to eq(0) + end + end + + context 'when given invalid parameters' do + it 'responds with a 400 when given an invalid version' do + params = { name: 'new-feature', version: 'bad_value' } + + post api("/projects/#{project.id}/feature_flags", user), params: params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => 'Version is invalid' }) + end + end + end + + describe 'POST /projects/:id/feature_flags/:name/enable' do + subject do + post api("/projects/#{project.id}/feature_flags/#{params[:name]}/enable", user), + params: params + end + + let(:params) do + { + name: 'awesome-feature', + environment_scope: 'production', + strategy: { name: 'userWithId', parameters: { userIds: 'Project:1' } }.to_json + } + end + + context 'when feature flag does not exist yet' do + it 'creates a new feature flag with the specified scope and strategy' do + subject + + feature_flag = project.operations_feature_flags.last + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(feature_flag.name).to eq(params[:name]) + expect(scope.strategies).to eq([Gitlab::Json.parse(params[:strategy])]) + expect(feature_flag.version).to eq('legacy_flag') + end + + it 'returns the flag version and strategies in the json response' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response.slice('version', 'strategies')).to eq({ + 'version' => 'legacy_flag', + 'strategies' => [] + }) + end + + it_behaves_like 'check user permission' + end + + context 'when feature flag exists already' do + let!(:feature_flag) { create_flag(project, params[:name]) } + + context 'when feature flag scope does not exist yet' do + it 'creates a new scope with the specified strategy' do + subject + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(response).to have_gitlab_http_status(:ok) + expect(scope.strategies).to eq([Gitlab::Json.parse(params[:strategy])]) + end + + it_behaves_like 'check user permission' + end + + context 'when feature flag scope exists already' do + let(:defined_strategy) { { name: 'userWithId', parameters: { userIds: 'Project:2' } } } + + before do + create_scope(feature_flag, params[:environment_scope], true, [defined_strategy]) + end + + it 'adds an additional strategy to the scope' do + subject + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(response).to have_gitlab_http_status(:ok) + expect(scope.strategies).to eq([defined_strategy.deep_stringify_keys, Gitlab::Json.parse(params[:strategy])]) + end + + context 'when the specified strategy exists already' do + let(:defined_strategy) { Gitlab::Json.parse(params[:strategy]) } + + it 'does not add a duplicate strategy' do + subject + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + strategy_count = scope.strategies.count { |strategy| strategy['name'] == 'userWithId' } + expect(response).to have_gitlab_http_status(:ok) + expect(strategy_count).to eq(1) + end + end + end + end + + context 'with a version 2 flag' do + let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project, name: params[:name]) } + + it 'does not change the flag and returns an unprocessable_entity response' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to eq({ 'message' => 'Version 2 flags not supported' }) + feature_flag.reload + expect(feature_flag.scopes).to eq([]) + expect(feature_flag.strategies).to eq([]) + end + end + end + + describe 'POST /projects/:id/feature_flags/:name/disable' do + subject do + post api("/projects/#{project.id}/feature_flags/#{params[:name]}/disable", user), + params: params + end + + let(:params) do + { + name: 'awesome-feature', + environment_scope: 'production', + strategy: { name: 'userWithId', parameters: { userIds: 'Project:1' } }.to_json + } + end + + context 'when feature flag does not exist yet' do + it_behaves_like 'not found' + end + + context 'when feature flag exists already' do + let!(:feature_flag) { create_flag(project, params[:name]) } + + context 'when feature flag scope does not exist yet' do + it_behaves_like 'not found' + end + + context 'when feature flag scope exists already and has the specified strategy' do + let(:defined_strategies) do + [ + { name: 'userWithId', parameters: { userIds: 'Project:1' } }, + { name: 'userWithId', parameters: { userIds: 'Project:2' } } + ] + end + + before do + create_scope(feature_flag, params[:environment_scope], true, defined_strategies) + end + + it 'removes the strategy from the scope' do + subject + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(scope.strategies) + .to eq([{ name: 'userWithId', parameters: { userIds: 'Project:2' } }.deep_stringify_keys]) + end + + it 'returns the flag version and strategies in the json response' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response.slice('version', 'strategies')).to eq({ + 'version' => 'legacy_flag', + 'strategies' => [] + }) + end + + it_behaves_like 'check user permission' + + context 'when strategies become empty array after the removal' do + let(:defined_strategies) do + [{ name: 'userWithId', parameters: { userIds: 'Project:1' } }] + end + + it 'destroys the scope' do + subject + + scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope]) + expect(response).to have_gitlab_http_status(:ok) + expect(scope).to be_nil + end + + it_behaves_like 'check user permission' + end + end + + context 'when scope exists already but cannot find the corresponding strategy' do + let(:defined_strategy) { { name: 'userWithId', parameters: { userIds: 'Project:2' } } } + + before do + create_scope(feature_flag, params[:environment_scope], true, [defined_strategy]) + end + + it_behaves_like 'not found' + end + end + + context 'with a version 2 feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project, name: params[:name]) } + + it 'does not change the flag and returns an unprocessable_entity response' do + subject + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to eq({ 'message' => 'Version 2 flags not supported' }) + feature_flag.reload + expect(feature_flag.scopes).to eq([]) + expect(feature_flag.strategies).to eq([]) + end + end + end + + describe 'PUT /projects/:id/feature_flags/:name' do + context 'with a legacy feature flag' do + let!(:feature_flag) do + create(:operations_feature_flag, :legacy_flag, project: project, + name: 'feature1', description: 'old description') + end + + it 'returns a 404 if the feature is disabled' do + stub_feature_flags(feature_flags_new_version: false) + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:not_found) + expect(feature_flag.reload.description).to eq('old description') + end + + it 'returns a 422' do + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response).to eq({ 'message' => 'PUT operations are not supported for legacy feature flags' }) + expect(feature_flag.reload.description).to eq('old description') + end + end + + context 'with a version 2 feature flag' do + let!(:feature_flag) do + create(:operations_feature_flag, :new_version_flag, project: project, active: true, + name: 'feature1', description: 'old description') + end + + it 'returns a 404 if the feature is disabled' do + stub_feature_flags(feature_flags_new_version: false) + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:not_found) + expect(feature_flag.reload.description).to eq('old description') + end + + it 'returns a 404 if the feature flag does not exist' do + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/other_flag_name", user), params: params + + expect(response).to have_gitlab_http_status(:not_found) + expect(feature_flag.reload.description).to eq('old description') + end + + it 'forbids a request for a reporter' do + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/feature1", reporter), params: params + + expect(response).to have_gitlab_http_status(:forbidden) + expect(feature_flag.reload.description).to eq('old description') + end + + it 'returns an error for an invalid update of gradual rollout' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + params = { + strategies: [{ + id: strategy.id, + name: 'gradualRolloutUserId', + parameters: { bad: 'params' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).not_to be_nil + result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + expect(result).to eq([{ + id: strategy.id, + name: 'default', + parameters: {} + }]) + end + + it 'returns an error for an invalid update of flexible rollout' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + params = { + strategies: [{ + id: strategy.id, + name: 'flexibleRollout', + parameters: { bad: 'params' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).not_to be_nil + result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + expect(result).to eq([{ + id: strategy.id, + name: 'default', + parameters: {} + }]) + end + + it 'updates the feature flag' do + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(feature_flag.reload.description).to eq('new description') + end + + it 'updates the flag active value' do + params = { active: false } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response['active']).to eq(false) + expect(feature_flag.reload.active).to eq(false) + end + + it 'updates the feature flag name' do + params = { name: 'new-name' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(json_response['name']).to eq('new-name') + expect(feature_flag.reload.name).to eq('new-name') + end + + it 'ignores a provided version parameter' do + params = { description: 'other description', version: 'bad_value' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(feature_flag.reload.description).to eq('other description') + end + + it 'returns the feature flag json' do + params = { description: 'new description' } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + feature_flag.reload + expect(json_response).to eq({ + 'name' => 'feature1', + 'description' => 'new description', + 'active' => true, + 'created_at' => feature_flag.created_at.as_json, + 'updated_at' => feature_flag.updated_at.as_json, + 'scopes' => [], + 'strategies' => [], + 'version' => 'new_version_flag' + }) + end + + it 'updates an existing feature flag strategy to be gradual rollout strategy' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + params = { + strategies: [{ + id: strategy.id, + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '10' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + expect(result).to eq([{ + id: strategy.id, + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '10' } + }]) + end + + it 'updates an existing feature flag strategy to be flexible rollout strategy' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + params = { + strategies: [{ + id: strategy.id, + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + expect(result).to eq([{ + id: strategy.id, + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' } + }]) + end + + it 'adds a new gradual rollout strategy to a feature flag' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + params = { + strategies: [{ + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '10' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + result = feature_flag.reload.strategies + .map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + .sort_by { |s| s[:name] } + expect(result.first[:id]).to eq(strategy.id) + expect(result.map { |s| s.slice(:name, :parameters) }).to eq([{ + name: 'default', + parameters: {} + }, { + name: 'gradualRolloutUserId', + parameters: { groupId: 'default', percentage: '10' } + }]) + end + + it 'adds a new gradual flexible strategy to a feature flag' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + params = { + strategies: [{ + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + result = feature_flag.reload.strategies + .map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + .sort_by { |s| s[:name] } + expect(result.first[:id]).to eq(strategy.id) + expect(result.map { |s| s.slice(:name, :parameters) }).to eq([{ + name: 'default', + parameters: {} + }, { + name: 'flexibleRollout', + parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' } + }]) + end + + it 'deletes a feature flag strategy' do + strategy_a = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + strategy_b = create(:operations_strategy, feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: 'userA,userB' }) + params = { + strategies: [{ + id: strategy_a.id, + name: 'default', + parameters: {}, + _destroy: true + }, { + id: strategy_b.id, + name: 'userWithId', + parameters: { userIds: 'userB' } + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + result = feature_flag.reload.strategies + .map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys } + .sort_by { |s| s[:name] } + expect(result).to eq([{ + id: strategy_b.id, + name: 'userWithId', + parameters: { userIds: 'userB' } + }]) + end + + it 'updates an existing feature flag scope' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + scope = create(:operations_scope, strategy: strategy, environment_scope: '*') + params = { + strategies: [{ + id: strategy.id, + scopes: [{ + id: scope.id, + environment_scope: 'production' + }] + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + result = feature_flag.reload.strategies.first.scopes.map { |s| s.slice(:id, :environment_scope).deep_symbolize_keys } + expect(result).to eq([{ + id: scope.id, + environment_scope: 'production' + }]) + end + + it 'deletes an existing feature flag scope' do + strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {}) + scope = create(:operations_scope, strategy: strategy, environment_scope: '*') + params = { + strategies: [{ + id: strategy.id, + scopes: [{ + id: scope.id, + _destroy: true + }] + }] + } + + put api("/projects/#{project.id}/feature_flags/feature1", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/feature_flag') + expect(feature_flag.reload.strategies.first.scopes.count).to eq(0) + end + end + end + + describe 'DELETE /projects/:id/feature_flags/:name' do + subject do + delete api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", user), + params: params + end + + let!(:feature_flag) { create(:operations_feature_flag, project: project) } + let(:params) { {} } + + it 'destroys the feature flag' do + expect { subject }.to change { Operations::FeatureFlag.count }.by(-1) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns version' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['version']).to eq('legacy_flag') + end + + it 'does not return version when new version flags are disabled' do + stub_feature_flags(feature_flags_new_version: false) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.key?('version')).to eq(false) + end + + context 'with a version 2 feature flag' do + let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project) } + + it 'destroys the flag' do + expect { subject }.to change { Operations::FeatureFlag.count }.by(-1) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns a 404 if the feature is disabled' do + stub_feature_flags(feature_flags_new_version: false) + + expect { subject }.not_to change { Operations::FeatureFlag.count } + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/feature_flags_user_lists_spec.rb b/spec/requests/api/feature_flags_user_lists_spec.rb new file mode 100644 index 00000000000..469210040dd --- /dev/null +++ b/spec/requests/api/feature_flags_user_lists_spec.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::FeatureFlagsUserLists do + let_it_be(:project, refind: true) { create(:project) } + let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } + + before_all do + project.add_developer(developer) + project.add_reporter(reporter) + end + + def create_list(name: 'mylist', user_xids: 'user1') + create(:operations_feature_flag_user_list, project: project, name: name, user_xids: user_xids) + end + + def disable_repository(project) + project.project_feature.update!( + repository_access_level: ::ProjectFeature::DISABLED, + merge_requests_access_level: ::ProjectFeature::DISABLED, + builds_access_level: ::ProjectFeature::DISABLED + ) + end + + describe 'GET /projects/:id/feature_flags_user_lists' do + it 'forbids the request for a reporter' do + get api("/projects/#{project.id}/feature_flags_user_lists", reporter) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns forbidden if the feature is unavailable' do + disable_repository(project) + + get api("/projects/#{project.id}/feature_flags_user_lists", developer) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns all the user lists' do + create_list(name: 'list_a', user_xids: 'user1') + create_list(name: 'list_b', user_xids: 'user1,user2,user3') + + get api("/projects/#{project.id}/feature_flags_user_lists", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.map { |list| list['name'] }.sort).to eq(%w[list_a list_b]) + end + + it 'returns all the data for a user list' do + user_list = create_list(name: 'list_a', user_xids: 'user1') + + get api("/projects/#{project.id}/feature_flags_user_lists", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq([{ + 'id' => user_list.id, + 'iid' => user_list.iid, + 'project_id' => project.id, + 'created_at' => user_list.created_at.as_json, + 'updated_at' => user_list.updated_at.as_json, + 'name' => 'list_a', + 'user_xids' => 'user1', + 'path' => project_feature_flags_user_list_path(user_list.project, user_list), + 'edit_path' => edit_project_feature_flags_user_list_path(user_list.project, user_list) + }]) + end + + it 'paginates user lists' do + create_list(name: 'list_a', user_xids: 'user1') + create_list(name: 'list_b', user_xids: 'user1,user2,user3') + + get api("/projects/#{project.id}/feature_flags_user_lists?page=2&per_page=1", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.map { |list| list['name'] }).to eq(['list_b']) + end + + it 'returns the user lists for only the specified project' do + create(:operations_feature_flag_user_list, project: project, name: 'list') + other_project = create(:project) + create(:operations_feature_flag_user_list, project: other_project, name: 'other_list') + + get api("/projects/#{project.id}/feature_flags_user_lists", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.map { |list| list['name'] }).to eq(['list']) + end + + it 'returns an empty list' do + get api("/projects/#{project.id}/feature_flags_user_lists", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq([]) + end + end + + describe 'GET /projects/:id/feature_flags_user_lists/:iid' do + it 'forbids the request for a reporter' do + list = create_list + + get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", reporter) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns forbidden if the feature is unavailable' do + disable_repository(project) + list = create_list + + get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns the user list' do + list = create_list(name: 'testers', user_xids: 'test1,test2') + + get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq({ + 'name' => 'testers', + 'user_xids' => 'test1,test2', + 'id' => list.id, + 'iid' => list.iid, + 'project_id' => project.id, + 'created_at' => list.created_at.as_json, + 'updated_at' => list.updated_at.as_json, + 'path' => project_feature_flags_user_list_path(list.project, list), + 'edit_path' => edit_project_feature_flags_user_list_path(list.project, list) + }) + end + + it 'returns the correct user list identified by the iid' do + create_list(name: 'list_a', user_xids: 'test1') + list_b = create_list(name: 'list_b', user_xids: 'test2') + + get api("/projects/#{project.id}/feature_flags_user_lists/#{list_b.iid}", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('list_b') + end + + it 'scopes the iid search to the project' do + other_project = create(:project) + other_project.add_developer(developer) + create(:operations_feature_flag_user_list, project: other_project, name: 'other_list') + list = create(:operations_feature_flag_user_list, project: project, name: 'list') + + get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('list') + end + + it 'returns not found when the list does not exist' do + get api("/projects/#{project.id}/feature_flags_user_lists/1", developer) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq({ 'message' => '404 Not found' }) + end + end + + describe 'POST /projects/:id/feature_flags_user_lists' do + it 'forbids the request for a reporter' do + post api("/projects/#{project.id}/feature_flags_user_lists", reporter), params: { + name: 'mylist', user_xids: 'user1' + } + + expect(response).to have_gitlab_http_status(:forbidden) + expect(project.operations_feature_flags_user_lists.count).to eq(0) + end + + it 'returns forbidden if the feature is unavailable' do + disable_repository(project) + + post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: { + name: 'mylist', user_xids: 'user1' + } + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'creates the flag' do + post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: { + name: 'mylist', user_xids: 'user1' + } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response.slice('name', 'user_xids', 'project_id', 'iid')).to eq({ + 'name' => 'mylist', + 'user_xids' => 'user1', + 'project_id' => project.id, + 'iid' => 1 + }) + expect(project.operations_feature_flags_user_lists.count).to eq(1) + expect(project.operations_feature_flags_user_lists.last.name).to eq('mylist') + end + + it 'requires name' do + post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: { + user_xids: 'user1' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => 'name is missing' }) + expect(project.operations_feature_flags_user_lists.count).to eq(0) + end + + it 'requires user_xids' do + post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: { + name: 'empty_list' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => 'user_xids is missing' }) + expect(project.operations_feature_flags_user_lists.count).to eq(0) + end + + it 'returns an error when name is already taken' do + create_list(name: 'myname') + post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: { + name: 'myname', user_xids: 'a' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => ['Name has already been taken'] }) + expect(project.operations_feature_flags_user_lists.count).to eq(1) + end + + it 'does not create a flag for a project of which the developer is not a member' do + other_project = create(:project) + + post api("/projects/#{other_project.id}/feature_flags_user_lists", developer), params: { + name: 'mylist', user_xids: 'user1' + } + + expect(response).to have_gitlab_http_status(:not_found) + expect(other_project.operations_feature_flags_user_lists.count).to eq(0) + expect(project.operations_feature_flags_user_lists.count).to eq(0) + end + end + + describe 'PUT /projects/:id/feature_flags_user_lists/:iid' do + it 'forbids the request for a reporter' do + list = create_list(name: 'original_name') + + put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", reporter), params: { + name: 'mylist' + } + + expect(response).to have_gitlab_http_status(:forbidden) + expect(list.reload.name).to eq('original_name') + end + + it 'returns forbidden if the feature is unavailable' do + list = create_list(name: 'original_name') + disable_repository(project) + + put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: { + name: 'mylist', user_xids: '456,789' + } + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'updates the list' do + list = create_list(name: 'original_name', user_xids: '123') + + put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: { + name: 'mylist', user_xids: '456,789' + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.slice('name', 'user_xids')).to eq({ + 'name' => 'mylist', + 'user_xids' => '456,789' + }) + expect(list.reload.name).to eq('mylist') + end + + it 'preserves attributes not listed in the request' do + list = create_list(name: 'original_name', user_xids: '123') + + put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: {} + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.slice('name', 'user_xids')).to eq({ + 'name' => 'original_name', + 'user_xids' => '123' + }) + expect(list.reload.name).to eq('original_name') + expect(list.reload.user_xids).to eq('123') + end + + it 'returns an error when the update is invalid' do + create_list(name: 'taken', user_xids: '123') + list = create_list(name: 'original_name', user_xids: '123') + + put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: { + name: 'taken' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq({ 'message' => ['Name has already been taken'] }) + end + + it 'returns not found when the list does not exist' do + list = create_list(name: 'original_name', user_xids: '123') + + put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid + 1}", developer), params: { + name: 'new_name' + } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq({ 'message' => '404 Not found' }) + end + end + + describe 'DELETE /projects/:id/feature_flags_user_lists/:iid' do + it 'forbids the request for a reporter' do + list = create_list + + delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", reporter) + + expect(response).to have_gitlab_http_status(:forbidden) + expect(project.operations_feature_flags_user_lists.count).to eq(1) + end + + it 'returns forbidden if the feature is unavailable' do + list = create_list + disable_repository(project) + + delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns not found when the list does not exist' do + delete api("/projects/#{project.id}/feature_flags_user_lists/1", developer) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response).to eq({ 'message' => '404 Not found' }) + end + + it 'deletes the list' do + list = create_list + + delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer) + + expect(response).to have_gitlab_http_status(:no_content) + expect(response.body).to be_blank + expect(project.operations_feature_flags_user_lists.count).to eq(0) + end + + it 'does not delete the list if it is associated with a strategy' do + list = create_list + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project) + create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: list) + + delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer) + + expect(response).to have_gitlab_http_status(:conflict) + expect(json_response).to eq({ 'message' => ['User list is associated with a strategy'] }) + expect(list.reload).to be_persisted + end + end +end diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb index a20cf90a770..d8c95a70bd0 100644 --- a/spec/services/clusters/gcp/finalize_creation_service_spec.rb +++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb @@ -83,10 +83,7 @@ RSpec.describe Clusters::Gcp::FinalizeCreationService, '#execute' do shared_context 'kubernetes information successfully fetched' do before do stub_cloud_platform_get_zone_cluster( - provider.gcp_project_id, provider.zone, cluster.name, - endpoint: endpoint, - username: username, - password: password + provider.gcp_project_id, provider.zone, cluster.name, { endpoint: endpoint, username: username, password: password } ) stub_kubeclient_discover(api_url) diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb index 286f3c03357..840f948e377 100644 --- a/spec/support/google_api/cloud_platform_helpers.rb +++ b/spec/support/google_api/cloud_platform_helpers.rb @@ -22,9 +22,9 @@ module GoogleApi .to_return(cloud_platform_response(cloud_platform_projects_billing_info_body(project_id, billing_enabled))) end - def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, **options) + def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, options = {}) WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)) - .to_return(cloud_platform_response(cloud_platform_cluster_body(**options))) + .to_return(cloud_platform_response(cloud_platform_cluster_body(options))) end def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id) @@ -32,7 +32,7 @@ module GoogleApi .to_return(status: [500, "Internal Server Error"]) end - def stub_cloud_platform_create_cluster(project_id, zone, **options) + def stub_cloud_platform_create_cluster(project_id, zone, options = {}) WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone)) .to_return(cloud_platform_response(cloud_platform_operation_body(options))) end @@ -42,7 +42,7 @@ module GoogleApi .to_return(status: [500, "Internal Server Error"]) end - def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, **options) + def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, options = {}) WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id)) .to_return(cloud_platform_response(cloud_platform_operation_body(options))) end @@ -86,7 +86,7 @@ module GoogleApi # https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.zones.clusters/create # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity - def cloud_platform_cluster_body(**options) + def cloud_platform_cluster_body(options) { "name": options[:name] || 'string', "description": options[:description] || 'string', @@ -121,7 +121,7 @@ module GoogleApi } end - def cloud_platform_operation_body(**options) + def cloud_platform_operation_body(options) { "name": options[:name] || 'operation-1234567891234-1234567', "zone": options[:zone] || 'us-central1-a', @@ -136,7 +136,7 @@ module GoogleApi } end - def cloud_platform_projects_body(**options) + def cloud_platform_projects_body(options) { "projects": [ { |