Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMayra Cabrera <mcabrera@gitlab.com>2018-02-06 03:10:58 +0300
committerDouwe Maan <douwe@gitlab.com>2018-02-06 03:10:58 +0300
commit68a419c8792798cfb09730c4ea52ac16e31c3fc9 (patch)
tree973e75c7941119c19f91107f96a674938daf18dd
parent976413ad0f01c1c1f49227c2f5265bda4dc2e548 (diff)
31885 - Ability to transfer a single group to another group
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js34
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js6
-rw-r--r--app/assets/stylesheets/pages/groups.scss13
-rw-r--r--app/controllers/groups_controller.rb15
-rw-r--r--app/helpers/groups_helper.rb13
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb4
-rw-r--r--app/models/namespace.rb26
-rw-r--r--app/services/groups/transfer_service.rb96
-rw-r--r--app/views/groups/edit.html.haml16
-rw-r--r--changelogs/unreleased/31885-ability-to-transfer-groups-to-another-group.yml5
-rw-r--r--config/routes/group.rb1
-rw-r--r--doc/user/group/index.md14
-rw-r--r--doc/user/project/index.md3
-rw-r--r--spec/controllers/groups_controller_spec.rb83
-rw-r--r--spec/models/group_spec.rb16
-rw-r--r--spec/models/namespace_spec.rb70
-rw-r--r--spec/models/user_spec.rb2
-rw-r--r--spec/services/groups/transfer_service_spec.rb414
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