Unique cross-model-slug check is likely misconfigured (#19263)
* Page unique_cross_model_slug seems misconfigured * Update validator test * More verbose invalid attribute message * Try dynamic model/attribute registration * Rubocop * Use the cross-model-slug existence check * Register with the dynamic cross-model-slug checker * Cleanup * Add missing keys to fr * Update format regex * Adjust validation: always presence, unique if changed * Tests can create reserved-word pages when they need to * Slugs can be mixed case? * Case-sensitive is better, actually * Refactor to use CrossModel check * Refactor, rename for clarity * Refactor, avoid mocking oneself * Refactor, injectable everything * Add reservedword check to extracted exists? checker * Move to concerns * Without dynamic registration * extend when needed * Cleanup comments, remove registration references --------- Co-authored-by: Goran <gorang.pub@gmail.com>
This commit is contained in:
parent
f019085d1e
commit
a20cf3e02b
16 changed files with 205 additions and 111 deletions
8
app/models/concerns/unique_across_models.rb
Normal file
8
app/models/concerns/unique_across_models.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# extend to add :unique_across_models, which validates a slug or name across
|
||||
# all "slug-like" models via CrossModelSlug
|
||||
module UniqueAcrossModels
|
||||
def unique_across_models(attribute, **options)
|
||||
validates attribute, presence: true
|
||||
validates attribute, cross_model_slug: true, **options, if: :"#{attribute}_changed?"
|
||||
end
|
||||
end
|
||||
37
app/models/cross_model_slug.rb
Normal file
37
app/models/cross_model_slug.rb
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#
|
||||
# We have a simple top-level route equivalent to `/slug`. Because of this,
|
||||
# we want to verify that newly created records don't overlap with a previously-
|
||||
# defined `slug` -- in other words, slug values should be unique across all
|
||||
# relevant models. Except also, models might use "username" instead of "slug".
|
||||
#
|
||||
# "Slug-like" models are all included in a cross-model-uniqueness check. An
|
||||
# impacted models are checked for the existence of a record matching a given
|
||||
# value. Additionally, we have some special cases (eg, sitemap) that we want to
|
||||
# apply across all registered models.
|
||||
#
|
||||
class CrossModelSlug
|
||||
MODELS = {
|
||||
"User" => :username,
|
||||
"Page" => :slug,
|
||||
"Podcast" => :slug,
|
||||
"Organization" => :slug
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def exists?(value)
|
||||
# Presence check is likely redundant, but is **much** cheaper than the
|
||||
# cross-model check
|
||||
return false if value.blank?
|
||||
|
||||
value = value.downcase
|
||||
|
||||
# Reserved check may be redundant, but allows this to be used outside of Validator
|
||||
return true if ReservedWords.all.include?(value)
|
||||
return true if value.include?("sitemap-") # https://github.com/forem/forem/pull/6704
|
||||
|
||||
MODELS.detect do |class_name, attribute|
|
||||
class_name.constantize.exists?({ attribute => value })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,9 +3,9 @@ class Organization < ApplicationRecord
|
|||
|
||||
include Images::Profile.for(:profile_image_url)
|
||||
|
||||
extend UniqueAcrossModels
|
||||
COLOR_HEX_REGEXP = /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/
|
||||
INTEGER_REGEXP = /\A\d+\z/
|
||||
SLUG_REGEXP = /\A[a-zA-Z0-9\-_]+\z/
|
||||
|
||||
acts_as_followable
|
||||
|
||||
|
|
@ -47,9 +47,6 @@ class Organization < ApplicationRecord
|
|||
validates :proof, length: { maximum: 1500 }
|
||||
validates :secret, length: { is: 100 }, allow_nil: true
|
||||
validates :secret, uniqueness: true
|
||||
validates :slug, exclusion: { in: ReservedWords.all, message: :reserved_word }
|
||||
validates :slug, format: { with: SLUG_REGEXP }, length: { in: 2..30 }
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :spent_credits_count, presence: true
|
||||
validates :summary, length: { maximum: 250 }
|
||||
validates :tag_line, length: { maximum: 60 }
|
||||
|
|
@ -59,7 +56,7 @@ class Organization < ApplicationRecord
|
|||
validates :unspent_credits_count, presence: true
|
||||
validates :url, length: { maximum: 200 }, url: { allow_blank: true, no_local: true }
|
||||
|
||||
validates :slug, unique_cross_model_slug: true, if: :slug_changed?
|
||||
unique_across_models :slug, length: { in: 2..30 }
|
||||
|
||||
mount_uploader :profile_image, ProfileImageUploader
|
||||
mount_uploader :nav_image, ProfileImageUploader
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
class Page < ApplicationRecord
|
||||
extend UniqueAcrossModels
|
||||
TEMPLATE_OPTIONS = %w[contained full_within_layout nav_bar_included json].freeze
|
||||
|
||||
TERMS_SLUG = "terms".freeze
|
||||
|
|
@ -7,11 +8,10 @@ class Page < ApplicationRecord
|
|||
|
||||
validates :title, presence: true
|
||||
validates :description, presence: true
|
||||
validates :slug, presence: true, format: /\A[0-9a-z\-_]*\z/
|
||||
validates :template, inclusion: { in: TEMPLATE_OPTIONS }
|
||||
validate :body_present
|
||||
validates :slug, unique_cross_model_slug: true, if: :slug_changed?
|
||||
validates :slug, uniqueness: true
|
||||
|
||||
unique_across_models :slug
|
||||
|
||||
before_validation :set_default_template
|
||||
before_save :evaluate_markdown
|
||||
|
|
|
|||
|
|
@ -17,13 +17,9 @@ class Podcast < ApplicationRecord
|
|||
validates :main_color_hex, :title, :feed_url, :image, presence: true
|
||||
validates :main_color_hex, format: /\A([a-fA-F]|[0-9]){6}\Z/
|
||||
validates :feed_url, uniqueness: true, url: { schemes: %w[https http] }
|
||||
validates :slug,
|
||||
presence: true,
|
||||
uniqueness: true,
|
||||
format: { with: /\A[a-zA-Z0-9\-_]+\Z/ },
|
||||
exclusion: { in: ReservedWords.all, message: I18n.t("models.podcast.slug_is_reserved") }
|
||||
|
||||
validates :slug, unique_cross_model_slug: true, if: :slug_changed?
|
||||
extend UniqueAcrossModels
|
||||
unique_across_models :slug
|
||||
|
||||
after_save :bust_cache
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ class User < ApplicationRecord
|
|||
|
||||
include StringAttributeCleaner.nullify_blanks_for(:email)
|
||||
|
||||
extend UniqueAcrossModels
|
||||
USERNAME_MAX_LENGTH = 30
|
||||
USERNAME_REGEXP = /\A[a-zA-Z0-9_]+\z/
|
||||
# follow the syntax in https://interledger.org/rfcs/0026-payment-pointers/#payment-pointer-syntax
|
||||
PAYMENT_POINTER_REGEXP = %r{
|
||||
\A # start
|
||||
|
|
@ -136,14 +136,6 @@ class User < ApplicationRecord
|
|||
validates :spent_credits_count, presence: true
|
||||
validates :subscribed_to_user_subscriptions_count, presence: true
|
||||
validates :unspent_credits_count, presence: true
|
||||
validates :username, length: { in: 2..USERNAME_MAX_LENGTH }, format: USERNAME_REGEXP
|
||||
validates :username, presence: true, exclusion: {
|
||||
in: ReservedWords.all,
|
||||
message: proc { I18n.t("models.user.username_is_reserved") }
|
||||
}
|
||||
validates :username, uniqueness: { case_sensitive: false, message: lambda do |_obj, data|
|
||||
I18n.t("models.user.is_taken", username: (data[:value]))
|
||||
end }, if: :username_changed?
|
||||
|
||||
# add validators for provider related usernames
|
||||
Authentication::Providers.username_fields.each do |username_field|
|
||||
|
|
@ -158,7 +150,9 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
validate :non_banished_username, :username_changed?
|
||||
validates :username, unique_cross_model_slug: true, if: :username_changed?
|
||||
|
||||
unique_across_models :username, length: { in: 2..USERNAME_MAX_LENGTH }
|
||||
|
||||
validate :can_send_confirmation_email
|
||||
validate :update_rate_limit
|
||||
# NOTE: when updating the password on a Devise enabled model, the :encrypted_password
|
||||
|
|
|
|||
|
|
@ -7,46 +7,55 @@
|
|||
# @todo Extract username validation in separate class
|
||||
module Users
|
||||
class UsernameGenerator
|
||||
attr_reader :usernames
|
||||
|
||||
def self.call(...)
|
||||
new(...).call
|
||||
end
|
||||
|
||||
# @param list [Array<String>] a list of usernames
|
||||
def initialize(list = [])
|
||||
@list = list
|
||||
# @param usernames [Array<String>] a list of usernames
|
||||
def initialize(usernames = [], detector: CrossModelSlug, generator: nil)
|
||||
@detector = detector
|
||||
@generator = generator || method(:random_username)
|
||||
@usernames = usernames
|
||||
end
|
||||
|
||||
def call
|
||||
from_list(modified_list) || from_list(list_with_suffix) || from_list(Array.new(3) { random_letters })
|
||||
first_available_from(normalized_usernames) ||
|
||||
first_available_from(suffixed_usernames) ||
|
||||
first_available_from(random_usernames)
|
||||
end
|
||||
|
||||
def normalized_usernames
|
||||
@normalized_usernames ||= filtered_usernames.map { |s| s.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ") }
|
||||
end
|
||||
|
||||
def filtered_usernames
|
||||
@filtered_usernames ||= usernames.select { |s| s.is_a?(String) && s.present? }
|
||||
end
|
||||
|
||||
def random_username
|
||||
("a".."z").to_a.sample(12).join
|
||||
end
|
||||
|
||||
def random_usernames
|
||||
Array.new(3) { @generator.call }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def from_list(list)
|
||||
def first_available_from(list)
|
||||
list.detect { |username| !username_exists?(username) }
|
||||
end
|
||||
|
||||
def username_exists?(username)
|
||||
User.exists?(username: username) ||
|
||||
Organization.exists?(slug: username) ||
|
||||
Page.exists?(slug: username) ||
|
||||
Podcast.exists?(slug: username)
|
||||
@detector.exists?(username)
|
||||
end
|
||||
|
||||
def filtered_list
|
||||
@list.select { |s| s.is_a?(String) && s.present? }
|
||||
end
|
||||
def suffixed_usernames
|
||||
return [] unless filtered_usernames.any?
|
||||
|
||||
def modified_list
|
||||
filtered_list.map { |s| s.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ") }
|
||||
end
|
||||
|
||||
def list_with_suffix
|
||||
modified_list.map { |s| [s, rand(100)].join("_") }
|
||||
end
|
||||
|
||||
def random_letters
|
||||
("a".."z").to_a.sample(12).join
|
||||
normalized_usernames.map { |stem| [stem, rand(100)].join("_") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
39
app/validators/cross_model_slug_validator.rb
Normal file
39
app/validators/cross_model_slug_validator.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
##
|
||||
# Validates if the give attribute is used across the reserved spaces.
|
||||
class CrossModelSlugValidator < ActiveModel::EachValidator
|
||||
FORMAT_REGEX = /\A[0-9a-z\-_]+\z/
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
return if value.blank?
|
||||
|
||||
correct_format?(record, attribute, value)
|
||||
not_on_reserved_list?(record, attribute, value)
|
||||
unique_across_models?(record, attribute, value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def not_on_reserved_list?(record, attribute, value)
|
||||
return unless ReservedWords.all.include?(value)
|
||||
|
||||
record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.is_reserved"))
|
||||
end
|
||||
|
||||
def correct_format?(record, attribute, value)
|
||||
return if value.match?(FORMAT_REGEX)
|
||||
|
||||
record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.is_invalid"))
|
||||
end
|
||||
|
||||
def unique_across_models?(record, attribute, value)
|
||||
# attribute_changed? is likely redundant, but is much cheaper than the cross-model exists check
|
||||
return unless record.public_send("#{attribute}_changed?")
|
||||
return unless already_exists?(value)
|
||||
|
||||
record.errors.add(attribute, I18n.t("validators.cross_model_slug_validator.is_taken"))
|
||||
end
|
||||
|
||||
def already_exists?(value)
|
||||
CrossModelSlug.exists?(value.downcase)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
##
|
||||
# Validates if the give attribute is used across the reserved spaces.
|
||||
class UniqueCrossModelSlugValidator < ActiveModel::EachValidator
|
||||
class_attribute :model_and_attribute_name_for_uniqueness_test
|
||||
|
||||
# Why a class attribute? Allow for other implementations to extend
|
||||
# this behavior.
|
||||
self.model_and_attribute_name_for_uniqueness_test = {
|
||||
Organization => :slug,
|
||||
Page => :slug,
|
||||
Podcast => :slug,
|
||||
User => :username
|
||||
}
|
||||
|
||||
def validate_each(record, attribute, value)
|
||||
return unless already_exists?(value: value, record: record)
|
||||
|
||||
record.errors.add(attribute, options[:message] || I18n.t("validators.unique_cross_model_slug_validator.is_taken"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
##
|
||||
# Answers the question if it's okay for the record to use the given value.
|
||||
#
|
||||
# @param value [String] the value we're to check in the various classes
|
||||
# @param record [ActiveRecord::Base] the record that we're attempting to validate
|
||||
#
|
||||
# @return [TrueClass] if the value already exists across the various classes.
|
||||
# @return [FalseClass] if the given value is not already used.
|
||||
#
|
||||
# @see CLASS_AND_ATTRIBUTE_NAME_FOR_UNIQUENESS_TEST
|
||||
def already_exists?(value:, record:)
|
||||
return false unless value
|
||||
return true if value.include?("sitemap-")
|
||||
|
||||
model_and_attribute_name_for_uniqueness_test.detect do |model, attribute|
|
||||
next if record.is_a?(model)
|
||||
|
||||
model.exists?(attribute => value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -11,7 +11,9 @@ en:
|
|||
default: must be a valid published Article identifier
|
||||
profile_validator:
|
||||
bio_too_long: Bio is too long
|
||||
unique_cross_model_slug_validator:
|
||||
is_taken: is taken
|
||||
cross_model_slug_validator:
|
||||
is_taken: has already been taken
|
||||
is_invalid: is invalid
|
||||
is_reserved: is reserved
|
||||
valid_domain_csv_validator:
|
||||
invalid_list_format: must be a comma-separated list of valid domains
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ fr:
|
|||
default: doit être un identifiant d'article publié valide
|
||||
profile_validator:
|
||||
bio_too_long: La biographie est trop longue
|
||||
unique_cross_model_slug_validator:
|
||||
cross_model_slug_validator:
|
||||
is_taken: est déjà pris
|
||||
is_invalid: is invalid
|
||||
is_reserved: is reserved
|
||||
valid_domain_csv_validator:
|
||||
invalid_list_format: doit être une liste de domaines valides, séparés par des virgules.
|
||||
|
|
|
|||
|
|
@ -5,5 +5,12 @@ FactoryBot.define do
|
|||
slug { Faker::Internet.slug }
|
||||
description { Faker::Lorem.sentence }
|
||||
template { "contained" }
|
||||
|
||||
# Validations prevent creating pages with reserved words, but empty test
|
||||
# database populated without these objects, this allows a bypass to create
|
||||
# those pages for specific test scenarios.
|
||||
trait :without_validations do
|
||||
to_create { |instance| instance.save(validate: false) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ RSpec.describe User do
|
|||
context "when evaluating the custom error message for username uniqueness" do
|
||||
subject { create(:user, username: "test_user_123") }
|
||||
|
||||
it { is_expected.to validate_uniqueness_of(:username).with_message("test_user_123 is taken.").case_insensitive }
|
||||
it { is_expected.to validate_uniqueness_of(:username).with_message("has already been taken").case_insensitive }
|
||||
end
|
||||
# rubocop:enable RSpec/NestedGroups
|
||||
|
||||
|
|
@ -222,7 +222,7 @@ RSpec.describe User do
|
|||
create(:user, username: "test_user_123")
|
||||
same_username = build(:user, username: "test_user_123")
|
||||
expect(same_username).not_to be_valid
|
||||
expect(same_username.errors[:username].to_s).to include("test_user_123 is taken.")
|
||||
expect(same_username.errors[:username].to_s).to include("has already been taken")
|
||||
end
|
||||
|
||||
it "validates username against reserved words" do
|
||||
|
|
|
|||
|
|
@ -1,31 +1,64 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe Users::UsernameGenerator, type: :service do
|
||||
let(:user) { create(:user) }
|
||||
let(:does_not_exist) do
|
||||
detector = Object.new
|
||||
def detector.exists?(_username)
|
||||
false
|
||||
end
|
||||
detector
|
||||
end
|
||||
|
||||
def result_from(usernames)
|
||||
described_class.call(usernames, detector: does_not_exist)
|
||||
end
|
||||
|
||||
it "returns randomly generated username if empty list is passed" do
|
||||
expect(described_class.call([""])).to be_present
|
||||
expect(result_from([""])).to match(%([a-z]+{12}))
|
||||
expect(result_from([])).to match(%([a-z]+{12}))
|
||||
end
|
||||
|
||||
it "returns randomly generated username if bad list is passed" do
|
||||
expect(result_from([nil, nil, 123, User])).to match(%([a-z]+{12}))
|
||||
end
|
||||
|
||||
it "returns supplied username if does not exist" do
|
||||
expect(described_class.call(["foo"])).to eq("foo")
|
||||
expect(result_from(["username"])).to eq("username")
|
||||
end
|
||||
|
||||
it "returns modified username" do
|
||||
expect(described_class.call(["foo.bar"])).to eq("foobar")
|
||||
it "returns normalized username" do
|
||||
expect(result_from(["user.name"])).to eq("username")
|
||||
end
|
||||
|
||||
it "returns supplied username with suffix if exists" do
|
||||
expect(described_class.call([user.username])).to start_with("#{user.username}_")
|
||||
end
|
||||
context "when username already exists" do
|
||||
subject(:result) { described_class.call ["username"], detector: username_exists }
|
||||
|
||||
it "returns randomly generated username" do
|
||||
expect(described_class.call).to be_present
|
||||
end
|
||||
let(:username_exists) do
|
||||
detector = Object.new
|
||||
def detector.exists?(username)
|
||||
username == "username"
|
||||
end
|
||||
detector
|
||||
end
|
||||
|
||||
it "returns nil if all generation methods are exhausted" do
|
||||
username_generator = described_class.new
|
||||
allow(username_generator).to receive(:random_letters).and_return(user.username)
|
||||
expect(username_generator.call).to be_nil
|
||||
it "returns supplied username with suffix" do
|
||||
expect(result).to match(/username_\d+/)
|
||||
end
|
||||
|
||||
context "when all generation methods are exhausted" do
|
||||
subject(:result) do
|
||||
described_class.call [],
|
||||
detector: username_exists,
|
||||
generator: pseudo_random
|
||||
end
|
||||
|
||||
let(:pseudo_random) do
|
||||
-> { "username" }
|
||||
end
|
||||
|
||||
it "returns nil" do
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -80,17 +80,20 @@ RSpec.describe "Admin manages pages" do
|
|||
describe "when the defaults are overridden" do
|
||||
before do
|
||||
create(:page,
|
||||
:without_validations, # validations prevent reserved_word "code-of-conduct"
|
||||
slug: "code-of-conduct",
|
||||
body_html: "<div>Code of Conduct</div>",
|
||||
title: "Code of Conduct",
|
||||
description: "A page that describes how to behave on this platform",
|
||||
is_top_level_path: true)
|
||||
create(:page,
|
||||
:without_validations, # validations prevent reserved_word "privacy"
|
||||
slug: "privacy",
|
||||
body_html: "<div>Privacy Policy</div>",
|
||||
title: "Privacy Policy",
|
||||
description: "A page that describes the privacy policy", is_top_level_path: true)
|
||||
create(:page,
|
||||
:without_validations, # validations prevent reserved_word "terms"
|
||||
slug: "terms",
|
||||
body_html: "<div>Terms of Use</div>",
|
||||
title: "Terms of Use",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
require "rails_helper"
|
||||
|
||||
RSpec.describe UniqueCrossModelSlugValidator do
|
||||
RSpec.describe CrossModelSlugValidator do
|
||||
subject(:record) { validatable.new.tap { |m| m.name = name } }
|
||||
|
||||
let(:validatable) do
|
||||
|
|
@ -15,10 +15,14 @@ RSpec.describe UniqueCrossModelSlugValidator do
|
|||
@enforce_validation = true
|
||||
end
|
||||
|
||||
def name_changed?
|
||||
true
|
||||
end
|
||||
|
||||
attr_accessor :enforce_validation
|
||||
alias_method :enforce_validation?, :enforce_validation
|
||||
|
||||
validates :name, unique_cross_model_slug: true, if: :enforce_validation?
|
||||
validates :name, cross_model_slug: true, if: :enforce_validation?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -36,6 +40,12 @@ RSpec.describe UniqueCrossModelSlugValidator do
|
|||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
|
||||
context "when name is a ReservedWord" do
|
||||
let(:name) { "members" }
|
||||
|
||||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
|
||||
context "when name exists in User model" do
|
||||
let(:user) { create(:user) }
|
||||
let(:name) { user.username }
|
||||
Loading…
Add table
Reference in a new issue