diff --git a/app/services/doc_auth/classification_concern.rb b/app/services/doc_auth/classification_concern.rb index 61adf0f76b6..0630f329abd 100644 --- a/app/services/doc_auth/classification_concern.rb +++ b/app/services/doc_auth/classification_concern.rb @@ -20,14 +20,18 @@ def id_type_supported? alias_method :doc_type_supported?, :id_type_supported? - private + private # @param [Object] classification_info assureid classification info # @param [String] doc_side value of ['Front', 'Back'] def doc_side_class_ok?(classification_info, doc_side) side_type = classification_info&.with_indifferent_access&.dig(doc_side, :ClassName) !side_type.present? || - DocAuth::Response::ID_TYPE_SLUGS.key?(side_type) || + ( + IdentityConfig.store.doc_auth_passports_enabled ? + DocAuth::Response::ID_TYPE_SLUGS.key?(side_type) : + DocAuth::Response::STATE_ID_TYPE_SLUGS.key?(side_type) + ) || side_type == 'Unknown' end @@ -50,5 +54,5 @@ def doc_issuer_type_ok?(classification_info, doc_side) def supported_country_codes IdentityConfig.store.doc_auth_supported_country_codes end -end + end end diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb index f37f104bfa8..4251d0aa572 100644 --- a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -26,7 +26,9 @@ def read_pii(true_id_product) return nil unless id_auth_field_data.present? id_doc_type_slug = id_auth_field_data['Fields_DocumentClassName'] - @id_doc_type = DocAuth::Response::ID_TYPE_SLUGS[id_doc_type_slug] + @id_doc_type = IdentityConfig.store.doc_auth_passports_enabled ? + DocAuth::Response::ID_TYPE_SLUGS[id_doc_type_slug] : + DocAuth::Response::STATE_ID_TYPE_SLUGS[id_doc_type_slug] if id_doc_type == 'drivers_license' || id_doc_type == 'state_id_card' generate_state_id_pii diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index 925ca251a5c..e5c50d5e769 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -11,9 +11,10 @@ class Response ID_TYPE_SLUGS = { 'Identification Card' => 'state_id_card', 'Drivers License' => 'drivers_license', + 'Passport' => 'passport', }.freeze - SOCURE_ID_TYPE_SLUGS = { + STATE_ID_TYPE_SLUGS = { 'Identification Card' => 'state_id_card', 'Drivers License' => 'drivers_license', }.freeze diff --git a/app/services/doc_auth/socure/responses/docv_result_response.rb b/app/services/doc_auth/socure/responses/docv_result_response.rb index 20abbac5e8d..b23346b5672 100644 --- a/app/services/doc_auth/socure/responses/docv_result_response.rb +++ b/app/services/doc_auth/socure/responses/docv_result_response.rb @@ -201,7 +201,7 @@ def parse_date(date_string) end def id_type_supported? - DocAuth::Response::SOCURE_ID_TYPE_SLUGS.key?(document_id_type) + DocAuth::Response::STATE_ID_TYPE_SLUGS.key?(document_id_type) end def reason_codes_selfie_pass diff --git a/spec/controllers/idv/choose_id_type_controller_spec.rb b/spec/controllers/idv/choose_id_type_controller_spec.rb index d47c8009b3e..eb20a9e7328 100644 --- a/spec/controllers/idv/choose_id_type_controller_spec.rb +++ b/spec/controllers/idv/choose_id_type_controller_spec.rb @@ -129,6 +129,24 @@ expect(response).to redirect_to(idv_document_capture_url) end end + + context 'user selects passport' do + let(:chosen_id_type) { 'passport' } + + it 'sets document_capture_session to passport requested' do + put :update, params: params + + expect(subject.document_capture_session.passport_requested?).to eq(true) + end + + # currently we do not have a passport route so it redirects to ipp route + # change when the new passport is added + it 'redirects to passport document capture' do + put :update, params: params + + expect(response).to redirect_to(idv_document_capture_url) + end + end end describe '#step_info' do diff --git a/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb index 0d9ea5d5e5d..fbef7c68578 100644 --- a/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb @@ -104,5 +104,18 @@ expect(response).to redirect_to idv_hybrid_mobile_document_capture_url end end + + context 'user chooses passport' do + let(:chosen_id_type) { 'passport' } + let(:params) do + { doc_auth: { choose_id_type_preference: chosen_id_type } } + end + + it 'sets passport_status to requested and redirects to vendor that supports passport' do + put :update, params: params + expect(document_capture_session.passport_status).to eq('requested') + expect(response).to redirect_to idv_hybrid_mobile_document_capture_url + end + end end end diff --git a/spec/controllers/idv/in_person/choose_id_type_controller_spec.rb b/spec/controllers/idv/in_person/choose_id_type_controller_spec.rb index 9fa8a6ce136..091c5789157 100644 --- a/spec/controllers/idv/in_person/choose_id_type_controller_spec.rb +++ b/spec/controllers/idv/in_person/choose_id_type_controller_spec.rb @@ -141,6 +141,49 @@ let!(:enrollment) { create(:in_person_enrollment, :establishing, user: user) } context 'when the form submission is successful' do + context 'when the chosen ID type is "passport"' do + let(:chosen_id_type) { 'passport' } + let(:params) do + { + doc_auth: { + choose_id_type_preference: chosen_id_type, + }, + } + end + let(:analytics_arguments) do + { + flow_path: 'standard', + step: 'choose_id_type', + analytics_id: 'In Person Proofing', + skip_hybrid_handoff: false, + opted_in_to_in_person_proofing: true, + chosen_id_type: chosen_id_type, + success: true, + } + end + + before do + subject.idv_session.opted_in_to_in_person_proofing = + analytics_arguments[:opted_in_to_in_person_proofing] + subject.idv_session.skip_hybrid_handoff = analytics_arguments[:skip_hybrid_handoff] + put :update, params: params + end + + it 'logs the idv_in_person_proofing_choose_id_type_submitted event' do + expect(@analytics).to have_logged_event( + :idv_in_person_proofing_choose_id_type_submitted, analytics_arguments + ) + end + + it 'updates the passport status to "requested" in document capture session' do + expect(controller.document_capture_session.passport_status).to eq('requested') + end + + it 'redirects to the in person passport page' do + expect(response).to redirect_to(idv_in_person_passport_path) + end + end + context 'when the chosen ID type is "drivers_license"' do let(:chosen_id_type) { 'drivers_license' } let(:params) do diff --git a/spec/features/idv/doc_auth/choose_id_type_spec.rb b/spec/features/idv/doc_auth/choose_id_type_spec.rb index 1e2c47c298e..50329b6915d 100644 --- a/spec/features/idv/doc_auth/choose_id_type_spec.rb +++ b/spec/features/idv/doc_auth/choose_id_type_spec.rb @@ -18,6 +18,26 @@ reload_ab_tests end + context 'desktop flow', :js do + before do + complete_doc_auth_steps_before_hybrid_handoff_step + end + + it 'shows choose id type screen and continues after passport option' do + expect(page).to have_content(t('doc_auth.headings.upload_from_computer')) + click_on t('forms.buttons.upload_photos') + expect(page).to have_current_path(idv_choose_id_type_url) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_document_capture_url) + visit idv_choose_id_type_url + expect(page).to have_checked_field( + 'doc_auth_choose_id_type_preference_passport', + visible: :all, + ) + end + end + context 'mobile flow', :js, driver: :headless_chrome_mobile do before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 9d7be58e012..82df0c0290e 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -200,6 +200,187 @@ end end + context 'Passports enabled', allow_browser_log: true do + let(:passports_enabled) { true } + let(:api_status) { 'UP' } + let(:ipp_service_provider) do + create(:service_provider, :active, :in_person_proofing_enabled) + end + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled).and_return(true) + allow_any_instance_of(ServiceProvider).to receive( + :in_person_proofing_enabled, + ).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) + .to_return({ status: 200, body: { status: api_status }.to_json }) + reload_ab_tests + + visit_idp_from_sp_with_ial2( + :oidc, + **{ client_id: ipp_service_provider.issuer }, + ) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + end + + after do + reload_ab_tests + end + + context 'with a valid passport' do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_credential.yml' + ) + end + + it 'happy path' do + choose_id_type(:passport) + expect(page).to have_content(t('doc_auth.headings.document_capture_passport')) + expect(page).to have_current_path(idv_document_capture_url) + + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image(passport_image) + submit_images + expect(page).to have_content(t('doc_auth.headings.capture_complete')) + fill_out_ssn_form_ok + click_idv_continue + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + expect(page).to have_content(t('doc_auth.headings.address')) + fill_in 'idv_form_address1', with: '123 Main St' + fill_in 'idv_form_city', with: 'Nowhere' + select 'Virginia', from: 'idv_form_state' + fill_in 'idv_form_zipcode', with: '66044' + click_idv_continue + expect(page).to have_current_path(idv_verify_info_path) + expect(page).to have_content('VA') + expect(page).to have_content('123 Main St') + expect(page).to have_content('Nowhere') + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + + context 'with an invalid passport' do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_mrz_credential.yml' + ) + end + + it 'fails due to mrz' do + choose_id_type(:passport) + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image(passport_image) + submit_images + expect(page).not_to have_content(t('doc_auth.headings.capture_complete')) + expect(page).to have_content(t('doc_auth.info.review_passport')) + expect(page).to have_content(t('in_person_proofing.headings.cta')) + expect_to_try_again + expect(page).to have_content(t('doc_auth.info.review_passport')) + expect_rate_limit_warning(max_attempts - 1) + end + end + + context 'with a network error' do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_credential.yml' + ) + end + before do + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_passport_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + end + + it 'shows the error message' do + choose_id_type(:passport) + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image(passport_image) + submit_images + expect(page).to have_content(t('doc_auth.errors.general.network_error')) + expect(page).to have_content(t('in_person_proofing.headings.cta')) + expect_rate_limit_warning(max_attempts - 1) + end + end + + context 'pii validation error' do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_pii_credentials.yml' + ) + end + + it 'fails pii check' do + choose_id_type(:passport) + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image(passport_image) + submit_images + expect(page).to have_content(t('in_person_proofing.headings.cta')) + expect_to_try_again + expect(page).to have_current_path(idv_document_capture_url) + expect_rate_limit_warning(max_attempts - 1) + end + end + + context 'api 400 error' do + let(:fake_dos_api_endpoint) { 'http://fake_dos_api_endpoint/' } + + before do + allow(IdentityConfig.store).to receive(:dos_passport_mrz_endpoint) + .and_return(fake_dos_api_endpoint) + stub_request(:post, fake_dos_api_endpoint) + .to_return(status: 400, body: '{}', headers: {}) + end + + it 'shows the error message' do + choose_id_type(:passport) + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image + submit_images + expect(page).to have_content(t('doc_auth.headings.review_issues_passport')) + expect(page).to have_current_path(idv_document_capture_url) + end + end + + context 'api 500 error' do + let(:fake_dos_api_endpoint) { 'http://fake_dos_api_endpoint/' } + + before do + allow(IdentityConfig.store).to receive(:dos_passport_mrz_endpoint) + .and_return(fake_dos_api_endpoint) + stub_request(:post, fake_dos_api_endpoint) + .to_return(status: 500, body: '{}', headers: {}) + end + + it 'shows the error message' do + choose_id_type(:passport) + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image + submit_images + expect(page).to have_content(t('doc_auth.headings.review_issues_passport')) + expect(page).to have_current_path(idv_document_capture_url) + end + end + end + context 'standard desktop flow' do before do visit_idp_from_oidc_sp_with_ial2 diff --git a/spec/features/idv/doc_auth/redo_document_capture_spec.rb b/spec/features/idv/doc_auth/redo_document_capture_spec.rb index 1f4bc295eac..82280e2d9e6 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -273,6 +273,84 @@ end end + context 'standard desktop passport flow', allow_browser_log: true do + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) + .to_return({ status: 200, body: { status: 'UP' }.to_json }) + reload_ab_tests + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_hybrid_handoff_step + end + + after do + reload_ab_tests + end + + it 'shows only one image on review step if passport selected' do + expect(page).to have_content(t('doc_auth.headings.upload_from_computer')) + click_on t('forms.buttons.upload_photos') + expect(page).to have_current_path(idv_choose_id_type_url) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_document_capture_url) + # Attach fail images and then continue to retry + attach_passport_image( + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_mrz_credential.yml' + ), + ) + submit_images + expect(page).to have_current_path(idv_document_capture_url) + click_on t('idv.failure.button.warning') + expect(page).to have_content(t('doc_auth.headings.document_capture_passport')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).to have_content(t('doc_auth.headings.review_issues_passport')) + expect(page).to have_content(t('doc_auth.info.review_passport')) + end + end + + context 'standard mobile passport flow', allow_browser_log: true do + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) + .to_return({ status: 200, body: { status: 'UP' }.to_json }) + reload_ab_tests + end + + after do + reload_ab_tests + end + + it 'shows only one image on review step if passport selected' do + perform_in_browser(:mobile) do + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + expect(page).to have_current_path(idv_choose_id_type_url) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_document_capture_url) + # Attach fail images and then continue to retry + attach_passport_image( + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_mrz_credential.yml' + ), + ) + submit_images + expect(page).to have_current_path(idv_document_capture_url) + click_on t('idv.failure.button.warning') + expect(page).to have_content(t('doc_auth.headings.document_capture_passport')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).to have_content(t('doc_auth.headings.review_issues_passport')) + expect(page).to have_content(t('doc_auth.info.review_passport')) + end + end + end + context 'standard mobile flow' do it 'proceeds to the next page with valid info' do perform_in_browser(:mobile) do diff --git a/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb index 548ce7ee044..a743d64b581 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb @@ -25,6 +25,31 @@ reload_ab_tests end + it 'choose id type screen before doc capture in hybrid flow and proceeds after passport select', + js: true do + perform_in_browser(:desktop) do + sign_in_and_2fa_user + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + end + + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + visit idv_hybrid_mobile_choose_id_type_url + expect(page).to have_checked_field( + 'doc_auth_choose_id_type_preference_passport', + visible: :all, + ) + end + end + it 'choose id type screen before doc capture in hybrid flow and proceeds after state id select', js: true do perform_in_browser(:desktop) do diff --git a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb index df8c4187663..be1bdb33d40 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb @@ -212,6 +212,255 @@ end end + context 'Passports Enabled', allow_net_connect_on_start: false, allow_browser_log: true do + let(:passports_enabled) { true } + let(:api_status) { 'UP' } + + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) + .to_return({ status: 200, body: { status: api_status }.to_json }) + reload_ab_tests + end + + after do + reload_ab_tests + end + + context 'valid passport data', js: true do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_credential.yml' + ) + end + + it 'works with valid passport data' do + user = nil + + perform_in_browser(:desktop) do + user = sign_in_and_2fa_user + + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + + expect(page).to have_content(t('doc_auth.headings.text_message')) + expect(page).to have_content(t('doc_auth.info.you_entered')) + expect(page).to have_content('+1 415-555-0199') + + # Confirm that Continue button is not shown when polling is enabled + expect(page).not_to have_content(t('doc_auth.buttons.continue')) + end + + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image(passport_image) + submit_images + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + # Confirm app disallows jumping back to DocumentCapture page + visit idv_hybrid_mobile_document_capture_url + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + end + + perform_in_browser(:desktop) do + expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) + expect(page).to have_current_path(idv_ssn_path) + + fill_out_ssn_form_ok + click_idv_continue + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + expect(page).to have_content(t('doc_auth.headings.address')) + fill_in 'idv_form_address1', with: '123 Main St' + fill_in 'idv_form_city', with: 'Nowhere' + select 'Virginia', from: 'idv_form_state' + fill_in 'idv_form_zipcode', with: '66044' + click_idv_continue + expect(page).to have_current_path(idv_verify_info_path) + expect(page).to have_content('VA') + expect(page).to have_content('123 Main St') + expect(page).to have_content('Nowhere') + complete_verify_step + + prefilled_phone = page.find(id: 'idv_phone_form_phone').value + + expect( + PhoneFormatter.format(prefilled_phone), + ).to eq( + PhoneFormatter.format(user.default_phone_configuration.phone), + ) + + fill_out_phone_form_ok + verify_phone_otp + end + end + end + + context 'invalid passport data', js: true do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_mrz_credential.yml' + ) + end + + before do + user = nil + + perform_in_browser(:desktop) do + user = sign_in_and_2fa_user + + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + + expect(page).to have_content(t('doc_auth.headings.text_message')) + expect(page).to have_content(t('doc_auth.info.you_entered')) + expect(page).to have_content('+1 415-555-0199') + + # Confirm that Continue button is not shown when polling is enabled + expect(page).not_to have_content(t('doc_auth.buttons.continue')) + end + end + + it 'correctly processes invalid passport mrz data', js: true do + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image(passport_image) + submit_images + expect(page).not_to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_content(t('doc_auth.info.review_passport')) + expect_to_try_again(is_hybrid: true) + expect(page).to have_content(t('doc_auth.info.review_passport')) + end + end + + context 'with a network error' do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_credential.yml' + ) + end + before do + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_passport_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + end + + it 'shows the error message' do + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image(passport_image) + submit_images + expect(page).not_to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_content(t('doc_auth.errors.general.network_error')) + expect_rate_limit_warning(max_attempts - 1) + end + end + end + + context 'pii validation error' do + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_pii_credentials.yml' + ) + end + + it 'fails pii check' do + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image(passport_image) + submit_images + expect(page).not_to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect_to_try_again(is_hybrid: true) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect_rate_limit_warning(max_attempts - 1) + end + end + end + + context 'api 400 error' do + let(:fake_dos_api_endpoint) { 'http://fake_dos_api_endpoint/' } + + before do + allow(IdentityConfig.store).to receive(:dos_passport_mrz_endpoint) + .and_return(fake_dos_api_endpoint) + stub_request(:post, fake_dos_api_endpoint) + .to_return(status: 400, body: '{}', headers: {}) + end + it 'shows the error message' do + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image + submit_images + expect(page).not_to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + end + end + end + + context 'api 500 error' do + let(:fake_dos_api_endpoint) { 'http://fake_dos_api_endpoint/' } + + before do + allow(IdentityConfig.store).to receive(:dos_passport_mrz_endpoint) + .and_return(fake_dos_api_endpoint) + stub_request(:post, fake_dos_api_endpoint) + .to_return(status: 500, body: '{}', headers: {}) + end + it 'shows the error message' do + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image + submit_images + expect(page).not_to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + end + end + end + end + end + it 'shows the waiting screen correctly after cancelling from mobile and restarting', js: true do user = nil @@ -293,6 +542,54 @@ end end + context 'passport hybrid flow', allow_net_connect_on_start: false do + before do + allow(IdentityConfig.store).to receive(:socure_docv_enabled).and_return(false) + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return('mock') + stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) + .to_return({ status: 200, body: { status: 'UP' }.to_json }) + reload_ab_tests + end + + after do + reload_ab_tests + end + + it 'review step shows one image if passport selected', js: true do + perform_in_browser(:desktop) do + sign_in_and_2fa_user + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + end + + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image( + Rails.root.join( + 'spec', 'fixtures', + 'passport_bad_mrz_credential.yml' + ), + ) + submit_images + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + click_on t('idv.failure.button.warning') + expect(page).to have_content(t('doc_auth.headings.document_capture_passport')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_back')) + expect(page).to have_content(t('doc_auth.headings.review_issues_passport')) + expect(page).to have_content(t('doc_auth.info.review_passport')) + end + end + end + context 'after rate limiting user can capture on last attempt' do let(:max_attempts) { 1 } diff --git a/spec/features/idv/in_person/passport_scenario_spec.rb b/spec/features/idv/in_person/passport_scenario_spec.rb index 6b38cbbbc6a..5930139c0a1 100644 --- a/spec/features/idv/in_person/passport_scenario_spec.rb +++ b/spec/features/idv/in_person/passport_scenario_spec.rb @@ -20,6 +20,281 @@ allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) end + context 'when passports are allowed' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + stub_health_check_settings + stub_health_check_endpoints_success + end + + context 'when in person passports are enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_passports_enabled).and_return(true) + end + + it 'allows the user to access in person passport content', allow_browser_log: true do + reload_ab_tests + visit_idp_from_sp_with_ial2(service_provider) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + expect(page).to have_content t('doc_auth.headings.welcome', sp_name: service_provider_name) + expect(page).to have_content t('doc_auth.instructions.bullet1b') + + complete_welcome_step + + expect(page).to have_current_path(idv_agreement_path) + complete_agreement_step + + expect(page).to have_content t('doc_auth.info.verify_online_description_passport') + + click_on t('forms.buttons.continue_ipp') + + expect(page).to have_current_path(idv_document_capture_path(step: 'hybrid_handoff')) + + click_on t('forms.buttons.continue') + + complete_location_step(user) + + expect(page).to have_current_path(idv_in_person_choose_id_type_path) + expect(page).to have_content t('doc_auth.headings.choose_id_type') + expect(page).to have_content t('in_person_proofing.info.choose_id_type') + expect(page).to have_content t('doc_auth.forms.id_type_preference.drivers_license') + expect(page).to have_content t('doc_auth.forms.id_type_preference.passport') + end + + context 'when the user chooses the state_id path during enrollment creation' do + it 'creates a state_id enrollment' do + reload_ab_tests + visit_idp_from_sp_with_ial2(service_provider) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + expect(page).to have_content t( + 'doc_auth.headings.welcome', + sp_name: service_provider_name, + ) + expect(page).to have_content t('doc_auth.instructions.bullet1b') + + complete_welcome_step + + expect(page).to have_current_path(idv_agreement_path) + complete_agreement_step + + expect(page).to have_content t('doc_auth.info.verify_online_description_passport') + + click_on t('forms.buttons.continue_ipp') + + expect(page).to have_current_path(idv_document_capture_path(step: 'hybrid_handoff')) + + click_on t('forms.buttons.continue') + complete_location_step(user) + + expect(page).to have_current_path(idv_in_person_choose_id_type_path) + + expect(page).to have_content t('doc_auth.headings.choose_id_type') + expect(page).to have_content t('in_person_proofing.info.choose_id_type') + expect(page).to have_content t('doc_auth.forms.id_type_preference.drivers_license') + expect(page).to have_content t('doc_auth.forms.id_type_preference.passport') + + choose t('doc_auth.forms.id_type_preference.drivers_license') + click_on t('forms.buttons.continue') + + expect(page).to have_current_path(idv_in_person_state_id_path) + end + end + + context 'when the user chooses the passport path during enrollment creation' do + it 'creates a passport enrollment' do + reload_ab_tests + visit_idp_from_sp_with_ial2(service_provider) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + expect(page).to have_content t( + 'doc_auth.headings.welcome', + sp_name: service_provider_name, + ) + expect(page).to have_content t('doc_auth.instructions.bullet1b') + + complete_welcome_step + + expect(page).to have_current_path(idv_agreement_path) + complete_agreement_step + + expect(page).to have_content t('doc_auth.info.verify_online_description_passport') + + click_on t('forms.buttons.continue_ipp') + + expect(page).to have_current_path(idv_document_capture_path(step: 'hybrid_handoff')) + + click_on t('forms.buttons.continue') + complete_location_step(user) + + expect(page).to have_current_path(idv_in_person_choose_id_type_path) + + expect(page).to have_content t('doc_auth.headings.choose_id_type') + expect(page).to have_content t('in_person_proofing.info.choose_id_type') + expect(page).to have_content t('doc_auth.forms.id_type_preference.drivers_license') + expect(page).to have_content t('doc_auth.forms.id_type_preference.passport') + + choose t('doc_auth.forms.id_type_preference.passport') + click_on t('forms.buttons.continue') + + expect(page).to have_current_path(idv_in_person_passport_path) + expect(page).to have_content t('in_person_proofing.headings.passport') + expect(page).to have_content t('in_person_proofing.body.passport.info') + + fill_in_passport_form + + click_on t('forms.buttons.submit.default') + + expect(page).to have_current_path(idv_in_person_address_path) + + fill_out_address_form_ok + + click_on t('forms.buttons.continue') + + expect(page).to have_current_path(idv_in_person_ssn_path) + + fill_out_ssn_form_ok + + click_on t('forms.buttons.continue') + + expect(page).to have_current_path(idv_in_person_verify_info_path) + + check_passport_verify_info_page_content + end + end + + context 'when the first DOS health check fails on the welcome page' do + before do + stub_composite_health_check_endpoint_failure + end + + it 'does not allow the user to access passport content' do + reload_ab_tests + visit_idp_from_sp_with_ial2(service_provider) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + expect(page).to have_content t( + 'doc_auth.headings.welcome', + sp_name: service_provider_name, + ) + expect(page).to have_content t('doc_auth.instructions.bullet1a') + + complete_welcome_step + + expect(page).to have_current_path(idv_agreement_path) + complete_agreement_step + + click_on t('forms.buttons.continue_ipp') + + expect(page).to have_current_path(idv_document_capture_path(step: 'hybrid_handoff')) + + click_on t('forms.buttons.continue') + complete_location_step(user) + + expect(page).to have_current_path(idv_in_person_state_id_url) + + expect(page).to have_content strip_nbsp( + t('in_person_proofing.headings.state_id_milestone_2'), + ) + end + end + + context 'when the second DOS health check fails after the user selects a post office' do + before do + stub_health_check_settings + # The first health check passes + stub_health_check_endpoints_success + end + + it 'directs the user to the choose id page with a deactivated passport option & warning' do + reload_ab_tests + visit_idp_from_sp_with_ial2(service_provider) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + expect(page).to have_content t( + 'doc_auth.headings.welcome', + sp_name: service_provider_name, + ) + expect(page).to have_content t('doc_auth.instructions.bullet1b') + + complete_welcome_step + + expect(page).to have_current_path(idv_agreement_path) + complete_agreement_step + + expect(page).to have_content t('doc_auth.info.verify_online_description_passport') + + click_on t('forms.buttons.continue_ipp') + + expect(page).to have_current_path(idv_document_capture_path(step: 'hybrid_handoff')) + + click_on t('forms.buttons.continue') + # The second health check fails + stub_composite_health_check_endpoint_failure + complete_location_step(user) + + expect(page).to have_current_path(idv_in_person_choose_id_type_path) + + expect(page).to have_content strip_nbsp( + t('doc_auth.headings.choose_id_type'), + ) + expect(page).to have_content strip_nbsp( + t('doc_auth.info.dos_passport_api_down_message'), + ) + expect(page).to have_field( + 'doc_auth_choose_id_type_preference_passport', + visible: :all, + disabled: true, + ) + expect(page).to have_content strip_nbsp( + t('doc_auth.forms.id_type_preference.passport'), + ) + end + end + end + + context 'when in person passports are disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_passports_enabled).and_return(false) + end + + it 'does not allow the user to access in person passport content', allow_browser_log: true do + reload_ab_tests + visit_idp_from_sp_with_ial2(service_provider) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + expect(page).to have_content t('doc_auth.headings.welcome', sp_name: service_provider_name) + expect(page).to have_content t('doc_auth.instructions.bullet1b') + + complete_welcome_step + + expect(page).to have_current_path(idv_agreement_path) + complete_agreement_step + + expect(page).to have_content strip_tags( + t('doc_auth.info.verify_at_post_office_description_passport_html'), + ) + + click_on t('forms.buttons.continue_ipp') + + expect(page).to have_current_path(idv_document_capture_path(step: 'hybrid_handoff')) + + click_on t('forms.buttons.continue') + complete_location_step(user) + + expect(page).to have_current_path(idv_in_person_state_id_path) + end + end + end + context 'when passports are not allowed' do before do allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(false) diff --git a/spec/forms/idv/choose_id_type_form_spec.rb b/spec/forms/idv/choose_id_type_form_spec.rb index cf71135dfe6..746b618673f 100644 --- a/spec/forms/idv/choose_id_type_form_spec.rb +++ b/spec/forms/idv/choose_id_type_form_spec.rb @@ -5,7 +5,7 @@ describe '#submit' do context 'when the form is valid' do - let(:params) { { choose_id_type_preference: DocAuth::Response::ID_TYPE_SLUGS.values.sample } } + let(:params) { { choose_id_type_preference: 'passport' } } it 'returns a successful form response' do result = subject.submit(params) diff --git a/spec/services/doc_auth/classification_concern_spec.rb b/spec/services/doc_auth/classification_concern_spec.rb index 4f7cffb6dcb..1a36162a35c 100644 --- a/spec/services/doc_auth/classification_concern_spec.rb +++ b/spec/services/doc_auth/classification_concern_spec.rb @@ -36,6 +36,18 @@ def initialize(classification_info) end end + context 'with US passport card' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + end + + let(:class_name) { 'Passport' } + let(:issuer_type) { 'Country' } + it 'returns true' do + expect(subject.id_type_supported?).to eq(true) + end + end + context 'with state issued drivers license' do let(:class_name) { 'Drivers License' } it 'returns true' do diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index cf7671bc79f..9f5f8ace5c2 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -277,6 +277,151 @@ end end + context 'when the response is a success for passport' do + let(:response) do + described_class.new( + success_with_passport_response, + config, + liveness_checking_enabled, + request_context, + ) + end + + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + end + + it 'is a successful result' do + expect(response.successful_result?).to eq(true) + expect(response.to_h[:vendor]).to eq('TrueID') + end + + it 'has no error messages' do + expect(response.error_messages).to be_empty + end + it 'has extra attributes' do + extra_attributes = response.extra_attributes + expect(extra_attributes).not_to be_empty + expect(extra_attributes[:classification_info]).to include(:Front) + expect(extra_attributes).to have_key(:workflow) + expect(extra_attributes).to have_key(:reference) + end + it 'has PII data' do + expected_passport_pii = Pii::Passport.new( + first_name: 'DAVID', + last_name: 'SAMPLE', + middle_name: 'PASSPORT', + dob: '1986-07-01', + sex: 'male', + birth_place: 'MY CITY. U.S.A.', + passport_expiration: '2099-10-15', + passport_issued: '2016-10-15', + nationality_code: 'USA', + issuing_country_code: 'USA', + mrz: mrz, + id_doc_type: 'passport', + document_number: 'Z12345678', + ) + + expect(response.pii_from_doc.to_h).to eq(expected_passport_pii.to_h) + end + + it 'excludes pii fields from logging' do + expect(response.extra_attributes.keys).to_not include(*described_class::PII_EXCLUDES) + end + + it 'excludes unnecessary raw Alert data from logging' do + expect(response.extra_attributes.keys.any? { |key| key.start_with?('Alert_') }).to eq(false) + end + + it 'produces expected hash output' do + response_hash = response.to_h + expect(response_hash).to match( + success: true, + exception: nil, + errors: {}, + attention_with_barcode: false, + conversation_id: a_kind_of(String), + request_id: a_kind_of(String), + doc_type_supported: true, + reference: a_kind_of(String), + vendor: 'TrueID', + billed: true, + log_alert_results: a_hash_including('2d_barcode_content': { no_side: 'Passed' }), + transaction_status: 'passed', + transaction_reason_code: 'trueid_pass', + product_status: 'pass', + decision_product_status: 'pass', + processed_alerts: a_hash_including(:failed), + address_line2_present: false, + alert_failure_count: a_kind_of(Numeric), + portrait_match_results: nil, + image_metrics: a_hash_including(:front), + doc_auth_result: 'Passed', + 'ClassificationMode' => 'Automatic', + 'DocAuthResult' => 'Passed', + 'DocClass' => 'Passport', + 'DocClassCode' => 'Passport', + 'DocClassName' => 'Passport', + 'DocumentName' => 'United States (USA) Passport - STAR', + 'DocIssuerCode' => 'USA', + 'DocIssuerName' => 'United States', + 'DocIssue' => '2016', + 'DocIsGeneric' => 'false', + 'DocIssuerType' => 'StateProvince', + 'DocIssueType' => 'Passport - STAR', + 'OrientationChanged' => 'true', + 'PresentationChanged' => 'false', + 'DocAuthTamperResult' => 'Passed', + 'DocAuthTamperSensitivity' => 'Normal', + classification_info: { + Front: a_hash_including(:ClassName, :CountryCode, :IssuerType), + }, + doc_auth_success: true, + selfie_status: :not_processed, + selfie_live: true, + selfie_quality_good: true, + liveness_enabled: false, + workflow: anything, + ) + passed_alerts = response_hash.dig(:processed_alerts, :passed) + passed_alerts.each do |alert| + expect(alert).to have_key(:disposition) + end + alerts_with_mode_etc = passed_alerts.select do |alert| + alert[:model].present? && alert[:region].present? && alert[:region_ref].present? + end + expect(alerts_with_mode_etc).not_to be_empty + alerts_with_mode_etc.each do |alert| + alert[:region_ref].each do |region_ref| + expect(region_ref).to include(:side, :key) + end + end + end + + it 'mark doc type as supported' do + expect(response.doc_type_supported?).to eq(true) + end + end + + context 'when the response is a failure for passport' do + it 'produces appropriate errors with passport tampering' do + response = described_class.new(failure_response_passport_tampering, config) + output = response.to_h + errors = output[:errors] + expect(output.to_h[:log_alert_results]).to include( + document_tampering_detection: { no_side: 'Failed' }, + ) + expect(output[:success]).to eq(false) + expect(errors.keys).to contain_exactly(:general, :front, :back, :hints) + # we dont have specific error for tampering yet + expect(errors[:general]).to contain_exactly(DocAuth::Errors::GENERAL_ERROR) + expect(errors[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(errors[:hints]).to eq(true) + expect(response.doc_auth_success?).to eq(false) + end + end + context 'when there is no address line 2' do let(:success_response_no_line2) do body_no_line2 = JSON.parse(LexisNexisFixtures.true_id_response_success_3).tap do |json| @@ -593,6 +738,18 @@ def get_decision_product(resp) end end + context 'when the dob is incorrectly parsed in passport' do + let(:response) { described_class.new(success_with_passport_failed_to_ocr_dob, config) } + + before do + allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) + end + + it 'does not throw an exception when getting pii from doc' do + expect(response.pii_from_doc.dob).to be_nil + end + end + describe '#parse_date' do let(:response) { described_class.new(success_response, config) }