diff --git a/app/assets/images/idv/laptop-icon.svg b/app/assets/images/idv/laptop-icon.svg new file mode 100644 index 00000000000..680054edaf3 --- /dev/null +++ b/app/assets/images/idv/laptop-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/idv/phone-icon.svg b/app/assets/images/idv/phone-icon.svg new file mode 100644 index 00000000000..b409f134cb6 --- /dev/null +++ b/app/assets/images/idv/phone-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/services/idv/actions/cancel_link_sent_action.rb b/app/services/idv/actions/cancel_link_sent_action.rb index 5d5d51d8bca..a1d32d19ae2 100644 --- a/app/services/idv/actions/cancel_link_sent_action.rb +++ b/app/services/idv/actions/cancel_link_sent_action.rb @@ -7,6 +7,9 @@ def self.analytics_submitted_event def call mark_step_incomplete(:send_link) + if IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled + mark_step_incomplete(:upload) + end end end end diff --git a/app/services/idv/steps/upload_step.rb b/app/services/idv/steps/upload_step.rb index d5517a8b036..210c494e3c1 100644 --- a/app/services/idv/steps/upload_step.rb +++ b/app/services/idv/steps/upload_step.rb @@ -1,6 +1,7 @@ module Idv module Steps class UploadStep < DocAuthBaseStep + include ActionView::Helpers::DateHelper STEP_INDICATOR_STEP = :verify_id def self.analytics_visited_event @@ -20,11 +21,29 @@ def call # app/views/idv/doc_auth/upload.html.erb if params[:type] == 'desktop' handle_desktop_selection + elsif params[:combined] + # The user was shown the new combined view and + # submitted a phone number to this step with feature flag on + # OR + # The user was originally shown the new combined view, + # but has submitted to a step with the feature flag off + # (50/50 from new to old) + handle_phone_submission else handle_mobile_selection end end + def extra_view_variables + if IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled + { + idv_phone_form: build_form, + } + else + {} + end + end + private def handle_desktop_selection @@ -35,6 +54,27 @@ def handle_desktop_selection end end + def build_form + Idv::PhoneForm.new( + previous_params: {}, + user: current_user, + delivery_methods: [:sms], + ) + end + + def form_submit + return super if !IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled + return super if params[:type] == 'desktop' + + # Remove after 50/50 deploy w/ flag + return super if params[:type] != 'combined' + + params = permit(:phone) + params[:otp_delivery_preference] = 'sms' + build_form.submit(params) + end + + # To be removed after 50/50 def handle_mobile_selection if mobile_device? bypass_send_link_steps @@ -43,6 +83,26 @@ def handle_mobile_selection end end + def handle_phone_submission + throttle.increment! + return throttled_failure if throttle.throttled? + telephony_result = send_link + failure_reason = nil + if !telephony_result.success? + failure_reason = { telephony: [telephony_result.error.class.name.demodulize] } + end + @flow.irs_attempts_api_tracker.idv_phone_upload_link_sent( + success: telephony_result.success?, + phone_number: formatted_destination_phone, + failure_reason: failure_reason, + ) + + mark_step_complete(:send_link) + mark_step_complete(:email_sent) + + build_telephony_form_response(telephony_result) + end + def identity current_user&.identities&.order('created_at DESC')&.first end @@ -76,6 +136,81 @@ def bypass_send_link_steps form_response(destination: :document_capture) end + def throttle + @throttle ||= Throttle.new( + user: current_user, + throttle_type: :idv_send_link, + ) + end + + def throttled_failure + @flow.analytics.throttler_rate_limit_triggered( + throttle_type: :idv_send_link, + ) + message = I18n.t( + 'errors.doc_auth.send_link_throttle', + timeout: distance_of_time_in_words( + Time.zone.now, + [throttle.expires_at, Time.zone.now].compact.max, + except: :seconds, + ), + ) + + @flow.irs_attempts_api_tracker.idv_phone_send_link_rate_limited( + phone_number: formatted_destination_phone, + ) + + failure(message) + end + + def formatted_destination_phone + raw_phone = permit(:phone)[:phone] + PhoneFormatter.format(raw_phone, country_code: 'US') + end + + def update_document_capture_session_requested_at(session_uuid) + document_capture_session = DocumentCaptureSession.find_by(uuid: session_uuid) + return unless document_capture_session + document_capture_session.update!( + requested_at: Time.zone.now, + cancelled_at: nil, + issuer: sp_session[:issuer], + ) + end + + def sp_or_app_name + current_sp&.friendly_name.presence || APP_NAME + end + + def link_for_send_link(session_uuid) + idv_capture_doc_dashes_url( + 'document-capture-session': session_uuid, + request_id: sp_session[:request_id], + ) + end + + def send_link + session_uuid = flow_session[:document_capture_session_uuid] + update_document_capture_session_requested_at(session_uuid) + Telephony.send_doc_auth_link( + to: formatted_destination_phone, + link: link_for_send_link(session_uuid), + country_code: Phonelib.parse(formatted_destination_phone).country, + sp_or_app_name: sp_or_app_name, + ) + end + + def build_telephony_form_response(telephony_result) + FormResponse.new( + success: telephony_result.success?, + errors: { message: telephony_result.error&.friendly_message }, + extra: { + telephony_response: telephony_result.to_h, + destination: :link_sent, + }, + ) + end + def mobile_device? # See app/javascript/packs/document-capture-welcome.js # And app/services/idv/steps/agreement_step.rb diff --git a/app/views/idv/doc_auth/_combined_upload.html.erb b/app/views/idv/doc_auth/_combined_upload.html.erb new file mode 100644 index 00000000000..e72be579759 --- /dev/null +++ b/app/views/idv/doc_auth/_combined_upload.html.erb @@ -0,0 +1,74 @@ +<% title t('titles.doc_auth.upload') %> + +<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %> + +<%= render PageHeadingComponent.new do %> + <%= t('doc_auth.headings.combined_upload') %> +<% end %> + +

