From 75144b1e03db342730535f1f49b0e7cd2987d755 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 31 Jan 2018 10:52:09 -0600 Subject: [PATCH 1/3] Validate path uniqueness only on Route, and bubble up appropriately --- app/models/concerns/routable.rb | 8 +++++++- app/models/namespace.rb | 1 - app/models/project.rb | 3 +-- app/models/route.rb | 2 +- app/models/user.rb | 22 ++++++++-------------- spec/models/concerns/routable_spec.rb | 2 +- spec/models/group_spec.rb | 1 - spec/models/namespace_spec.rb | 1 - spec/models/project_spec.rb | 1 - spec/models/route_spec.rb | 6 +++--- spec/models/user_spec.rb | 12 +++--------- spec/services/users/update_service_spec.rb | 4 ++-- 12 files changed, 26 insertions(+), 37 deletions(-) diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 5c1cce98ad4..dfd7d94450b 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -7,11 +7,12 @@ module Routable has_one :route, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :redirect_routes, as: :source, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - validates_associated :route validates :route, presence: true scope :with_route, -> { includes(:route) } + after_validation :set_path_errors + before_validation do if full_path_changed? || full_name_changed? prepare_route @@ -125,6 +126,11 @@ module Routable private + def set_path_errors + route_path_errors = self.errors.delete(:"route.path") + self.errors[:path].concat(route_path_errors) if route_path_errors + end + def uncached_full_path if route && route.path.present? @full_path ||= route.path # rubocop:disable Gitlab/ModuleWithInstanceVariables diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 44c5bb13a59..d95489ee9f2 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -32,7 +32,6 @@ class Namespace < ActiveRecord::Base validates :owner, presence: true, unless: ->(n) { n.type == "Group" } validates :name, presence: true, - uniqueness: { scope: :parent_id }, length: { maximum: 255 }, namespace_name: true diff --git a/app/models/project.rb b/app/models/project.rb index 12d5f28f5ea..dbd908670fd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -245,8 +245,7 @@ class Project < ActiveRecord::Base validates :path, presence: true, project_path: true, - length: { maximum: 255 }, - uniqueness: { scope: :namespace_id } + length: { maximum: 255 } validates :namespace, presence: true validates :name, uniqueness: { scope: :namespace_id } diff --git a/app/models/route.rb b/app/models/route.rb index 3d4b5a8b5ee..07d96c21cf1 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -75,7 +75,7 @@ class Route < ActiveRecord::Base def ensure_permanent_paths return if path.nil? - errors.add(:path, "#{path} has been taken before. Please use another one") if conflicting_redirect_exists? + errors.add(:path, "has been taken before") if conflicting_redirect_exists? end def conflicting_redirect_exists? diff --git a/app/models/user.rb b/app/models/user.rb index 1df3772e63f..da56446b6ab 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -153,10 +153,9 @@ class User < ActiveRecord::Base numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } validates :username, user_path: true, - presence: true, - uniqueness: { case_sensitive: false } + presence: true - validate :namespace_uniq, if: :username_changed? + validates :namespace, presence: true validate :namespace_move_dir_allowed, if: :username_changed? validate :unique_email, if: :email_changed? @@ -172,6 +171,7 @@ class User < ActiveRecord::Base before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } before_validation :ensure_namespace_correct + after_validation :set_username_errors after_update :username_changed_hook, if: :username_changed? after_destroy :post_destroy_hook after_destroy :remove_key_cache @@ -505,17 +505,6 @@ class User < ActiveRecord::Base end end - def namespace_uniq - # Return early if username already failed the first uniqueness validation - return if errors.key?(:username) && - errors[:username].include?('has already been taken') - - existing_namespace = Namespace.by_path(username) - if existing_namespace && existing_namespace != namespace - errors.add(:username, 'has already been taken') - end - end - def namespace_move_dir_allowed if namespace&.any_project_has_container_registry_tags? errors.add(:username, 'cannot be changed if a personal project has container registry tags.') @@ -891,6 +880,11 @@ class User < ActiveRecord::Base end end + def set_username_errors + namespace_path_errors = self.errors.delete(:"namespace.path") + self.errors[:username].concat(namespace_path_errors) if namespace_path_errors + end + def username_changed_hook system_hook_service.execute_hooks_for(self, :rename) end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 3106207811a..8cb50d7465c 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -39,7 +39,7 @@ describe Group, 'Routable' do create(:group, parent: group, path: 'xyz') duplicate = build(:project, namespace: group, path: 'xyz') - expect { duplicate.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Route path has already been taken, Route is invalid') + expect { duplicate.save! }.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Path has already been taken') end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 5ea4acb6687..338fb314ee9 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -41,7 +41,6 @@ describe Group do describe 'validations' do it { is_expected.to validate_presence_of :name } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } it { is_expected.to validate_presence_of :path } it { is_expected.not_to validate_presence_of :owner } it { is_expected.to validate_presence_of :two_factor_grace_period } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 5e126bc4bea..191b60e4383 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -15,7 +15,6 @@ describe Namespace do describe 'validations' do it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_uniqueness_of(:name).scoped_to(:parent_id) } it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(255) } it { is_expected.to validate_presence_of(:path) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index da940571bc1..a63f5d6d5a1 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -129,7 +129,6 @@ describe Project do it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_presence_of(:path) } - it { is_expected.to validate_uniqueness_of(:path).scoped_to(:namespace_id) } it { is_expected.to validate_length_of(:path).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(2000) } diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 88f54fd10e5..dfac82b327a 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -27,7 +27,7 @@ describe Route do redirect.save!(validate: false) expect(new_route.valid?).to be_falsey - expect(new_route.errors.first[1]).to eq('foo has been taken before. Please use another one') + expect(new_route.errors.first[1]).to eq('has been taken before') end end @@ -49,7 +49,7 @@ describe Route do redirect.save!(validate: false) expect(route.valid?).to be_falsey - expect(route.errors.first[1]).to eq('foo has been taken before. Please use another one') + expect(route.errors.first[1]).to eq('has been taken before') end end @@ -368,7 +368,7 @@ describe Route do group2.path = 'foo' group2.valid? - expect(group2.errors["route.path"].first).to eq('foo has been taken before. Please use another one') + expect(group2.errors[:path]).to eq(['has been taken before']) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 24d4d8f1741..a52dc93befe 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -116,12 +116,6 @@ describe User do expect(user).to be_valid end - it 'validates uniqueness' do - user = build(:user) - - expect(user).to validate_uniqueness_of(:username).case_insensitive - end - context 'when username is changed' do let(:user) { build_stubbed(:user, username: 'old_path', namespace: build_stubbed(:namespace)) } @@ -2287,17 +2281,17 @@ describe User do end context 'when there is a validation error (namespace name taken) while updating namespace' do - let!(:conflicting_namespace) { create(:group, name: new_username, path: 'quz') } + let!(:conflicting_namespace) { create(:group, path: new_username) } it 'causes the user save to fail' do expect(user.update_attributes(username: new_username)).to be_falsey - expect(user.namespace.errors.messages[:name].first).to eq('has already been taken') + expect(user.namespace.errors.messages[:path].first).to eq('has already been taken') end it 'adds the namespace errors to the user' do user.update_attributes(username: new_username) - expect(user.errors.full_messages.first).to eq('Namespace name has already been taken') + expect(user.errors.full_messages.first).to eq('Username has already been taken') end end end diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index f8d4a47b212..a4b7fe4674f 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -21,13 +21,13 @@ describe Users::UpdateService do end it 'includes namespace error messages' do - create(:group, name: 'taken', path: 'something_else') + create(:group, path: 'taken') result = {} expect do result = update_user(user, { username: 'taken' }) end.not_to change { user.reload.username } expect(result[:status]).to eq(:error) - expect(result[:message]).to eq('Namespace name has already been taken') + expect(result[:message]).to eq('Username has already been taken') end def update_user(user, opts) From a03d29da1dbbef3c202899cef3cd89b453a8b76f Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 31 Jan 2018 11:39:03 -0600 Subject: [PATCH 2/3] Validate User username only on Namespace, and bubble up appropriately --- app/models/user.rb | 4 +- app/validators/abstract_path_validator.rb | 6 +-- app/validators/namespace_path_validator.rb | 4 -- app/validators/project_path_validator.rb | 4 -- app/validators/user_path_validator.rb | 15 -------- lib/constraints/user_url_constrainer.rb | 2 +- lib/gitlab/o_auth/user.rb | 2 +- lib/gitlab/path_regex.rb | 12 ------ spec/lib/gitlab/path_regex_spec.rb | 8 ++-- spec/models/user_spec.rb | 16 +++++++- spec/services/groups/transfer_service_spec.rb | 2 +- spec/validators/user_path_validator_spec.rb | 38 ------------------- 12 files changed, 23 insertions(+), 90 deletions(-) delete mode 100644 app/validators/user_path_validator.rb delete mode 100644 spec/validators/user_path_validator_spec.rb diff --git a/app/models/user.rb b/app/models/user.rb index da56446b6ab..05c93d3cb17 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -151,9 +151,7 @@ class User < ActiveRecord::Base validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } - validates :username, - user_path: true, - presence: true + validates :username, presence: true validates :namespace, presence: true validate :namespace_move_dir_allowed, if: :username_changed? diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb index adbccb65a84..e43b66cbe3a 100644 --- a/app/validators/abstract_path_validator.rb +++ b/app/validators/abstract_path_validator.rb @@ -13,10 +13,6 @@ class AbstractPathValidator < ActiveModel::EachValidator raise NotImplementedError end - def self.full_path(record, value) - value - end - def self.valid_path?(path) encode!(path) "#{path}/" =~ path_regex @@ -28,7 +24,7 @@ class AbstractPathValidator < ActiveModel::EachValidator return end - full_path = self.class.full_path(record, value) + full_path = record.build_full_path return unless full_path unless self.class.valid_path?(full_path) diff --git a/app/validators/namespace_path_validator.rb b/app/validators/namespace_path_validator.rb index 4a0aa64ae0c..7b0ae4db5d4 100644 --- a/app/validators/namespace_path_validator.rb +++ b/app/validators/namespace_path_validator.rb @@ -12,8 +12,4 @@ class NamespacePathValidator < AbstractPathValidator def self.format_error_message Gitlab::PathRegex.namespace_format_message end - - def self.full_path(record, value) - record.build_full_path - end end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb index 829b596ad3c..424fd77a6a3 100644 --- a/app/validators/project_path_validator.rb +++ b/app/validators/project_path_validator.rb @@ -12,8 +12,4 @@ class ProjectPathValidator < AbstractPathValidator def self.format_error_message Gitlab::PathRegex.project_path_format_message end - - def self.full_path(record, value) - record.build_full_path - end end diff --git a/app/validators/user_path_validator.rb b/app/validators/user_path_validator.rb deleted file mode 100644 index adf02901802..00000000000 --- a/app/validators/user_path_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -class UserPathValidator < AbstractPathValidator - extend Gitlab::EncodingHelper - - def self.path_regex - Gitlab::PathRegex.root_namespace_path_regex - end - - def self.format_regex - Gitlab::PathRegex.namespace_format_regex - end - - def self.format_error_message - Gitlab::PathRegex.namespace_format_message - end -end diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index b7633aa7cbb..3b3ed1c6ddb 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -2,7 +2,7 @@ class UserUrlConstrainer def matches?(request) full_path = request.params[:username] - return false unless UserPathValidator.valid_path?(full_path) + return false unless NamespacePathValidator.valid_path?(full_path) User.find_by_full_path(full_path, follow_redirects: request.get?).present? end diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb index e40a001d20c..a3e1c66c19f 100644 --- a/lib/gitlab/o_auth/user.rb +++ b/lib/gitlab/o_auth/user.rb @@ -178,7 +178,7 @@ module Gitlab valid_username = ::Namespace.clean_path(username) uniquify = Uniquify.new - valid_username = uniquify.string(valid_username) { |s| !UserPathValidator.valid_path?(s) } + valid_username = uniquify.string(valid_username) { |s| !NamespacePathValidator.valid_path?(s) } name = auth_hash.name name = valid_username if name.strip.empty? diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb index 7e5dfd33502..ef3f786686a 100644 --- a/lib/gitlab/path_regex.rb +++ b/lib/gitlab/path_regex.rb @@ -171,26 +171,14 @@ module Gitlab @project_git_route_regex ||= /#{project_route_regex}\.git/.freeze end - def root_namespace_path_regex - @root_namespace_path_regex ||= %r{\A#{root_namespace_route_regex}/\z} - end - def full_namespace_path_regex @full_namespace_path_regex ||= %r{\A#{full_namespace_route_regex}/\z} end - def project_path_regex - @project_path_regex ||= %r{\A#{project_route_regex}/\z} - end - def full_project_path_regex @full_project_path_regex ||= %r{\A#{full_namespace_route_regex}/#{project_route_regex}/\z} end - def full_namespace_format_regex - @namespace_format_regex ||= /A#{FULL_NAMESPACE_FORMAT_REGEX}\z/.freeze - end - def namespace_format_regex @namespace_format_regex ||= /\A#{NAMESPACE_FORMAT_REGEX}\z/.freeze end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 85991c38363..a40330d853f 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -194,8 +194,8 @@ describe Gitlab::PathRegex do end end - describe '.root_namespace_path_regex' do - subject { described_class.root_namespace_path_regex } + describe '.root_namespace_route_regex' do + subject { %r{\A#{described_class.root_namespace_route_regex}/\z} } it 'rejects top level routes' do expect(subject).not_to match('admin/') @@ -318,8 +318,8 @@ describe Gitlab::PathRegex do end end - describe '.project_path_regex' do - subject { described_class.project_path_regex } + describe '.project_route_regex' do + subject { %r{\A#{described_class.project_route_regex}/\z} } it 'accepts top level routes' do expect(subject).to match('admin/') diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a52dc93befe..cb02d526a98 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -140,7 +140,19 @@ describe User do user = build(:user, username: username) expect(user).not_to be_valid - expect(user.errors.messages[:"namespace.route.path"].first).to eq('foo has been taken before. Please use another one') + expect(user.errors.full_messages).to eq(['Username has been taken before']) + end + end + + context 'when the username is in use by another user' do + let(:username) { 'foo' } + let!(:other_user) { create(:user, username: username) } + + it 'is invalid' do + user = build(:user, username: username) + + expect(user).not_to be_valid + expect(user.errors.full_messages).to eq(['Username has already been taken']) end end end @@ -2634,7 +2646,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, /Namespace route path foo has been taken before/) + expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Username has been taken before/) end end diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index bcc01b087f3..e1c873f8c1e 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -177,7 +177,7 @@ describe Groups::TransferService, :postgresql do 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') + expect(transfer_service.error).to eq('Transfer failed: Validation failed: Path has already been taken') end end diff --git a/spec/validators/user_path_validator_spec.rb b/spec/validators/user_path_validator_spec.rb deleted file mode 100644 index a46089cc24f..00000000000 --- a/spec/validators/user_path_validator_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -require 'spec_helper' - -describe UserPathValidator do - let(:validator) { described_class.new(attributes: [:username]) } - - describe '.valid_path?' do - it 'handles invalid utf8' do - expect(described_class.valid_path?("a\0weird\255path")).to be_falsey - end - end - - describe '#validates_each' do - it 'adds a message when the path is not in the correct format' do - user = build(:user) - - validator.validate_each(user, :username, "Path with spaces, and comma's!") - - expect(user.errors[:username]).to include(Gitlab::PathRegex.namespace_format_message) - end - - it 'adds a message when the path is reserved when creating' do - user = build(:user, username: 'help') - - validator.validate_each(user, :username, 'help') - - expect(user.errors[:username]).to include('help is a reserved name') - end - - it 'adds a message when the path is reserved when updating' do - user = create(:user) - user.username = 'help' - - validator.validate_each(user, :username, 'help') - - expect(user.errors[:username]).to include('help is a reserved name') - end - end -end From 4f335663068bb0c52e7258013ee607891a662d6c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 2 Feb 2018 16:53:45 -0600 Subject: [PATCH 3/3] Add changelog --- changelogs/unreleased/dm-route-path-validation.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/dm-route-path-validation.yml diff --git a/changelogs/unreleased/dm-route-path-validation.yml b/changelogs/unreleased/dm-route-path-validation.yml new file mode 100644 index 00000000000..df3ed1de1b9 --- /dev/null +++ b/changelogs/unreleased/dm-route-path-validation.yml @@ -0,0 +1,5 @@ +--- +title: Validate user, group and project paths consistently, and only once +merge_request: +author: +type: fixed