# 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', '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