summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/projects/feature_flags_clients_controller_spec.rb57
-rw-r--r--spec/controllers/projects/feature_flags_controller_spec.rb1604
-rw-r--r--spec/controllers/projects/feature_flags_user_lists_controller_spec.rb113
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb2
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb2
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb10
-rw-r--r--spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb6
-rw-r--r--spec/features/merge_request/user_sees_discussions_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb2
-rw-r--r--spec/features/projects/commit/builds_spec.rb2
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb63
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb21
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb21
-rw-r--r--spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb200
-rw-r--r--spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb31
-rw-r--r--spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb147
-rw-r--r--spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb195
-rw-r--r--spec/features/projects/releases/user_views_edit_release_spec.rb2
-rw-r--r--spec/fixtures/api/schemas/feature_flag.json23
-rw-r--r--spec/fixtures/api/schemas/feature_flag_scope.json18
-rw-r--r--spec/fixtures/api/schemas/feature_flag_strategy.json13
-rw-r--r--spec/fixtures/api/schemas/feature_flags.json13
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag.json15
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json22
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json17
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json13
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flags.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/operations/scope.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/operations/strategy.json14
-rw-r--r--spec/fixtures/lib/backup/personal_snippet_repo.bundlebin0 -> 686 bytes
-rw-r--r--spec/fixtures/lib/backup/project_snippet_repo.bundlebin0 -> 696 bytes
-rw-r--r--spec/frontend/ide/lib/languages/hcl_spec.js290
-rw-r--r--spec/frontend/notes/components/discussion_reply_placeholder_spec.js7
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js4
-rw-r--r--spec/frontend/packages/details/utils_spec.js24
-rw-r--r--spec/frontend/packages/mock_data.js4
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb22
-rw-r--r--spec/lib/backup/repositories_spec.rb12
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/models/blob_viewer/markup_spec.rb38
-rw-r--r--spec/presenters/packages/detail/package_presenter_spec.rb2
-rw-r--r--spec/requests/api/feature_flag_scopes_spec.rb319
-rw-r--r--spec/requests/api/feature_flags_spec.rb1130
-rw-r--r--spec/requests/api/feature_flags_user_lists_spec.rb371
-rw-r--r--spec/services/clusters/gcp/finalize_creation_service_spec.rb5
-rw-r--r--spec/support/google_api/cloud_platform_helpers.rb14
49 files changed, 4851 insertions, 63 deletions
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
new file mode 100644
index 00000000000..452cf6a19fe
--- /dev/null
+++ b/spec/fixtures/lib/backup/personal_snippet_repo.bundle
Binary files differ
diff --git a/spec/fixtures/lib/backup/project_snippet_repo.bundle b/spec/fixtures/lib/backup/project_snippet_repo.bundle
new file mode 100644
index 00000000000..c05f8ec9495
--- /dev/null
+++ b/spec/fixtures/lib/backup/project_snippet_repo.bundle
Binary files differ
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": [
{