Add ability to block words in usernames (#35407)

This commit is contained in:
Eugen Rochko 2025-07-29 12:19:15 +02:00 committed by GitHub
parent 8cf7a77808
commit 20bbd20ef1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 560 additions and 34 deletions

View File

@ -0,0 +1,77 @@
# frozen_string_literal: true
class Admin::UsernameBlocksController < Admin::BaseController
before_action :set_username_block, only: [:edit, :update]
def index
authorize :username_block, :index?
@username_blocks = UsernameBlock.order(username: :asc).page(params[:page])
@form = Form::UsernameBlockBatch.new
end
def batch
authorize :username_block, :index?
@form = Form::UsernameBlockBatch.new(form_username_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.username_blocks.no_username_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.username_blocks.not_permitted')
ensure
redirect_to admin_username_blocks_path
end
def new
authorize :username_block, :create?
@username_block = UsernameBlock.new(exact: true)
end
def edit
authorize @username_block, :update?
end
def create
authorize :username_block, :create?
@username_block = UsernameBlock.new(resource_params)
if @username_block.save
log_action :create, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.created_msg')
else
render :new
end
end
def update
authorize @username_block, :update?
if @username_block.update(resource_params)
log_action :update, @username_block
redirect_to admin_username_blocks_path, notice: I18n.t('admin.username_blocks.updated_msg')
else
render :new
end
end
private
def set_username_block
@username_block = UsernameBlock.find(params[:id])
end
def form_username_block_batch_params
params
.expect(form_username_block_batch: [username_block_ids: []])
end
def resource_params
params
.expect(username_block: [:username, :comparison, :allow_with_approval])
end
def action_from_button
'delete' if params[:delete]
end
end

View File

@ -13,6 +13,8 @@ module Admin::ActionLogsHelper
end
when 'UserRole'
link_to log.human_identifier, admin_roles_path(log.target_id)
when 'UsernameBlock'
link_to log.human_identifier, edit_admin_username_block_path(log.target_id)
when 'Report'
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m791-55-91-91q-48 32-103.5 49T480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-61 17-116.5T146-700l-91-91 57-57 736 736-57 57ZM477-161q45 0 86-11.5t77-33.5l-91-91q-38 17-74.5 47.5T412-168q16 3 32 5t33 2Zm-124-25q35-72 79.5-107t67.5-47q-29-9-58.5-14.5T380-360q-45 0-89 11t-85 31q26 43 63.5 77.5T353-186Zm461-74L690-384q31-10 50.5-36t19.5-60q0-42-29-71t-71-29q-34 0-60 19.5T564-510l-44-44q2-61-41-104.5T374-700L260-814q48-32 103.5-49T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 61-17 116.5T814-260ZM380-420q11 0 20.5-1.5T420-426L246-600q-3 10-4.5 19.5T240-560q0 58 41 99t99 41Z"/></svg>

After

Width:  |  Height:  |  Size: 698 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m791-55-91-91q-48 32-103.5 49T480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-61 17-116.5T146-700l-91-91 57-57 736 736-57 57ZM412-168q26-51 62-81.5t75-47.5L204-642q-21 36-32.5 76.5T160-480q0 45 11.5 86t34.5 76q41-20 85-31t89-11q32 0 61.5 5.5T500-340q-23 12-43.5 28T418-278q-12-2-20.5-2H380q-32 0-63.5 7T256-252q32 32 71.5 53.5T412-168Zm402-92-58-58q21-35 32.5-76t11.5-86q0-134-93-227t-227-93q-45 0-85.5 11.5T318-756l-58-58q48-32 103.5-49T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 61-17 116.5T814-260ZM520-554 374-700q62-2 105 41.5T520-554ZM380-420q-58 0-99-41t-41-99q0-33 14.5-60.5T292-668l196 196q-20 23-47.5 37.5T380-420Zm310 36L564-510q10-31 36-50.5t60-19.5q42 0 71 29t29 71q0 34-19.5 60T690-384ZM537-537ZM423-423Z"/></svg>

After

Width:  |  Height:  |  Size: 843 B

View File

@ -77,6 +77,9 @@ class Admin::ActionLogFilter
update_user_role: { target_type: 'UserRole', action: 'update' }.freeze,
update_ip_block: { target_type: 'IpBlock', action: 'update' }.freeze,
unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
create_username_block: { target_type: 'UsernameBlock', action: 'create' }.freeze,
update_username_block: { target_type: 'UsernameBlock', action: 'update' }.freeze,
destroy_username_block: { target_type: 'UsernameBlock', action: 'destroy' }.freeze,
}.freeze
attr_reader :params

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class Form::UsernameBlockBatch < Form::BaseBatch
attr_accessor :username_block_ids
def save
case action
when 'delete'
delete!
end
end
private
def username_blocks
@username_blocks ||= UsernameBlock.where(id: username_block_ids)
end
def delete!
verify_authorization(:destroy?)
username_blocks.each do |username_block|
username_block.destroy
log_action :destroy, username_block
end
end
def verify_authorization(permission)
username_blocks.each { |username_block| authorize(username_block, permission) }
end
end

View File

@ -443,7 +443,7 @@ class User < ApplicationRecord
def set_approved
self.approved = begin
if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval?
if sign_up_from_ip_requires_approval? || sign_up_email_requires_approval? || sign_up_username_requires_approval?
false
else
open_registrations? || valid_invitation? || external?
@ -499,6 +499,10 @@ class User < ApplicationRecord
EmailDomainBlock.requires_approval?(records + [domain], attempt_ip: sign_up_ip)
end
def sign_up_username_requires_approval?
account.username? && UsernameBlock.matches?(account.username, allow_with_approval: true)
end
def open_registrations?
Setting.registrations_mode == 'open'
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: username_blocks
#
# id :bigint(8) not null, primary key
# allow_with_approval :boolean default(FALSE), not null
# exact :boolean default(FALSE), not null
# normalized_username :string not null
# username :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
class UsernameBlock < ApplicationRecord
HOMOGLYPHS = {
'1' => 'i',
'2' => 'z',
'3' => 'e',
'4' => 'a',
'5' => 's',
'7' => 't',
'8' => 'b',
'9' => 'g',
'0' => 'o',
}.freeze
validates :username, presence: true, uniqueness: true
scope :matches_exactly, ->(str) { where(exact: true).where(normalized_username: str) }
scope :matches_partially, ->(str) { where(exact: false).where(Arel::Nodes.build_quoted(str).matches(Arel::Nodes.build_quoted('%').concat(arel_table[:normalized_username]).concat(Arel::Nodes.build_quoted('%')))) }
before_save :set_normalized_username
def comparison
exact? ? 'equals' : 'contains'
end
def comparison=(val)
self.exact = val == 'equals'
end
def self.matches?(str, allow_with_approval: false)
normalized_str = str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
where(allow_with_approval: allow_with_approval).matches_exactly(normalized_str).or(matches_partially(normalized_str)).any?
end
def to_log_human_identifier
username
end
private
def set_normalized_username
self.normalized_username = normalize(username)
end
def normalize(str)
str.downcase.gsub(Regexp.union(HOMOGLYPHS.keys), HOMOGLYPHS)
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class UsernameBlockPolicy < ApplicationPolicy
def index?
role.can?(:manage_blocks)
end
def create?
role.can?(:manage_blocks)
end
def update?
role.can?(:manage_blocks)
end
def destroy?
role.can?(:manage_blocks)
end
end

View File

@ -28,14 +28,6 @@ class UnreservedUsernameValidator < ActiveModel::Validator
end
def settings_username_reserved?
settings_has_reserved_usernames? && settings_reserves_username?
end
def settings_has_reserved_usernames?
Setting.reserved_usernames.present?
end
def settings_reserves_username?
Setting.reserved_usernames.include?(@username.downcase)
UsernameBlock.matches?(@username, allow_with_approval: false)
end
end

View File

@ -0,0 +1,16 @@
.fields-group
= form.input :username,
wrapper: :with_block_label,
input_html: { autocomplete: 'new-password', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT }
.fields-group
= form.input :comparison,
as: :select,
wrapper: :with_block_label,
collection: %w(equals contains),
include_blank: false,
label_method: ->(type) { I18n.t(type, scope: 'admin.username_blocks.comparison') }
.fields-group
= form.input :allow_with_approval,
wrapper: :with_label

View File

@ -0,0 +1,12 @@
.batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :username_block_ids, { multiple: true, include_hidden: false }, username_block.id
.sr-only= username_block.username
.batch-table__row__content.pending-account
.pending-account__header
= t(username_block.exact? ? 'admin.username_blocks.matches_exactly_html' : 'admin.username_blocks.contains_html', string: content_tag(:samp, link_to(username_block.username, edit_admin_username_block_path(username_block))))
%br/
- if username_block.allow_with_approval?
= t('admin.email_domain_blocks.allow_registrations_with_approval')
- else
= t('admin.username_blocks.block_registrations')

View File

@ -0,0 +1,10 @@
- content_for :page_title do
= t('admin.username_blocks.edit.title')
= simple_form_for @username_block, url: admin_username_block_path(@username_block) do |form|
= render 'shared/error_messages', object: @username_block
= render form
.actions
= form.button :button, t('generic.save_changes'), type: :submit

View File

@ -0,0 +1,26 @@
- content_for :page_title do
= t('admin.username_blocks.title')
- content_for :heading_actions do
= link_to t('admin.username_blocks.add_new'), new_admin_username_block_path, class: 'button'
= form_with model: @form, url: batch_admin_username_blocks_path do |f|
= hidden_field_tag :page, params[:page] || 1
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
= f.button safe_join([material_symbol('close'), t('admin.username_blocks.delete')]),
class: 'table-action-link',
data: { confirm: t('admin.reports.are_you_sure') },
name: :delete,
type: :submit
.batch-table__body
- if @username_blocks.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'username_block', collection: @username_blocks, locals: { f: f }
= paginate @username_blocks

View File

@ -0,0 +1,10 @@
- content_for :page_title do
= t('admin.username_blocks.new.title')
= simple_form_for @username_block, url: admin_username_blocks_path do |form|
= render 'shared/error_messages', object: @username_block
= render form
.actions
= form.button :button, t('admin.username_blocks.new.create'), type: :submit

View File

@ -72,6 +72,8 @@ ignore_unused:
- 'preferences.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'edit_profile.other' # some locales are missing other keys, therefore leading i18n-tasks to detect `preferences` as plural and not finding use
- 'admin.terms_of_service.generate' # temporarily disabled
- 'admin.username_blocks.matches_exactly_html'
- 'admin.username_blocks.contains_html'
ignore_inconsistent_interpolations:
- '*.one'

View File

@ -190,6 +190,7 @@ en:
create_relay: Create Relay
create_unavailable_domain: Create Unavailable Domain
create_user_role: Create Role
create_username_block: Create Username Rule
demote_user: Demote User
destroy_announcement: Delete Announcement
destroy_canonical_email_block: Delete Email Block
@ -203,6 +204,7 @@ en:
destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain
destroy_user_role: Destroy Role
destroy_username_block: Delete Username Rule
disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji
disable_relay: Disable Relay
@ -237,6 +239,7 @@ en:
update_report: Update Report
update_status: Update Post
update_user_role: Update Role
update_username_block: Update Username Rule
actions:
approve_appeal_html: "%{name} approved moderation decision appeal from %{target}"
approve_user_html: "%{name} approved sign-up from %{target}"
@ -255,6 +258,7 @@ en:
create_relay_html: "%{name} created a relay %{target}"
create_unavailable_domain_html: "%{name} stopped delivery to domain %{target}"
create_user_role_html: "%{name} created %{target} role"
create_username_block_html: "%{name} added rule for usernames containing %{target}"
demote_user_html: "%{name} demoted user %{target}"
destroy_announcement_html: "%{name} deleted announcement %{target}"
destroy_canonical_email_block_html: "%{name} unblocked email with the hash %{target}"
@ -268,6 +272,7 @@ en:
destroy_status_html: "%{name} removed post by %{target}"
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
destroy_user_role_html: "%{name} deleted %{target} role"
destroy_username_block_html: "%{name} removed rule for usernames containing %{target}"
disable_2fa_user_html: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji_html: "%{name} disabled emoji %{target}"
disable_relay_html: "%{name} disabled the relay %{target}"
@ -302,6 +307,7 @@ en:
update_report_html: "%{name} updated report %{target}"
update_status_html: "%{name} updated post by %{target}"
update_user_role_html: "%{name} changed %{target} role"
update_username_block_html: "%{name} updated rule for usernames containing %{target}"
deleted_account: deleted account
empty: No logs found.
filter_by_action: Filter by action
@ -1085,6 +1091,25 @@ en:
other: Used by %{count} people over the last week
title: Recommendations & Trends
trending: Trending
username_blocks:
add_new: Add new
block_registrations: Block registrations
comparison:
contains: Contains
equals: Equals
contains_html: Contains %{string}
created_msg: Successfully created username rule
delete: Delete
edit:
title: Edit username rule
matches_exactly_html: Equals %{string}
new:
create: Create rule
title: Create new username rule
no_username_block_selected: No username rules were changed as none were selected
not_permitted: Not permitted
title: Username rules
updated_msg: Successfully updated username rule
warning_presets:
add_new: Add new
delete: Delete

View File

@ -160,6 +160,10 @@ en:
name: Public name of the role, if role is set to be displayed as a badge
permissions_as_keys: Users with this role will have access to...
position: Higher role decides conflict resolution in certain situations. Certain actions can only be performed on roles with a lower priority
username_block:
allow_with_approval: Instead of preventing sign-up outright, matching sign-ups will require your approval
comparison: Please be mindful of the Scunthorpe Problem when blocking partial matches
username: Will be matched regardless of casing and common homoglyphs like "4" for "a" or "3" for "e"
webhook:
events: Select events to send
template: Compose your own JSON payload using variable interpolation. Leave blank for default JSON.
@ -371,6 +375,10 @@ en:
name: Name
permissions_as_keys: Permissions
position: Priority
username_block:
allow_with_approval: Allow registrations with approval
comparison: Method of comparison
username: Word to match
webhook:
events: Enabled events
template: Payload template

View File

@ -59,6 +59,7 @@ SimpleNavigation::Configuration.run do |navigation|
current_user.can?(:manage_federation)
}
s.item :email_domain_blocks, safe_join([material_symbol('mail'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_path, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :username_blocks, safe_join([material_symbol('supervised_user_circle_off'), t('admin.username_blocks.title')]), admin_username_blocks_path, highlights_on: %r{/admin/username_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :ip_blocks, safe_join([material_symbol('hide_source'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) }
s.item :action_logs, safe_join([material_symbol('list'), t('admin.action_logs.title')]), admin_action_logs_path, if: -> { current_user.can?(:view_audit_log) }
end

View File

@ -230,4 +230,10 @@ namespace :admin do
end
resources :software_updates, only: [:index]
resources :username_blocks, except: [:show, :destroy] do
collection do
post :batch
end
end
end

View File

@ -20,28 +20,6 @@ defaults: &defaults
trends: true
trends_as_landing_page: true
trendable_by_default: false
reserved_usernames:
- abuse
- account
- accounts
- admin
- administration
- administrator
- admins
- help
- helpdesk
- instance
- mod
- moderator
- moderators
- mods
- owner
- root
- security
- server
- staff
- support
- webmaster
disallowed_hashtags: # space separated string or list of hashtags without the hash
bootstrap_timeline_accounts: ''
activity_api_enabled: true

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class CreateUsernameBlocks < ActiveRecord::Migration[8.0]
def change
create_table :username_blocks do |t|
t.string :username, null: false
t.string :normalized_username, null: false
t.boolean :exact, null: false, default: false
t.boolean :allow_with_approval, null: false, default: false
t.timestamps
end
add_index :username_blocks, 'lower(username)', unique: true, name: 'index_username_blocks_on_username_lower_btree'
add_index :username_blocks, :normalized_username
reversible do |dir|
dir.up do
load Rails.root.join('db', 'seeds', '05_blocked_usernames.rb')
end
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
ActiveRecord::Schema[8.0].define(version: 2025_07_17_003848) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -1238,6 +1238,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.datetime "updated_at", null: false
end
create_table "username_blocks", force: :cascade do |t|
t.string "username", null: false
t.string "normalized_username", null: false
t.boolean "exact", default: false, null: false
t.boolean "allow_with_approval", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index "lower((username)::text)", name: "index_username_blocks_on_username_lower_btree", unique: true
t.index ["normalized_username"], name: "index_username_blocks_on_normalized_username"
end
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.datetime "created_at", precision: nil, null: false

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
%w(
abuse
account
accounts
admin
administration
administrator
admins
help
helpdesk
instance
mod
moderator
moderators
mods
owner
root
security
server
staff
support
webmaster
).each do |str|
UsernameBlock.create_with(username: str, exact: true).find_or_create_by(username: str)
end
%w(
mastodon
mastadon
).each do |str|
UsernameBlock.create_with(username: str, exact: false).find_or_create_by(username: str)
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:username_block) do
username { sequence(:email) { |i| "#{i}#{Faker::Internet.username}" } }
exact false
allow_with_approval false
end

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe UsernameBlock do
describe '.matches?' do
context 'when there is an exact block' do
before do
Fabricate(:username_block, username: 'carriage', exact: true)
end
it 'returns true on exact match' do
expect(described_class.matches?('carriage')).to be true
end
it 'returns true on case insensitive match' do
expect(described_class.matches?('CaRRiagE')).to be true
end
it 'returns true on homoglyph match' do
expect(described_class.matches?('c4rr14g3')).to be true
end
it 'returns false on partial match' do
expect(described_class.matches?('foo_carriage')).to be false
end
it 'returns false on no match' do
expect(described_class.matches?('foo')).to be false
end
end
context 'when there is a partial block' do
before do
Fabricate(:username_block, username: 'carriage', exact: false)
end
it 'returns true on exact match' do
expect(described_class.matches?('carriage')).to be true
end
it 'returns true on case insensitive match' do
expect(described_class.matches?('CaRRiagE')).to be true
end
it 'returns true on homoglyph match' do
expect(described_class.matches?('c4rr14g3')).to be true
end
it 'returns true on suffix match' do
expect(described_class.matches?('foo_carriage')).to be true
end
it 'returns true on prefix match' do
expect(described_class.matches?('carriage_foo')).to be true
end
it 'returns false on no match' do
expect(described_class.matches?('foo')).to be false
end
end
end
end

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin Username Blocks' do
describe 'GET /admin/username_blocks' do
before { sign_in Fabricate(:admin_user) }
it 'returns http success' do
get admin_username_blocks_path
expect(response)
.to have_http_status(200)
end
end
describe 'POST /admin/username_blocks' do
before { sign_in Fabricate(:admin_user) }
it 'gracefully handles invalid nested params' do
post admin_username_blocks_path(username_block: 'invalid')
expect(response)
.to have_http_status(400)
end
it 'creates a username block' do
post admin_username_blocks_path(username_block: { username: 'banana', comparison: 'contains', allow_with_approval: '0' })
expect(response)
.to redirect_to(admin_username_blocks_path)
expect(UsernameBlock.find_by(username: 'banana'))
.to_not be_nil
end
end
describe 'POST /admin/username_blocks/batch' do
before { sign_in Fabricate(:admin_user) }
let(:username_blocks) { Fabricate.times(2, :username_block) }
it 'gracefully handles invalid nested params' do
post batch_admin_username_blocks_path(form_username_block_batch: 'invalid')
expect(response)
.to redirect_to(admin_username_blocks_path)
end
it 'deletes selected username blocks' do
post batch_admin_username_blocks_path(form_username_block_batch: { username_block_ids: username_blocks.map(&:id) }, delete: '1')
expect(response)
.to redirect_to(admin_username_blocks_path)
expect(UsernameBlock.where(id: username_blocks.map(&:id)))
.to be_empty
end
end
describe 'GET /admin/username_blocks/new' do
before { sign_in Fabricate(:admin_user) }
it 'returns http success' do
get new_admin_username_block_path
expect(response)
.to have_http_status(200)
end
end
describe 'GET /admin/username_blocks/:id/edit' do
before { sign_in Fabricate(:admin_user) }
let(:username_block) { Fabricate(:username_block) }
it 'returns http success' do
get edit_admin_username_block_path(username_block)
expect(response)
.to have_http_status(200)
end
end
describe 'PUT /admin/username_blocks/:id' do
before { sign_in Fabricate(:admin_user) }
let(:username_block) { Fabricate(:username_block, username: 'banana') }
it 'updates username block' do
put admin_username_block_path(username_block, username_block: { username: 'bebebe' })
expect(response)
.to redirect_to(admin_username_blocks_path)
expect(username_block.reload.username)
.to eq 'bebebe'
end
end
end

View File

@ -10,8 +10,13 @@ RSpec.describe UnreservedUsernameValidator do
attr_accessor :username
validates_with UnreservedUsernameValidator
def self.name
'Foo'
end
end
end
let(:record) { record_class.new }
describe '#validate' do
@ -114,7 +119,7 @@ RSpec.describe UnreservedUsernameValidator do
end
def stub_reserved_usernames(value)
allow(Setting).to receive(:[]).with('reserved_usernames').and_return(value)
value&.each { |str| Fabricate(:username_block, username: str, exact: true) }
end
end
end