+ <%= t('doc_auth.info.combined_upload') %> +

+ +
+
+ <%= image_tag( + asset_url('idv/phone-icon.svg'), + alt: t('image_description.camera_mobile_phone'), + width: 88, + height: 88, + ) %> +
+
+
+ <%= t('doc_auth.info.tag') %> +
+

+ <%= t('doc_auth.headings.combined_upload_from_phone') %> +

+ <%= t('doc_auth.info.combined_upload_from_phone') %> + <%= simple_form_for( + idv_phone_form, + as: :doc_auth, + url: url_for(type: :mobile, combined: true), + method: 'PUT', + html: { autocomplete: 'off' }, + ) do |f| %> + <%= render PhoneInputComponent.new( + form: f, + required: true, + delivery_methods: [:sms], + class: 'margin-bottom-4', + ) %> + <%= f.submit t('forms.buttons.send_link') %> + <% end %> +
+
+ +
+
+
+ <%= image_tag( + asset_url('idv/laptop-icon.svg'), + alt: t('image_description.laptop'), + width: 88, + height: 88, + ) %> +
+
+

+ <%= t('doc_auth.headings.combined_upload_from_computer') %> +

+ <%= t('doc_auth.info.combined_upload_from_computer') %>  + <%= simple_form_for( + :doc_auth, + url: url_for(type: :desktop), + method: 'PUT', + class: 'margin-bottom-4', + ) do |f| %> + <%= f.submit t('forms.buttons.upload_photos'), outline: true %> + <% end %> +
+
+ +<%= render 'idv/doc_auth/cancel', step: 'upload' %> diff --git a/app/views/idv/doc_auth/_upload.html.erb b/app/views/idv/doc_auth/_upload.html.erb new file mode 100644 index 00000000000..e2f8db520f9 --- /dev/null +++ b/app/views/idv/doc_auth/_upload.html.erb @@ -0,0 +1,55 @@ +<% title t('titles.doc_auth.upload') %> + +<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %> + +<%= render PageHeadingComponent.new do %> + <%= t('doc_auth.headings.upload') %> +<% end %> + +

+ <%= t('doc_auth.info.upload') %> +

+ +
+ +
+
+ <%= image_tag( + asset_url('idv/phone.png'), + alt: t('image_description.camera_mobile_phone'), + width: 80, + height: 119, + ) %> +
+
+
+ <%= t('doc_auth.info.tag') %> +
+

