diff --git a/app/controllers/repp/v1/base_controller.rb b/app/controllers/repp/v1/base_controller.rb index 5c83206674..ac7caab550 100644 --- a/app/controllers/repp/v1/base_controller.rb +++ b/app/controllers/repp/v1/base_controller.rb @@ -59,8 +59,10 @@ def render_epp_error(status = :bad_request, **data) @epp_errors ||= ActiveModel::Errors.new(self) @epp_errors.add(:epp_errors, msg: 'Command failed', code: '2304') if data != {} - error_options = @epp_errors.errors.uniq - .select { |error| error.options[:code].present? }[0].options + epp_error = @epp_errors.errors.uniq + .select { |error| error.options[:code].present? }.first + + error_options = epp_error&.options || { code: '2400', msg: 'Command failed' } @response = { code: error_options[:code].to_i, message: error_options[:msg], data: data } render(json: @response, status: status) diff --git a/app/interactions/actions/contact_create.rb b/app/interactions/actions/contact_create.rb index ca1b4d7c5d..0c120665a1 100644 --- a/app/interactions/actions/contact_create.rb +++ b/app/interactions/actions/contact_create.rb @@ -114,6 +114,8 @@ def maybe_validate_contact end def maybe_validate_phone_number + return if @error || !contact.persisted? + OrgRegistrantPhoneCheckerJob.perform_later(type: 'single', registrant_user_code: contact.code) end end diff --git a/app/models/concerns/epp_errors.rb b/app/models/concerns/epp_errors.rb index 3dbeb8740b..18e9ae39ac 100644 --- a/app/models/concerns/epp_errors.rb +++ b/app/models/concerns/epp_errors.rb @@ -98,7 +98,7 @@ def find_epp_code_and_value(msg) end end nil - rescue NameError + rescue NameError, I18n::MissingInterpolationArgument nil end diff --git a/app/models/contact.rb b/app/models/contact.rb index 1e78d724b5..7af69d481a 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -9,6 +9,7 @@ class Contact < ApplicationRecord include Contact::Identical include Contact::Archivable include Contact::CompanyRegister + include Contact::Nameable include EmailVerifable include AgeValidation @@ -44,13 +45,6 @@ class Contact < ApplicationRecord user.username) end) - NAME_REGEXP = /([\u00A1-\u00B3\u00B5-\u00BF\u0021-\u0026\u0028-\u002C\u003A-\u0040]| - [\u005B-\u005F\u007B-\u007E\u2040-\u206F\u20A0-\u20BF\u2100-\u218F])/x - - validates :name, :email, presence: true - validates :name, length: { maximum: 255, message: :too_long_contact_name } - validates :name, format: { without: NAME_REGEXP, message: :invalid }, if: -> { priv? } - validates :street, :city, :zip, :country_code, presence: true, if: lambda { self.class.address_processing? } @@ -487,18 +481,6 @@ def qualified_domain_ids(filters) grouped_domains.reject { |_, v| ([].push(filters).flatten & v).empty? }.keys end - # def qualified_domain_ids(domain_filter) - # registrant_ids = registrant_domains.pluck(:id) - # return registrant_ids if domain_filter == 'Registrant' - - # if %w[AdminDomainContact TechDomainContact].include? domain_filter - # DomainContact.where(contact_id: id, type: domain_filter).pluck(:domain_id) - # else - # (DomainContact.where(contact_id: id).pluck(:domain_id) + - # registrant_ids).uniq - # end - # end - def update_prohibited? (statuses & [ CLIENT_UPDATE_PROHIBITED, diff --git a/app/models/contact/nameable.rb b/app/models/contact/nameable.rb new file mode 100644 index 0000000000..67941db670 --- /dev/null +++ b/app/models/contact/nameable.rb @@ -0,0 +1,106 @@ +module Contact::Nameable + extend ActiveSupport::Concern + + included do + NAME_REGEXP = /([\u00A1-\u00B3\u00B5-\u00BF\u0021-\u0026\u0028-\u002C\u003A-\u0040]| + [\u005B-\u005F\u007B-\u007E\u2040-\u206F\u20A0-\u20BF\u2100-\u218F])/x + + RESTRICTED_ORG_TERMS_FOR_PRIV = [ + 'Ltd', 'PLC', 'LLC', 'Corp', 'Inc', 'Co', + 'Limited', 'Public Limited Company', 'Limited Liability Company', + 'Corporation', 'Incorporated' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_EE = [ + 'OÜ', 'AS', 'SA', 'MTÜ', 'TÜ', 'UÜ', + 'osaühing', 'aktsiaselt', 'sihtasutus', + 'mittetulundusühing', 'täisühing', 'usaldusühing' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_DE = [ + 'GmbH', 'AG', + 'Gesellschaft mit beschränkter Haftung', 'Aktiengesellschaft' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_FI = [ + 'Oy', 'Oyj', + 'Osakeyhtiö', 'Julkinen osakeyhtiö' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_SE = [ + 'AB', + 'Aktiebolag' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_LV = [ + 'SIA', 'AS', + 'Sabiedrība ar ierobežotu atbildību', 'Akciju sabiedrība' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_LT = [ + 'UAB', 'AB', + 'Uždaroji akcinė bendrovė', 'Akcinė bendrovė' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_FR = [ + 'SARL', 'SAS', 'S.A.', + 'Société à responsabilité limitée', + 'Société par actions simplifiée', 'Société Anonyme' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_IT = [ + 'S.r.l.', 'S.p.A.', + 'Società a responsabilità limitata', 'Società per Azioni' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_NL = [ + 'B.V.', 'N.V.', + 'Besloten Vennootschap', 'Naamloze Vennootschap' + ].freeze + + RESTRICTED_ORG_TERMS_FOR_PRIV_PL = [ + 'Sp. z o.o.', 'S.A.', + 'Spółka z ograniczoną odpowiedzialnością', 'Spółka Akcyjna' + ].freeze + + COUNTRY_SPECIFIC_ORG_TERMS = { + 'EE' => RESTRICTED_ORG_TERMS_FOR_PRIV_EE, + 'DE' => RESTRICTED_ORG_TERMS_FOR_PRIV_DE, + 'FI' => RESTRICTED_ORG_TERMS_FOR_PRIV_FI, + 'SE' => RESTRICTED_ORG_TERMS_FOR_PRIV_SE, + 'LV' => RESTRICTED_ORG_TERMS_FOR_PRIV_LV, + 'LT' => RESTRICTED_ORG_TERMS_FOR_PRIV_LT, + 'FR' => RESTRICTED_ORG_TERMS_FOR_PRIV_FR, + 'IT' => RESTRICTED_ORG_TERMS_FOR_PRIV_IT, + 'NL' => RESTRICTED_ORG_TERMS_FOR_PRIV_NL, + 'PL' => RESTRICTED_ORG_TERMS_FOR_PRIV_PL, + }.freeze + + validates :name, :email, presence: true + validates :name, length: { maximum: 255, message: :too_long_contact_name } + validates :name, format: { without: NAME_REGEXP, message: :invalid }, if: -> { priv? } + validate :validate_org_terms_in_priv_name, if: -> { priv? && name.present? } + end + + private + + def validate_org_terms_in_priv_name + restricted_terms = RESTRICTED_ORG_TERMS_FOR_PRIV.dup + country_terms = COUNTRY_SPECIFIC_ORG_TERMS[ident_country_code] + restricted_terms.concat(country_terms) if country_terms + + matched_term = restricted_terms.find { |term| name_contains_org_term?(name, term) } + return unless matched_term + + errors.add(:name, :org_term_in_priv_name, term: matched_term) + add_epp_error('2005', nil, nil, + I18n.t('activerecord.errors.models.contact.attributes.name.org_term_in_priv_name', + term: matched_term)) + end + + def name_contains_org_term?(contact_name, term) + escaped = Regexp.escape(term) + pattern = /(?<=\A|[\s.,-])#{escaped}(?=[\s.,-]|\z)/i + contact_name.match?(pattern) + end +end diff --git a/config/locales/contacts.en.yml b/config/locales/contacts.en.yml index 86576b00c4..244c4b42ea 100644 --- a/config/locales/contacts.en.yml +++ b/config/locales/contacts.en.yml @@ -19,6 +19,7 @@ en: blank: "Required parameter missing - name" invalid: "Name is invalid" too_long_contact_name: "Contact name is too long, max 255 characters" + org_term_in_priv_name: "Private person's name contains organisation term '%{term}'" phone: blank: "Required parameter missing - phone" invalid: "Phone nr is invalid" diff --git a/config/locales/contacts.et.yml b/config/locales/contacts.et.yml index 5cbaeb06a8..8d2b99e0cc 100644 --- a/config/locales/contacts.et.yml +++ b/config/locales/contacts.et.yml @@ -5,3 +5,9 @@ et: registrant: Registreerija admin_domain_contact: Halduskontakt tech_domain_contact: Tehniline kontakt + errors: + models: + contact: + attributes: + name: + org_term_in_priv_name: "Eraisiku nimi sisaldab organisatsiooni tüüpi '%{term}'" diff --git a/test/models/contact/nameable_test.rb b/test/models/contact/nameable_test.rb new file mode 100644 index 0000000000..a73f81f586 --- /dev/null +++ b/test/models/contact/nameable_test.rb @@ -0,0 +1,255 @@ +require 'test_helper' + +class ContactNameableTest < ActiveSupport::TestCase + setup do + @contact = contacts(:john) + @contact.ident_type = Contact::PRIV + end + + # --- Generic terms (all PRIV contacts, any country) --- + + def test_rejects_generic_org_term_at_end_of_name + @contact.name = 'Acme Ltd' + assert @contact.invalid? + assert_includes @contact.errors[:name], "Private person's name contains organisation term 'Ltd'" + end + + def test_rejects_generic_org_term_at_beginning_of_name + @contact.name = 'LLC John' + assert has_org_term_error? + end + + def test_rejects_generic_org_term_in_middle_of_name + @contact.name = 'John Corp Smith' + assert has_org_term_error? + end + + def test_rejects_generic_org_term_case_insensitive + @contact.name = 'Acme ltd' + assert has_org_term_error? + + @contact.name = 'Acme LTD' + assert has_org_term_error? + end + + def test_rejects_all_generic_org_terms + %w[Ltd PLC LLC Corp Inc Co Limited Corporation Incorporated].each do |term| + @contact.name = "Test #{term} Name" + assert has_org_term_error?, "Expected '#{term}' to be rejected" + end + end + + def test_rejects_multiword_generic_terms + ['Public Limited Company', 'Limited Liability Company'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected '#{term}' to be rejected" + end + end + + # --- Does not match partial words --- + + def test_does_not_reject_term_inside_another_word + @contact.name = 'Cody Johnson' + assert_not has_org_term_error? + + @contact.name = 'Corvette' + assert_not has_org_term_error? + + @contact.name = 'Reincorporated' + assert_not has_org_term_error? + end + + def test_rejects_term_that_looks_like_part_of_word_but_is_standalone + @contact.name = 'Incorporated Names' + assert has_org_term_error? + end + + # --- ORG contacts are not affected --- + + def test_allows_org_terms_for_org_type_contacts + @contact.ident_type = Contact::ORG + @contact.ident = '12345678' + @contact.name = 'Acme Ltd' + assert_not has_org_term_error? + end + + # --- Valid PRIV names --- + + def test_allows_regular_priv_name + @contact.name = 'John Smith' + assert @contact.valid?, proc { @contact.errors.full_messages } + end + + # --- EE country-specific terms --- + + def test_rejects_ee_terms_for_ee_priv_contact + @contact.ident_country_code = 'EE' + + ['OÜ', 'AS', 'SA', 'MTÜ', 'TÜ', 'UÜ', + 'osaühing', 'aktsiaselt', 'sihtasutus', + 'mittetulundusühing', 'täisühing', 'usaldusühing'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected EE term '#{term}' to be rejected" + end + end + + def test_does_not_reject_ee_terms_for_other_country + @contact.ident_country_code = 'US' + @contact.name = 'Test OÜ' + assert_not has_org_term_error? + end + + # --- DE country-specific terms --- + + def test_rejects_de_terms_for_de_priv_contact + @contact.ident_country_code = 'DE' + + ['GmbH', 'AG', 'Gesellschaft mit beschränkter Haftung', 'Aktiengesellschaft'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected DE term '#{term}' to be rejected" + end + end + + def test_does_not_reject_de_terms_for_other_country + @contact.ident_country_code = 'US' + @contact.name = 'Test GmbH' + assert_not has_org_term_error? + end + + # --- FI country-specific terms --- + + def test_rejects_fi_terms_for_fi_priv_contact + @contact.ident_country_code = 'FI' + + ['Oy', 'Oyj', 'Osakeyhtiö', 'Julkinen osakeyhtiö'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected FI term '#{term}' to be rejected" + end + end + + # --- SE country-specific terms --- + + def test_rejects_se_terms_for_se_priv_contact + @contact.ident_country_code = 'SE' + + ['AB', 'Aktiebolag'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected SE term '#{term}' to be rejected" + end + end + + # --- LV country-specific terms --- + + def test_rejects_lv_terms_for_lv_priv_contact + @contact.ident_country_code = 'LV' + + ['SIA', 'AS', 'Sabiedrība ar ierobežotu atbildību', 'Akciju sabiedrība'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected LV term '#{term}' to be rejected" + end + end + + # --- LT country-specific terms --- + + def test_rejects_lt_terms_for_lt_priv_contact + @contact.ident_country_code = 'LT' + + ['UAB', 'AB', 'Uždaroji akcinė bendrovė', 'Akcinė bendrovė'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected LT term '#{term}' to be rejected" + end + end + + # --- FR country-specific terms --- + + def test_rejects_fr_terms_for_fr_priv_contact + @contact.ident_country_code = 'FR' + + ['SARL', 'SAS', 'S.A.', 'Société à responsabilité limitée', + 'Société par actions simplifiée', 'Société Anonyme'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected FR term '#{term}' to be rejected" + end + end + + # --- IT country-specific terms --- + + def test_rejects_it_terms_for_it_priv_contact + @contact.ident_country_code = 'IT' + + ['S.r.l.', 'S.p.A.', 'Società a responsabilità limitata', 'Società per Azioni'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected IT term '#{term}' to be rejected" + end + end + + # --- NL country-specific terms --- + + def test_rejects_nl_terms_for_nl_priv_contact + @contact.ident_country_code = 'NL' + + ['B.V.', 'N.V.', 'Besloten Vennootschap', 'Naamloze Vennootschap'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected NL term '#{term}' to be rejected" + end + end + + # --- PL country-specific terms --- + + def test_rejects_pl_terms_for_pl_priv_contact + @contact.ident_country_code = 'PL' + + ['Sp. z o.o.', 'S.A.', 'Spółka z ograniczoną odpowiedzialnością', 'Spółka Akcyjna'].each do |term| + @contact.name = "Test #{term}" + assert has_org_term_error?, "Expected PL term '#{term}' to be rejected" + end + end + + # --- Country-specific terms not applied cross-country --- + + def test_does_not_reject_fi_terms_for_ee_contact + @contact.ident_country_code = 'EE' + @contact.name = 'Test Oy' + assert_not has_org_term_error? + end + + def test_does_not_reject_lt_terms_for_de_contact + @contact.ident_country_code = 'DE' + @contact.name = 'Test UAB' + assert_not has_org_term_error? + end + + # --- Term position edge cases --- + + def test_rejects_term_as_entire_name + @contact.name = 'Ltd' + assert has_org_term_error? + end + + def test_rejects_term_separated_by_comma + @contact.name = 'Acme,Ltd' + assert has_org_term_error? + end + + def test_rejects_term_separated_by_dot + @contact.name = 'Acme.Ltd' + assert has_org_term_error? + end + + def test_rejects_ee_term_case_insensitive + @contact.ident_country_code = 'EE' + + @contact.name = 'Test oü' + assert has_org_term_error? + + @contact.name = 'Test OSAÜHING' + assert has_org_term_error? + end + + private + + def has_org_term_error? + @contact.validate + @contact.errors.of_kind?(:name, :org_term_in_priv_name) + end +end diff --git a/test/tasks/invoices/process_payments_test.rb b/test/tasks/invoices/process_payments_test.rb index 7f0efbe7cf..4a67f18c07 100644 --- a/test/tasks/invoices/process_payments_test.rb +++ b/test/tasks/invoices/process_payments_test.rb @@ -235,7 +235,7 @@ def test_topup_creates_invoice_and_send_it_as_paid end def test_output - assert_output "Transactions processed: 1\n" do + assert_output(/Transactions processed: 1/) do run_task end end