diff --git a/app/controllers/admin/username_blocks_controller.rb b/app/controllers/admin/username_blocks_controller.rb new file mode 100644 index 00000000000..22ac9408178 --- /dev/null +++ b/app/controllers/admin/username_blocks_controller.rb @@ -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 diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 859f9246876..4a55a36ecd1 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -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' diff --git a/app/javascript/material-icons/400-24px/supervised_user_circle_off-fill.svg b/app/javascript/material-icons/400-24px/supervised_user_circle_off-fill.svg new file mode 100644 index 00000000000..1daf50f858a --- /dev/null +++ b/app/javascript/material-icons/400-24px/supervised_user_circle_off-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/supervised_user_circle_off.svg b/app/javascript/material-icons/400-24px/supervised_user_circle_off.svg new file mode 100644 index 00000000000..060c515ae18 --- /dev/null +++ b/app/javascript/material-icons/400-24px/supervised_user_circle_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb index fd6b4289cee..a5bdd97420f 100644 --- a/app/models/admin/action_log_filter.rb +++ b/app/models/admin/action_log_filter.rb @@ -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 diff --git a/app/models/form/username_block_batch.rb b/app/models/form/username_block_batch.rb new file mode 100644 index 00000000000..f490391159a --- /dev/null +++ b/app/models/form/username_block_batch.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 2ba8c2926d9..0c876c64bf8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/username_block.rb b/app/models/username_block.rb new file mode 100644 index 00000000000..227def66e14 --- /dev/null +++ b/app/models/username_block.rb @@ -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 diff --git a/app/policies/username_block_policy.rb b/app/policies/username_block_policy.rb new file mode 100644 index 00000000000..9f6b8cfc018 --- /dev/null +++ b/app/policies/username_block_policy.rb @@ -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 diff --git a/app/validators/unreserved_username_validator.rb b/app/validators/unreserved_username_validator.rb index 55a8c835fae..f20f4a7494b 100644 --- a/app/validators/unreserved_username_validator.rb +++ b/app/validators/unreserved_username_validator.rb @@ -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 diff --git a/app/views/admin/username_blocks/_form.html.haml b/app/views/admin/username_blocks/_form.html.haml new file mode 100644 index 00000000000..bfbbb18e2f9 --- /dev/null +++ b/app/views/admin/username_blocks/_form.html.haml @@ -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 diff --git a/app/views/admin/username_blocks/_username_block.html.haml b/app/views/admin/username_blocks/_username_block.html.haml new file mode 100644 index 00000000000..617ec65bc6d --- /dev/null +++ b/app/views/admin/username_blocks/_username_block.html.haml @@ -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') diff --git a/app/views/admin/username_blocks/edit.html.haml b/app/views/admin/username_blocks/edit.html.haml new file mode 100644 index 00000000000..eee0fedef07 --- /dev/null +++ b/app/views/admin/username_blocks/edit.html.haml @@ -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 diff --git a/app/views/admin/username_blocks/index.html.haml b/app/views/admin/username_blocks/index.html.haml new file mode 100644 index 00000000000..697edfda51a --- /dev/null +++ b/app/views/admin/username_blocks/index.html.haml @@ -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 diff --git a/app/views/admin/username_blocks/new.html.haml b/app/views/admin/username_blocks/new.html.haml new file mode 100644 index 00000000000..0f5bd27952b --- /dev/null +++ b/app/views/admin/username_blocks/new.html.haml @@ -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 diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 0dbc0873855..b934696bda6 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -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' diff --git a/config/locales/en.yml b/config/locales/en.yml index 204340f504c..a149c18c775 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 1b410a802d5..86fb4528de4 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -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 diff --git a/config/navigation.rb b/config/navigation.rb index d60f8cbc5b3..a8f686fd8b1 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -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 diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 3d9f24ae838..97f84da44e7 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -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 diff --git a/config/settings.yml b/config/settings.yml index ba81fcb8c6a..7d2f0a00c07 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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 diff --git a/db/migrate/20250717003848_create_username_blocks.rb b/db/migrate/20250717003848_create_username_blocks.rb new file mode 100644 index 00000000000..01649d06574 --- /dev/null +++ b/db/migrate/20250717003848_create_username_blocks.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 0237b476445..272d6fac182 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/db/seeds/05_blocked_usernames.rb b/db/seeds/05_blocked_usernames.rb new file mode 100644 index 00000000000..8bfe536c898 --- /dev/null +++ b/db/seeds/05_blocked_usernames.rb @@ -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 diff --git a/spec/fabricators/username_block_fabricator.rb b/spec/fabricators/username_block_fabricator.rb new file mode 100644 index 00000000000..edca5419aba --- /dev/null +++ b/spec/fabricators/username_block_fabricator.rb @@ -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 diff --git a/spec/models/username_block_spec.rb b/spec/models/username_block_spec.rb new file mode 100644 index 00000000000..72dbe028bdf --- /dev/null +++ b/spec/models/username_block_spec.rb @@ -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 diff --git a/spec/requests/admin/username_blocks_spec.rb b/spec/requests/admin/username_blocks_spec.rb new file mode 100644 index 00000000000..6e17ca2d470 --- /dev/null +++ b/spec/requests/admin/username_blocks_spec.rb @@ -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 diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb index 67a2921885e..55dca7db844 100644 --- a/spec/validators/unreserved_username_validator_spec.rb +++ b/spec/validators/unreserved_username_validator_spec.rb @@ -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