+ <%= t('doc_auth.headings.upload_from_phone') %> +

+ <%= t('doc_auth.info.upload_from_phone') %> + <%= simple_form_for( + :doc_auth, + url: url_for(type: :mobile), + method: 'PUT', + html: { autocomplete: 'off', class: 'margin-top-2' }, + ) do |f| %> + <%= f.submit t('doc_auth.buttons.use_phone'), wide: false, class: 'margin-top-05' %> + <% end %> +
+
+ +
+ +<%= t('doc_auth.info.upload_from_computer') %>  +<%= simple_form_for( + :doc_auth, + url: url_for(type: :desktop), + method: 'PUT', + html: { class: 'display-inline' }, + ) do |f| %> + <%= f.submit t('doc_auth.info.upload_computer_link'), unstyled: true, big: false %> +<% end %> + +<%= render 'idv/doc_auth/cancel', step: 'upload' %> diff --git a/app/views/idv/doc_auth/upload.html.erb b/app/views/idv/doc_auth/upload.html.erb index 310e189aa6c..16061aa99d4 100644 --- a/app/views/idv/doc_auth/upload.html.erb +++ b/app/views/idv/doc_auth/upload.html.erb @@ -1,55 +1,10 @@ -<% title t('titles.doc_auth.upload') %> - -<%= render 'idv/doc_auth/error_messages', flow_session: flow_session %> - -<%= render PageHeadingComponent.new do %> - <%= t('doc_auth.headings.upload') %> +<% if IdentityConfig.store.doc_auth_combined_hybrid_handoff_enabled %> + <%= render partial: 'idv/doc_auth/combined_upload', locals: { + flow_session: flow_session, + idv_phone_form: idv_phone_form, + } %> +<% else %> + <%= render partial: 'idv/doc_auth/upload', locals: { + flow_session: flow_session, + } %> <% end %> - -

- <%= t('doc_auth.info.upload') %> -

- -
- -
-
- <%= image_tag( - asset_url('idv/phone.png'), - alt: t('image_description.camera_mobile_phone'), - width: 80, - height: 119, - ) %> -
-
-
- <%= t('doc_auth.info.tag') %> -
-

- <%= t('doc_auth.headings.upload_from_phone') %> -

