diff --git a/app/controllers/concerns/idv/step_utilities_concern.rb b/app/controllers/concerns/idv/step_utilities_concern.rb new file mode 100644 index 00000000000..cd8b183cd56 --- /dev/null +++ b/app/controllers/concerns/idv/step_utilities_concern.rb @@ -0,0 +1,41 @@ +module Idv + module StepUtilitiesConcern + extend ActiveSupport::Concern + + def flow_session + user_session['idv/doc_auth'] + end + + # copied from doc_auth_controller + def flow_path + flow_session[:flow_path] + end + + def confirm_pii_from_doc + @pii = flow_session['pii_from_doc'] # hash with indifferent access + return if @pii.present? + flow_session.delete('Idv::Steps::DocumentCaptureStep') + redirect_to idv_doc_auth_url + end + + # Copied from capture_doc_flow.rb + # and from doc_auth_flow.rb + def acuant_sdk_ab_test_analytics_args + capture_session_uuid = flow_session[:document_capture_session_uuid] + if capture_session_uuid + { + acuant_sdk_upgrade_ab_test_bucket: + AbTests::ACUANT_SDK.bucket(capture_session_uuid), + } + else + {} + end + end + + def irs_reproofing? + effective_user&.decorate&.reproof_for_irs?( + service_provider: current_sp, + ).present? + end + end +end diff --git a/app/controllers/idv/ssn_controller.rb b/app/controllers/idv/ssn_controller.rb new file mode 100644 index 00000000000..fc4f938a90e --- /dev/null +++ b/app/controllers/idv/ssn_controller.rb @@ -0,0 +1,88 @@ +module Idv + class SsnController < ApplicationController + include IdvSession + include StepIndicatorConcern + include StepUtilitiesConcern + include Steps::ThreatMetrixStepHelper + + before_action :render_404_if_ssn_controller_disabled + before_action :confirm_two_factor_authenticated + before_action :confirm_pii_from_doc + + attr_accessor :error_message + + def show + increment_step_counts + + analytics.idv_doc_auth_redo_ssn_submitted(**analytics_arguments) if updating_ssn + + analytics.idv_doc_auth_ssn_visited(**analytics_arguments) + + render :show, locals: extra_view_variables + end + + def update + @error_message = nil + form_response = form_submit + + unless form_response.success? + @error_message = form_response.first_error_message + redirect_to idv_ssn_url + end + + flow_session['pii_from_doc'][:ssn] = params[:doc_auth][:ssn] + + analytics.idv_doc_auth_ssn_submitted(**analytics_arguments) + + irs_attempts_api_tracker.idv_ssn_submitted( + ssn: params[:doc_auth][:ssn], + ) + + idv_session.ssn_updated! + + redirect_to idv_verify_info_url + end + + def extra_view_variables + { + updating_ssn: updating_ssn, + success_alert_enabled: !updating_ssn, + **threatmetrix_view_variables, + } + end + + private + + def render_404_if_ssn_controller_disabled + render_not_found unless IdentityConfig.store.doc_auth_ssn_controller_enabled + end + + def analytics_arguments + { + flow_path: flow_path, + step: 'ssn', + step_count: current_flow_step_counts['Idv::Steps::SsnStep'], + analytics_id: 'Doc Auth', + irs_reproofing: irs_reproofing?, + }.merge(**acuant_sdk_ab_test_analytics_args) + end + + def current_flow_step_counts + user_session['idv/doc_auth_flow_step_counts'] ||= {} + user_session['idv/doc_auth_flow_step_counts'].default = 0 + user_session['idv/doc_auth_flow_step_counts'] + end + + def increment_step_counts + current_flow_step_counts['Idv::Steps::SsnStep'] += 1 + end + + def form_submit + Idv::SsnFormatForm.new(current_user).submit(params.require(:doc_auth).permit(:ssn)) + end + + def updating_ssn + flow_session.dig('pii_from_doc', :ssn).present? + end + end +end diff --git a/app/controllers/idv/verify_info_controller.rb b/app/controllers/idv/verify_info_controller.rb index 10e5e6063f4..c60bc760926 100644 --- a/app/controllers/idv/verify_info_controller.rb +++ b/app/controllers/idv/verify_info_controller.rb @@ -1,6 +1,7 @@ module Idv class VerifyInfoController < ApplicationController include IdvSession + include StepUtilitiesConcern before_action :confirm_two_factor_authenticated before_action :confirm_ssn_step_complete @@ -73,21 +74,6 @@ def update private - # copied from doc_auth_controller - def flow_session - user_session['idv/doc_auth'] - end - - def flow_path - flow_session[:flow_path] - end - - def irs_reproofing? - effective_user&.decorate&.reproof_for_irs?( - service_provider: current_sp, - ).present? - end - def analytics_arguments { flow_path: flow_path, @@ -98,20 +84,6 @@ def analytics_arguments }.merge(**acuant_sdk_ab_test_analytics_args) end - # Copied from capture_doc_flow.rb - # and from doc_auth_flow.rb - def acuant_sdk_ab_test_analytics_args - capture_session_uuid = flow_session[:document_capture_session_uuid] - if capture_session_uuid - { - acuant_sdk_upgrade_ab_test_bucket: - AbTests::ACUANT_SDK.bucket(capture_session_uuid), - } - else - {} - end - end - # copied from verify_step def pii @pii = flow_session[:pii_from_doc] if flow_session @@ -125,7 +97,11 @@ def delete_pii # copied from address_controller def confirm_ssn_step_complete return if pii.present? && pii[:ssn].present? - redirect_to idv_doc_auth_url + if IdentityConfig.store.doc_auth_ssn_controller_enabled + redirect_to idv_ssn_url + else + redirect_to idv_doc_auth_url + end end def confirm_profile_not_already_confirmed diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 3f7234bf895..08b7d97a3d2 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -136,6 +136,24 @@ def user_phone_confirmation_session=(new_user_phone_confirmation_session) session[:user_phone_confirmation_session] = new_user_phone_confirmation_session.to_h end + def ssn_updated! + # Guard against unvalidated attributes from in-person flow in review controller + session[:applicant] = nil + + invalidate_verify_info_step! + invalidate_phone_step! + end + + def invalidate_verify_info_step! + session[:resolution_successful] = nil + session[:profile_confirmation] = nil + end + + def invalidate_phone_step! + session[:vendor_phone_confirmation] = nil + session[:user_phone_confirmation] = nil + end + private attr_accessor :user_session diff --git a/app/services/idv/steps/document_capture_step.rb b/app/services/idv/steps/document_capture_step.rb index e2df7019cdd..9f3408b2f5a 100644 --- a/app/services/idv/steps/document_capture_step.rb +++ b/app/services/idv/steps/document_capture_step.rb @@ -15,6 +15,8 @@ def self.analytics_submitted_event def call handle_stored_result if !FeatureManagement.document_capture_async_uploads_enabled? + + exit_flow_state_machine if IdentityConfig.store.doc_auth_ssn_controller_enabled end def extra_view_variables @@ -38,6 +40,11 @@ def extra_view_variables private + def exit_flow_state_machine + flow_session[:flow_path] = @flow.flow_path + redirect_to idv_ssn_url + end + def native_camera_ab_testing_variables { acuant_sdk_upgrade_ab_test_bucket: diff --git a/app/views/idv/ssn/show.html.erb b/app/views/idv/ssn/show.html.erb new file mode 100644 index 00000000000..f744c8ccedb --- /dev/null +++ b/app/views/idv/ssn/show.html.erb @@ -0,0 +1,91 @@ +<%# +Renders a page asking the user to enter their SSN or update their SSN if they had previously entered it. + +locals: +* success_alert_enabled: whether or not to display a "We've successfully verified your ID" success alert +* updating_ssn: true if the user is updating their SSN instead of providing it for the first time. This + will render a different page heading and different navigation buttons in the page footer +%> +<% content_for(:pre_flash_content) do %> + <%= render StepIndicatorComponent.new( + steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS, + current_step: :verify_info, + locale_scope: 'idv', + class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', + ) %> +<% end %> + +<% title t('titles.doc_auth.ssn') %> + +<% if success_alert_enabled %> + <%= render AlertComponent.new( + type: :success, + class: 'margin-bottom-4', + ) do %> + <%= t('doc_auth.headings.capture_complete') %> + <% end %> +<% end %> + +<% if updating_ssn %> + <%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.ssn_update')) %> +<% else %> + <%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.ssn')) %> +<% end %> + +

