diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 1e43757e7a4..1a873998793 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -1,7 +1,9 @@ module Idv module DocumentCaptureConcern def override_document_capture_step_csp - return if params[:step] != 'document_capture' + if !IdentityConfig.store.doc_auth_document_capture_controller_enabled + return if params[:step] != 'document_capture' + end policy = current_content_security_policy policy.connect_src(*policy.connect_src, 'us.acas.acuant.net') diff --git a/app/controllers/concerns/idv/step_utilities_concern.rb b/app/controllers/concerns/idv/step_utilities_concern.rb index abe4a2c268c..626abf69c62 100644 --- a/app/controllers/concerns/idv/step_utilities_concern.rb +++ b/app/controllers/concerns/idv/step_utilities_concern.rb @@ -43,5 +43,15 @@ def irs_reproofing? service_provider: current_sp, ).present? end + + def document_capture_session + @document_capture_session ||= DocumentCaptureSession.find_by( + uuid: flow_session[document_capture_session_uuid_key], + ) + end + + def document_capture_session_uuid_key + :document_capture_session_uuid + end end end diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index 5a75c89d5d9..9505554cba6 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -3,18 +3,35 @@ class DocumentCaptureController < ApplicationController include IdvSession include StepIndicatorConcern include StepUtilitiesConcern + include DocumentCaptureConcern before_action :render_404_if_document_capture_controller_disabled before_action :confirm_two_factor_authenticated + before_action :confirm_agreement_step_complete + before_action :override_document_capture_step_csp def show increment_step_counts analytics.idv_doc_auth_document_capture_visited(**analytics_arguments) + Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). + call('document_capture', :view, true) + render :show, locals: extra_view_variables end + def update + handle_stored_result + + analytics.idv_doc_auth_document_capture_submitted(**analytics_arguments) + + Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). + call('document_capture', :update, true) + + redirect_to idv_ssn_url + end + def extra_view_variables url_builder = ImageUploadPresignedUrlGenerator.new @@ -33,7 +50,6 @@ def extra_view_variables transaction_id: flow_session[:document_capture_session_uuid], ), }.merge( - native_camera_ab_testing_variables, acuant_sdk_upgrade_a_b_testing_variables, in_person_cta_variant_testing_variables, ) @@ -45,6 +61,12 @@ def render_404_if_document_capture_controller_disabled render_not_found unless IdentityConfig.store.doc_auth_document_capture_controller_enabled end + def confirm_agreement_step_complete + return if flow_session['Idv::Steps::AgreementStep'] + + redirect_to idv_doc_auth_url + end + def analytics_arguments { flow_path: flow_path, @@ -65,13 +87,6 @@ def increment_step_counts current_flow_step_counts['Idv::Steps::DocumentCaptureStep'] += 1 end - def native_camera_ab_testing_variables - { - acuant_sdk_upgrade_ab_test_bucket: - AbTests::ACUANT_SDK.bucket(flow_session[:document_capture_session_uuid]), - } - end - def acuant_sdk_upgrade_a_b_testing_variables bucket = AbTests::ACUANT_SDK.bucket(flow_session[:document_capture_session_uuid]) testing_enabled = IdentityConfig.store.idv_acuant_sdk_upgrade_a_b_testing_enabled @@ -97,5 +112,79 @@ def in_person_cta_variant_testing_variables in_person_cta_variant_active: bucket, } end + + def handle_stored_result + if stored_result&.success? + save_proofing_components + extract_pii_from_doc(stored_result, store_in_session: !hybrid_flow_mobile?) + else + extra = { stored_result_present: stored_result.present? } + failure(I18n.t('doc_auth.errors.general.network_error'), extra) + end + end + + def stored_result + return @stored_result if defined?(@stored_result) + @stored_result = document_capture_session&.load_result + end + + def save_proofing_components + return unless current_user + + doc_auth_vendor = DocAuthRouter.doc_auth_vendor( + discriminator: flow_session[document_capture_session_uuid_key], + analytics: analytics, + ) + + component_attributes = { + document_check: doc_auth_vendor, + document_type: 'state_id', + } + ProofingComponent.create_or_find_by(user: current_user).update(component_attributes) + end + + def hybrid_flow_mobile? + user_id_from_token.present? + end + + def user_id_from_token + flow_session[:doc_capture_user_id] + end + + # copied from doc_auth_base_step.rb + # @param [DocAuth::Response, + # DocumentCaptureSessionAsyncResult, + # DocumentCaptureSessionResult] response + def extract_pii_from_doc(response, store_in_session: false) + pii_from_doc = response.pii_from_doc.merge( + uuid: effective_user.uuid, + phone: effective_user.phone_configurations.take&.phone, + uuid_prefix: ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id, + ) + + flow_session[:had_barcode_read_failure] = response.attention_with_barcode? + if store_in_session + flow_session[:pii_from_doc] ||= {} + flow_session[:pii_from_doc].merge!(pii_from_doc) + idv_session.clear_applicant! + end + track_document_state(pii_from_doc[:state]) + end + + def track_document_state(state) + return unless IdentityConfig.store.state_tracking_enabled && state + doc_auth_log = DocAuthLog.find_by(user_id: current_user.id) + return unless doc_auth_log + doc_auth_log.state = state + doc_auth_log.save! + end + + # copied from Flow::Failure module + def failure(message, extra = nil) + flow_session[:error_message] = message + form_response_params = { success: false, errors: { message: message } } + form_response_params[:extra] = extra unless extra.nil? + FormResponse.new(**form_response_params) + end end end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 685969558c4..4640f44f461 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -149,12 +149,16 @@ def phone_confirmed? def invalidate_steps_after_ssn! # Guard against unvalidated attributes from in-person flow in review controller - session[:applicant] = nil + clear_applicant! invalidate_verify_info_step! invalidate_phone_step! end + def clear_applicant! + session[:applicant] = nil + end + def mark_verify_info_step_complete! session[:resolution_successful] = true # This is here to maintain backwards compadibility with old code. diff --git a/config/routes.rb b/config/routes.rb index 22d549d501a..3f7ebbc6f11 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -308,6 +308,7 @@ get '/forgot_password' => 'forgot_password#new' post '/forgot_password' => 'forgot_password#update' get '/document_capture' => 'document_capture#show' + put '/document_capture' => 'document_capture#update' get '/ssn' => 'ssn#show' put '/ssn' => 'ssn#update' get '/verify_info' => 'verify_info#show' diff --git a/spec/controllers/idv/document_capture_controller_spec.rb b/spec/controllers/idv/document_capture_controller_spec.rb index 71f3d831ee0..1539520f7d3 100644 --- a/spec/controllers/idv/document_capture_controller_spec.rb +++ b/spec/controllers/idv/document_capture_controller_spec.rb @@ -7,10 +7,11 @@ { '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' } + :flow_path => 'standard', + 'Idv::Steps::AgreementStep' => true } end - let(:user) { build(:user) } + let(:user) { create(:user) } let(:service_provider) do create( :service_provider, @@ -41,6 +42,13 @@ :confirm_two_factor_authenticated, ) end + + it 'checks that agreement step is complete' do + expect(subject).to have_actions( + :before, + :confirm_agreement_step_complete, + ) + end end context 'when doc_auth_document_capture_controller_enabled' do @@ -64,28 +72,79 @@ } end - context '#show' do - it 'renders the show template' do - get :show + it 'renders the show template' do + get :show - expect(response).to render_template :show - end + expect(response).to render_template :show + end - it 'sends analytics_visited event' do - get :show + it 'sends analytics_visited event' do + get :show - expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) - end + 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 + + it 'updates DocAuthLog document_capture_view_count' do + doc_auth_log = DocAuthLog.create(user_id: user.id) + + expect { get :show }.to( + change { doc_auth_log.reload.document_capture_view_count }.from(0).to(1), + ) + end + + context 'agreement step is not complete' do + it 'redirects to idv_doc_auth_url' do + flow_session['Idv::Steps::AgreementStep'] = nil - 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) + expect(response).to redirect_to(idv_doc_auth_url) end end end + + describe '#update' do + let(:analytics_name) { 'IdV: doc auth document_capture submitted' } + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + flow_path: 'standard', + irs_reproofing: false, + step: 'document capture', + step_count: 1, + } + end + + it 'does not raise an exception when stored_result is nil' do + allow(FeatureManagement).to receive(:document_capture_async_uploads_enabled?). + and_return(false) + allow(subject).to receive(:stored_result).and_return(nil) + put :update + end + + it 'sends analytics_submitted event with correct step count' do + get :show + put :update + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + + it 'updates DocAuthLog document_capture_submit_count' do + doc_auth_log = DocAuthLog.create(user_id: user.id) + + expect { put :update }.to( + change { doc_auth_log.reload.document_capture_submit_count }.from(0).to(1), + ) + end + end end context 'when doc_auth_document_capture_controller_enabled is false' do diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 40b20635272..fa354541058 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -24,10 +24,10 @@ sign_in_and_2fa_user(user) complete_doc_auth_steps_before_document_capture_step + visit(idv_document_capture_url) end it 'shows the new DocumentCapture page for desktop standard flow' do - visit(idv_document_capture_url) expect(page).to have_current_path(idv_document_capture_url) expect(page).to have_content(t('doc_auth.headings.document_capture')) @@ -43,4 +43,110 @@ acuant_sdk_upgrade_ab_test_bucket: :default, ) end + + it 'logs return to sp link click' do + new_window = window_opened_by do + click_on t('idv.troubleshooting.options.get_help_at_sp', sp_name: sp_name) + end + + within_window new_window do + expect(fake_analytics).to have_logged_event( + 'Return to SP: Failed to proof', + flow: nil, + location: 'document_capture_troubleshooting_options', + redirect_url: instance_of(String), + step: 'document_capture', + ) + end + end + + context 'throttles calls to acuant', allow_browser_log: true do + let(:fake_attempts_tracker) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } + before do + allow_any_instance_of(ApplicationController).to receive( + :irs_attempts_api_tracker, + ).and_return(fake_attempts_tracker) + allow(fake_attempts_tracker).to receive(:idv_document_upload_rate_limited) + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + + (max_attempts - 1).times do + attach_and_submit_images + click_on t('idv.failure.button.warning') + end + end + + it 'redirects to the throttled error page' do + freeze_time do + attach_and_submit_images + timeout = distance_of_time_in_words( + Throttle.attempt_window_in_minutes(:idv_doc_auth).minutes, + ) + message = strip_tags(t('errors.doc_auth.throttled_text_html', timeout: timeout)) + expect(page).to have_content(message) + expect(page).to have_current_path(idv_session_errors_throttled_path) + end + end + + it 'logs the throttled analytics event for doc_auth' do + attach_and_submit_images + expect(fake_analytics).to have_logged_event( + 'Throttler Rate Limit Triggered', + throttle_type: :idv_doc_auth, + ) + end + + it 'logs irs attempts event for rate limiting' do + attach_and_submit_images + expect(fake_attempts_tracker).to have_received(:idv_document_upload_rate_limited) + end + end + + it 'proceeds to the next page with valid info' 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) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: user.id).state).to eq('MT') + end + + it 'catches network connection errors on post_front_image', allow_browser_log: true do + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + + attach_and_submit_images + + expect(page).to have_current_path(idv_document_capture_url) + expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) + end + + it 'does not track state if state tracking is disabled' do + allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(false) + attach_and_submit_images + + expect(DocAuthLog.find_by(user_id: user.id).state).to be_nil + end + + def expect_costing_for_document + %i[acuant_front_image acuant_back_image acuant_result].each do |cost_type| + expect(costing_for(cost_type)).to be_present + end + end + + def costing_for(cost_type) + SpCost.where(ial: 2, issuer: 'urn:gov:gsa:openidconnect:sp:server', cost_type: cost_type.to_s) + end end