- <%= t('doc_auth.info.upload_from_phone') %> - <%= simple_form_for( - :doc_auth, - url: url_for(type: :mobile), - method: 'PUT', - html: { autocomplete: 'off', class: 'margin-top-2' }, - ) do |f| %> - <%= f.submit t('doc_auth.buttons.use_phone'), wide: false, class: 'margin-top-05' %> - <% end %> -
-
- -
- -<%= t('doc_auth.info.upload_from_computer') %>  -<%= simple_form_for( - :doc_auth, - url: url_for(type: :desktop), - method: 'PUT', - html: { class: 'display-inline' }, - ) do |f| %> - <%= f.submit t('doc_auth.info.upload_computer_link'), unstyled: true, big: false %> -<% end %> - -<%= render 'idv/doc_auth/cancel', step: 'upload' %> diff --git a/config/application.yml.default b/config/application.yml.default index e9b1e7412f4..351be8be1cb 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -65,6 +65,7 @@ broken_personal_key_window_start: '2021-07-29T00:00:00Z' broken_personal_key_window_finish: '2021-09-22T00:00:00Z' component_previews_enabled: false country_phone_number_overrides: '{}' +doc_auth_combined_hybrid_handoff_enabled: false doc_auth_error_dpi_threshold: 290 doc_auth_error_sharpness_threshold: 40 doc_auth_error_glare_threshold: 40 diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index 3bb2eaa7457..293b7869c76 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -113,6 +113,9 @@ en: information below is incorrect, please %{link} of your state-issued ID. capture_scan_warning_link: upload new photos capture_troubleshooting_tips: Having trouble adding your state-issued ID? + combined_upload: How would you like to add your ID? + combined_upload_from_computer: Continue on this computer + combined_upload_from_phone: Use your phone to take photos document_capture: Add your state-issued ID document_capture_back: Back of your ID document_capture_front: Front of your ID @@ -146,6 +149,11 @@ en: capture_status_none: Align capture_status_small_document: Move Closer capture_status_tap_to_capture: Tap to Capture + combined_upload: We’ll collect information about you by reading your state-issued ID. + combined_upload_from_computer: Don’t have a phone? Upload photos of your ID from this computer. + combined_upload_from_phone: You won’t have to sign in again, and you’ll switch + back to this computer after you take photos. Your mobile phone must have + a camera and a web browser. document_capture_intro_acknowledgment: We’ll collect information about you by reading your state-issued ID. We use this information to verify your identity. diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index a8091e798e3..8759204de69 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -139,6 +139,9 @@ es: de su ID emitido por el estado. capture_scan_warning_link: suba nuevas fotos capture_troubleshooting_tips: '¿Tiene problemas para agregar su identificación emitida por el estado?' + combined_upload: '¿Cómo desea añadir su documento de identidad?' + combined_upload_from_computer: Continuar en esta computadora + combined_upload_from_phone: Utilice su teléfono para tomar las fotos document_capture: Añada su documento de identidad expedido por el estado document_capture_back: Parte trasera de su documento de identidad document_capture_front: Parte delantera de su documento de identidad @@ -175,6 +178,13 @@ es: capture_status_none: Alinea capture_status_small_document: Muévete mas cerca capture_status_tap_to_capture: Toque para capturar + combined_upload: Recopilaremos información sobre usted leyendo su documento de + identidad expedido por el estado. + combined_upload_from_computer: ¿No tiene teléfono? Suba fotos de su documento de + identidad desde esta computadora. + combined_upload_from_phone: No tendrá que volver a iniciar sesión y volverá a + cambiar a esta computadora después de tomar las fotos. Su teléfono móvil + debe tener una cámara y un navegador web. document_capture_intro_acknowledgment: Recopilaremos información sobre usted leyendo su documento de identidad expedido por el Estado. Usamos esta información para verificar su identidad. diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 6be14becdc2..ca6d047fc38 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -145,6 +145,9 @@ fr: veuillez %{link} de votre carte d’identité délivrée par l’État. capture_scan_warning_link: télécharger de nouvelles photos capture_troubleshooting_tips: Vous rencontrez des difficultés pour ajouter votre pièce d’identité? + combined_upload: Comment voulez-vous ajouter votre identifiant ? + combined_upload_from_computer: Continuer sur cet ordinateur + combined_upload_from_phone: Utilisez votre téléphone pour prendre des photos document_capture: Ajoutez votre carte d’identité délivrée par l’État document_capture_back: Verso de votre carte d’identité document_capture_front: Recto de votre carte d’identité @@ -181,6 +184,13 @@ fr: capture_status_none: Alignez capture_status_small_document: Approchez-vous capture_status_tap_to_capture: Appuyez pour capturer + combined_upload: Nous recueillons des informations sur vous en lisant votre + carte d’identité délivrée par l’État. + combined_upload_from_computer: Vous n’avez pas de téléphone ? Téléchargez les + photos de votre carte d’identité depuis cet ordinateur. + combined_upload_from_phone: Vous n’aurez pas à vous reconnecter. Vous reviendrez + sur cet ordinateur après avoir pris des photos. Votre téléphone portable + doit être équipé d’un appareil photo et d’un navigateur web. document_capture_intro_acknowledgment: Nous recueillons des informations sur vous en lisant votre pièce d’identité délivrée par l’État. Nous utilisons ces informations pour vérifier votre identité. @@ -218,7 +228,7 @@ fr: take_picture: Utilisez l’appareil photo sur votre téléphone portable et téléchargez des images de votre identifiant. Nous utilisons uniquement les images pour vérifier votre identité. - upload: Nous recueillons des informations vous concernant en lisant votre carte + upload: Nous recueillons des informations sur vous en lisant votre carte d’identité délivrée par l’État. upload_computer_link: Téléchargez depuis votre ordinateur upload_from_computer: Vous n’avez pas de téléphone? diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index e9d01484d10..e419da73a71 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -42,11 +42,13 @@ en: edit: Edit manage: Manage resend_confirmation: Resend confirmation instructions + send_link: Send link send_one_time_code: Send code submit: confirm_change: Confirm change default: Submit update: Update + upload_photos: Upload photos confirmation: show_hdr: Create a strong password email: diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 2d8553542f4..309a2642399 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -47,11 +47,13 @@ es: edit: Editar manage: Administrar resend_confirmation: Reenviar instrucciones de confirmación + send_link: Enviar enlace send_one_time_code: Enviar código submit: confirm_change: Confirmar cambio default: Enviar update: Actualización + upload_photos: Subir fotos confirmation: show_hdr: Crear una contraseña segura email: diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index c4a0b1a0319..396d0c49004 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -48,11 +48,13 @@ fr: edit: Modifier manage: Administrer resend_confirmation: Envoyer les instructions de confirmation de nouveau + send_link: Envoyer le lien send_one_time_code: Envoyer le code submit: confirm_change: Confirmer le changement default: Soumettre update: Mettre à jour + upload_photos: Télécharger des photos confirmation: show_hdr: Créez un mot de passe fort email: diff --git a/config/locales/image_description/en.yml b/config/locales/image_description/en.yml index 256abf41038..0ad51b973e4 100644 --- a/config/locales/image_description/en.yml +++ b/config/locales/image_description/en.yml @@ -6,6 +6,7 @@ en: error: Red error x error_lock: Red error lock info_question: Blue question mark + laptop: Laptop computer personal_key: Personal key totp_qrcode: QR code for authenticator app us_flag: US flag diff --git a/config/locales/image_description/es.yml b/config/locales/image_description/es.yml index 2fcd4e59573..16f5ec5b245 100644 --- a/config/locales/image_description/es.yml +++ b/config/locales/image_description/es.yml @@ -1,11 +1,12 @@ --- es: image_description: - camera_mobile_phone: Cámara parpadeando en un teléfono móvil + camera_mobile_phone: Cámara del teléfono móvil que parpadea delete: Bote de basura rojo error: x roja de error error_lock: candado rojo de error info_question: signo de interrogación azul + laptop: Computadora portátil personal_key: Clave personal totp_qrcode: Código QR para la aplicación de autenticación us_flag: Bandera de estados unidos diff --git a/config/locales/image_description/fr.yml b/config/locales/image_description/fr.yml index 769d2969d71..2acde6571d7 100644 --- a/config/locales/image_description/fr.yml +++ b/config/locales/image_description/fr.yml @@ -1,11 +1,12 @@ --- fr: image_description: - camera_mobile_phone: Appareil photo clignotant sur un téléphone mobile + camera_mobile_phone: Caméra flash d’un téléphone portable delete: Poubelle rouge error: erreur rouge x error_lock: verrouillage rouge des erreurs info_question: point d’interrogation bleu + laptop: Ordinateur portable personal_key: Clé personnelle totp_qrcode: Code QR pour l’application d’authentification us_flag: Drapeau américain diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 73b03b99e33..f4790c68cf1 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -154,6 +154,7 @@ def self.build_store(config_map) config.add(:doc_auth_attempt_window_in_minutes, type: :integer) config.add(:doc_auth_client_glare_threshold, type: :integer) config.add(:doc_auth_client_sharpness_threshold, type: :integer) + config.add(:doc_auth_combined_hybrid_handoff_enabled, type: :boolean) config.add(:doc_auth_enable_presigned_s3_urls, type: :boolean) config.add(:doc_auth_error_dpi_threshold, type: :integer) config.add(:doc_auth_error_glare_threshold, type: :integer) diff --git a/spec/features/idv/doc_auth/upload_step_spec.rb b/spec/features/idv/doc_auth/upload_step_spec.rb index 6f12ad2fd02..47f00985e9a 100644 --- a/spec/features/idv/doc_auth/upload_step_spec.rb +++ b/spec/features/idv/doc_auth/upload_step_spec.rb @@ -3,88 +3,281 @@ feature 'doc auth upload step' do include IdvStepHelper include DocAuthHelper + include ActionView::Helpers::DateHelper - let(:fake_analytics) { FakeAnalytics.new } - let(:fake_attempts_tracker) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } + context 'with combined hybrid handoff disabled', js: true do + let(:fake_analytics) { FakeAnalytics.new } + let(:fake_attempts_tracker) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } - before do - sign_in_and_2fa_user - allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true) - complete_doc_auth_steps_before_upload_step - allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) - allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker). - and_return(fake_attempts_tracker) - end - - context 'on a mobile device' do before do - allow(BrowserCache).to receive(:parse).and_return(mobile_device) + allow(IdentityConfig.store). + to receive(:doc_auth_combined_hybrid_handoff_enabled). + and_return(false) + sign_in_and_2fa_user + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true) + complete_doc_auth_steps_before_upload_step + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker). + and_return(fake_attempts_tracker) end - it 'proceeds to send link via email page when user chooses to upload from computer' do - expect(fake_attempts_tracker).to receive( - :idv_document_upload_method_selected, - ).with({ upload_method: 'desktop' }) + context 'on a desktop device' do + before do + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(false) + end - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + it 'proceeds to document capture when user chooses to upload from computer' do + expect(fake_attempts_tracker).to receive( + :idv_document_upload_method_selected, + ).with({ upload_method: 'desktop' }) - click_on t('doc_auth.info.upload_computer_link') + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(page).to have_current_path(idv_doc_auth_email_sent_step) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth upload submitted', - hash_including(step: 'upload', destination: :email_sent), - ) - end + click_on t('doc_auth.info.upload_computer_link') - it 'proceeds to document capture when user chooses to use phone' do - expect(fake_attempts_tracker).to receive( - :idv_document_upload_method_selected, - ).with({ upload_method: 'mobile' }) + expect(page).to have_current_path(idv_doc_auth_document_capture_step) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth upload submitted', + hash_including(step: 'upload', destination: :document_capture), + ) + end - click_on t('doc_auth.buttons.use_phone') + it 'proceeds to send link to phone page when user chooses to use phone' do + expect(fake_attempts_tracker).to receive( + :idv_document_upload_method_selected, + ).with({ upload_method: 'mobile' }) - expect(page).to have_current_path(idv_doc_auth_document_capture_step) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth upload submitted', - hash_including(step: 'upload', destination: :document_capture), - ) + click_on t('doc_auth.buttons.use_phone') + + expect(page).to have_current_path(idv_doc_auth_send_link_step) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth upload submitted', + hash_including(step: 'upload', destination: :send_link), + ) + end end end - context 'on a desktop device' do + context 'with combined hybrid handoff enabled' do + let(:fake_analytics) { FakeAnalytics.new } + let(:fake_attempts_tracker) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } + let(:document_capture_session) { DocumentCaptureSession.create! } + let(:idv_send_link_max_attempts) { 3 } + let(:idv_send_link_attempt_window_in_minutes) do + IdentityConfig.store.idv_send_link_attempt_window_in_minutes + end + before do - allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(false) + allow(IdentityConfig.store). + to receive(:doc_auth_combined_hybrid_handoff_enabled). + and_return(true) + sign_in_and_2fa_user + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true) + complete_doc_auth_steps_before_upload_step + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker). + and_return(fake_attempts_tracker) end - it 'proceeds to document capture when user chooses to upload from computer' do - expect(fake_attempts_tracker).to receive( - :idv_document_upload_method_selected, - ).with({ upload_method: 'desktop' }) + context 'on a desktop device', js: true do + before do + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(false) + end - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + it 'proceeds to document capture when user chooses to upload from computer' do + expect(fake_attempts_tracker).to receive( + :idv_document_upload_method_selected, + ).with({ upload_method: 'desktop' }) - click_on t('doc_auth.info.upload_computer_link') + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(page).to have_current_path(idv_doc_auth_document_capture_step) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth upload submitted', - hash_including(step: 'upload', destination: :document_capture), - ) - end + click_upload_from_computer + + expect(page).to have_current_path(idv_doc_auth_document_capture_step) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth upload submitted', + hash_including(step: 'upload', destination: :document_capture), + ) + end + + it "defaults phone to user's 2fa phone number" do + field = page.find_field(t('two_factor_authentication.phone_label')) + expect(field.value).to eq('(202) 555-1212') + end + + it 'proceeds to link sent page when user chooses to use phone' do + expect(fake_attempts_tracker).to receive( + :idv_document_upload_method_selected, + ).with({ upload_method: 'mobile' }) + + click_send_link + + expect(page).to have_current_path(idv_doc_auth_link_sent_step) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth upload submitted', + hash_including(step: 'upload', destination: :link_sent), + ) + end + + it 'proceeds to the next page with valid info' do + expect(fake_attempts_tracker).to receive( + :idv_phone_upload_link_sent, + ).with( + success: true, + phone_number: '+1 415-555-0199', + failure_reason: nil, + ) + + expect(Telephony).to receive(:send_doc_auth_link). + with(hash_including(to: '+1 415-555-0199')). + and_call_original + + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + + expect(page).to have_current_path(idv_doc_auth_link_sent_step) + end + + it 'does not proceed to the next page with invalid info' do + fill_in :doc_auth_phone, with: '' + click_send_link + + expect(page).to have_current_path(idv_doc_auth_upload_step, ignore_query: true) + end + + it 'sends a link that does not contain any underscores' do + # because URLs with underscores sometimes get messed up by carriers + expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + expect(config[:link]).to_not include('_') + + impl.call(**config) + end + + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + + expect(page).to have_current_path(idv_doc_auth_link_sent_step) + end + + it 'does not proceed if Telephony raises an error', js: true do + expect(fake_attempts_tracker).to receive(:idv_phone_upload_link_sent).with( + success: false, + phone_number: '+1 225-555-1000', + failure_reason: { telephony: ['TelephonyError'] }, + ) + fill_in :doc_auth_phone, with: '225-555-1000' + click_send_link + + expect(page).to have_current_path(idv_doc_auth_upload_step, ignore_query: true) + expect(page).to have_content I18n.t('telephony.error.friendly_message.generic') + end + + it 'displays error if user selects a country to which we cannot send SMS', js: true do + page.find('div[aria-label="Country code"]').click + within(page.find('.iti__flag-container', visible: :all)) do + find('span', text: 'Sri Lanka').click + end + focused_input = page.find('.phone-input__number:focus') + + error_message_id = focused_input[:'aria-describedby']&.split(' ')&.find do |id| + page.has_css?(".usa-error-message##{id}") + end + expect(error_message_id).to_not be_empty + + error_message = page.find_by_id(error_message_id) + expect(error_message).to have_content( + t( + 'two_factor_authentication.otp_delivery_preference.sms_unsupported', + location: 'Sri Lanka', + ), + ) + click_send_link + expect(page.find(':focus')).to match_css('.phone-input__number') + end + + it 'throttles sending the link', js: true do + user = user_with_2fa + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_upload_step + timeout = distance_of_time_in_words( + Throttle.attempt_window_in_minutes(:idv_send_link).minutes, + ) + allow(IdentityConfig.store).to receive(:idv_send_link_max_attempts). + and_return(idv_send_link_max_attempts) + + expect(fake_attempts_tracker).to receive( + :idv_phone_send_link_rate_limited, + ).with({ phone_number: '+1 415-555-0199' }) + + freeze_time do + (idv_send_link_max_attempts - 1).times do + expect(page).to_not have_content( + I18n.t('errors.doc_auth.send_link_throttle', timeout: timeout), + ) + + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + + expect(page).to have_current_path(idv_doc_auth_link_sent_step) + + click_doc_auth_back_link + end + + fill_in :doc_auth_phone, with: '415-555-0199' + + click_send_link + expect(page).to have_current_path(idv_doc_auth_upload_step, ignore_query: true) + expect(page).to have_content( + I18n.t( + 'errors.doc_auth.send_link_throttle', + timeout: timeout, + ), + ) + end + expect(fake_analytics).to have_logged_event( + 'Throttler Rate Limit Triggered', + throttle_type: :idv_send_link, + ) + + # Manual expiration is needed for now since the Throttle uses + # Redis ttl instead of expiretime + Throttle.new(throttle_type: :idv_send_link, user: user).reset! + travel_to(Time.zone.now + idv_send_link_attempt_window_in_minutes.minutes) do + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + expect(page).to have_current_path(idv_doc_auth_link_sent_step) + end + end + + it 'includes expected URL parameters' do + allow_any_instance_of(Flow::BaseFlow).to receive(:flow_session).and_return( + document_capture_session_uuid: document_capture_session.uuid, + ) + + expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + params = Rack::Utils.parse_nested_query URI(config[:link]).query + expect(params).to eq('document-capture-session' => document_capture_session.uuid) + + impl.call(**config) + end + + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + end - it 'proceeds to send link to phone page when user chooses to use phone' do - expect(fake_attempts_tracker).to receive( - :idv_document_upload_method_selected, - ).with({ upload_method: 'mobile' }) + it 'sets requested_at on the capture session' do + allow_any_instance_of(Flow::BaseFlow).to receive(:flow_session).and_return( + document_capture_session_uuid: document_capture_session.uuid, + ) - click_on t('doc_auth.buttons.use_phone') + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link - expect(page).to have_current_path(idv_doc_auth_send_link_step) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth upload submitted', - hash_including(step: 'upload', destination: :send_link), - ) + document_capture_session.reload + expect(document_capture_session).to have_attributes(requested_at: a_kind_of(Time)) + end end end end diff --git a/spec/services/idv/steps/upload_step_spec.rb b/spec/services/idv/steps/upload_step_spec.rb new file mode 100644 index 00000000000..5d3beba11de --- /dev/null +++ b/spec/services/idv/steps/upload_step_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +describe Idv::Steps::UploadStep do + context 'with combined hybrid handoff enabled' do + let(:user) { build(:user) } + + let(:service_provider) do + create( + :service_provider, + issuer: 'http://sp.example.com', + app_id: '123', + ) + end + + let(:request) do + double( + 'request', + remote_ip: Faker::Internet.ip_v4_address, + headers: { 'X-Amzn-Trace-Id' => amzn_trace_id }, + ) + end + + let(:params) do + ActionController::Parameters.new( + { + doc_auth: { phone: '(201) 555-1212' }, + }, + ) + end + + let(:controller) do + instance_double( + 'controller', + session: { sp: { issuer: service_provider.issuer } }, + params: params, + current_user: user, + current_sp: service_provider, + analytics: FakeAnalytics.new, + url_options: {}, + request: request, + ) + end + + let(:amzn_trace_id) { SecureRandom.uuid } + + let(:pii_from_doc) do + { + ssn: '123-45-6789', + first_name: 'bob', + } + end + + let(:flow) do + Idv::Flows::DocAuthFlow.new(controller, {}, 'idv/doc_auth').tap do |flow| + flow.flow_session = { pii_from_doc: pii_from_doc } + end + end + + let(:irs_attempts_api_tracker) do + IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new + end + + subject(:step) do + Idv::Steps::SendLinkStep.new(flow) + end + + before do + allow(controller).to receive(:irs_attempts_api_tracker). + and_return(irs_attempts_api_tracker) + allow(IdentityConfig.store). + to receive(:doc_auth_combined_hybrid_handoff_enabled). + and_return(true) + end + + describe '#extra_view_variables' do + it 'includes form' do + expect(step.extra_view_variables).to include( + { + idv_phone_form: be_an_instance_of(Idv::PhoneForm), + }, + ) + end + end + + describe 'the return value from #call' do + let(:response) { step.call } + + it 'includes the telephony response' do + expect(response.extra[:telephony_response]).to eq( + { + errors: {}, + message_id: 'fake-message-id', + request_id: 'fake-message-request-id', + success: true, + }, + ) + end + end + end +end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index 110d19d6f39..ce3708d3b5d 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -35,6 +35,14 @@ def click_doc_auth_back_link click_on '‹ ' + t('forms.buttons.back') end + def click_send_link + click_on t('forms.buttons.send_link') + end + + def click_upload_from_computer + click_on t('forms.buttons.upload_photos') + end + def idv_doc_auth_welcome_step idv_doc_auth_step_path(step: :welcome) end