Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-19 04:45:44 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-19 04:45:44 +0300
commit85dc423f7090da0a52c73eb66faf22ddb20efff9 (patch)
tree9160f299afd8c80c038f08e1545be119f5e3f1e1 /spec/models/operations
parent15c2c8c66dbe422588e5411eee7e68f1fa440bb8 (diff)
Add latest changes from gitlab-org/gitlab@13-4-stable-ee
Diffstat (limited to 'spec/models/operations')
-rw-r--r--spec/models/operations/feature_flag_scope_spec.rb391
-rw-r--r--spec/models/operations/feature_flag_spec.rb258
-rw-r--r--spec/models/operations/feature_flags/strategy_spec.rb323
-rw-r--r--spec/models/operations/feature_flags/user_list_spec.rb102
-rw-r--r--spec/models/operations/feature_flags_client_spec.rb21
5 files changed, 1095 insertions, 0 deletions
diff --git a/spec/models/operations/feature_flag_scope_spec.rb b/spec/models/operations/feature_flag_scope_spec.rb
new file mode 100644
index 00000000000..29d338d8b29
--- /dev/null
+++ b/spec/models/operations/feature_flag_scope_spec.rb
@@ -0,0 +1,391 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlagScope do
+ describe 'associations' do
+ it { is_expected.to belong_to(:feature_flag) }
+ end
+
+ describe 'validations' do
+ context 'when duplicate environment scope is going to be created' do
+ let!(:existing_feature_flag_scope) do
+ create(:operations_feature_flag_scope)
+ end
+
+ let(:new_feature_flag_scope) do
+ build(:operations_feature_flag_scope,
+ feature_flag: existing_feature_flag_scope.feature_flag,
+ environment_scope: existing_feature_flag_scope.environment_scope)
+ end
+
+ it 'validates uniqueness of environment scope' do
+ new_feature_flag_scope.save
+
+ expect(new_feature_flag_scope.errors[:environment_scope])
+ .to include("(#{existing_feature_flag_scope.environment_scope})" \
+ " has already been taken")
+ end
+ end
+
+ context 'when environment scope of a default scope is updated' do
+ let!(:feature_flag) { create(:operations_feature_flag) }
+ let!(:scope_default) { feature_flag.default_scope }
+
+ it 'keeps default scope intact' do
+ scope_default.update(environment_scope: 'review/*')
+
+ expect(scope_default.errors[:environment_scope])
+ .to include("cannot be changed from default scope")
+ end
+ end
+
+ context 'when a default scope is destroyed' do
+ let!(:feature_flag) { create(:operations_feature_flag) }
+ let!(:scope_default) { feature_flag.default_scope }
+
+ it 'prevents from destroying the default scope' do
+ expect { scope_default.destroy! }.to raise_error(ActiveRecord::ReadOnlyRecord)
+ end
+ end
+
+ describe 'strategy validations' do
+ it 'handles null strategies which can occur while adding the column during migration' do
+ scope = create(:operations_feature_flag_scope, active: true)
+ allow(scope).to receive(:strategies).and_return(nil)
+
+ scope.active = false
+ scope.save
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+
+ it 'validates multiple strategies' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: "default", parameters: {} },
+ { name: "invalid", parameters: {} }])
+
+ expect(scope.errors[:strategies]).not_to be_empty
+ end
+
+ where(:invalid_value) do
+ [{}, 600, "bad", [{ name: 'default', parameters: {} }, 300]]
+ end
+ with_them do
+ it 'must be an array of strategy hashes' do
+ scope = create(:operations_feature_flag_scope)
+
+ scope.strategies = invalid_value
+ scope.save
+
+ expect(scope.errors[:strategies]).to eq(['must be an array of strategy hashes'])
+ end
+ end
+
+ describe 'name' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:name, :params, :expected) do
+ 'default' | {} | []
+ 'gradualRolloutUserId' | { groupId: 'mygroup', percentage: '50' } | []
+ 'userWithId' | { userIds: 'sam' } | []
+ 5 | nil | ['strategy name is invalid']
+ nil | nil | ['strategy name is invalid']
+ "nothing" | nil | ['strategy name is invalid']
+ "" | nil | ['strategy name is invalid']
+ 40.0 | nil | ['strategy name is invalid']
+ {} | nil | ['strategy name is invalid']
+ [] | nil | ['strategy name is invalid']
+ end
+ with_them do
+ it 'must be one of "default", "gradualRolloutUserId", or "userWithId"' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: name, parameters: params }])
+
+ expect(scope.errors[:strategies]).to eq(expected)
+ end
+ end
+ end
+
+ describe 'parameters' do
+ context 'when the strategy name is gradualRolloutUserId' do
+ it 'must have parameters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId' }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+
+ where(:invalid_parameters) do
+ [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' },
+ { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: invalid_parameters }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'allows the parameters in any order' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { percentage: '10', groupId: 'mygroup' } }])
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+
+ describe 'percentage' do
+ where(:invalid_value) do
+ [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
+ "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t",
+ "\n10", "20\n", "\n100", "100\n", "\n ", nil]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: invalid_value } }])
+
+ expect(scope.errors[:strategies]).to eq(['percentage must be a string between 0 and 100 inclusive'])
+ end
+ end
+
+ where(:valid_value) do
+ %w[0 1 10 38 100 93]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: valid_value } }])
+
+ expect(scope.errors[:strategies]).to eq([])
+ end
+ end
+ end
+
+ describe 'groupId' do
+ where(:invalid_value) do
+ [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>', '!bad',
+ '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: invalid_value, percentage: '40' } }])
+
+ expect(scope.errors[:strategies]).to eq(['groupId parameter is invalid'])
+ end
+ end
+
+ where(:valid_value) do
+ ["somegroup", "anothergroup", "okay", "g", "a" * 32]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: valid_value, percentage: '40' } }])
+
+ expect(scope.errors[:strategies]).to eq([])
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is userWithId' do
+ it 'must have parameters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId' }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+
+ where(:invalid_parameters) do
+ [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId', parameters: invalid_parameters }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+ end
+
+ describe 'userIds' do
+ where(:valid_value) do
+ ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
+ "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
+ "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
+ "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
+ end
+ with_them do
+ it 'is valid with a string of comma separated values' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId', parameters: { userIds: valid_value } }])
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+ end
+
+ where(:invalid_value) do
+ [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
+ "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
+ " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
+ end
+ with_them do
+ it 'is invalid' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'userWithId', parameters: { userIds: invalid_value } }])
+
+ expect(scope.errors[:strategies]).to include(
+ 'userIds must be a string of unique comma separated values each 256 characters or less'
+ )
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is default' do
+ it 'must have parameters' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'default' }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+
+ where(:invalid_value) do
+ [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5]
+ end
+ with_them do
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'default',
+ parameters: invalid_value }])
+
+ expect(scope.errors[:strategies]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag)
+ scope = described_class.create(feature_flag: feature_flag,
+ environment_scope: 'production', active: true,
+ strategies: [{ name: 'default',
+ parameters: {} }])
+
+ expect(scope.errors[:strategies]).to be_empty
+ end
+ end
+ end
+ end
+ end
+
+ describe '.enabled' do
+ subject { described_class.enabled }
+
+ let!(:feature_flag_scope) do
+ create(:operations_feature_flag_scope, active: active)
+ end
+
+ context 'when scope is active' do
+ let(:active) { true }
+
+ it 'returns the scope' do
+ is_expected.to include(feature_flag_scope)
+ end
+ end
+
+ context 'when scope is inactive' do
+ let(:active) { false }
+
+ it 'returns an empty array' do
+ is_expected.not_to include(feature_flag_scope)
+ end
+ end
+ end
+
+ describe '.disabled' do
+ subject { described_class.disabled }
+
+ let!(:feature_flag_scope) do
+ create(:operations_feature_flag_scope, active: active)
+ end
+
+ context 'when scope is active' do
+ let(:active) { true }
+
+ it 'returns an empty array' do
+ is_expected.not_to include(feature_flag_scope)
+ end
+ end
+
+ context 'when scope is inactive' do
+ let(:active) { false }
+
+ it 'returns the scope' do
+ is_expected.to include(feature_flag_scope)
+ end
+ end
+ end
+
+ describe '.for_unleash_client' do
+ it 'returns scopes for the specified project' do
+ project1 = create(:project)
+ project2 = create(:project)
+ expected_feature_flag = create(:operations_feature_flag, project: project1)
+ create(:operations_feature_flag, project: project2)
+
+ scopes = described_class.for_unleash_client(project1, 'sandbox').to_a
+
+ expect(scopes).to contain_exactly(*expected_feature_flag.scopes)
+ end
+
+ it 'returns a scope that matches exactly over a match with a wild card' do
+ project = create(:project)
+ feature_flag = create(:operations_feature_flag, project: project)
+ create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production*')
+ expected_scope = create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production')
+
+ scopes = described_class.for_unleash_client(project, 'production').to_a
+
+ expect(scopes).to contain_exactly(expected_scope)
+ end
+ end
+end
diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb
new file mode 100644
index 00000000000..83d6c6b95a3
--- /dev/null
+++ b/spec/models/operations/feature_flag_spec.rb
@@ -0,0 +1,258 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlag do
+ include FeatureFlagHelpers
+
+ subject { create(:operations_feature_flag) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to have_many(:scopes) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
+ it { is_expected.to define_enum_for(:version).with_values(legacy_flag: 1, new_version_flag: 2) }
+
+ context 'a version 1 feature flag' do
+ it 'is valid if associated with Operations::FeatureFlagScope models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 1,
+ scopes_attributes: [{ environment_scope: '*', active: false }] })
+
+ expect(feature_flag).to be_valid
+ end
+
+ it 'is invalid if associated with Operations::FeatureFlags::Strategy models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 1,
+ strategies_attributes: [{ name: 'default', parameters: {} }] })
+
+ expect(feature_flag.errors.messages).to eq({
+ version_associations: ["version 1 feature flags may not have strategies"]
+ })
+ end
+ end
+
+ context 'a version 2 feature flag' do
+ it 'is invalid if associated with Operations::FeatureFlagScope models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 2,
+ scopes_attributes: [{ environment_scope: '*', active: false }] })
+
+ expect(feature_flag.errors.messages).to eq({
+ version_associations: ["version 2 feature flags may not have scopes"]
+ })
+ end
+
+ it 'is valid if associated with Operations::FeatureFlags::Strategy models' do
+ project = create(:project)
+ feature_flag = described_class.create({ name: 'test', project: project, version: 2,
+ strategies_attributes: [{ name: 'default', parameters: {} }] })
+
+ expect(feature_flag).to be_valid
+ end
+ end
+
+ it_behaves_like 'AtomicInternalId', validate_presence: true do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(:operations_feature_flag) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :operations_feature_flags }
+ end
+ end
+
+ describe 'feature flag version' do
+ it 'defaults to 1 if unspecified' do
+ project = create(:project)
+
+ feature_flag = described_class.create(name: 'my_flag', project: project, active: true)
+
+ expect(feature_flag).to be_valid
+ expect(feature_flag.version_before_type_cast).to eq(1)
+ end
+ end
+
+ describe 'Scope creation' do
+ subject { described_class.new(**params) }
+
+ let(:project) { create(:project) }
+
+ let(:params) do
+ { name: 'test', project: project, scopes_attributes: scopes_attributes }
+ end
+
+ let(:scopes_attributes) do
+ [{ environment_scope: '*', active: false },
+ { environment_scope: 'review/*', active: true }]
+ end
+
+ it { is_expected.to be_valid }
+
+ context 'when the first scope is not wildcard' do
+ let(:scopes_attributes) do
+ [{ environment_scope: 'review/*', active: true },
+ { environment_scope: '*', active: false }]
+ end
+
+ it { is_expected.not_to be_valid }
+ end
+ end
+
+ describe 'the default scope' do
+ let_it_be(:project) { create(:project) }
+
+ context 'with a version 1 feature flag' do
+ it 'creates a default scope' do
+ feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 1 })
+
+ expect(feature_flag.scopes.count).to eq(1)
+ expect(feature_flag.scopes.first.environment_scope).to eq('*')
+ end
+
+ it 'allows specifying the default scope in the parameters' do
+ feature_flag = described_class.create({ name: 'test', project: project,
+ scopes_attributes: [{ environment_scope: '*', active: false },
+ { environment_scope: 'review/*', active: true }], version: 1 })
+
+ expect(feature_flag.scopes.count).to eq(2)
+ expect(feature_flag.scopes.first.environment_scope).to eq('*')
+ end
+ end
+
+ context 'with a version 2 feature flag' do
+ it 'does not create a default scope' do
+ feature_flag = described_class.create({ name: 'test', project: project, scopes_attributes: [], version: 2 })
+
+ expect(feature_flag.scopes).to eq([])
+ end
+ end
+ end
+
+ describe '.enabled' do
+ subject { described_class.enabled }
+
+ context 'when the feature flag is active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'returns the flag' do
+ is_expected.to eq([feature_flag])
+ end
+ end
+
+ context 'when the feature flag is active and all scopes are inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'returns the flag' do
+ feature_flag.default_scope.update!(active: false)
+
+ is_expected.to eq([feature_flag])
+ end
+ end
+
+ context 'when the feature flag is inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'does not return the flag' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the feature flag is inactive and all scopes are active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'does not return the flag' do
+ feature_flag.default_scope.update!(active: true)
+
+ is_expected.to be_empty
+ end
+ end
+ end
+
+ describe '.disabled' do
+ subject { described_class.disabled }
+
+ context 'when the feature flag is active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'does not return the flag' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the feature flag is active and all scopes are inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: true) }
+
+ it 'does not return the flag' do
+ feature_flag.default_scope.update!(active: false)
+
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the feature flag is inactive' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'returns the flag' do
+ is_expected.to eq([feature_flag])
+ end
+ end
+
+ context 'when the feature flag is inactive and all scopes are active' do
+ let!(:feature_flag) { create(:operations_feature_flag, active: false) }
+
+ it 'returns the flag' do
+ feature_flag.default_scope.update!(active: true)
+
+ is_expected.to eq([feature_flag])
+ end
+ end
+ end
+
+ describe '.for_unleash_client' do
+ let_it_be(:project) { create(:project) }
+ let!(:feature_flag) do
+ create(:operations_feature_flag, project: project,
+ name: 'feature1', active: true, version: 2)
+ end
+
+ let!(:strategy) do
+ create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ end
+
+ it 'matches wild cards in the scope' do
+ create(:operations_scope, strategy: strategy, environment_scope: 'review/*')
+
+ flags = described_class.for_unleash_client(project, 'review/feature-branch')
+
+ expect(flags).to eq([feature_flag])
+ end
+
+ it 'matches wild cards case sensitively' do
+ create(:operations_scope, strategy: strategy, environment_scope: 'Staging/*')
+
+ flags = described_class.for_unleash_client(project, 'staging/feature')
+
+ expect(flags).to eq([])
+ end
+
+ it 'returns feature flags ordered by id' do
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+ feature_flag_b = create(:operations_feature_flag, project: project,
+ name: 'feature2', active: true, version: 2)
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag_b,
+ name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy_b, environment_scope: '*')
+
+ flags = described_class.for_unleash_client(project, 'production')
+
+ expect(flags.map(&:id)).to eq([feature_flag.id, feature_flag_b.id])
+ end
+ end
+end
diff --git a/spec/models/operations/feature_flags/strategy_spec.rb b/spec/models/operations/feature_flags/strategy_spec.rb
new file mode 100644
index 00000000000..04e3ef26e9d
--- /dev/null
+++ b/spec/models/operations/feature_flags/strategy_spec.rb
@@ -0,0 +1,323 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlags::Strategy do
+ let_it_be(:project) { create(:project) }
+
+ describe 'validations' do
+ it do
+ is_expected.to validate_inclusion_of(:name)
+ .in_array(%w[default gradualRolloutUserId userWithId gitlabUserList])
+ .with_message('strategy name is invalid')
+ end
+
+ describe 'parameters' do
+ context 'when the strategy name is invalid' do
+ where(:invalid_name) do
+ [nil, {}, [], 'nothing', 3]
+ end
+ with_them do
+ it 'skips parameters validation' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: invalid_name, parameters: { bad: 'params' })
+
+ expect(strategy.errors[:name]).to eq(['strategy name is invalid'])
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+ end
+
+ context 'when the strategy name is gradualRolloutUserId' do
+ where(:invalid_parameters) do
+ [nil, {}, { percentage: '40', groupId: 'mygroup', userIds: '4' }, { percentage: '40' },
+ { percentage: '40', groupId: 'mygroup', extra: nil }, { groupId: 'mygroup' }]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId', parameters: invalid_parameters)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'allows the parameters in any order' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { percentage: '10', groupId: 'mygroup' })
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+
+ describe 'percentage' do
+ where(:invalid_value) do
+ [50, 40.0, { key: "value" }, "garbage", "00", "01", "101", "-1", "-10", "0100",
+ "1000", "10.0", "5%", "25%", "100hi", "e100", "30m", " ", "\r\n", "\n", "\t",
+ "\n10", "20\n", "\n100", "100\n", "\n ", nil]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: invalid_value })
+
+ expect(strategy.errors[:parameters]).to eq(['percentage must be a string between 0 and 100 inclusive'])
+ end
+ end
+
+ where(:valid_value) do
+ %w[0 1 10 38 100 93]
+ end
+ with_them do
+ it 'must be a string value between 0 and 100 inclusive and without a percentage sign' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'mygroup', percentage: valid_value })
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+
+ describe 'groupId' do
+ where(:invalid_value) do
+ [nil, 4, 50.0, {}, 'spaces bad', 'bad$', '%bad', '<bad', 'bad>', '!bad',
+ '.bad', 'Bad', 'bad1', "", " ", "b" * 33, "ba_d", "ba\nd"]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: invalid_value, percentage: '40' })
+
+ expect(strategy.errors[:parameters]).to eq(['groupId parameter is invalid'])
+ end
+ end
+
+ where(:valid_value) do
+ ["somegroup", "anothergroup", "okay", "g", "a" * 32]
+ end
+ with_them do
+ it 'must be a string value of up to 32 lowercase characters' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: valid_value, percentage: '40' })
+
+ expect(strategy.errors[:parameters]).to eq([])
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is userWithId' do
+ where(:invalid_parameters) do
+ [nil, { userIds: 'sam', percentage: '40' }, { userIds: 'sam', some: 'param' }, { percentage: '40' }, {}]
+ end
+ with_them do
+ it 'must have valid parameters for the strategy' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId', parameters: invalid_parameters)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ describe 'userIds' do
+ where(:valid_value) do
+ ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
+ "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
+ "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
+ "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
+ end
+ with_them do
+ it 'is valid with a string of comma separated values' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: valid_value })
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+
+ where(:invalid_value) do
+ [1, 2.5, {}, [], nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
+ "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
+ " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
+ end
+ with_them do
+ it 'is invalid' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: invalid_value })
+
+ expect(strategy.errors[:parameters]).to include(
+ 'userIds must be a string of unique comma separated values each 256 characters or less'
+ )
+ end
+ end
+ end
+ end
+
+ context 'when the strategy name is default' do
+ where(:invalid_value) do
+ [{ groupId: "hi", percentage: "7" }, "", "nothing", 7, nil, [], 2.5]
+ end
+ with_them do
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ parameters: invalid_value)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ parameters: {})
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+
+ context 'when the strategy name is gitlabUserList' do
+ where(:invalid_value) do
+ [{ groupId: "default", percentage: "7" }, "", "nothing", 7, nil, [], 2.5, { userIds: 'user1' }]
+ end
+ with_them do
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ parameters: invalid_value)
+
+ expect(strategy.errors[:parameters]).to eq(['parameters are invalid'])
+ end
+ end
+
+ it 'must be empty' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ parameters: {})
+
+ expect(strategy.errors[:parameters]).to be_empty
+ end
+ end
+ end
+
+ describe 'associations' do
+ context 'when name is gitlabUserList' do
+ it 'is valid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ user_list: user_list,
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+
+ it 'is invalid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to eq(["can't be blank"])
+ end
+
+ it 'is invalid when associated with a user list from another project' do
+ other_project = create(:project)
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: other_project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gitlabUserList',
+ user_list: user_list,
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to eq(['must belong to the same project'])
+ end
+ end
+
+ context 'when name is default' do
+ it 'is invalid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ user_list: user_list,
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'default',
+ parameters: {})
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
+
+ context 'when name is userWithId' do
+ it 'is invalid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId',
+ user_list: user_list,
+ parameters: { userIds: 'user1' })
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'userWithId',
+ parameters: { userIds: 'user1' })
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
+
+ context 'when name is gradualRolloutUserId' do
+ it 'is invalid when associated with a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ user_list = create(:operations_feature_flag_user_list, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ user_list: user_list,
+ parameters: { groupId: 'default', percentage: '10' })
+
+ expect(strategy.errors[:user_list]).to eq(['must be blank'])
+ end
+
+ it 'is valid without a user list' do
+ feature_flag = create(:operations_feature_flag, project: project)
+ strategy = described_class.create(feature_flag: feature_flag,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' })
+
+ expect(strategy.errors[:user_list]).to be_empty
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/operations/feature_flags/user_list_spec.rb b/spec/models/operations/feature_flags/user_list_spec.rb
new file mode 100644
index 00000000000..020416aa7bc
--- /dev/null
+++ b/spec/models/operations/feature_flags/user_list_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlags::UserList do
+ subject { create(:operations_feature_flag_user_list) }
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
+ it { is_expected.to validate_length_of(:name).is_at_least(1).is_at_most(255) }
+
+ describe 'user_xids' do
+ where(:valid_value) do
+ ["", "sam", "1", "a", "uuid-of-some-kind", "sam,fred,tom,jane,joe,mike",
+ "gitlab@example.com", "123,4", "UPPER,Case,charActeRS", "0",
+ "$valid$email#2345#$%..{}+=-)?\\/@example.com", "spaces allowed",
+ "a" * 256, "a,#{'b' * 256},ccc", "many spaces"]
+ end
+ with_them do
+ it 'is valid with a string of comma separated values' do
+ user_list = described_class.create(user_xids: valid_value)
+
+ expect(user_list.errors[:user_xids]).to be_empty
+ end
+ end
+
+ where(:typecast_value) do
+ [1, 2.5, {}, []]
+ end
+ with_them do
+ it 'automatically casts values of other types' do
+ user_list = described_class.create(user_xids: typecast_value)
+
+ expect(user_list.errors[:user_xids]).to be_empty
+ expect(user_list.user_xids).to eq(typecast_value.to_s)
+ end
+ end
+
+ where(:invalid_value) do
+ [nil, "123\n456", "1,2,3,12\t3", "\n", "\n\r",
+ "joe\r,sam", "1,2,2", "1,,2", "1,2,,,,", "b" * 257, "1, ,2", "tim, ,7", " ",
+ " ", " ,1", "1, ", " leading,1", "1,trailing ", "1, both ,2"]
+ end
+ with_them do
+ it 'is invalid' do
+ user_list = described_class.create(user_xids: invalid_value)
+
+ expect(user_list.errors[:user_xids]).to include(
+ 'user_xids must be a string of unique comma separated values each 256 characters or less'
+ )
+ end
+ end
+ end
+ end
+
+ describe 'url_helpers' do
+ it 'generates paths based on the internal id' do
+ create(:operations_feature_flag_user_list)
+ project_b = create(:project)
+ list_b = create(:operations_feature_flag_user_list, project: project_b)
+
+ path = ::Gitlab::Routing.url_helpers.project_feature_flags_user_list_path(project_b, list_b)
+
+ expect(path).to eq("/#{project_b.full_path}/-/feature_flags_user_lists/#{list_b.iid}")
+ end
+ end
+
+ describe '#destroy' do
+ it 'deletes the model if it is not associated with any feature flag strategies' do
+ project = create(:project)
+ user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2')
+
+ user_list.destroy
+
+ expect(described_class.count).to eq(0)
+ end
+
+ it 'does not delete the model if it is associated with a feature flag strategy' do
+ project = create(:project)
+ user_list = described_class.create(project: project, name: 'My User List', user_xids: 'user1,user2')
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project)
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: user_list)
+
+ user_list.destroy
+
+ expect(described_class.count).to eq(1)
+ expect(::Operations::FeatureFlags::StrategyUserList.count).to eq(1)
+ expect(strategy.reload.user_list).to eq(user_list)
+ expect(strategy.valid?).to eq(true)
+ end
+ end
+
+ it_behaves_like 'AtomicInternalId' do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(:operations_feature_flag_user_list) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :operations_user_lists }
+ end
+end
diff --git a/spec/models/operations/feature_flags_client_spec.rb b/spec/models/operations/feature_flags_client_spec.rb
new file mode 100644
index 00000000000..05988d676f3
--- /dev/null
+++ b/spec/models/operations/feature_flags_client_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Operations::FeatureFlagsClient do
+ subject { create(:operations_feature_flags_client) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:project) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:project) }
+ end
+
+ describe '#token' do
+ it "ensures that token is always set" do
+ expect(subject.token).not_to be_empty
+ end
+ end
+end