+ <%= t('doc_auth.info.ssn') %> + <%= new_window_link_to(t('doc_auth.instructions.learn_more'), MarketingSite.security_and_privacy_practices_url) %> +

+ +<% if FeatureManagement.proofing_device_profiling_collecting_enabled? %> + <% if threatmetrix_session_id.present? %> + <% threatmetrix_javascript_urls.each do |threatmetrix_javascript_url| %> + <%= javascript_include_tag threatmetrix_javascript_url, nonce: true %> + <% end %> + + <% end %> +<% end %> + +<% if IdentityConfig.store.proofer_mock_fallback %> +
+
+

+ <%= t('doc_auth.instructions.test_ssn') %> +

+
+
+<% end %> + +<%= simple_form_for( + :doc_auth, + url: idv_ssn_url, + method: :put, + html: { autocomplete: 'off' }, + ) do |f| %> +
+ <%= render 'shared/ssn_field', f: f %> +
+ +

<%= @error_message %>

+ + <%= f.submit class: 'display-block margin-y-5' do %> + <% if updating_ssn %> + <%= t('forms.buttons.submit.update') %> + <% else %> + <%= t('forms.buttons.continue') %> + <% end %> + <% end %> +<% end %> + +<% if updating_ssn %> + <%= render 'idv/shared/back', action: 'cancel_update_ssn' %> +<% else %> + <%= render 'idv/doc_auth/cancel', step: 'ssn' %> +<% end %> diff --git a/app/views/idv/verify_info/show.html.erb b/app/views/idv/verify_info/show.html.erb index 26c528d38ed..42a8f012139 100644 --- a/app/views/idv/verify_info/show.html.erb +++ b/app/views/idv/verify_info/show.html.erb @@ -102,6 +102,16 @@ locals: toggle_label: t('forms.ssn.show'), ) %> +<% if IdentityConfig.store.doc_auth_ssn_controller_enabled %> +
+ <%= button_to( + idv_ssn_url, + method: :get, + class: 'usa-button usa-button--unstyled', + 'aria-label': t('idv.buttons.change_ssn_label'), + ) { t('idv.buttons.change_label') } %> +
+<% else %>
<%= button_to( idv_doc_auth_step_url(step: :redo_ssn), @@ -110,6 +120,7 @@ locals: 'aria-label': t('idv.buttons.change_ssn_label'), ) { t('idv.buttons.change_label') } %>
+<% end %>
<%= render SpinnerButtonComponent.new( diff --git a/config/application.yml.default b/config/application.yml.default index 39df9509e4d..90352964ee5 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -70,6 +70,7 @@ country_phone_number_overrides: '{}' doc_auth_error_dpi_threshold: 290 doc_auth_error_sharpness_threshold: 40 doc_auth_error_glare_threshold: 40 +doc_auth_ssn_controller_enabled: false database_pool_extra_connections_for_worker: 4 database_pool_idp: 5 database_statement_timeout: 2_500 diff --git a/config/routes.rb b/config/routes.rb index 497774d2ffb..8f31eb5ac83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -308,6 +308,8 @@ post '/forgot_password' => 'forgot_password#update' get '/otp_delivery_method' => 'otp_delivery_method#new' put '/otp_delivery_method' => 'otp_delivery_method#create' + get '/ssn' => 'ssn#show' + put '/ssn' => 'ssn#update' get '/verify_info' => 'verify_info#show' put '/verify_info' => 'verify_info#update' get '/phone' => 'phone#new' diff --git a/lib/identity_config.rb b/lib/identity_config.rb index b206c48da18..ebd4f974ec1 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -166,6 +166,7 @@ def self.build_store(config_map) config.add(:doc_auth_max_submission_attempts_before_native_camera, type: :integer) config.add(:doc_auth_max_capture_attempts_before_tips, type: :integer) config.add(:doc_auth_s3_request_timeout, type: :integer) + config.add(:doc_auth_ssn_controller_enabled, type: :boolean) config.add(:doc_auth_vendor, type: :string) config.add(:doc_auth_vendor_randomize, type: :boolean) config.add(:doc_auth_vendor_randomize_percent, type: :integer) diff --git a/spec/controllers/idv/ssn_controller_spec.rb b/spec/controllers/idv/ssn_controller_spec.rb new file mode 100644 index 00000000000..b12d9dc9c73 --- /dev/null +++ b/spec/controllers/idv/ssn_controller_spec.rb @@ -0,0 +1,175 @@ +require 'rails_helper' + +describe Idv::SsnController do + include IdvHelper + + let(:flow_session) do + { 'document_capture_session_uuid' => 'fd14e181-6fb1-4cdc-92e0-ef66dad0df4e', + 'pii_from_doc' => Idp::Constants::MOCK_IDV_APPLICANT.dup, + :threatmetrix_session_id => 'c90ae7a5-6629-4e77-b97c-f1987c2df7d0', + :flow_path => 'standard' } + end + + let(:user) { build(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) } + + before do + allow(subject).to receive(:flow_session).and_return(flow_session) + stub_sign_in(user) + end + + describe 'before_actions' do + it 'checks that feature flag is enabled' do + expect(subject).to have_actions( + :before, + :render_404_if_ssn_controller_disabled, + ) + end + + it 'includes authentication before_action' do + expect(subject).to have_actions( + :before, + :confirm_two_factor_authenticated, + ) + end + + it 'checks that the previous step is complete' do + expect(subject).to have_actions( + :before, + :confirm_pii_from_doc, + ) + end + end + + context 'when doc_auth_ssn_controller_enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + stub_analytics + stub_attempts_tracker + allow(@analytics).to receive(:track_event) + end + + describe '#show' do + let(:analytics_name) { 'IdV: doc auth ssn visited' } + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + flow_path: 'standard', + irs_reproofing: false, + step: 'ssn', + step_count: 1, + } + end + + context 'when doc_auth_ssn_controller_enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + end + + it 'renders the show template' do + get :show + + expect(response).to render_template :show + end + + it 'sends analytics_visited event' do + get :show + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + + it 'sends correct step count to analytics' do + get :show + get :show + analytics_args[:step_count] = 2 + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + end + end + + describe '#update' do + context 'with valid ssn' do + let(:ssn) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN[:ssn] } + let(:params) { { doc_auth: { ssn: ssn } } } + let(:analytics_name) { 'IdV: doc auth ssn submitted' } + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + flow_path: 'standard', + irs_reproofing: false, + step: 'ssn', + step_count: 1, + } + end + + it 'merges ssn into pii session value' do + put :update, params: params + + expect(flow_session['pii_from_doc'][:ssn]).to eq(ssn) + end + + it 'sends analytics_submitted event with correct step count' do + get :show + put :update, params: params + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + + it 'logs attempts api event' do + expect(@irs_attempts_api_tracker).to receive(:idv_ssn_submitted).with( + ssn: ssn, + ) + put :update, params: params + end + + context 'with existing session applicant' do + it 'clears applicant' do + subject.idv_session.applicant = Idp::Constants::MOCK_IDV_APPLICANT + + put :update, params: params + + expect(subject.idv_session.applicant).to be_blank + end + end + + it 'adds a threatmetrix session id to flow session' do + subject.extra_view_variables + expect(flow_session[:threatmetrix_session_id]).to_not eq(nil) + end + + it 'does not change threatmetrix_session_id when updating ssn' do + flow_session['pii_from_doc'][:ssn] = ssn + put :update, params: params + session_id = flow_session[:threatmetrix_session_id] + subject.extra_view_variables + expect(flow_session[:threatmetrix_session_id]).to eq(session_id) + end + end + + context 'when pii_from_doc is not present' do + it 'marks previous step as incomplete' do + flow_session.delete('pii_from_doc') + flow_session['Idv::Steps::DocumentCaptureStep'] = true + put :update + expect(flow_session['Idv::Steps::DocumentCaptureStep']).to eq nil + expect(response.status).to eq 302 + end + end + end + end + + context 'when doc_auth_ssn_controller_enabled is false' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(false) + end + + it 'returns 404' do + get :show + + expect(response.status).to eq(404) + end + end +end diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index bea98420782..ac56fda9afd 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -100,10 +100,36 @@ ).increment_to_throttled! end - it 'redirects to ssn failure url' do - get :show + context 'when using new ssn controller' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + end - expect(response).to redirect_to idv_session_errors_ssn_failure_url + it 'redirects to ssn controller when ssn info is missing' do + flow_session[:pii_from_doc][:ssn] = nil + + get :show + + expect(response).to redirect_to(idv_ssn_url) + end + end + + context 'when the user is ssn throttled' do + before do + Throttle.new( + target: Pii::Fingerprinter.fingerprint( + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN[:ssn], + ), + throttle_type: :proof_ssn, + ).increment_to_throttled! + end + + it 'redirects to ssn failure url' do + get :show + + expect(response).to redirect_to idv_session_errors_ssn_failure_url + end end end diff --git a/spec/features/idv/doc_auth/document_capture_step_spec.rb b/spec/features/idv/doc_auth/document_capture_step_spec.rb index bce2bbdefa5..a630d6094c0 100644 --- a/spec/features/idv/doc_auth/document_capture_step_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_step_spec.rb @@ -207,6 +207,20 @@ end end + context 'when new ssn controller is enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + end + it 'redirects to ssn controller' do + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + attach_and_submit_images + + expect(page).to have_current_path(idv_ssn_url) + end + end + def next_step idv_doc_auth_ssn_step end diff --git a/spec/features/idv/doc_auth/ssn_spec.rb b/spec/features/idv/doc_auth/ssn_spec.rb new file mode 100644 index 00000000000..d1d7c6b373d --- /dev/null +++ b/spec/features/idv/doc_auth/ssn_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +feature 'doc auth ssn step', :js do + include IdvStepHelper + include DocAuthHelper + include DocCaptureHelper + + before do + allow(IdentityConfig.store).to receive(:proofing_device_profiling).and_return(:enabled) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_org_id).and_return('test_org') + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled).and_return(true) + + sign_in_and_2fa_user + complete_doc_auth_steps_before_ssn_step + end + + it 'proceeds to the next page with valid info' do + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + + fill_out_ssn_form_ok + + match = page.body.match(/session_id=(?[^"&]+)/) + session_id = match && match[:session_id] + expect(session_id).to be_present + + select 'Review', from: 'mock_profiling_result' + + expect(page.find_field(t('idv.form.ssn_label_html'))['aria-invalid']).to eq('false') + click_idv_continue + + expect(page).to have_current_path(idv_verify_info_url) + + profiling_result = Proofing::Mock::DeviceProfilingBackend.new.profiling_result(session_id) + expect(profiling_result).to eq('review') + end + + it 'does not proceed to the next page with invalid info' do + fill_out_ssn_form_fail + click_idv_continue + + expect(page.find_field(t('idv.form.ssn_label_html'))['aria-invalid']).to eq('true') + + expect(page).to have_current_path(idv_ssn_url) + end +end diff --git a/spec/features/idv/doc_auth/verify_info_step_spec.rb b/spec/features/idv/doc_auth/verify_info_step_spec.rb index 64cd6c07a85..fd281d50f1c 100644 --- a/spec/features/idv/doc_auth/verify_info_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_info_step_spec.rb @@ -374,4 +374,32 @@ expect(page).to have_current_path(idv_phone_path) end end + + context 'with ssn_controller enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_ssn_controller_enabled). + and_return(true) + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + sign_in_and_2fa_user + complete_doc_auth_steps_before_verify_step + end + + it 'uses ssn controller to enter a new ssn and displays updated info' do + click_button t('idv.buttons.change_ssn_label') + expect(page).to have_current_path(idv_ssn_path) + + fill_in t('idv.form.ssn_label_html'), with: '900456789' + click_button t('forms.buttons.submit.update') + + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth redo_ssn submitted', + ) + + expect(page).to have_current_path(idv_verify_info_path) + + expect(page).to have_text('9**-**-***9') + check t('forms.ssn.show') + expect(page).to have_text('900-45-6789') + end + end end