diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd489e3a7f343a03a7acaceef92f58476e80ec13 --- /dev/null +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +# +# Cascading attributes enables managing settings in a flexible way. +# +# - Instance administrator can define an instance-wide default setting, or +# lock the setting to prevent change by group owners. +# - Group maintainers/owners can define a default setting for their group, or +# lock the setting to prevent change by sub-group maintainers/owners. +# +# Behavior: +# +# - When a group does not have a value (value is `nil`), cascade up the +# hierarchy to find the first non-nil value. +# - Settings can be locked at any level to prevent groups/sub-groups from +# overriding. +# - If the setting isn't locked, the default can be overridden. +# - An instance administrator or group maintainer/owner can push settings values +# to groups/sub-groups to override existing values, even when the setting +# is not otherwise locked. +# +module CascadingNamespaceSettingAttribute + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + class_methods do + def cascading_settings_feature_enabled? + ::Feature.enabled?(:cascading_namespace_settings, default_enabled: false) + end + + private + + # Facilitates the cascading lookup of values and, + # similar to Rails' `attr_accessor`, defines convenience methods such as + # a reader, writer, and validators. + # + # Example: `cascading_attr :delayed_project_removal` + # + # Public methods defined: + # - `delayed_project_removal` + # - `delayed_project_removal=` + # - `delayed_project_removal_locked?` + # - `delayed_project_removal_locked_by_ancestor?` + # - `delayed_project_removal_locked_by_application_setting?` + # - `delayed_project_removal?` (only defined for boolean attributes) + # - `delayed_project_removal_locked_ancestor` - Returns locked namespace settings object (only namespace_id) + # + # Defined validators ensure attribute value cannot be updated if locked by + # an ancestor or application settings. + # + # Requires database columns be present in both `namespace_settings` and + # `application_settings`. + def cascading_attr(*attributes) + attributes.map(&:to_sym).each do |attribute| + # public methods + define_attr_reader(attribute) + define_attr_writer(attribute) + define_lock_methods(attribute) + alias_boolean(attribute) + + # private methods + define_validator_methods(attribute) + define_after_update(attribute) + + validate :"#{attribute}_changeable?" + validate :"lock_#{attribute}_changeable?" + + after_update :"clear_descendant_#{attribute}_locks", if: -> { saved_change_to_attribute?("lock_#{attribute}", to: true) } + end + end + + # The cascading attribute reader method handles lookups + # with the following criteria: + # + # 1. Returns the dirty value, if the attribute has changed. + # 2. Return locked ancestor value. + # 3. Return locked instance-level application settings value. + # 4. Return this namespace's attribute, if not nil. + # 5. Return value from nearest ancestor where value is not nil. + # 6. Return instance-level application setting. + def define_attr_reader(attribute) + define_method(attribute) do + strong_memoize(attribute) do + next self[attribute] unless self.class.cascading_settings_feature_enabled? + + next self[attribute] if will_save_change_to_attribute?(attribute) + next locked_value(attribute) if cascading_attribute_locked?(attribute) + next self[attribute] unless self[attribute].nil? + + cascaded_value = cascaded_ancestor_value(attribute) + next cascaded_value unless cascaded_value.nil? + + application_setting_value(attribute) + end + end + end + + def define_attr_writer(attribute) + define_method("#{attribute}=") do |value| + clear_memoization(attribute) + + super(value) + end + end + + def define_lock_methods(attribute) + define_method("#{attribute}_locked?") do + cascading_attribute_locked?(attribute) + end + + define_method("#{attribute}_locked_by_ancestor?") do + locked_by_ancestor?(attribute) + end + + define_method("#{attribute}_locked_by_application_setting?") do + locked_by_application_setting?(attribute) + end + + define_method("#{attribute}_locked_ancestor") do + locked_ancestor(attribute) + end + end + + def alias_boolean(attribute) + return unless Gitlab::Database.exists? && type_for_attribute(attribute).type == :boolean + + alias_method :"#{attribute}?", attribute + end + + # Defines two validations - one for the cascadable attribute itself and one + # for the lock attribute. Only allows the respective value to change if + # an ancestor has not already locked the value. + def define_validator_methods(attribute) + define_method("#{attribute}_changeable?") do + return unless cascading_attribute_changed?(attribute) + return unless cascading_attribute_locked?(attribute) + + errors.add(attribute, s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) + end + + define_method("lock_#{attribute}_changeable?") do + return unless cascading_attribute_changed?("lock_#{attribute}") + + if cascading_attribute_locked?(attribute) + return errors.add(:"lock_#{attribute}", s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) + end + + # Don't allow locking a `nil` attribute. + # Even if the value being locked is currently cascaded from an ancestor, + # it should be copied to this record to avoid the ancestor changing the + # value unexpectedly later. + return unless self[attribute].nil? && public_send("lock_#{attribute}?") # rubocop:disable GitlabSecurity/PublicSend + + errors.add(attribute, s_('CascadingSettings|cannot be nil when locking the attribute')) + end + + private :"#{attribute}_changeable?", :"lock_#{attribute}_changeable?" + end + + # When a particular group locks the attribute, clear all sub-group locks + # since the higher lock takes priority. + def define_after_update(attribute) + define_method("clear_descendant_#{attribute}_locks") do + self.class.where(namespace_id: descendants).update_all("lock_#{attribute}" => false) + end + + private :"clear_descendant_#{attribute}_locks" + end + end + + private + + def locked_value(attribute) + ancestor = locked_ancestor(attribute) + return ancestor.read_attribute(attribute) if ancestor + + Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + end + + def locked_ancestor(attribute) + return unless self.class.cascading_settings_feature_enabled? + return unless namespace.has_parent? + + strong_memoize(:"#{attribute}_locked_ancestor") do + self.class + .select(:namespace_id, "lock_#{attribute}", attribute) + .where(namespace_id: namespace_ancestor_ids) + .where(self.class.arel_table["lock_#{attribute}"].eq(true)) + .limit(1).load.first + end + end + + def locked_by_ancestor?(attribute) + return false unless self.class.cascading_settings_feature_enabled? + + locked_ancestor(attribute).present? + end + + def locked_by_application_setting?(attribute) + return false unless self.class.cascading_settings_feature_enabled? + + Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend + end + + def cascading_attribute_locked?(attribute) + locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute) + end + + def cascading_attribute_changed?(attribute) + public_send("#{attribute}_changed?") # rubocop:disable GitlabSecurity/PublicSend + end + + def cascaded_ancestor_value(attribute) + return unless namespace.has_parent? + + # rubocop:disable GitlabSecurity/SqlInjection + self.class + .select(attribute) + .joins("join unnest(ARRAY[#{namespace_ancestor_ids.join(',')}]) with ordinality t(namespace_id, ord) USING (namespace_id)") + .where("#{attribute} IS NOT NULL") + .order('t.ord') + .limit(1).first&.read_attribute(attribute) + # rubocop:enable GitlabSecurity/SqlInjection + end + + def application_setting_value(attribute) + Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + end + + def namespace_ancestor_ids + strong_memoize(:namespace_ancestor_ids) do + namespace.self_and_ancestors(hierarchy_order: :asc).pluck(:id).reject { |id| id == namespace_id } + end + end + + def descendants + strong_memoize(:descendants) do + namespace.descendants.pluck(:id) + end + end +end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 573f6a48cc0a1c13a02f8b958c77dfb4a3fabe86..d21f9632e1876adb6a7c3c31b478ce6ee2c62180 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class NamespaceSetting < ApplicationRecord + include CascadingNamespaceSettingAttribute + + cascading_attr :delayed_project_removal + belongs_to :namespace, inverse_of: :namespace_settings validate :default_branch_name_content @@ -9,7 +13,8 @@ class NamespaceSetting < ApplicationRecord before_validation :normalize_default_branch_name - NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, :resource_access_token_creation_allowed].freeze + NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, + :lock_delayed_project_removal, :resource_access_token_creation_allowed].freeze self.primary_key = :namespace_id diff --git a/changelogs/unreleased/dblessing_cascading_settings_final.yml b/changelogs/unreleased/dblessing_cascading_settings_final.yml new file mode 100644 index 0000000000000000000000000000000000000000..49f5cd5c9fcf02bdc3bd48c7b38dad97fcce5db8 --- /dev/null +++ b/changelogs/unreleased/dblessing_cascading_settings_final.yml @@ -0,0 +1,5 @@ +--- +title: Cascade delayed project removal setting lookup to parent namespace +merge_request: 55678 +author: +type: added diff --git a/config/feature_flags/development/cascading_namespace_settings.yml b/config/feature_flags/development/cascading_namespace_settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..ca4ad32d7c337a22631cb3ae897528b59f4e9549 --- /dev/null +++ b/config/feature_flags/development/cascading_namespace_settings.yml @@ -0,0 +1,8 @@ +--- +name: cascading_namespace_settings +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55678 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321724 +milestone: '13.11' +type: development +group: group::access +default_enabled: false diff --git a/db/migrate/20210308175224_change_namespace_settings_delayed_project_removal_null.rb b/db/migrate/20210308175224_change_namespace_settings_delayed_project_removal_null.rb new file mode 100644 index 0000000000000000000000000000000000000000..5b731b78117f972a79f88788966e80dff31f628d --- /dev/null +++ b/db/migrate/20210308175224_change_namespace_settings_delayed_project_removal_null.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ChangeNamespaceSettingsDelayedProjectRemovalNull < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + change_column :namespace_settings, :delayed_project_removal, :boolean, null: true, default: nil + end + + def down + change_column_default :namespace_settings, :delayed_project_removal, false + change_column_null :namespace_settings, :delayed_project_removal, false, false + end +end diff --git a/db/migrate/20210308175225_add_lock_delayed_project_removal_to_namespace_settings.rb b/db/migrate/20210308175225_add_lock_delayed_project_removal_to_namespace_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..e88f3e7ea0d98e6ac1852b7191be88dc867a65ce --- /dev/null +++ b/db/migrate/20210308175225_add_lock_delayed_project_removal_to_namespace_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLockDelayedProjectRemovalToNamespaceSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :namespace_settings, :lock_delayed_project_removal, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20210308175226_add_delayed_project_removal_to_application_settings.rb b/db/migrate/20210308175226_add_delayed_project_removal_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ccb25878e433ce0b39d8077f52d510353948355 --- /dev/null +++ b/db/migrate/20210308175226_add_delayed_project_removal_to_application_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddDelayedProjectRemovalToApplicationSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :application_settings, :delayed_project_removal, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20210308175227_add_lock_delayed_project_removal_to_application_settings.rb b/db/migrate/20210308175227_add_lock_delayed_project_removal_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..c63175493debe0cf4aef0839f178c9aa75430c9b --- /dev/null +++ b/db/migrate/20210308175227_add_lock_delayed_project_removal_to_application_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLockDelayedProjectRemovalToApplicationSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :application_settings, :lock_delayed_project_removal, :boolean, default: false, null: false + end +end diff --git a/db/schema_migrations/20210308175224 b/db/schema_migrations/20210308175224 new file mode 100644 index 0000000000000000000000000000000000000000..c222b9101afb61e68ff27a55aa29a778fbd3d58d --- /dev/null +++ b/db/schema_migrations/20210308175224 @@ -0,0 +1 @@ +ad6e0feff16589839714098a69673edcba50af7a62d98cd078585c5d2aada919 \ No newline at end of file diff --git a/db/schema_migrations/20210308175225 b/db/schema_migrations/20210308175225 new file mode 100644 index 0000000000000000000000000000000000000000..7fb92d10f8cbd61d0845537cac43a26e17f04ccf --- /dev/null +++ b/db/schema_migrations/20210308175225 @@ -0,0 +1 @@ +9263c522f0632f5b4fc0004e1fe9666bc3a44e4f70cf0d21aab5bb229f08ab5c \ No newline at end of file diff --git a/db/schema_migrations/20210308175226 b/db/schema_migrations/20210308175226 new file mode 100644 index 0000000000000000000000000000000000000000..4d126ff2b63873f4a73553784da6f419e8ff2eb2 --- /dev/null +++ b/db/schema_migrations/20210308175226 @@ -0,0 +1 @@ +72491b1834a1256a197e8f49c599b28b41773226db4fe70ce402903674d2f622 \ No newline at end of file diff --git a/db/schema_migrations/20210308175227 b/db/schema_migrations/20210308175227 new file mode 100644 index 0000000000000000000000000000000000000000..66aaf4ca5587fe0aeb906023cda31a25b7a37779 --- /dev/null +++ b/db/schema_migrations/20210308175227 @@ -0,0 +1 @@ +e99b8a6242589992ae8b618cb502d16b67672856cef024c1aafe00a1e64e41b9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 643d1bcca9c7ff7ddf52efe0b10147543676c07d..b0dc24456d82c7c6c04340888e0c585397cfedf9 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9439,6 +9439,8 @@ CREATE TABLE application_settings ( in_product_marketing_emails_enabled boolean DEFAULT true NOT NULL, asset_proxy_whitelist text, admin_mode boolean DEFAULT false NOT NULL, + delayed_project_removal boolean DEFAULT false NOT NULL, + lock_delayed_project_removal boolean DEFAULT false NOT NULL, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_registry_exp_policies_worker_capacity_positive CHECK ((container_registry_expiration_policies_worker_capacity >= 0)), CONSTRAINT check_17d9558205 CHECK ((char_length((kroki_url)::text) <= 1024)), @@ -14665,8 +14667,9 @@ CREATE TABLE namespace_settings ( allow_mfa_for_subgroups boolean DEFAULT true NOT NULL, default_branch_name text, repository_read_only boolean DEFAULT false NOT NULL, - delayed_project_removal boolean DEFAULT false NOT NULL, + delayed_project_removal boolean, resource_access_token_creation_allowed boolean DEFAULT true NOT NULL, + lock_delayed_project_removal boolean DEFAULT false NOT NULL, CONSTRAINT check_0ba93c78c7 CHECK ((char_length(default_branch_name) <= 255)) ); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8c0dc7534ab6e89ee9cadea9bbe5e9b1049e6c8a..1130fdf409464d12325819d7c4f6a201ee869551 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5646,6 +5646,12 @@ msgstr "" msgid "Capacity threshold" msgstr "" +msgid "CascadingSettings|cannot be changed because it is locked by an ancestor" +msgstr "" + +msgid "CascadingSettings|cannot be nil when locking the attribute" +msgstr "" + msgid "Certain user content will be moved to a system-wide \"Ghost User\" in order to maintain content for posterity. For further information, please refer to the %{link_start}user account deletion documentation.%{link_end}" msgstr "" diff --git a/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb b/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ddff9ce32b473e8b5a461ff94c3c34287dc65d69 --- /dev/null +++ b/spec/models/concerns/cascading_namespace_setting_attribute_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe NamespaceSetting, 'CascadingNamespaceSettingAttribute' do + let(:group) { create(:group) } + let(:subgroup) { create(:group, parent: group) } + + def group_settings + group.namespace_settings + end + + def subgroup_settings + subgroup.namespace_settings + end + + describe '#delayed_project_removal' do + subject(:delayed_project_removal) { subgroup_settings.delayed_project_removal } + + context 'when the feature is disabled' do + before do + stub_feature_flags(cascading_namespace_settings: false) + + group_settings.update!(delayed_project_removal: true) + end + + it 'does not cascade' do + expect(delayed_project_removal).to eq(nil) + end + end + + context 'when there is no parent' do + context 'and the value is not nil' do + before do + group_settings.update!(delayed_project_removal: true) + end + + it 'returns the local value' do + expect(group_settings.delayed_project_removal).to eq(true) + end + end + + context 'and the value is nil' do + before do + group_settings.update!(delayed_project_removal: nil) + stub_application_setting(delayed_project_removal: false) + end + + it 'returns the application settings value' do + expect(group_settings.delayed_project_removal).to eq(false) + end + end + end + + context 'when parent does not lock the attribute' do + context 'and value is not nil' do + before do + group_settings.update!(delayed_project_removal: false) + end + + it 'returns local setting when present' do + subgroup_settings.update!(delayed_project_removal: true) + + expect(delayed_project_removal).to eq(true) + end + + it 'returns the parent value when local value is nil' do + subgroup_settings.update!(delayed_project_removal: nil) + + expect(delayed_project_removal).to eq(false) + end + + it 'returns the correct dirty value' do + subgroup_settings.delayed_project_removal = true + + expect(delayed_project_removal).to eq(true) + end + + it 'does not return the application setting value when parent value is false' do + stub_application_setting(delayed_project_removal: true) + + expect(delayed_project_removal).to eq(false) + end + end + + context 'and the value is nil' do + before do + group_settings.update!(delayed_project_removal: nil, lock_delayed_project_removal: false) + subgroup_settings.update!(delayed_project_removal: nil) + + subgroup_settings.clear_memoization(:delayed_project_removal) + end + + it 'cascades to the application settings value' do + expect(delayed_project_removal).to eq(false) + end + end + + context 'when multiple ancestors set a value' do + let(:third_level_subgroup) { create(:group, parent: subgroup) } + + before do + group_settings.update!(delayed_project_removal: true) + subgroup_settings.update!(delayed_project_removal: false) + end + + it 'returns the closest ancestor value' do + expect(third_level_subgroup.namespace_settings.delayed_project_removal).to eq(false) + end + end + end + + context 'when parent locks the attribute' do + before do + subgroup_settings.update!(delayed_project_removal: true) + group_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: false) + + subgroup_settings.clear_memoization(:delayed_project_removal) + subgroup_settings.clear_memoization(:delayed_project_removal_locked_ancestor) + end + + it 'returns the parent value' do + expect(delayed_project_removal).to eq(false) + end + + it 'does not allow the local value to be saved' do + subgroup_settings.delayed_project_removal = nil + + expect { subgroup_settings.save! } + .to raise_error(ActiveRecord::RecordInvalid, /Delayed project removal cannot be changed because it is locked by an ancestor/) + end + end + + context 'when the application settings locks the attribute' do + before do + subgroup_settings.update!(delayed_project_removal: true) + stub_application_setting(lock_delayed_project_removal: true, delayed_project_removal: true) + end + + it 'returns the application setting value' do + expect(delayed_project_removal).to eq(true) + end + + it 'does not allow the local value to be saved' do + subgroup_settings.delayed_project_removal = nil + + expect { subgroup_settings.save! } + .to raise_error(ActiveRecord::RecordInvalid, /Delayed project removal cannot be changed because it is locked by an ancestor/) + end + end + end + + describe '#delayed_project_removal?' do + before do + subgroup_settings.update!(delayed_project_removal: true) + group_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: false) + + subgroup_settings.clear_memoization(:delayed_project_removal) + subgroup_settings.clear_memoization(:delayed_project_removal_locked_ancestor) + end + + it 'aliases the method when the attribute is a boolean' do + expect(subgroup_settings.delayed_project_removal?).to eq(subgroup_settings.delayed_project_removal) + end + end + + describe '#delayed_project_removal_locked?' do + shared_examples 'not locked' do + it 'is not locked by an ancestor' do + expect(subgroup_settings.delayed_project_removal_locked_by_ancestor?).to eq(false) + end + + it 'is not locked by application setting' do + expect(subgroup_settings.delayed_project_removal_locked_by_application_setting?).to eq(false) + end + + it 'does not return a locked namespace' do + expect(subgroup_settings.delayed_project_removal_locked_ancestor).to be_nil + end + end + + context 'when the feature is disabled' do + before do + stub_feature_flags(cascading_namespace_settings: false) + + group_settings.update!(delayed_project_removal: true) + end + + it_behaves_like 'not locked' + end + + context 'when parent does not lock the attribute' do + it_behaves_like 'not locked' + end + + context 'when parent locks the attribute' do + before do + group_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: false) + + subgroup_settings.clear_memoization(:delayed_project_removal) + subgroup_settings.clear_memoization(:delayed_project_removal_locked_ancestor) + end + + it 'is locked by an ancestor' do + expect(subgroup_settings.delayed_project_removal_locked_by_ancestor?).to eq(true) + end + + it 'is not locked by application setting' do + expect(subgroup_settings.delayed_project_removal_locked_by_application_setting?).to eq(false) + end + + it 'returns a locked namespace settings object' do + expect(subgroup_settings.delayed_project_removal_locked_ancestor.namespace_id).to eq(group_settings.namespace_id) + end + end + + context 'when not locked by application settings' do + before do + stub_application_setting(lock_delayed_project_removal: false) + end + + it_behaves_like 'not locked' + end + + context 'when locked by application settings' do + before do + stub_application_setting(lock_delayed_project_removal: true) + end + + it 'is not locked by an ancestor' do + expect(subgroup_settings.delayed_project_removal_locked_by_ancestor?).to eq(false) + end + + it 'is locked by application setting' do + expect(subgroup_settings.delayed_project_removal_locked_by_application_setting?).to eq(true) + end + + it 'does not return a locked namespace' do + expect(subgroup_settings.delayed_project_removal_locked_ancestor).to be_nil + end + end + end + + describe '#lock_delayed_project_removal=' do + context 'when parent locks the attribute' do + before do + group_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: false) + + subgroup_settings.clear_memoization(:delayed_project_removal) + subgroup_settings.clear_memoization(:delayed_project_removal_locked_ancestor) + end + + it 'does not allow the attribute to be saved' do + subgroup_settings.lock_delayed_project_removal = true + + expect { subgroup_settings.save! } + .to raise_error(ActiveRecord::RecordInvalid, /Lock delayed project removal cannot be changed because it is locked by an ancestor/) + end + end + + context 'when parent does not lock the attribute' do + before do + group_settings.update!(lock_delayed_project_removal: false) + + subgroup_settings.lock_delayed_project_removal = true + end + + it 'allows the lock to be set when the attribute is not nil' do + subgroup_settings.delayed_project_removal = true + + expect(subgroup_settings.save).to eq(true) + end + + it 'does not allow the lock to be saved when the attribute is nil' do + subgroup_settings.delayed_project_removal = nil + + expect { subgroup_settings.save! } + .to raise_error(ActiveRecord::RecordInvalid, /Delayed project removal cannot be nil when locking the attribute/) + end + end + + context 'when application settings locks the attribute' do + before do + stub_application_setting(lock_delayed_project_removal: true) + end + + it 'does not allow the attribute to be saved' do + subgroup_settings.lock_delayed_project_removal = true + + expect { subgroup_settings.save! } + .to raise_error(ActiveRecord::RecordInvalid, /Lock delayed project removal cannot be changed because it is locked by an ancestor/) + end + end + + context 'when application_settings does not lock the attribute' do + before do + stub_application_setting(lock_delayed_project_removal: false) + end + + it 'allows the attribute to be saved' do + subgroup_settings.delayed_project_removal = true + subgroup_settings.lock_delayed_project_removal = true + + expect(subgroup_settings.save).to eq(true) + end + end + end + + describe 'after update callback' do + before do + subgroup_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: false) + end + + it 'clears descendant locks' do + group_settings.update!(lock_delayed_project_removal: true, delayed_project_removal: true) + + expect(subgroup_settings.reload.lock_delayed_project_removal).to eq(false) + end + end +end