diff --git a/app/forms/idv/in_person/address_form.rb b/app/forms/idv/in_person/address_form.rb index dd229925f64..a9bf9764bdf 100644 --- a/app/forms/idv/in_person/address_form.rb +++ b/app/forms/idv/in_person/address_form.rb @@ -15,9 +15,14 @@ def self.model_name def submit(params) consume_params(params) + cleaned_errors = errors.deep_dup + cleaned_errors.delete(:city, :nontransliterable_field) + cleaned_errors.delete(:address1, :nontransliterable_field) + cleaned_errors.delete(:address2, :nontransliterable_field) + FormResponse.new( success: valid?, - errors: errors, + errors: cleaned_errors, ) end diff --git a/app/forms/idv/state_id_form.rb b/app/forms/idv/state_id_form.rb index 5b2a1a1b7c3..1ad31ec5d4a 100644 --- a/app/forms/idv/state_id_form.rb +++ b/app/forms/idv/state_id_form.rb @@ -18,9 +18,13 @@ def initialize(pii) def submit(params) consume_params(params) + cleaned_errors = errors.deep_dup + cleaned_errors.delete(:first_name, :nontransliterable_field) + cleaned_errors.delete(:last_name, :nontransliterable_field) + FormResponse.new( success: valid?, - errors: errors, + errors: cleaned_errors, ) end diff --git a/app/services/usps_in_person_proofing/transliterable_validator.rb b/app/services/usps_in_person_proofing/transliterable_validator.rb new file mode 100644 index 00000000000..7ab49ca5f00 --- /dev/null +++ b/app/services/usps_in_person_proofing/transliterable_validator.rb @@ -0,0 +1,91 @@ +module UspsInPersonProofing + # Validator that can be attached to a form or other model + # to verify that specific supported fields are transliterable + # and conform to additional character requirements + # + # == Example + # + # validates_with UspsInPersonProofing::TransliterableValidator, + # fields: [:first_name, :last_name], + # reject_chars: /[^A-Za-z\-' ]/, + # message: ->(invalid_chars) + # "Rejected chars: #{invalid_chars.join(', ')}" + # end + # + class TransliterableValidator < ActiveModel::Validator + # Initialize the validator with the given fields configured + # for transliteration validation + # + # @param [Hash] options + # @option options [Array] fields Fields for which to validate transliterability + # @option options [Regexp] reject_chars Regex of chars to reject post-transliteration + # @option options [String,#call] message Error message or message generator + def initialize(options) + super + @fields = options[:fields] + @reject_chars = options[:reject_chars] + @message = options[:message] + end + + # Check if the configured values on the record are transliterable + # + # @param [ActiveModel::Validations] record + def validate(record) + return unless IdentityConfig.store.usps_ipp_transliteration_enabled + + @fields.each do |field| + next unless record.respond_to?(field) + + value = record.send(field) + next unless value.respond_to?(:to_s) + + invalid_chars = get_invalid_chars(value) + next unless invalid_chars.present? + + record.errors.add( + field, + :nontransliterable_field, + message: get_error_message(invalid_chars), + ) + end + end + + def transliterator + @transliterator ||= Transliterator.new + end + + private + + # Use unsupported character list to generate error message + def get_error_message(unsupported_chars) + return unless unsupported_chars.present? + if @message.respond_to?(:call) + @message.call(unsupported_chars) + else + @message + end + end + + def get_invalid_chars(value) + # Get transliterated value + result = transliterator.transliterate(value) + transliterated = result.transliterated + + # Remove question marks corresponding with unsupported chars + # for transliteration + unless transliterated.count(Transliterator::REPLACEMENT) > result.unsupported_chars.length + transliterated = transliterated.gsub(Transliterator::REPLACEMENT, '') + end + + # Scan for unsupported chars for the field + if @reject_chars.is_a?(Regexp) + additional_chars = transliterated.scan(@reject_chars) + else + additional_chars = [] + end + + # Create sorted list of unique unsupported characters + (result.unsupported_chars + additional_chars).sort.uniq + end + end +end diff --git a/app/services/usps_in_person_proofing/transliterator.rb b/app/services/usps_in_person_proofing/transliterator.rb index dfe7315758e..7f77cbcd23a 100644 --- a/app/services/usps_in_person_proofing/transliterator.rb +++ b/app/services/usps_in_person_proofing/transliterator.rb @@ -11,7 +11,8 @@ class Transliterator :original, # Transliterated value :transliterated, - # Characters from the original that could not be transliterated + # Characters from the original that could not be transliterated, + # in the same order and quantity as in the original string :unsupported_chars, keyword_init: true, ) diff --git a/app/validators/idv/form_state_id_validator.rb b/app/validators/idv/form_state_id_validator.rb index 47152083c51..97a7193d3ac 100644 --- a/app/validators/idv/form_state_id_validator.rb +++ b/app/validators/idv/form_state_id_validator.rb @@ -9,6 +9,16 @@ module FormStateIdValidator :state_id_jurisdiction, :state_id_number, presence: true + + validates_with UspsInPersonProofing::TransliterableValidator, + fields: [:first_name, :last_name], + reject_chars: /[^A-Za-z\-' ]/, + message: ->(invalid_chars) do + I18n.t( + 'in_person_proofing.form.state_id.errors.unsupported_chars', + char_list: invalid_chars.join(', '), + ) + end end end end diff --git a/app/validators/idv/in_person/form_address_validator.rb b/app/validators/idv/in_person/form_address_validator.rb index 176601d605f..fe302148872 100644 --- a/app/validators/idv/in_person/form_address_validator.rb +++ b/app/validators/idv/in_person/form_address_validator.rb @@ -7,6 +7,26 @@ module FormAddressValidator included do validates :same_address_as_id, presence: true + + validates_with UspsInPersonProofing::TransliterableValidator, + fields: [:city], + reject_chars: /[^A-Za-z\-' ]/, + message: ->(invalid_chars) do + I18n.t( + 'in_person_proofing.form.address.errors.unsupported_chars', + char_list: invalid_chars.join(', '), + ) + end + + validates_with UspsInPersonProofing::TransliterableValidator, + fields: [:address1, :address2], + reject_chars: /[^A-Za-z0-9\-' .\/#]/, + message: ->(invalid_chars) do + I18n.t( + 'in_person_proofing.form.address.errors.unsupported_chars', + char_list: invalid_chars.join(', '), + ) + end end end end diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index 9342ce86453..e4dfc6aafa7 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -89,6 +89,10 @@ en: ID in person. form: address: + errors: + unsupported_chars: 'Our system cannot read the following characters: + %{char_list}. Please try again using substitutes for those + characters.' same_address: Is this address displayed on the state-issued ID that you are bringing to the Post Office? same_address_choice_no: I have a different address on my ID @@ -101,6 +105,9 @@ en: year: 'Example: 1986' dob: Date of birth dob_hint: 'Example: 4 28 1986' + errors: + unsupported_chars: 'Our system cannot read the following characters: + %{char_list}. Please try again using the characters on your ID.' first_name: First name last_name: Last name memorable_date: diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index 21f038496cb..b1f95d04ff4 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -101,6 +101,10 @@ es: información para confirmar que coincide con su cédula en persona. form: address: + errors: + unsupported_chars: 'Los siguientes caracteres no pueden ser leídos por nuestro + sistema: %{char_list}. Inténtelo nuevamente usando sustitutos para + estos caracteres.' same_address: '¿Aparece esta dirección en la cédula de identidad emitido por el Estado que va a llevar a la oficina de Correos?' same_address_choice_no: Tengo una dirección diferente en mi cédula de identidad @@ -113,6 +117,10 @@ es: year: 'Ejemplo: 1986' dob: Fecha de nacimiento dob_hint: 'Ejemplo: 4 28 1986' + errors: + unsupported_chars: 'Los siguientes caracteres no pueden ser leídos por nuestro + sistema: %{char_list}. Inténtelo nuevamente usando los caracteres de + su documento de identidad.' first_name: Nombre last_name: Apellido memorable_date: diff --git a/config/locales/in_person_proofing/fr.yml b/config/locales/in_person_proofing/fr.yml index 4a6b33fc8a3..acb120b9e73 100644 --- a/config/locales/in_person_proofing/fr.yml +++ b/config/locales/in_person_proofing/fr.yml @@ -103,6 +103,10 @@ fr: d’identité en personne. form: address: + errors: + unsupported_chars: 'Notre système ne parvient pas à lire les caractères suivants + : %{char_list}. Veuillez réessayer en utilisant d’autres caractères + alternatifs.' same_address: Cette adresse figure-t-elle sur la pièce d’identité délivrée par l’État que vous apportez au bureau de poste? same_address_choice_no: J’ai une adresse différente sur mon document d’identité @@ -115,6 +119,10 @@ fr: year: 'Exemple: 1986' dob: Date de naissance dob_hint: 'Exemple: 4 28 1986' + errors: + unsupported_chars: 'Notre système ne parvient pas à lire les caractères suivants + : %{char_list}. Veuillez réessayer en utilisant les caractères de + votre carte d’identité.' first_name: Prénom last_name: Nom de famille memorable_date: diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 021ab7ec7de..40e8e4e86aa 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -411,4 +411,76 @@ expect(page).to have_current_path(idv_doc_auth_welcome_step) end end + + context 'transliteration' do + before(:each) do + allow(IdentityConfig.store).to receive(:usps_ipp_transliteration_enabled). + and_return(true) + end + it 'shows validation errors', allow_browser_log: true do + sign_in_and_2fa_user + begin_in_person_proofing + complete_location_step + complete_prepare_step + expect(page).to have_current_path(idv_in_person_step_path(step: :state_id), wait: 10) + + fill_out_state_id_form_ok + fill_in t('in_person_proofing.form.state_id.first_name'), with: 'T0mmy "Lee"' + fill_in t('in_person_proofing.form.state_id.last_name'), with: 'Джейкоб' + click_idv_continue + expect(page).to have_content( + I18n.t( + 'in_person_proofing.form.state_id.errors.unsupported_chars', + char_list: '", 0', + ), + ) + + expect(page).to have_content( + I18n.t( + 'in_person_proofing.form.state_id.errors.unsupported_chars', + char_list: 'Д, б, е, ж, й, к, о', + ), + ) + + fill_in t('in_person_proofing.form.state_id.first_name'), + with: InPersonHelper::GOOD_FIRST_NAME + fill_in t('in_person_proofing.form.state_id.last_name'), with: InPersonHelper::GOOD_LAST_NAME + click_idv_continue + + expect(page).to have_current_path(idv_in_person_step_path(step: :address), wait: 10) + fill_out_address_form_ok + + fill_in t('idv.form.address1'), with: 'Джордж' + fill_in t('idv.form.address2_optional'), with: '(Nope) = %' + fill_in t('idv.form.city'), with: 'Елена' + click_idv_continue + + expect(page).to have_content( + I18n.t( + 'in_person_proofing.form.address.errors.unsupported_chars', + char_list: 'Д, д, ж, о, р', + ), + ) + + expect(page).to have_content( + I18n.t( + 'in_person_proofing.form.address.errors.unsupported_chars', + char_list: '%, (, ), =', + ), + ) + + expect(page).to have_content( + I18n.t( + 'in_person_proofing.form.address.errors.unsupported_chars', + char_list: 'Е, а, е, л, н', + ), + ) + + fill_in t('idv.form.address1'), with: InPersonHelper::GOOD_ADDRESS1 + fill_in t('idv.form.address2_optional'), with: InPersonHelper::GOOD_ADDRESS2 + fill_in t('idv.form.city'), with: InPersonHelper::GOOD_CITY + click_idv_continue + expect(page).to have_current_path(idv_in_person_step_path(step: :ssn), wait: 10) + end + end end diff --git a/spec/services/usps_in_person_proofing/transliterable_validator_spec.rb b/spec/services/usps_in_person_proofing/transliterable_validator_spec.rb new file mode 100644 index 00000000000..e06a8ff13a8 --- /dev/null +++ b/spec/services/usps_in_person_proofing/transliterable_validator_spec.rb @@ -0,0 +1,233 @@ +require 'rails_helper' + +RSpec.describe UspsInPersonProofing::TransliterableValidator do + let(:errors) { ActiveModel::Errors.new(nil) } + let(:valid_field) { nil } + let(:invalid_field) { nil } + let(:extra_field) { nil } + let(:other_invalid_field) { nil } + let(:model) do + double( + 'SomeModel', + errors:, + valid_field:, + invalid_field:, + extra_field:, + other_invalid_field:, + ) + end + + let(:message) { 'Test message' } + let(:fields) { [:valid_field] } + let(:reject_chars) { /[^A-Za-z]/ } + let(:options) do + { + fields:, + reject_chars:, + message:, + } + end + + before do + allow(IdentityConfig.store).to receive(:usps_ipp_transliteration_enabled). + and_return(usps_ipp_transliteration_enabled) + end + + subject(:validator) { described_class.new(options) } + + describe '#validate' do + context 'transliteration enabled' do + let(:usps_ipp_transliteration_enabled) { true } + + let(:valid_field) { 'abc' } + let(:extra_field) { 'hello world' } + + before do + allow(validator.transliterator).to receive(:transliterate) do |param| + UspsInPersonProofing::Transliterator::TransliterationResult.new( + changed?: true, + original: param, + transliterated: "transliterated#{param}", + unsupported_chars: [], + ) + end + end + + context 'no invalid fields' do + context 'with missing field' do + let(:fields) { [:missing_field, :valid_field] } + + it 'does not check the configured field that is missing' do + expect do + validator.validate(model) + end.not_to raise_error + end + end + + context 'with non-stringable field' do + let(:valid_field) { non_str_double } + let(:non_str_double) { double } + + it 'does not attempt to transliterate the field' do + allow(non_str_double).to receive(:respond_to?).with(:to_s).and_return(false) + + validator.validate(model) + expect(validator.transliterator).not_to have_received(:transliterate) + end + end + + it 'does not check the non-configured field that is present' do + validator.validate(model) + expect(model).not_to have_received(:extra_field) + end + + it 'checks the configured field that is present' do + validator.validate(model) + expect(model).to have_received(:valid_field) + end + + it 'does not set validation message' do + validator.validate(model) + expect(errors).to be_empty + end + end + + context 'one invalid field' do + let(:fields) { [:valid_field, :invalid_field] } + + context 'failing regex check' do + let(:invalid_field) { '123' } + + it 'sets a validation message' do + validator.validate(model) + + expect(model.errors).to include(:invalid_field) + end + end + + context 'failing transliteration' do + let(:invalid_field) { 'def' } + + before do + allow(validator.transliterator).to receive(:transliterate).with('def'). + and_return( + UspsInPersonProofing::Transliterator::TransliterationResult.new( + changed?: true, + original: 'def', + transliterated: 'efg', + unsupported_chars: ['*', '3', 'C'], + ), + ) + end + + it 'sets a validation message' do + validator.validate(model) + + error = model.errors.group_by_attribute[:invalid_field].first + + expect(error.type).to eq(:nontransliterable_field) + expect(error.options[:message]).to eq(message) + end + end + + context 'with callable error message' do + let(:generated_message) { 'my_generated_message' } + let(:chars_passed) { [] } + let(:message) do + proc do |invalid_chars| + chars_passed.push(*invalid_chars) + generated_message + end + end + + context 'combined transliteration and regex issues' do + let(:unsupported_chars_returned) { ['*', '3', 'C'] } + let(:transliterated_value_returned) { '1234' } + let(:invalid_field) { 'def' } + before do + allow(validator.transliterator).to receive(:transliterate).with('def'). + and_return( + UspsInPersonProofing::Transliterator::TransliterationResult.new( + changed?: true, + original: 'def', + transliterated: transliterated_value_returned, + unsupported_chars: unsupported_chars_returned, + ), + ) + end + + context 'with remaining question mark in transliterated string' do + let(:transliterated_value_returned) do + "#{UspsInPersonProofing::Transliterator::REPLACEMENT * 4}1234" + end + + it 'passes unique sorted chars to message generator' do + # The replacement character needs special treatment in this test, + # hence this precondition check. + expect( + transliterated_value_returned.count( + UspsInPersonProofing::Transliterator::REPLACEMENT, + ), + ).to be > unsupported_chars_returned.size + + validator.validate(model) + + expect(chars_passed).to eq( + ['*', '1', '2', '3', '4', + UspsInPersonProofing::Transliterator::REPLACEMENT, 'C'], + ) + end + end + + context 'without remaining question mark in transliterated string' do + it 'passes unique sorted chars to message generator' do + # The replacement character needs special treatment in this test, + # hence this precondition check. + expect( + transliterated_value_returned.count( + UspsInPersonProofing::Transliterator::REPLACEMENT, + ), + ).to be <= unsupported_chars_returned.size + + validator.validate(model) + + expect(chars_passed).to eq(['*', '1', '2', '3', '4', 'C']) + end + end + + it 'sets the error from the message returned by the message generator' do + validator.validate(model) + + error = model.errors.group_by_attribute[:invalid_field].first + expect(error.type).to eq(:nontransliterable_field) + expect(error.options[:message]).to eq(generated_message) + end + end + end + end + + context 'multiple invalid fields' do + let(:fields) { [:other_invalid_field, :valid_field, :invalid_field] } + let(:invalid_field) { '123' } + let(:other_invalid_field) { "\#@$%" } + + it 'sets multiple validation messages' do + validator.validate(model) + + expect(errors).to include(:invalid_field) + expect(errors).to include(:other_invalid_field) + end + end + end + + context 'transliteration disabled' do + let(:usps_ipp_transliteration_enabled) { false } + + it 'does not validate fields' do + validator.validate(model) + + expect(errors).to be_empty + end + end + end +end