Commit 7b7114d8 authored by Doug Stull's avatar Doug Stull Committed by Alper Akgun

Add free user cap feature flag and controls

- for control of free user cap logic

Changelog: added
EE: true
parent e0f5dc24
......@@ -342,12 +342,20 @@ module EE
billable_ids[:user_ids].count
end
override :free_plan_members_count
def free_plan_members_count
billable_ids = billed_user_ids_including_guests
billable_ids[:user_ids].count
end
# For now, we are not billing for members with a Guest role for subscriptions
# with a Gold/Ultimate plan. The other plans will treat Guest members as a regular member
# for billing purposes.
#
# For the user_ids key, we are plucking the user_ids from the "Members" table in an array and
# converting the array of user_ids to a Set which will have unique user_ids.
override :billed_user_ids
def billed_user_ids(requested_hosted_plan = nil)
exclude_guests = ([actual_plan_name, requested_hosted_plan] & [::Plan::GOLD, ::Plan::ULTIMATE, ::Plan::ULTIMATE_TRIAL]).any?
......@@ -527,6 +535,11 @@ module EE
user_cap <= members_count
end
override :user_limit_reached?
def user_limit_reached?(use_cache: false)
super || user_cap_reached?(use_cache: use_cache)
end
def shared_externally?
strong_memoize(:shared_externally) do
internal_groups = ::Group.groups_including_descendants_by([self])
......@@ -687,10 +700,6 @@ module EE
::GroupMember.active_without_invites_and_requests.where(source_id: groups.self_and_ancestors)
end
def users_without_project_bots(members)
::User.where(id: members.distinct.select(:user_id)).without_project_bot
end
override :_safe_read_repository_read_only_column
def _safe_read_repository_read_only_column
::NamespaceSetting.where(namespace: self).pick(:repository_read_only)
......
......@@ -147,10 +147,10 @@ module EE
end
def set_membership_activation
return unless group
return if user && ::Member.in_hierarchy(group).with_user(user).with_state(:active).any?
return unless source.root_ancestor.apply_user_cap? # this guard is likely cheaper than doing the Member query all the time
return if user && ::Member.in_hierarchy(source).with_user(user).with_state(:active).any?
self.state = ::Member::STATE_AWAITING if group.user_cap_reached?
self.state = ::Member::STATE_AWAITING if source.root_ancestor.user_limit_reached?
end
end
end
......@@ -338,6 +338,10 @@ module EE
}
end
def free_plan_members_count
free_plan_user_ids.count
end
def eligible_for_trial?
::Gitlab.com? &&
!has_parent? &&
......@@ -484,8 +488,39 @@ module EE
user_cap_available? || apply_free_user_cap?
end
def free_user_cap_reached?
return false unless apply_free_user_cap?
members_count = root_ancestor.free_plan_members_count
return false unless members_count
::Plan::FREE_USER_LIMIT <= members_count
end
def user_limit_reached?(use_cache: false)
free_user_cap_reached?
end
def free_plan_user_ids
strong_memoize(:free_plan_user_ids) do
billed_users.pluck(:id)
end
end
private
# Members belonging directly to Projects within user/project namespaces
def billed_users
# this will include the namespace owner(user namespace) as well
members = ::ProjectMember.without_invites_and_requests.where(source_id: ::Project.in_namespace(self))
users_without_project_bots(members).with_state(:active)
end
def users_without_project_bots(members)
::User.id_in(members.distinct.select(:user_id)).without_project_bot
end
def any_project_with_shared_runners_enabled_with_cte?
projects_query = if user_namespace?
projects
......
......@@ -16,6 +16,8 @@ module EE
PREMIUM_TRIAL = 'premium_trial'
OPEN_SOURCE = 'opensource'
FREE_USER_LIMIT = 5
EE_DEFAULT_PLANS = (const_get(:DEFAULT_PLANS, false) + [FREE]).freeze
PAID_HOSTED_PLANS = [BRONZE, SILVER, PREMIUM, GOLD, ULTIMATE, ULTIMATE_TRIAL, PREMIUM_TRIAL, OPEN_SOURCE].freeze
EE_ALL_PLANS = (EE_DEFAULT_PLANS + PAID_HOSTED_PLANS).freeze
......
......@@ -3,9 +3,12 @@
require 'spec_helper'
RSpec.describe Group do
using RSpec::Parameterized::TableSyntax
let(:group) { create(:group) }
it { is_expected.to include_module(EE::Group) }
it { is_expected.to be_kind_of(ReactiveCaching) }
describe 'associations' do
it { is_expected.to have_many(:audit_events).dependent(false) }
......@@ -1605,6 +1608,55 @@ RSpec.describe Group do
end
end
describe '#calculate_reactive_cache' do
let(:group) { build(:group) }
subject { group.calculate_reactive_cache }
it 'returns cache data for the free plan members count' do
expect(group).to receive(:billable_members_count).and_return(5)
is_expected.to eq(5)
end
end
describe '#user_limit_reached?' do
where(:user_cap_reached, :free_user_cap_reached, :result) do
false | false | false
false | true | true
true | false | true
true | true | true
end
subject { group.user_limit_reached? }
with_them do
before do
allow(group).to receive(:user_cap_reached?).and_return(user_cap_reached)
allow(group).to receive(:free_user_cap_reached?).and_return(free_user_cap_reached)
end
it { is_expected.to eq(result) }
end
end
describe '#free_plan_members_count' do
let_it_be(:namespace) { create(:group) }
let_it_be(:owner) { create(:user) }
let_it_be(:project) { create(:project, group: namespace) }
let_it_be(:project_user) { create(:project_member, project: project).user }
let_it_be(:project_2) { create(:project, group: namespace) }
let_it_be(:project2_user) { create(:project_member, project: project_2).user }
before do
namespace.add_owner(owner)
end
it 'has the correct count' do
expect(namespace.free_plan_members_count).to eq 3
end
end
describe '#shared_externally?' do
let_it_be(:group, refind: true) { create(:group) }
let_it_be(:subgroup_1) { create(:group, parent: group) }
......
......@@ -1025,6 +1025,7 @@ RSpec.describe Namespace do
group.add_developer(developer)
group.add_developer(create(:user, :blocked))
group.add_guest(guest)
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: group)
end
......@@ -1054,6 +1055,7 @@ RSpec.describe Namespace do
project.add_guest(create(:user))
project.add_developer(developer)
project.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:project_member, :awaiting, :developer, source: project)
end
......@@ -1088,6 +1090,7 @@ RSpec.describe Namespace do
invited_group.add_guest(create(:user))
invited_group.add_developer(create(:user, :blocked))
invited_group.add_developer(developer)
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: invited_group)
end
......@@ -1133,6 +1136,8 @@ RSpec.describe Namespace do
shared_group.add_developer(shared_group_developer)
shared_group.add_guest(create(:user))
shared_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
shared_group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: shared_group)
create(:group_group_link, { shared_with_group: shared_group,
......@@ -1162,6 +1167,7 @@ RSpec.describe Namespace do
another_shared_group.add_developer(another_shared_group_developer)
another_shared_group.add_guest(create(:user))
another_shared_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: another_shared_group)
end
......@@ -1242,6 +1248,7 @@ RSpec.describe Namespace do
project.add_guest(project_guest)
project.add_developer(create(:user, :blocked))
project.add_developer(developer)
group.clear_memoization(:billed_user_ids_including_guests)
create(:project_member, :awaiting, :developer, source: project)
end
......@@ -1274,6 +1281,7 @@ RSpec.describe Namespace do
invited_group.add_developer(developer)
invited_group.add_guest(invited_group_guest)
invited_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: invited_group)
create(:project_group_link, project: project, group: invited_group)
end
......@@ -1314,6 +1322,8 @@ RSpec.describe Namespace do
shared_group.add_developer(shared_group_developer)
shared_group.add_guest(shared_group_guest)
shared_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
shared_group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: shared_group)
create(:group_group_link, { shared_with_group: shared_group,
......@@ -1360,6 +1370,7 @@ RSpec.describe Namespace do
group.add_developer(developer)
group.add_developer(create(:user, :blocked))
group.add_guest(create(:user))
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: group)
end
......@@ -1380,6 +1391,7 @@ RSpec.describe Namespace do
project.add_guest(create(:user))
project.add_developer(developer)
project.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:project_member, :awaiting, :developer, source: project)
end
......@@ -1404,6 +1416,7 @@ RSpec.describe Namespace do
invited_group.add_guest(create(:user))
invited_group.add_developer(create(:user, :blocked))
invited_group.add_developer(developer)
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: invited_group)
create(:project_group_link, project: project, group: invited_group)
end
......@@ -1421,6 +1434,7 @@ RSpec.describe Namespace do
other_group.add_developer(create(:user))
other_group.add_guest(create(:user))
other_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: other_group)
create(:group_group_link, { shared_with_group: other_group,
......@@ -1449,6 +1463,7 @@ RSpec.describe Namespace do
project.add_guest(create(:user))
project.add_developer(create(:user, :blocked))
project.add_developer(developer)
group.clear_memoization(:billed_user_ids_including_guests)
create(:project_member, :awaiting, :developer, source: project)
end
......@@ -1473,6 +1488,7 @@ RSpec.describe Namespace do
invited_group.add_developer(developer)
invited_group.add_guest(create(:user))
invited_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: invited_group)
create(:project_group_link, project: project, group: invited_group)
end
......@@ -1491,6 +1507,7 @@ RSpec.describe Namespace do
other_group.add_developer(create(:user))
other_group.add_guest(create(:user))
other_group.add_developer(create(:user, :blocked))
group.clear_memoization(:billed_user_ids_including_guests)
create(:group_member, :awaiting, :developer, source: other_group)
create(:group_group_link, { shared_with_group: other_group,
......@@ -1506,6 +1523,139 @@ RSpec.describe Namespace do
end
end
describe '#free_user_cap_reached?' do
let(:group) { create(:group) }
subject(:free_user_cap_reached_for_group?) { group.free_user_cap_reached? }
context 'when free user cap feature is not applied' do
before do
allow(group).to receive(:apply_free_user_cap?).and_return(false)
end
it { is_expected.to be_falsey }
end
context 'when free user cap feature is applied' do
before do
allow(group).to receive(:apply_free_user_cap?).and_return(true)
end
context 'when the :saas_user_caps feature flag is not enabled' do
it { is_expected.to be_falsey }
end
context 'when the :free_user_cap feature flag is enabled' do
before do
stub_feature_flags(free_user_cap: true)
end
let(:free_plan) { false }
shared_examples 'returning the right value for free_user_cap_reached?' do
before do
allow(root_group).to receive(:apply_free_user_cap?).and_return(true)
allow(root_group).to receive(:has_free_or_no_subscription?).and_return(free_plan)
end
context 'when no free user cap has been set to that root ancestor' do
it { is_expected.to be_falsey }
end
context 'when a free user cap has been set to that root ancestor' do
let(:free_plan) { true }
before do
allow(root_group).to receive(:free_plan_members_count).and_return(free_plan_members_count)
allow(group).to receive(:root_ancestor).and_return(root_group)
end
context 'when the free cap is higher than the number of billable members' do
let(:free_plan_members_count) { 3 }
it { is_expected.to be_falsey }
end
context 'when the free cap is the same as the number of billable members' do
let(:free_plan_members_count) { ::Plan::FREE_USER_LIMIT }
it { is_expected.to be_truthy }
end
context 'when the free cap is lower than the number of billable members' do
let(:free_plan_members_count) { 6 }
it { is_expected.to be_truthy }
end
end
end
context 'when this is a user namespace' do
it_behaves_like 'returning the right value for free_user_cap_reached?' do
let(:root_group) { create(:user).namespace }
end
end
context 'when this group has no root ancestor' do
it_behaves_like 'returning the right value for free_user_cap_reached?' do
let(:root_group) { group }
end
end
context 'when this group has a root ancestor' do
it_behaves_like 'returning the right value for free_user_cap_reached?' do
let(:root_group) { create(:group, children: [group]) }
end
end
end
end
end
describe '#user_limit_reached?' do
where(:free_user_cap_reached) do
[
true,
false
]
end
let(:namespace) { build(:namespace) }
subject { namespace.user_limit_reached? }
with_them do
before do
allow(namespace).to receive(:free_user_cap_reached?).and_return(free_user_cap_reached)
end
it { is_expected.to eq(free_user_cap_reached) }
end
end
describe '#free_plan_user_ids' do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:owner) { namespace.owner }
let_it_be(:project) { create(:project, namespace: namespace) }
let_it_be(:project_user) { create(:project_member, project: project).user }
let_it_be(:project_2) { create(:project, namespace: namespace) }
let_it_be(:project2_user) { create(:project_member, project: project_2).user }
let_it_be(:bot_project_user) { create(:project_member, project: project, user: create(:user, :bot)).user }
before do
create(:project_member, :invited, project: project) # invited member
create(:project_member, :access_request, project: project) # requested member
create(:project_member) # a random project member with project not under our namespace
create(:group_member) # a random group member with group not under our namespace
create(:project_member, project: project_2, user: project_user) # member using same user as project_user
create(:project_member, project: project, user: create(:user, :project_bot)) # project bot
create(:project_member, project: project, user: create(:user, :blocked)) # not active user
end
it 'only includes users from projects of a personal namespace and the owner of the namespace' do
expect(namespace.free_plan_user_ids).to contain_exactly(project_user.id, owner.id, project2_user.id, bot_project_user.id)
end
end
describe '#eligible_for_trial?' do
subject { namespace.eligible_for_trial? }
......
......@@ -103,6 +103,11 @@ RSpec.describe GitlabSubscription, :saas do
%w[bronze premium].each do |plan|
it 'excludes these members' do
gitlab_subscription.update!(plan_code: plan)
# plan has already memoized in ee/namespace.rb as `actual_plan`, so this then
# is not known at this point since `actual_plan` has already been set when
# `group.add_guest` in the before action, and was performed due to member set logic where we
# go through that path already
group.clear_memoization(:actual_plan)
expect(gitlab_subscription.calculate_seats_in_use).to eq(1)
end
......
......@@ -275,6 +275,169 @@ RSpec.describe Member, type: :model do
end
end
context 'check if free user cap has been reached', :saas do
let_it_be(:group, refind: true) { create(:group_with_plan, plan: :free_plan) }
let_it_be(:subgroup) { create(:group, parent: group) }
let_it_be(:project, refind: true) { create(:project, namespace: group)}
let_it_be(:user) { create(:user) }
before_all do
group.add_developer(create(:user))
end
context 'when the :free_user_cap feature flag is disabled' do
before do
stub_feature_flags(free_user_cap: false)
end
it 'sets the group member state to active' do
group.add_developer(user)
expect(user.group_members.last).to be_active
end
it 'sets the project member state to active' do
project.add_developer(user)
expect(user.project_members.last).to be_active
end
end
context 'when the :free_user_cap feature flag is enabled' do
before do
stub_feature_flags(free_user_cap: true)
end
context 'when the free user cap has not been reached' do
it 'sets the group member to active' do
group.add_developer(user)
expect(user.group_members.last).to be_active
end
it 'sets the project member to active' do
project.add_developer(user)
expect(user.project_members.last).to be_active
end
context 'when user is added to a group-less project' do
let(:project) do
project = create(:project)
namespace = project.namespace
create(:gitlab_subscription, hosted_plan: create(:free_plan), namespace: namespace)
project
end
it 'adds project member and leaves the state to active' do
project.root_ancestor.clear_memoization(:existing_free_plan)
project.add_developer(create(:user))
project.add_developer(user)
expect(user.project_members.last).to be_active
end
end
end
context 'when the free user cap has been reached' do
before do
stub_const('::Plan::FREE_USER_LIMIT', 1)
end
it 'sets the group member to awaiting' do
group.add_developer(user)
expect(user.group_members.last).to be_awaiting
end
it 'sets the group member to awaiting when added to a subgroup' do
subgroup.add_developer(user)
expect(user.group_members.last).to be_awaiting
end
it 'sets the project member to awaiting' do
project.add_developer(user)
expect(user.project_members.last).to be_awaiting
end
context 'when multiple members are added' do
before do
stub_const('::Plan::FREE_USER_LIMIT', 2)
end
it 'sets members to the correct status' do
over_limit_user = create(:user)
project.root_namespace.clear_memoization(:billed_user_ids_including_guests)
project.add_developer(user)
project.root_namespace.clear_memoization(:billed_user_ids_including_guests)
project.add_developer(over_limit_user)
expect(user.project_members.last).to be_active
expect(over_limit_user.project_members.last).to be_awaiting
end
end
context 'when the user is already an active root group member' do
it 'sets the group member to active' do
create(:group_member, :active, group: group, user: user)
subgroup.add_owner(user)
expect(user.group_members.last).to be_active
end
end
context 'when the user is already an active subgroup member' do
it 'sets the group member to active' do
other_subgroup = create(:group, parent: group)
create(:group_member, :active, group: other_subgroup, user: user)
subgroup.add_developer(user)
expect(user.group_members.last).to be_active
end
end
context 'when the user is already an active project member' do
it 'sets the group member to active' do
create(:project_member, :active, project: project, user: user)
expect { subgroup.add_owner(user) }.to change { ::Member.with_state(:active).count }.by(1)
expect(user.group_members.last).to be_active
end
end
context 'when user is added to a group-less project' do
let(:project) do
project = create(:project)
namespace = project.namespace
create(:gitlab_subscription, hosted_plan: create(:free_plan), namespace: namespace)
project
end
before do
stub_const('::Plan::FREE_USER_LIMIT', 2)
end
it 'adds multiple members and correctly shows the state' do
project.root_ancestor.clear_memoization(:has_free_or_no_subscription)
over_limit_user = create(:user)
project.root_ancestor.clear_memoization(:free_plan_user_ids)
project.add_developer(user)
project.root_ancestor.clear_memoization(:free_plan_user_ids)
project.add_developer(over_limit_user)
expect(user.project_members.last).to be_active
expect(over_limit_user.project_members.last).to be_awaiting
end
end
end
end
end
describe '.distinct_awaiting_or_invited_for_group' do
let_it_be(:other_sub_group) { create(:group, parent: group) }
let_it_be(:active_group_member) { create(:group_member, group: group) }
......
......@@ -193,6 +193,9 @@ RSpec.describe Ci::CreatePipelineService, '#execute', :saas do
before do
allow(::Gitlab).to receive(:com?).and_return(true)
namespace.gitlab_subscription.update!(hosted_plan: create(:free_plan))
# namespace gitlab_subscription is cached when source.root_ancestor.user_limit_reached?
# is called on member creation callback from add_developer call
project.root_ancestor.gitlab_subscription.reset
user.created_at = ::Users::CreditCardValidation::RELEASE_DAY
end
......
......@@ -18,7 +18,7 @@ RSpec.describe Members::CreateService do
}
end
subject { described_class.new(user, params.merge({ source: project })).execute }
subject(:execute_service) { described_class.new(user, params.merge({ source: project })).execute }
before_all do
project.add_maintainer(user)
......@@ -41,8 +41,8 @@ RSpec.describe Members::CreateService do
end
shared_examples 'quota limit exceeded' do |limit|
it { expect(subject).to include(status: :error, message: "Invite limit of #{limit} per day exceeded") }
it { expect { subject }.not_to change { Member.count } }
it { expect(execute_service).to include(status: :error, message: "Invite limit of #{limit} per day exceeded") }
it { expect { execute_service }.not_to change { Member.count } }
end
context 'already exceeded invite quota limit' do
......@@ -60,9 +60,11 @@ RSpec.describe Members::CreateService do
context 'within invite quota limit' do
let(:daily_invites) { 5 }
it { expect(subject).to eq({ status: :success }) }
it { expect(execute_service).to eq({ status: :success }) }
it do
subject
execute_service
expect(project.users).to include(*project_users)
end
end
......@@ -71,8 +73,10 @@ RSpec.describe Members::CreateService do
let(:daily_invites) { 0 }
it { expect(subject).to eq({ status: :success }) }
it do
subject
execute_service
expect(project.users).to include(*project_users)
end
end
......@@ -81,9 +85,11 @@ RSpec.describe Members::CreateService do
context 'without a plan' do
let(:plan) { nil }
it { expect(subject).to eq({ status: :success }) }
it { expect(execute_service).to eq({ status: :success }) }
it do
subject
execute_service
expect(project.users).to include(*project_users)
end
end
......@@ -107,7 +113,7 @@ RSpec.describe Members::CreateService do
.once
.and_call_original
expect { subject }.to change { project.issues.reload.count }.by(2)
expect { execute_service }.to change { project.issues.reload.count }.by(2)
expect(project.issues).to all have_attributes(
project: project,
......@@ -117,4 +123,39 @@ RSpec.describe Members::CreateService do
end
end
end
context 'when reaching the free user cap limit', :saas do
let_it_be(:project_user) { project_users.first }
let_it_be(:over_limit_user) { project_users.last }
before do
stub_const('::Plan::FREE_USER_LIMIT', 3)
end
context 'with a group-less project' do
let_it_be(:project) { create(:project) }
before do
project.add_maintainer(user)
end
it 'sets members to the correct status' do
expect(execute_service[:status]).to eq(:success)
expect(project_user.project_members.last).to be_active
expect(over_limit_user.project_members.last).to be_awaiting
end
end
context 'with a group project' do
before do
project.add_developer(create(:user))
end
it 'sets members to the correct status' do
expect(execute_service[:status]).to eq(:success)
expect(project_user.project_members.last).to be_active
expect(over_limit_user.project_members.last).to be_awaiting
end
end
end
end
......@@ -10,7 +10,7 @@ RSpec.describe API::Invitations do
let(:email) { 'email1@example.com' }
let(:email2) { 'email2@example.com' }
let_it_be(:project) do
let_it_be(:project, reload: true) do
create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
project.add_developer(developer)
project.add_maintainer(maintainer)
......
......@@ -47,7 +47,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do
it 'executes a limited number of queries' do
control_count = ActiveRecord::QueryRecorder.new { subject }.count
expect(control_count).to be <= 104
expect(control_count).to be <= 105
end
it 'schedules an import using a namespace' do
......
......@@ -7,7 +7,7 @@ RSpec.describe NotificationService, :mailer do
include ExternalAuthorizationServiceHelpers
include NotificationHelpers
let_it_be_with_refind(:project) { create(:project, :public) }
let_it_be_with_refind(:project, reload: true) { create(:project, :public) }
let_it_be_with_refind(:assignee) { create(:user) }
let(:notification) { described_class.new }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment