diff options
-rw-r--r-- | app/assets/javascripts/groups/transfer_dropdown.js | 34 | ||||
-rw-r--r-- | app/assets/javascripts/pages/groups/edit/index.js | 6 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/groups.scss | 13 | ||||
-rw-r--r-- | app/controllers/groups_controller.rb | 15 | ||||
-rw-r--r-- | app/helpers/groups_helper.rb | 13 | ||||
-rw-r--r-- | app/models/concerns/storage/legacy_namespace.rb | 4 | ||||
-rw-r--r-- | app/models/namespace.rb | 26 | ||||
-rw-r--r-- | app/services/groups/transfer_service.rb | 96 | ||||
-rw-r--r-- | app/views/groups/edit.html.haml | 16 | ||||
-rw-r--r-- | changelogs/unreleased/31885-ability-to-transfer-groups-to-another-group.yml | 5 | ||||
-rw-r--r-- | config/routes/group.rb | 1 | ||||
-rw-r--r-- | doc/user/group/index.md | 14 | ||||
-rw-r--r-- | doc/user/project/index.md | 3 | ||||
-rw-r--r-- | spec/controllers/groups_controller_spec.rb | 83 | ||||
-rw-r--r-- | spec/models/group_spec.rb | 16 | ||||
-rw-r--r-- | spec/models/namespace_spec.rb | 70 | ||||
-rw-r--r-- | spec/models/user_spec.rb | 2 | ||||
-rw-r--r-- | spec/services/groups/transfer_service_spec.rb | 414 |
18 files changed, 781 insertions, 50 deletions
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js new file mode 100644 index 00000000000..85b7b08db4d --- /dev/null +++ b/app/assets/javascripts/groups/transfer_dropdown.js @@ -0,0 +1,34 @@ +export default class TransferDropdown { + constructor() { + this.groupDropdown = $('.js-groups-dropdown'); + this.parentInput = $('#new_parent_group_id'); + this.data = this.groupDropdown.data('data'); + this.init(); + } + + init() { + this.buildDropdown(); + } + + buildDropdown() { + const extraOptions = [{ id: '', text: 'No parent group' }, 'divider']; + + this.groupDropdown.glDropdown({ + selectable: true, + filterable: true, + toggleLabel: item => item.text, + search: { fields: ['text'] }, + data: extraOptions.concat(this.data), + text: item => item.text, + clicked: (options) => { + const { e } = options; + e.preventDefault(); + this.assignSelected(options.selectedObj); + }, + }); + } + + assignSelected(selected) { + this.parentInput.val(selected.id); + } +} diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 48e8c9550bf..1aeec55a4be 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,3 +1,7 @@ import groupAvatar from '~/group_avatar'; +import TransferDropdown from '~/groups/transfer_dropdown'; -export default groupAvatar; +export default () => { + groupAvatar(); + new TransferDropdown(); // eslint-disable-line no-new +}; diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index f9a761e85fe..6ee8b33bd39 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -224,3 +224,16 @@ border-radius: $label-border-radius; font-weight: $gl-font-weight-normal; } + +.js-groups-dropdown { + width: 100%; +} + +.dropdown-group-transfer { + bottom: 100%; + top: initial; + + .dropdown-content { + overflow-y: unset; + } +} diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index bb652832cb1..7d129c5dece 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -10,7 +10,7 @@ class GroupsController < Groups::ApplicationController before_action :group, except: [:index, :new, :create] # Authorize - before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects] + before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects, :transfer] before_action :authorize_create_group!, only: [:new] before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] @@ -94,6 +94,19 @@ class GroupsController < Groups::ApplicationController redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion." end + def transfer + parent_group = Group.find_by(id: params[:new_parent_group_id]) + service = ::Groups::TransferService.new(@group, current_user) + + if service.execute(parent_group) + flash[:notice] = "Group '#{@group.name}' was successfully transferred." + redirect_to group_path(@group) + else + flash.now[:alert] = service.error + render :edit + end + end + protected def authorize_create_group! diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 676c1d1988b..09450eaa5b7 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -88,6 +88,19 @@ module GroupsHelper end end + def parent_group_options(current_group) + groups = current_user.owned_groups.sort_by(&:human_name).map do |group| + { id: group.id, text: group.human_name } + end + + groups.delete_if { |group| group[:id] == current_group.id } + groups.to_json + end + + def supports_nested_groups? + Group.supports_nested_groups? + end + private def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false) diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index b12c10a84de..67a988addbe 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -14,7 +14,11 @@ module Storage # Ensure old directory exists before moving it gitlab_shell.add_namespace(repository_storage_path, full_path_was) + # Ensure new directory exists before moving it (if there's a parent) + gitlab_shell.add_namespace(repository_storage_path, parent.full_path) if parent + unless gitlab_shell.mv_namespace(repository_storage_path, full_path_was, full_path) + Rails.logger.error "Exception moving path #{repository_storage_path} from #{full_path_was} to #{full_path}" # if we cannot move namespace directory we should rollback diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 7b82d076975..06655298950 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -40,7 +40,6 @@ class Namespace < ActiveRecord::Base namespace_path: true validate :nesting_level_allowed - validate :allowed_path_by_redirects delegate :name, to: :owner, allow_nil: true, prefix: true @@ -52,7 +51,7 @@ class Namespace < ActiveRecord::Base # Legacy Storage specific hooks - after_update :move_dir, if: :path_changed? + after_update :move_dir, if: :path_or_parent_changed? before_destroy(prepend: true) { prepare_for_destroy } after_destroy :rm_dir @@ -222,9 +221,12 @@ class Namespace < ActiveRecord::Base end def full_path_was - return path_was unless has_parent? - - "#{parent.full_path}/#{path_was}" + if parent_id_was.nil? + path_was + else + previous_parent = Group.find_by(id: parent_id_was) + previous_parent.full_path + '/' + path_was + end end # Exports belonging to projects with legacy storage are placed in a common @@ -241,6 +243,10 @@ class Namespace < ActiveRecord::Base private + def path_or_parent_changed? + path_changed? || parent_changed? + end + def refresh_access_of_projects_invited_groups Group .joins(project_group_links: :project) @@ -271,16 +277,6 @@ class Namespace < ActiveRecord::Base .update_all(share_with_group_lock: true) end - def allowed_path_by_redirects - return if path.nil? - - errors.add(:path, "#{path} has been taken before. Please use another one") if namespace_previously_created_with_same_path? - end - - def namespace_previously_created_with_same_path? - RedirectRoute.permanent.exists?(path: path) - end - def write_projects_repository_config all_projects.find_each do |project| project.expires_full_path_cache # we need to clear cache to validate renames correctly diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb new file mode 100644 index 00000000000..e591c820cff --- /dev/null +++ b/app/services/groups/transfer_service.rb @@ -0,0 +1,96 @@ +module Groups + class TransferService < Groups::BaseService + ERROR_MESSAGES = { + database_not_supported: 'Database is not supported.', + namespace_with_same_path: 'The parent group already has a subgroup with the same path.', + group_is_already_root: 'Group is already a root group.', + same_parent_as_current: 'Group is already associated to the parent group.', + invalid_policies: "You don't have enough permissions." + }.freeze + + TransferError = Class.new(StandardError) + + attr_reader :error + + def initialize(group, user, params = {}) + super + @error = nil + end + + def execute(new_parent_group) + @new_parent_group = new_parent_group + ensure_allowed_transfer + proceed_to_transfer + + rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e + @group.errors.clear + @error = "Transfer failed: " + e.message + false + end + + private + + def proceed_to_transfer + Group.transaction do + update_group_attributes + end + end + + def ensure_allowed_transfer + raise_transfer_error(:group_is_already_root) if group_is_already_root? + raise_transfer_error(:database_not_supported) unless Group.supports_nested_groups? + raise_transfer_error(:same_parent_as_current) if same_parent? + raise_transfer_error(:invalid_policies) unless valid_policies? + raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path? + end + + def group_is_already_root? + !@new_parent_group && !@group.has_parent? + end + + def same_parent? + @new_parent_group && @new_parent_group.id == @group.parent_id + end + + def valid_policies? + return false unless can?(current_user, :admin_group, @group) + + if @new_parent_group + can?(current_user, :create_subgroup, @new_parent_group) + else + can?(current_user, :create_group) + end + end + + def namespace_with_same_path? + Namespace.exists?(path: @group.path, parent: @new_parent_group) + end + + def update_group_attributes + if @new_parent_group && @new_parent_group.visibility_level < @group.visibility_level + update_children_and_projects_visibility + @group.visibility_level = @new_parent_group.visibility_level + end + + @group.parent = @new_parent_group + @group.save! + end + + def update_children_and_projects_visibility + descendants = @group.descendants.where("visibility_level > ?", @new_parent_group.visibility_level) + + Group + .where(id: descendants.select(:id)) + .update_all(visibility_level: @new_parent_group.visibility_level) + + @group + .all_projects + .where("visibility_level > ?", @new_parent_group.visibility_level) + .update_all(visibility_level: @new_parent_group.visibility_level) + end + + def raise_transfer_error(message) + raise TransferError, ERROR_MESSAGES[message] + end + end +end diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 76a8099d7c0..86cd0759a2c 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -57,4 +57,20 @@ .form-actions = button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) } +- if supports_nested_groups? + .panel.panel-warning + .panel-heading Transfer group + .panel-body + = form_for @group, url: transfer_group_path(@group), method: :put do |f| + .form-group + = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: "Search groups", data: { data: parent_group_options(@group) } }) + = hidden_field_tag 'new_parent_group_id' + + %ul + %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}. + %li You can only transfer the group to a group you manage. + %li You will need to update your local repositories to point to the new location. + %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility. + = f.submit 'Transfer group', class: "btn btn-warning" + = render 'shared/confirm_modal', phrase: @group.path diff --git a/changelogs/unreleased/31885-ability-to-transfer-groups-to-another-group.yml b/changelogs/unreleased/31885-ability-to-transfer-groups-to-another-group.yml new file mode 100644 index 00000000000..d2a5802af64 --- /dev/null +++ b/changelogs/unreleased/31885-ability-to-transfer-groups-to-another-group.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to transfer a group into another group +merge_request: 16302 +author: +type: added diff --git a/config/routes/group.rb b/config/routes/group.rb index 24c76bc55ab..b17611d8623 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -14,6 +14,7 @@ constraints(GroupUrlConstrainer.new) do get :merge_requests, as: :merge_requests_group get :projects, as: :projects_group get :activity, as: :activity_group + put :transfer, as: :transfer_group end get '/', action: :show, as: :group_canonical diff --git a/doc/user/group/index.md b/doc/user/group/index.md index 7f77a33aadc..f78e5089886 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -168,6 +168,20 @@ Alternatively, you can [lock the sharing with group feature](#share-with-group-l In GitLab Enterprise Edition it is possible to manage GitLab group memberships using LDAP groups. See [the GitLab Enterprise Edition documentation](../../integration/ldap.md) for more information. +## Transfer groups to another group + +From 10.5 there are two different ways to transfer a group: + +- Either by transferring a group into another group (making it a subgroup of that group). +- Or by converting a subgroup into a root group (a group with no parent). + +Please make sure to understand that: + +- Changing a group's parent can have unintended side effects. See [Redirects when changing repository paths](https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths) +- You can only transfer the group to a group you manage. +- You will need to update your local repositories to point to the new location. +- If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility. + ## Group settings Once you have created a group, you can manage its settings by navigating to diff --git a/doc/user/project/index.md b/doc/user/project/index.md index 4c772c62f8d..77eba8eda7c 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -128,8 +128,7 @@ and Git push/pull redirects. Depending on the situation, different things apply. -When [transferring a project](settings/index.md#transferring-an-existing-project-into-another-namespace), -or [renaming a user](../profile/index.md#changing-your-username) or +When [renaming a user](../profile/index.md#changing-your-username) or [changing a group path](../group/index.md#changing-a-group-s-path): - **The redirect to the new URL is permanent**, which means that the original diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 492fed42d31..8688fb33f0d 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -496,4 +496,87 @@ describe GroupsController do "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path." end end + + describe 'PUT transfer', :postgresql do + before do + sign_in(user) + end + + context 'when transfering to a subgroup goes right' do + let(:new_parent_group) { create(:group, :public) } + let!(:group_member) { create(:group_member, :owner, group: group, user: user) } + let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) } + + before do + put :transfer, + id: group.to_param, + new_parent_group_id: new_parent_group.id + end + + it 'should return a notice' do + expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.") + end + + it 'should redirect to the new path' do + expect(response).to redirect_to("/#{new_parent_group.path}/#{group.path}") + end + end + + context 'when converting to a root group goes right' do + let(:group) { create(:group, :public, :nested) } + let!(:group_member) { create(:group_member, :owner, group: group, user: user) } + + before do + put :transfer, + id: group.to_param, + new_parent_group_id: '' + end + + it 'should return a notice' do + expect(flash[:notice]).to eq("Group '#{group.name}' was successfully transferred.") + end + + it 'should redirect to the new path' do + expect(response).to redirect_to("/#{group.path}") + end + end + + context 'When the transfer goes wrong' do + let(:new_parent_group) { create(:group, :public) } + let!(:group_member) { create(:group_member, :owner, group: group, user: user) } + let!(:new_parent_group_member) { create(:group_member, :owner, group: new_parent_group, user: user) } + + before do + allow_any_instance_of(::Groups::TransferService).to receive(:proceed_to_transfer).and_raise(Gitlab::UpdatePathError, 'namespace directory cannot be moved') + + put :transfer, + id: group.to_param, + new_parent_group_id: new_parent_group.id + end + + it 'should return an alert' do + expect(flash[:alert]).to eq "Transfer failed: namespace directory cannot be moved" + end + + it 'should redirect to the current path' do + expect(response).to render_template(:edit) + end + end + + context 'when the user is not allowed to transfer the group' do + let(:new_parent_group) { create(:group, :public) } + let!(:group_member) { create(:group_member, :guest, group: group, user: user) } + let!(:new_parent_group_member) { create(:group_member, :guest, group: new_parent_group, user: user) } + + before do + put :transfer, + id: group.to_param, + new_parent_group_id: new_parent_group.id + end + + it 'should be denied' do + expect(response).to have_gitlab_http_status(404) + end + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 5e82a2988ce..5ea4acb6687 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -582,4 +582,20 @@ describe Group do end end end + + describe '#has_parent?' do + context 'when the group has a parent' do + it 'should be truthy' do + group = create(:group, :nested) + expect(group.has_parent?).to be_truthy + end + end + + context 'when the group has no parent' do + it 'should be falsy' do + group = create(:group, parent: nil) + expect(group.has_parent?).to be_falsy + end + end + end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 824cca66fb4..5e126bc4bea 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -567,36 +567,6 @@ describe Namespace do end end - describe "#allowed_path_by_redirects" do - let(:namespace1) { create(:namespace, path: 'foo') } - - context "when the path has been taken before" do - before do - namespace1.path = 'bar' - namespace1.save! - end - - it 'should be invalid' do - namespace2 = build(:group, path: 'foo') - expect(namespace2).to be_invalid - end - - it 'should return an error on path' do - namespace2 = build(:group, path: 'foo') - namespace2.valid? - expect(namespace2.errors.messages[:path].first).to eq('foo has been taken before. Please use another one') - end - end - - context "when the path has not been taken before" do - it 'should be valid' do - expect(RedirectRoute.count).to eq(0) - namespace = build(:namespace) - expect(namespace).to be_valid - end - end - end - describe '#remove_exports' do let(:legacy_project) { create(:project, :with_export, namespace: namespace) } let(:hashed_project) { create(:project, :with_export, :hashed, namespace: namespace) } @@ -616,4 +586,44 @@ describe Namespace do expect(File.exist?(hashed_export)).to be_falsy end end + + describe '#full_path_was' do + context 'when the group has no parent' do + it 'should return the path was' do + group = create(:group, parent: nil) + expect(group.full_path_was).to eq(group.path_was) + end + end + + context 'when a parent is assigned to a group with no previous parent' do + it 'should return the path was' do + group = create(:group, parent: nil) + + parent = create(:group) + group.parent = parent + + expect(group.full_path_was).to eq("#{group.path_was}") + end + end + + context 'when a parent is removed from the group' do + it 'should return the parent full path' do + parent = create(:group) + group = create(:group, parent: parent) + group.parent = nil + + expect(group.full_path_was).to eq("#{parent.full_path}/#{group.path}") + end + end + + context 'when changing parents' do + it 'should return the previous parent full path' do + parent = create(:group) + group = create(:group, parent: parent) + new_parent = create(:group) + group.parent = new_parent + expect(group.full_path_was).to eq("#{parent.full_path}/#{group.path}") + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index af79ea4c283..568aab8530e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2617,7 +2617,7 @@ describe User do it 'should raise an ActiveRecord::RecordInvalid exception' do user2 = build(:user, username: 'foo') - expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Path foo has been taken before/) + expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Route path foo has been taken before. Please use another one, Route is invalid/) end end diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb new file mode 100644 index 00000000000..bcc01b087f3 --- /dev/null +++ b/spec/services/groups/transfer_service_spec.rb @@ -0,0 +1,414 @@ +require 'rails_helper' + +describe Groups::TransferService, :postgresql do + let(:user) { create(:user) } + let(:new_parent_group) { create(:group, :public) } + let!(:group_member) { create(:group_member, :owner, group: group, user: user) } + let(:transfer_service) { described_class.new(group, user) } + + shared_examples 'ensuring allowed transfer for a group' do + context 'with other database than PostgreSQL' do + before do + allow(Group).to receive(:supports_nested_groups?).and_return(false) + end + + it 'should return false' do + expect(transfer_service.execute(new_parent_group)).to be_falsy + end + + it 'should add an error on group' do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq('Transfer failed: Database is not supported.') + end + end + + context "when there's an exception on Gitlab shell directories" do + let(:new_parent_group) { create(:group, :public) } + + before do + allow_any_instance_of(described_class).to receive(:update_group_attributes).and_raise(Gitlab::UpdatePathError, 'namespace directory cannot be moved') + create(:group_member, :owner, group: new_parent_group, user: user) + end + + it 'should return false' do + expect(transfer_service.execute(new_parent_group)).to be_falsy + end + + it 'should add an error on group' do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq('Transfer failed: namespace directory cannot be moved') + end + end + end + + describe '#execute' do + context 'when transforming a group into a root group' do + let!(:group) { create(:group, :public, :nested) } + + it_behaves_like 'ensuring allowed transfer for a group' + + context 'when the group is already a root group' do + let(:group) { create(:group, :public) } + + it 'should add an error on group' do + transfer_service.execute(nil) + expect(transfer_service.error).to eq('Transfer failed: Group is already a root group.') + end + end + + context 'when the user does not have the right policies' do + let!(:group_member) { create(:group_member, :guest, group: group, user: user) } + + it "should return false" do + expect(transfer_service.execute(nil)).to be_falsy + end + + it "should add an error on group" do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq("Transfer failed: You don't have enough permissions.") + end + end + + context 'when there is a group with the same path' do + let!(:group) { create(:group, :public, :nested, path: 'not-unique') } + + before do + create(:group, path: 'not-unique') + end + + it 'should return false' do + expect(transfer_service.execute(nil)).to be_falsy + end + + it 'should add an error on group' do + transfer_service.execute(nil) + expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup with the same path.') + end + end + + context 'when the group is a subgroup and the transfer is valid' do + let!(:subgroup1) { create(:group, :private, parent: group) } + let!(:subgroup2) { create(:group, :internal, parent: group) } + let!(:project1) { create(:project, :repository, :private, namespace: group) } + + before do + transfer_service.execute(nil) + group.reload + end + + it 'should update group attributes' do + expect(group.parent).to be_nil + end + + it 'should update group children path' do + group.children.each do |subgroup| + expect(subgroup.full_path).to eq("#{group.path}/#{subgroup.path}") + end + end + + it 'should update group projects path' do + group.projects.each do |project| + expect(project.full_path).to eq("#{group.path}/#{project.path}") + end + end + end + end + + context 'when transferring a subgroup into another group' do + let(:group) { create(:group, :public, :nested) } + + it_behaves_like 'ensuring allowed transfer for a group' + + context 'when the new parent group is the same as the previous parent group' do + let(:group) { create(:group, :public, :nested, parent: new_parent_group) } + + it 'should return false' do + expect(transfer_service.execute(new_parent_group)).to be_falsy + end + + it 'should add an error on group' do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq('Transfer failed: Group is already associated to the parent group.') + end + end + + context 'when the user does not have the right policies' do + let!(:group_member) { create(:group_member, :guest, group: group, user: user) } + + it "should return false" do + expect(transfer_service.execute(new_parent_group)).to be_falsy + end + + it "should add an error on group" do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq("Transfer failed: You don't have enough permissions.") + end + end + + context 'when the parent has a group with the same path' do + before do + create(:group_member, :owner, group: new_parent_group, user: user) + group.update_attribute(:path, "not-unique") + create(:group, path: "not-unique", parent: new_parent_group) + end + + it 'should return false' do + expect(transfer_service.execute(new_parent_group)).to be_falsy + end + + it 'should add an error on group' do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup with the same path.') + end + end + + context 'when the parent group has a project with the same path' do + let!(:group) { create(:group, :public, :nested, path: 'foo') } + + before do + create(:group_member, :owner, group: new_parent_group, user: user) + create(:project, path: 'foo', namespace: new_parent_group) + group.update_attribute(:path, 'foo') + end + + it 'should return false' do + expect(transfer_service.execute(new_parent_group)).to be_falsy + end + + it 'should add an error on group' do + transfer_service.execute(new_parent_group) + expect(transfer_service.error).to eq('Transfer failed: Validation failed: Route path has already been taken, Route is invalid') + end + end + + context 'when the group is allowed to be transferred' do + before do + create(:group_member, :owner, group: new_parent_group, user: user) + transfer_service.execute(new_parent_group) + end + + context 'when the group has a lower visibility than the parent group' do + let(:new_parent_group) { create(:group, :public) } + let(:group) { create(:group, :private, :nested) } + + it 'should not update the visibility for the group' do + group.reload + expect(group.private?).to be_truthy + expect(group.visibility_level).not_to eq(new_parent_group.visibility_level) + end + end + + context 'when the group has a higher visibility than the parent group' do + let(:new_parent_group) { create(:group, :private) } + let(:group) { create(:group, :public, :nested) } + + it 'should update visibility level based on the parent group' do + group.reload + expect(group.private?).to be_truthy + expect(group.visibility_level).to eq(new_parent_group.visibility_level) + end + end + + it 'should update visibility for the group based on the parent group' do + expect(group.visibility_level).to eq(new_parent_group.visibility_level) + end + + it 'should update parent group to the new parent ' do + expect(group.parent).to eq(new_parent_group) + end + + it 'should return the group as children of the new parent' do + expect(new_parent_group.children.count).to eq(1) + expect(new_parent_group.children.first).to eq(group) + end + + it 'should create a permanent redirect for the group' do + expect(group.redirect_routes.permanent.count).to eq(1) + end + end + + context 'when transferring a group with group descendants' do + let!(:subgroup1) { create(:group, :private, parent: group) } + let!(:subgroup2) { create(:group, :internal, parent: group) } + + before do + create(:group_member, :owner, group: new_parent_group, user: user) + transfer_service.execute(new_parent_group) + end + + it 'should update subgroups path' do + new_parent_path = new_parent_group.path + group.children.each do |subgroup| + expect(subgroup.full_path).to eq("#{new_parent_path}/#{group.path}/#{subgroup.path}") + end + end + + it 'should create permanent redirects for the subgroups' do + expect(group.redirect_routes.permanent.count).to eq(1) + expect(subgroup1.redirect_routes.permanent.count).to eq(1) + expect(subgroup2.redirect_routes.permanent.count).to eq(1) + end + + context 'when the new parent has a higher visibility than the children' do + it 'should not update the children visibility' do + expect(subgroup1.private?).to be_truthy + expect(subgroup2.internal?).to be_truthy + end + end + + context 'when the new parent has a lower visibility than the children' do + let!(:subgroup1) { create(:group, :public, parent: group) } + let!(:subgroup2) { create(:group, :public, parent: group) } + let(:new_parent_group) { create(:group, :private) } + + it 'should update children visibility to match the new parent' do + group.children.each do |subgroup| + expect(subgroup.private?).to be_truthy + end + end + end + end + + context 'when transferring a group with project descendants' do + let!(:project1) { create(:project, :repository, :private, namespace: group) } + let!(:project2) { create(:project, :repository, :internal, namespace: group) } + + before do + TestEnv.clean_test_path + create(:group_member, :owner, group: new_parent_group, user: user) + transfer_service.execute(new_parent_group) + end + + it 'should update projects path' do + new_parent_path = new_parent_group.path + group.projects.each do |project| + expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.name}") + end + end + + it 'should create permanent redirects for the projects' do + expect(group.redirect_routes.permanent.count).to eq(1) + expect(project1.redirect_routes.permanent.count).to eq(1) + expect(project2.redirect_routes.permanent.count).to eq(1) + end + + context 'when the new parent has a higher visibility than the projects' do + it 'should not update projects visibility' do + expect(project1.private?).to be_truthy + expect(project2.internal?).to be_truthy + end + end + + context 'when the new parent has a lower visibility than the projects' do + let!(:project1) { create(:project, :repository, :public, namespace: group) } + let!(:project2) { create(:project, :repository, :public, namespace: group) } + let(:new_parent_group) { create(:group, :private) } + + it 'should update projects visibility to match the new parent' do + group.projects.each do |project| + expect(project.private?).to be_truthy + end + end + end + end + + context 'when transferring a group with subgroups & projects descendants' do + let!(:project1) { create(:project, :repository, :private, namespace: group) } + let!(:project2) { create(:project, :repository, :internal, namespace: group) } + let!(:subgroup1) { create(:group, :private, parent: group) } + let!(:subgroup2) { create(:group, :internal, parent: group) } + + before do + TestEnv.clean_test_path + create(:group_member, :owner, group: new_parent_group, user: user) + transfer_service.execute(new_parent_group) + end + + it 'should update subgroups path' do + new_parent_path = new_parent_group.path + group.children.each do |subgroup| + expect(subgroup.full_path).to eq("#{new_parent_path}/#{group.path}/#{subgroup.path}") + end + end + + it 'should update projects path' do + new_parent_path = new_parent_group.path + group.projects.each do |project| + expect(project.full_path).to eq("#{new_parent_path}/#{group.path}/#{project.name}") + end + end + + it 'should create permanent redirect for the subgroups and projects' do + expect(group.redirect_routes.permanent.count).to eq(1) + expect(subgroup1.redirect_routes.permanent.count).to eq(1) + expect(subgroup2.redirect_routes.permanent.count).to eq(1) + expect(project1.redirect_routes.permanent.count).to eq(1) + expect(project2.redirect_routes.permanent.count).to eq(1) + end + end + + context 'when transfering a group with nested groups and projects' do + let!(:group) { create(:group, :public) } + let!(:project1) { create(:project, :repository, :private, namespace: group) } + let!(:subgroup1) { create(:group, :private, parent: group) } + let!(:nested_subgroup) { create(:group, :private, parent: subgroup1) } + let!(:nested_project) { create(:project, :repository, :private, namespace: subgroup1) } + + before do + TestEnv.clean_test_path + create(:group_member, :owner, group: new_parent_group, user: user) + transfer_service.execute(new_parent_group) + end + + it 'should update subgroups path' do + new_base_path = "#{new_parent_group.path}/#{group.path}" + group.children.each do |children| + expect(children.full_path).to eq("#{new_base_path}/#{children.path}") + end + + new_base_path = "#{new_parent_group.path}/#{group.path}/#{subgroup1.path}" + subgroup1.children.each do |children| + expect(children.full_path).to eq("#{new_base_path}/#{children.path}") + end + end + + it 'should update projects path' do + new_parent_path = "#{new_parent_group.path}/#{group.path}" + subgroup1.projects.each do |project| + project_full_path = "#{new_parent_path}/#{project.namespace.path}/#{project.name}" + expect(project.full_path).to eq(project_full_path) + end + end + + it 'should create permanent redirect for the subgroups and projects' do + expect(group.redirect_routes.permanent.count).to eq(1) + expect(project1.redirect_routes.permanent.count).to eq(1) + expect(subgroup1.redirect_routes.permanent.count).to eq(1) + expect(nested_subgroup.redirect_routes.permanent.count).to eq(1) + expect(nested_project.redirect_routes.permanent.count).to eq(1) + end + end + + context 'when updating the group goes wrong' do + let!(:subgroup1) { create(:group, :public, parent: group) } + let!(:subgroup2) { create(:group, :public, parent: group) } + let(:new_parent_group) { create(:group, :private) } + let!(:project1) { create(:project, :repository, :public, namespace: group) } + + before do + allow(group).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(group)) + TestEnv.clean_test_path + create(:group_member, :owner, group: new_parent_group, user: user) + transfer_service.execute(new_parent_group) + end + + it 'should restore group and projects visibility' do + subgroup1.reload + project1.reload + expect(subgroup1.public?).to be_truthy + expect(project1.public?).to be_truthy + end + end + end + end +end |