From 02ede991262afb071ad339dd152fddb75f7df8df Mon Sep 17 00:00:00 2001 From: ulferts Date: Tue, 5 May 2026 16:31:38 +0200 Subject: [PATCH] prevent sprint sharing on contract level --- .../projects/backlog_settings_contract.rb | 15 +++++ .../backlog_settings_contract_spec.rb | 62 ++++++++++++++++++- .../shared/model_contract_shared_context.rb | 6 +- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb b/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb index 05ce78b1a1e..f81281817a0 100644 --- a/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb +++ b/modules/backlogs/app/contracts/projects/backlog_settings_contract.rb @@ -36,6 +36,7 @@ module Projects validate :validate_global_sprint_sharer_uniqueness validates :sprint_sharing, presence: true validates :sprint_sharing, inclusion: { in: Project::SPRINT_SHARING_MODES }, allow_blank: true + validate :validate_sprint_sharing_in_ee_token def validate_model? = false @@ -59,5 +60,19 @@ module Projects end end end + + def validate_sprint_sharing_in_ee_token + if !model.not_sharing_sprints? && + !EnterpriseToken.allows_to?(:sprint_sharing) && + sprint_sharing_changed? + errors.add :sprint_sharing, + :enterprise_plan_required, + plan_name: I18n.t("ee.upsell.plan_name", plan: OpenProject::Token.lowest_plan_for(:sprint_sharing)) + end + end + + def sprint_sharing_changed? + model.settings_change&.any? { it.key?("sprint_sharing") } + end end end diff --git a/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb b/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb index 510289290d1..f6cadae0928 100644 --- a/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb +++ b/modules/backlogs/spec/contracts/projects/backlog_settings_contract_spec.rb @@ -3,11 +3,11 @@ require "spec_helper" require "contracts/shared/model_contract_shared_context" -RSpec.describe Projects::BacklogSettingsContract, type: :model do +RSpec.describe Projects::BacklogSettingsContract, type: :model, with_ee: %i[sprint_sharing] do include_context "ModelContract shared context" let(:current_user) { build_stubbed(:user) } - let(:project) { create(:project) } + let(:project) { build_stubbed(:project) } let(:permissions) { %i(share_sprint) } subject(:contract) { described_class.new(project, current_user) } @@ -35,7 +35,7 @@ RSpec.describe Projects::BacklogSettingsContract, type: :model do # This spec of explicitly setting sprint_sharing to empty is required because the # simple presence validation spec is not sufficient to catch certain corner cases. - # For example, when the sprint_sharing getter is overriden to provide a default value, + # For example, when the sprint_sharing getter is overridden to provide a default value, # and the user submits an empty value, the contract should be invalid. context "when sprint_sharing is empty" do before { project.sprint_sharing = "" } @@ -57,6 +57,62 @@ RSpec.describe Projects::BacklogSettingsContract, type: :model do end end + context "when the `sprint_sharing` is not part of the current EE token", with_ee: [] do + context "when sprint sharing is set to 'no_sharing'" do + before { project.sprint_sharing = Project::NO_SHARING } + + it_behaves_like "contract is valid" + end + + context "when sprint sharing is set to 'share_all_projects'" do + before { project.sprint_sharing = Project::SHARE_ALL_PROJECTS } + + it_behaves_like "contract is invalid", + sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" } + end + + context "when sprint sharing is set to 'share_subprojects'" do + before { project.sprint_sharing = Project::SHARE_SUBPROJECTS } + + it_behaves_like "contract is invalid", + sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" } + end + + context "when sprint sharing is set to 'receive_shared'" do + before { project.sprint_sharing = Project::RECEIVE_SHARED } + + it_behaves_like "contract is invalid", + sprint_sharing: { error: :enterprise_plan_required, plan_name: "corporate enterprise plan" } + end + + context "when sprint sharing remains on 'share_all_projects'" do + before do + project.sprint_sharing = Project::SHARE_ALL_PROJECTS + project.clear_changes_information + end + + it_behaves_like "contract is valid" + end + + context "when sprint sharing remains on 'share_subprojects'" do + before do + project.sprint_sharing = Project::SHARE_SUBPROJECTS + project.clear_changes_information + end + + it_behaves_like "contract is valid" + end + + context "when sprint sharing remains on 'receive_shared'" do + before do + project.sprint_sharing = Project::RECEIVE_SHARED + project.clear_changes_information + end + + it_behaves_like "contract is valid" + end + end + describe "#validate_global_sprint_sharer_uniqueness" do before do project.sprint_sharing = "share_all_projects" diff --git a/spec/contracts/shared/model_contract_shared_context.rb b/spec/contracts/shared/model_contract_shared_context.rb index ab36a18e7bc..94bf6284422 100644 --- a/spec/contracts/shared/model_contract_shared_context.rb +++ b/spec/contracts/shared/model_contract_shared_context.rb @@ -48,7 +48,11 @@ RSpec.shared_context "ModelContract shared context" do # rubocop:disable RSpec/C [error_symbols] end end - contract_errors = errors.keys.index_with { |key| contract.errors.symbols_for(key) } + + contract_errors = errors.keys.index_with do |key| + errors[key].is_a?(Hash) ? contract.errors.details[key] : contract.errors.symbols_for(key) + end + expect(contract_errors).to match(expected_errors) if RSpec.current_example.metadata[:check_errors_i18n] # ensure no I18n::MissingTranslationData is raised because of missing attributes and/or errors translations