diff --git a/Makefile b/Makefile index 059005d688f..bdc5239fdee 100644 --- a/Makefile +++ b/Makefile @@ -26,13 +26,13 @@ ARTIFACT_DESTINATION_FILE ?= ./tmp/idp.tar.gz lint \ lint_analytics_events \ lint_analytics_events_sorted \ - lint_tracker_events \ lint_country_dialing_codes \ lint_erb \ + lint_lockfiles \ lint_optimized_assets \ + lint_tracker_events \ lint_yaml \ lint_yarn_workspaces \ - lint_lockfiles \ lintfix \ normalize_yaml \ optimize_assets \ @@ -78,7 +78,7 @@ endif make lint_tracker_events make lint_analytics_events_sorted @echo "--- brakeman ---" - bundle exec brakeman + make brakeman @echo "--- bundler-audit ---" bundle exec bundler-audit check --update # JavaScript @@ -141,8 +141,8 @@ lintfix: ## Try to automatically fix any Ruby, ERB, JavaScript, YAML, or CSS lin @echo "--- normalize yaml ---" make normalize_yaml -brakeman: ## Runs brakeman - bundle exec brakeman +brakeman: ## Runs brakeman code security check + (bundle exec brakeman) || (echo "Error: update code as needed to remove security issues. For known exceptions already in brakeman.ignore, use brakeman to interactively update exceptions."; exit 1) public/packs/manifest.json: yarn.lock $(shell find app/javascript -type f) ## Builds JavaScript assets yarn build diff --git a/app/assets/stylesheets/tables-report.css.scss b/app/assets/stylesheets/tables-report.css.scss new file mode 100644 index 00000000000..71094a32411 --- /dev/null +++ b/app/assets/stylesheets/tables-report.css.scss @@ -0,0 +1,8 @@ +@forward 'uswds-core'; +@forward 'usa-prose'; +@forward 'usa-table'; + +.table-number { + font-variant-numeric: tabular-nums; + text-align: right; +} diff --git a/app/controllers/accounts/connected_accounts_controller.rb b/app/controllers/accounts/connected_accounts_controller.rb index da0e0940767..ada1d08095e 100644 --- a/app/controllers/accounts/connected_accounts_controller.rb +++ b/app/controllers/accounts/connected_accounts_controller.rb @@ -10,7 +10,7 @@ def show decrypted_pii: nil, personal_key: flash[:personal_key], sp_session_request_url: sp_session_request_url_with_updated_params, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, user: current_user, locked_for_session: pii_locked_for_session?(current_user), ) diff --git a/app/controllers/accounts/history_controller.rb b/app/controllers/accounts/history_controller.rb index 00b69f95789..542626588b1 100644 --- a/app/controllers/accounts/history_controller.rb +++ b/app/controllers/accounts/history_controller.rb @@ -10,7 +10,7 @@ def show decrypted_pii: nil, personal_key: flash[:personal_key], sp_session_request_url: sp_session_request_url_with_updated_params, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, user: current_user, locked_for_session: pii_locked_for_session?(current_user), ) diff --git a/app/controllers/accounts/two_factor_authentication_controller.rb b/app/controllers/accounts/two_factor_authentication_controller.rb index a17fa96ea0c..fb58dcbc14a 100644 --- a/app/controllers/accounts/two_factor_authentication_controller.rb +++ b/app/controllers/accounts/two_factor_authentication_controller.rb @@ -11,7 +11,7 @@ def show decrypted_pii: nil, personal_key: flash[:personal_key], sp_session_request_url: sp_session_request_url_with_updated_params, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, user: current_user, locked_for_session: pii_locked_for_session?(current_user), ) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index f9ec08ed644..ffce59fe5f9 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -13,7 +13,7 @@ def show decrypted_pii: cacher.fetch, personal_key: flash[:personal_key], sp_session_request_url: sp_session_request_url_with_updated_params, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, user: current_user, locked_for_session: pii_locked_for_session?(current_user), ) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index acbb0d18a13..54407ba694c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base include LocaleHelper include VerifySpAttributesConcern include EffectiveUser + include SecondMfaReminderConcern # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. @@ -24,7 +25,7 @@ class ApplicationController < ActionController::Base rescue_from error, with: :render_timeout end - helper_method :decorated_session, :user_fully_authenticated? + helper_method :decorated_sp_session, :user_fully_authenticated? prepend_before_action :add_new_relic_trace_attributes prepend_before_action :session_expires_at @@ -78,15 +79,15 @@ def user_event_creator @user_event_creator ||= UserEventCreator.new(request: request, current_user: current_user) end delegate :create_user_event, :create_user_event_with_disavowal, to: :user_event_creator - delegate :remember_device_default, to: :decorated_session + delegate :remember_device_default, to: :decorated_sp_session - def decorated_session - @decorated_session ||= DecoratedSession.new( + def decorated_sp_session + @decorated_sp_session ||= ServiceProviderSessionCreator.new( sp: current_sp, view_context: view_context, sp_session: sp_session, service_provider_request: service_provider_request, - ).call + ).create_session end def default_url_options @@ -213,6 +214,7 @@ def after_sign_in_path_for(_user) return fix_broken_personal_key_url if current_user.broken_personal_key? return user_session.delete(:stored_location) if user_session.key?(:stored_location) return reactivate_account_url if user_needs_to_reactivate_account? + return second_mfa_reminder_url if user_needs_second_mfa_reminder? return sp_session_request_url_with_updated_params if sp_session.key?(:request_url) signed_in_url end diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 79f3b77a48b..9a32b78e67c 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -44,6 +44,8 @@ def extract_pii_from_doc(user, response, store_in_session: false) if store_in_session flow_session[:pii_from_doc] ||= {} flow_session[:pii_from_doc].merge!(pii_from_doc) + idv_session.pii_from_doc ||= {} + idv_session.pii_from_doc.merge!(pii_from_doc) idv_session.clear_applicant! end end diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index 1af2ce6edbc..47862cf8bbf 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -10,7 +10,6 @@ def shared_update Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). call('verify', :update, true) - pii[:uuid_prefix] = ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id set_state_id_type ssn_rate_limiter.increment! @@ -25,11 +24,8 @@ def shared_update idv_session.vendor_phone_confirmation = false idv_session.user_phone_confirmation = false - # rubocop:disable Layout/LineLength - # TEMPORARY DEBUGGING - logger.info("ResolutionJobDebug: user_uuid=#{current_user.uuid} old=#{pii[:ssn].present?} new=#{idv_session.ssn.present?} controller=#{self.class.name}") - # rubocop:enable Layout/LineLength - pii[:ssn] = idv_session.ssn # Required for proof_resolution job + pii[:uuid_prefix] = ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id + pii[:ssn] = idv_session.ssn Idv::Agent.new(pii).proof_resolution( document_capture_session, should_proof_state_id: should_use_aamva?(pii), @@ -323,6 +319,7 @@ def move_applicant_to_idv_session def delete_pii flow_session.delete(:pii_from_doc) + idv_session.pii_from_doc = nil flow_session.delete(:pii_from_user) end diff --git a/app/controllers/concerns/idv_session.rb b/app/controllers/concerns/idv_session.rb index 82e107452b0..81658a78fca 100644 --- a/app/controllers/concerns/idv_session.rb +++ b/app/controllers/concerns/idv_session.rb @@ -8,7 +8,7 @@ module IdvSession def confirm_idv_needed return if idv_session_user.active_profile.blank? || - decorated_session.requested_more_recent_verification? || + decorated_sp_session.requested_more_recent_verification? || idv_session_user.reproof_for_irs?(service_provider: current_sp) redirect_to idv_activated_url diff --git a/app/controllers/concerns/idv_step_concern.rb b/app/controllers/concerns/idv_step_concern.rb index 157b6d8ab8b..cb5c27aca04 100644 --- a/app/controllers/concerns/idv_step_concern.rb +++ b/app/controllers/concerns/idv_step_concern.rb @@ -38,7 +38,7 @@ def redirect_for_mail_only end def pii_from_doc - flow_session[:pii_from_doc] + idv_session.pii_from_doc || flow_session[:pii_from_doc] end def pii_from_user diff --git a/app/controllers/concerns/mfa_setup_concern.rb b/app/controllers/concerns/mfa_setup_concern.rb index 1f0a290e660..ea4ef91b010 100644 --- a/app/controllers/concerns/mfa_setup_concern.rb +++ b/app/controllers/concerns/mfa_setup_concern.rb @@ -10,8 +10,10 @@ def next_setup_path if user_session[:mfa_selections] analytics.user_registration_mfa_setup_complete( mfa_method_counts: mfa_context.enabled_two_factor_configuration_counts_hash, + in_account_creation_flow: user_session[:in_account_creation_flow] || false, enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, pii_like_keypaths: [[:mfa_method_counts, :phone]], + second_mfa_reminder_conversion: user_session.delete(:second_mfa_reminder_conversion), success: true, ) end @@ -59,6 +61,10 @@ def suggest_second_mfa? mfa_selection_count < 2 && mfa_context.enabled_mfa_methods_count < 2 end + def in_account_creation_flow? + user_session[:in_account_creation_flow] || false + end + def mfa_selection_count user_session[:mfa_selections]&.count || 0 end diff --git a/app/controllers/concerns/remember_device_concern.rb b/app/controllers/concerns/remember_device_concern.rb index 12a35e1a55c..7617fb6b58a 100644 --- a/app/controllers/concerns/remember_device_concern.rb +++ b/app/controllers/concerns/remember_device_concern.rb @@ -18,7 +18,7 @@ def check_remember_device_preference return if remember_device_cookie.nil? return unless remember_device_cookie.valid_for_user?( user: current_user, - expiration_interval: decorated_session.mfa_expiration_interval, + expiration_interval: decorated_sp_session.mfa_expiration_interval, ) handle_valid_remember_device_cookie(remember_device_cookie: remember_device_cookie) @@ -35,7 +35,7 @@ def remember_device_cookie def remember_device_expired_for_sp? expired_for_interval?( current_user, - decorated_session.mfa_expiration_interval, + decorated_sp_session.mfa_expiration_interval, ) end diff --git a/app/controllers/concerns/second_mfa_reminder_concern.rb b/app/controllers/concerns/second_mfa_reminder_concern.rb new file mode 100644 index 00000000000..fd2f33282a4 --- /dev/null +++ b/app/controllers/concerns/second_mfa_reminder_concern.rb @@ -0,0 +1,30 @@ +module SecondMfaReminderConcern + def user_needs_second_mfa_reminder? + return false unless IdentityConfig.store.second_mfa_reminder_enabled + return false if user_has_dismissed_second_mfa_reminder? || user_has_multiple_mfa_methods? + exceeded_sign_in_count_for_second_mfa_reminder? || exceeded_account_age_for_second_mfa_reminder? + end + + private + + def user_has_dismissed_second_mfa_reminder? + current_user.second_mfa_reminder_dismissed_at.present? + end + + def user_has_multiple_mfa_methods? + MfaContext.new(current_user).enabled_mfa_methods_count > 1 + end + + def exceeded_sign_in_count_for_second_mfa_reminder? + current_user.sign_in_count(since: second_mfa_reminder_account_age_cutoff) >= + IdentityConfig.store.second_mfa_reminder_sign_in_count + end + + def exceeded_account_age_for_second_mfa_reminder? + current_user.created_at.before?(second_mfa_reminder_account_age_cutoff) + end + + def second_mfa_reminder_account_age_cutoff + IdentityConfig.store.second_mfa_reminder_account_age_in_days.days.ago + end +end diff --git a/app/controllers/concerns/secure_headers_concern.rb b/app/controllers/concerns/secure_headers_concern.rb index 380b6c291a6..d7e7e3496b1 100644 --- a/app/controllers/concerns/secure_headers_concern.rb +++ b/app/controllers/concerns/secure_headers_concern.rb @@ -21,7 +21,7 @@ def csp_uris # Returns fully formed CSP array w/"'self'" and redirect_uris SecureHeadersAllowList.csp_with_sp_redirect_uris( authorize_params[:redirect_uri], - decorated_session.sp_redirect_uris, + decorated_sp_session.sp_redirect_uris, ) end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index b05a2ebebcf..c3496720b99 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -11,7 +11,7 @@ def show decrypted_pii: nil, personal_key: nil, sp_session_request_url: sp_session_request_url_with_updated_params, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, user: current_user, locked_for_session: pii_locked_for_session?(current_user), ) diff --git a/app/controllers/idv/address_controller.rb b/app/controllers/idv/address_controller.rb index 5e35d7ad7b0..a877101c9e4 100644 --- a/app/controllers/idv/address_controller.rb +++ b/app/controllers/idv/address_controller.rb @@ -28,9 +28,13 @@ def idv_form end def success + # Make sure pii_from_doc is available in both places so we can + # update the address for both and keep them in sync + idv_session.pii_from_doc = pii_from_doc profile_params.each do |key, value| - flow_session[:pii_from_doc][key] = value + idv_session.pii_from_doc[key] = value end + flow_session[:pii_from_doc] = idv_session.pii_from_doc redirect_to idv_verify_info_url end diff --git a/app/controllers/idv/by_mail/enter_code_controller.rb b/app/controllers/idv/by_mail/enter_code_controller.rb index 95376bf6c19..daa730f6211 100644 --- a/app/controllers/idv/by_mail/enter_code_controller.rb +++ b/app/controllers/idv/by_mail/enter_code_controller.rb @@ -102,10 +102,10 @@ def prepare_for_personal_key UserAlerts::AlertUserAboutAccountVerified.call( user: current_user, date_time: event.created_at, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, ) flash[:success] = t('account.index.verification.success') - end + end idv_session.address_verification_mechanism = 'gpo' idv_session.address_confirmed! diff --git a/app/controllers/idv/cancellations_controller.rb b/app/controllers/idv/cancellations_controller.rb index 6cfe98418ef..c70401590b4 100644 --- a/app/controllers/idv/cancellations_controller.rb +++ b/app/controllers/idv/cancellations_controller.rb @@ -10,7 +10,7 @@ def new self.session_go_back_path = go_back_path || idv_path @hybrid_session = hybrid_session? @presenter = CancellationsPresenter.new( - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, url_options: url_options, ) end @@ -72,7 +72,7 @@ def cancel_session end def cancelled_redirect_path - if decorated_session.sp_name + if decorated_sp_session.sp_name return_to_sp_failure_to_proof_path(location_params) else account_path diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index a544879732a..1359b6483ca 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -39,7 +39,7 @@ def extra_view_variables { document_capture_session_uuid: document_capture_session_uuid, flow_path: 'standard', - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), }.merge( acuant_sdk_upgrade_a_b_testing_variables, diff --git a/app/controllers/idv/getting_started_controller.rb b/app/controllers/idv/getting_started_controller.rb index f52905b1c30..8b24f0e6c62 100644 --- a/app/controllers/idv/getting_started_controller.rb +++ b/app/controllers/idv/getting_started_controller.rb @@ -13,7 +13,7 @@ def show Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). call('agreement', :view, true) - @sp_name = decorated_session.sp_name || APP_NAME + @sp_name = decorated_sp_session.sp_name || APP_NAME @title = t('doc_auth.headings.getting_started', sp_name: @sp_name) end diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 1fc52488b2a..f72bb72d350 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -25,12 +25,15 @@ def new **ab_test_analytics_buckets, ) + @title = title + @heading = heading + flash_now = flash.now if gpo_mail_service.mail_spammed? flash_now[:error] = t('idv.errors.mail_limit_reached') - elsif address_verification_method == 'gpo' - flash_now[:info] = t('idv.messages.review.gpo_pending') end + + @verifying_by_mail = address_verification_method == 'gpo' end def create @@ -81,6 +84,18 @@ def step_indicator_step private + def title + gpo_user_flow? ? t('titles.idv.review_letter') : t('titles.idv.review') + end + + def heading + if gpo_user_flow? + t('idv.titles.session.review_letter', app_name: APP_NAME) + else + t('idv.titles.session.review', app_name: APP_NAME) + end + end + def confirm_current_password return if valid_password? @@ -128,7 +143,7 @@ def init_profile UserAlerts::AlertUserAboutAccountVerified.call( user: current_user, date_time: event.created_at, - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, ) end end diff --git a/app/controllers/idv/session_errors_controller.rb b/app/controllers/idv/session_errors_controller.rb index d7b9ca201c3..0ceaf3434b1 100644 --- a/app/controllers/idv/session_errors_controller.rb +++ b/app/controllers/idv/session_errors_controller.rb @@ -32,7 +32,7 @@ def failure rate_limit_type: :idv_resolution, ) @expires_at = rate_limiter.expires_at - @sp_name = decorated_session.sp_name + @sp_name = decorated_sp_session.sp_name log_event(based_on_limiter: rate_limiter) end diff --git a/app/controllers/idv/welcome_controller.rb b/app/controllers/idv/welcome_controller.rb index e5717309c49..9f3ac24af87 100644 --- a/app/controllers/idv/welcome_controller.rb +++ b/app/controllers/idv/welcome_controller.rb @@ -13,7 +13,7 @@ def show Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). call('welcome', :view, true) - @sp_name = decorated_session.sp_name || APP_NAME + @sp_name = decorated_sp_session.sp_name || APP_NAME @title = t('doc_auth.headings.getting_started', sp_name: @sp_name) @ab_test_bucket = getting_started_ab_test_bucket diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index 38b518e9ea4..5dfae346b05 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -10,7 +10,7 @@ class IdvController < ApplicationController before_action :confirm_not_rate_limited def index - if decorated_session.requested_more_recent_verification? || + if decorated_sp_session.requested_more_recent_verification? || current_user.reproof_for_irs?(service_provider: current_sp) verify_identity elsif active_profile? diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index b0e4014c6d4..de08e231166 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -88,7 +88,7 @@ def track_handoff_analytics(result, attributes = {}) def identity_needs_verification? (@authorize_form.ial2_requested? && (current_user.identity_not_verified? || - decorated_session.requested_more_recent_verification?)) || + decorated_sp_session.requested_more_recent_verification?)) || current_user.reproof_for_irs?(service_provider: current_sp) end diff --git a/app/controllers/openid_connect/token_controller.rb b/app/controllers/openid_connect/token_controller.rb index 92299cac934..4b6155f9b16 100644 --- a/app/controllers/openid_connect/token_controller.rb +++ b/app/controllers/openid_connect/token_controller.rb @@ -8,9 +8,14 @@ def create @token_form = OpenidConnectTokenForm.new(token_params) result = @token_form.submit - analytics.openid_connect_token(**result.to_h) + response = @token_form.response - render json: @token_form.response, + analytics_attributes = result.to_h + analytics_attributes[:expires_in] = response[:expires_in] + + analytics.openid_connect_token(**analytics_attributes) + + render json: response, status: (result.success? ? :ok : :bad_request) end diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 08817e30ebc..a9dc846e012 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -152,7 +152,7 @@ def handle_successful_handoff def render_template_for(message, action_url, type) # Returns fully formed CSP array w/"'self'", domain, and ServiceProvider#redirect_uris - redirect_uris = decorated_session.sp_redirect_uris || + redirect_uris = decorated_sp_session.sp_redirect_uris || sp_from_request_issuer_logout&.redirect_uris.to_a.compact csp_uris = SecureHeadersAllowList.csp_with_sp_redirect_uris( action_url, redirect_uris diff --git a/app/controllers/sign_out_controller.rb b/app/controllers/sign_out_controller.rb index 523bb78c3a2..e5c1d6cb09f 100644 --- a/app/controllers/sign_out_controller.rb +++ b/app/controllers/sign_out_controller.rb @@ -6,7 +6,7 @@ def destroy irs_attempts_api_tracker.logout_initiated( success: true, ) - url_after_cancellation = decorated_session.cancel_link_url + url_after_cancellation = decorated_sp_session.cancel_link_url sign_out flash[:success] = t('devise.sessions.signed_out') redirect_to(url_after_cancellation, allow_other_host: true) diff --git a/app/controllers/sign_up/cancellations_controller.rb b/app/controllers/sign_up/cancellations_controller.rb index a10328525f3..9fe393af9c3 100644 --- a/app/controllers/sign_up/cancellations_controller.rb +++ b/app/controllers/sign_up/cancellations_controller.rb @@ -12,7 +12,7 @@ def new def destroy track_account_deletion_event - url_after_cancellation = decorated_session.cancel_link_url + url_after_cancellation = decorated_sp_session.cancel_link_url destroy_user flash[:success] = t('sign_up.cancel.success') redirect_to url_after_cancellation diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb index d9a82d95cd3..b5147ae23a3 100644 --- a/app/controllers/sign_up/completions_controller.rb +++ b/app/controllers/sign_up/completions_controller.rb @@ -44,7 +44,7 @@ def completions_presenter current_user: current_user, current_sp: current_sp, decrypted_pii: pii, - requested_attributes: decorated_session.requested_attributes.map(&:to_sym), + requested_attributes: decorated_sp_session.requested_attributes.map(&:to_sym), ial2_requested: ial2_requested?, completion_context: needs_completion_screen_reason, ) @@ -75,7 +75,7 @@ def sign_user_out_and_instruct_to_go_back_to_mobile_app sign_out flash[:info] = t( 'instructions.go_back_to_mobile_app', - friendly_name: decorated_session.sp_name, + friendly_name: decorated_sp_session.sp_name, ) redirect_to new_user_session_url end @@ -83,15 +83,17 @@ def sign_user_out_and_instruct_to_go_back_to_mobile_app def analytics_attributes(page_occurence) { ial2: sp_session[:ial2], ialmax: sp_session[:ialmax], - service_provider_name: decorated_session.sp_name, + service_provider_name: decorated_sp_session.sp_name, sp_session_requested_attributes: sp_session[:requested_attributes], sp_request_requested_attributes: service_provider_request.requested_attributes, page_occurence: page_occurence, + in_account_creation_flow: user_session[:in_account_creation_flow] || false, needs_completion_screen_reason: needs_completion_screen_reason } end def track_completion_event(last_page) analytics.user_registration_complete(**analytics_attributes(last_page)) + user_session.delete(:in_account_creation_flow) end def pii diff --git a/app/controllers/sign_up/passwords_controller.rb b/app/controllers/sign_up/passwords_controller.rb index 268de4b34da..6db85b2effe 100644 --- a/app/controllers/sign_up/passwords_controller.rb +++ b/app/controllers/sign_up/passwords_controller.rb @@ -80,6 +80,7 @@ def process_unsuccessful_password_creation def sign_in_and_redirect_user sign_in @user + user_session[:in_account_creation_flow] = true if current_user.accepted_rules_of_use_still_valid? redirect_to authentication_methods_setup_url else diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 53ff18618f6..f60c17bdba6 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -64,6 +64,7 @@ def redirect_if_blank_phone def track_mfa_added analytics.multi_factor_auth_added_phone( enabled_mfa_methods_count: MfaContext.new(current_user).enabled_mfa_methods_count, + in_account_creation_flow: user_session[:in_account_creation_flow] || false, ) Funnel::Registration::AddMfa.call(current_user.id, 'phone', analytics) end @@ -158,7 +159,7 @@ def analytics_properties country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), phone_configuration_id: phone_configuration&.id, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: user_session[:in_account_creation_flow] || false, enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, } end diff --git a/app/controllers/two_factor_authentication/sms_opt_in_controller.rb b/app/controllers/two_factor_authentication/sms_opt_in_controller.rb index ff56c8dfd4e..b0de5b8a0eb 100644 --- a/app/controllers/two_factor_authentication/sms_opt_in_controller.rb +++ b/app/controllers/two_factor_authentication/sms_opt_in_controller.rb @@ -72,7 +72,7 @@ def other_options_mfa_url def cancel_url if user_fully_authenticated? account_path - elsif decorated_session.sp_name + elsif decorated_sp_session.sp_name return_to_sp_cancel_path else sign_out_path diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index da144db7e68..4d22f8ea43b 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -22,8 +22,8 @@ def index def create generate_codes result = BackupCodeSetupForm.new(current_user).submit - analytics_properties = result.to_h.merge(analytics_properties_for_visit) - analytics.backup_code_setup_visit(**analytics_properties) + visit_result = result.to_h.merge(analytics_properties_for_visit) + analytics.backup_code_setup_visit(**visit_result) irs_attempts_api_tracker.mfa_enroll_backup_code(success: result.success?) save_backup_codes @@ -65,12 +65,13 @@ def confirm_backup_codes; end private def analytics_properties_for_visit - { in_multi_mfa_selection_flow: in_multi_mfa_selection_flow? } + { in_account_creation_flow: in_account_creation_flow? } end def track_backup_codes_created analytics.backup_code_created( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, + in_account_creation_flow: in_account_creation_flow?, ) Funnel::Registration::AddMfa.call(current_user.id, 'backup_codes', analytics) end @@ -82,7 +83,7 @@ def mfa_user def track_backup_codes_confirmation_setup_visit analytics.multi_factor_auth_enter_backup_code_confirmation_visit( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: in_account_creation_flow?, ) end @@ -128,7 +129,7 @@ def analytics_properties { success: true, multi_factor_auth_method: 'backup_codes', - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: in_account_creation_flow?, enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, } end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index 579e68457fd..6fe2105e6f5 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -49,7 +49,7 @@ def recaptcha_enabled? def track_phone_setup_visit mfa_user = MfaContext.new(current_user) - if in_multi_mfa_selection_flow? + if user_session[:in_account_creation_flow] analytics.user_registration_phone_setup_visit( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, ) diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index f56bbc33e57..ddeb777d2d6 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -56,11 +56,7 @@ def submit_new_piv_cac private def track_piv_cac_setup_visit - mfa_user = MfaContext.new(current_user) - analytics.piv_cac_setup_visit( - enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, - ) + analytics.piv_cac_setup_visit(**analytics_properties) end def remove_piv_cac @@ -124,16 +120,11 @@ def process_valid_submission create_user_event(:piv_cac_enabled) track_mfa_method_added session[:needs_to_setup_piv_cac_after_sign_in] = false - final_path = after_sign_in_path_for(current_user) - redirect_to next_setup_path || final_path + redirect_to next_setup_path || after_sign_in_path_for(current_user) end def track_mfa_method_added - mfa_user = MfaContext.new(current_user) - analytics.multi_factor_auth_added_piv_cac( - enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, - ) + analytics.multi_factor_auth_added_piv_cac(**analytics_properties) Funnel::Registration::AddMfa.call(current_user.id, 'piv_cac', analytics) end @@ -163,7 +154,7 @@ def good_nickname def analytics_properties { - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: user_session[:in_account_creation_flow] || false, enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, } end diff --git a/app/controllers/users/piv_cac_login_controller.rb b/app/controllers/users/piv_cac_login_controller.rb index 3344cead017..ffcab1985de 100644 --- a/app/controllers/users/piv_cac_login_controller.rb +++ b/app/controllers/users/piv_cac_login_controller.rb @@ -37,7 +37,7 @@ def error private def render_prompt - analytics.piv_cac_setup_visit(in_multi_mfa_selection_flow: false) + analytics.piv_cac_setup_visit(in_account_creation_flow: false) @presenter = PivCacAuthenticationLoginPresenter.new(piv_cac_login_form, url_options) render :new end diff --git a/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb b/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb index abc1705c9e7..b32cc210e5a 100644 --- a/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb +++ b/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb @@ -32,7 +32,7 @@ def decline private def render_prompt - analytics.piv_cac_setup_visit(in_multi_mfa_selection_flow: false) + analytics.piv_cac_setup_visit(in_account_creation_flow: false) render :prompt end @@ -81,7 +81,7 @@ def process_valid_submission def analytics_properties { - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, enabled_mfa_methods_count: MfaContext.new(current_user).enabled_mfa_methods_count, } end diff --git a/app/controllers/users/second_mfa_reminder_controller.rb b/app/controllers/users/second_mfa_reminder_controller.rb new file mode 100644 index 00000000000..80f2f49b713 --- /dev/null +++ b/app/controllers/users/second_mfa_reminder_controller.rb @@ -0,0 +1,33 @@ +module Users + class SecondMfaReminderController < ApplicationController + include SecureHeadersConcern + + before_action :confirm_two_factor_authenticated + before_action :apply_secure_headers_override + + def new + analytics.second_mfa_reminder_visit + end + + def create + analytics.second_mfa_reminder_dismissed(opted_to_add: opted_to_add?) + current_user.update(second_mfa_reminder_dismissed_at: Time.zone.now) + user_session[:second_mfa_reminder_conversion] = true if opted_to_add? + redirect_to dismiss_redirect_path + end + + private + + def opted_to_add? + params[:add_method].present? + end + + def dismiss_redirect_path + if opted_to_add? + authentication_methods_setup_path + else + after_sign_in_path_for(current_user) + end + end + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index f533ae24d2b..8ccf1b2e4b8 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -22,7 +22,7 @@ def new @ial = sp_session_ial @issuer_forced_reauthentication = issuer_forced_reauthentication?( - issuer: decorated_session.sp_issuer, + issuer: decorated_sp_session.sp_issuer, ) analytics.sign_in_page_visit( flash: flash[:alert], diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index 61dae61c13b..6e74f1c2425 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -73,7 +73,7 @@ def track_event user_signed_up: MfaPolicy.new(current_user).two_factor_enabled?, totp_secret_present: new_totp_secret.present?, enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: in_account_creation_flow?, ) end @@ -97,7 +97,7 @@ def create_events mfa_user = MfaContext.new(current_user) analytics.multi_factor_auth_added_totp( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: in_account_creation_flow?, ) Funnel::Registration::AddMfa.call(current_user.id, 'auth_app', analytics) end @@ -141,7 +141,7 @@ def current_auth_app_count def analytics_properties { - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: in_account_creation_flow?, pii_like_keypaths: [[:mfa_method_counts, :phone]], } end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 479d06be738..fd5e88918df 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -16,8 +16,7 @@ def index def create result = submit_form - analytics_hash = result.to_h - analytics.user_registration_2fa_setup(**analytics_hash) + analytics.user_registration_2fa_setup(**result.to_h) irs_attempts_api_tracker.mfa_enroll_options_selected( success: result.success?, mfa_device_types: @two_factor_options_form.selection, diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index eb4503648dd..317a4fa4e0b 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -175,7 +175,7 @@ def process_valid_webauthn(form) def analytics_properties { - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + in_account_creation_flow: user_session[:in_account_creation_flow] || false, } end diff --git a/app/decorators/session_decorator.rb b/app/decorators/null_service_provider_session.rb similarity index 95% rename from app/decorators/session_decorator.rb rename to app/decorators/null_service_provider_session.rb index 9e773a68078..4608ca4d9d5 100644 --- a/app/decorators/session_decorator.rb +++ b/app/decorators/null_service_provider_session.rb @@ -1,4 +1,4 @@ -class SessionDecorator +class NullServiceProviderSession def initialize(view_context: nil) @view_context = view_context end diff --git a/app/decorators/service_provider_session_decorator.rb b/app/decorators/service_provider_session.rb similarity index 98% rename from app/decorators/service_provider_session_decorator.rb rename to app/decorators/service_provider_session.rb index cf9f35054ed..9a651d34b80 100644 --- a/app/decorators/service_provider_session_decorator.rb +++ b/app/decorators/service_provider_session.rb @@ -1,4 +1,4 @@ -class ServiceProviderSessionDecorator +class ServiceProviderSession include ActionView::Helpers::TranslationHelper include Rails.application.routes.url_helpers diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 7925d98f223..f9ba4f53c81 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -8,6 +8,7 @@ class ApiImageUploadForm validates_presence_of :document_capture_session validate :validate_images + validate :validate_duplicate_images, if: :image_resubmission_check? validate :limit_if_rate_limited def initialize(params, service_provider:, analytics: nil, @@ -41,8 +42,9 @@ def submit doc_pii_response: doc_pii_response, ) + failed_fingerprints = store_failed_images(client_response, doc_pii_response) + response.extra[:failed_image_fingerprints] = failed_fingerprints track_event(response) - response end @@ -92,7 +94,6 @@ def post_images_to_client client_response: response, vendor_request_time_in_ms: timer.results['vendor_request'], ) - response end @@ -122,7 +123,8 @@ def validate_pii_from_doc(client_response) end def extra_attributes - return @extra_attributes if defined?(@extra_attributes) + return @extra_attributes if defined?(@extra_attributes) && + @extra_attributes&.dig('attempts') == attempts @extra_attributes = { attempts: attempts, remaining_attempts: remaining_attempts, @@ -131,17 +133,26 @@ def extra_attributes flow_path: params[:flow_path], } + @extra_attributes[:front_image_fingerprint] = front_image_fingerprint + @extra_attributes[:back_image_fingerprint] = back_image_fingerprint + @extra_attributes.merge!(getting_started_ab_test_analytics_bucket) + @extra_attributes + end + + def front_image_fingerprint + return @front_image_fingerprint if @front_image_fingerprint if readable?(:front) - @extra_attributes[:front_image_fingerprint] = + @front_image_fingerprint = Digest::SHA256.urlsafe_base64digest(front_image_bytes) end + end + def back_image_fingerprint + return @back_image_fingerprint if @back_image_fingerprint if readable?(:back) - @extra_attributes[:back_image_fingerprint] = + @back_image_fingerprint = Digest::SHA256.urlsafe_base64digest(back_image_bytes) end - - @extra_attributes.merge!(getting_started_ab_test_analytics_bucket) end def remaining_attempts @@ -199,8 +210,31 @@ def validate_images end end + def validate_duplicate_images + capture_result = document_capture_session&.load_result + return unless capture_result + error_sides = [] + if capture_result&.failed_front_image?(front_image_fingerprint) + errors.add( + :front, t('doc_auth.errors.doc.resubmit_failed_image'), type: :duplicate_image + ) + error_sides << 'front' + end + + if capture_result&.failed_back_image?(back_image_fingerprint) + errors.add( + :back, t('doc_auth.errors.doc.resubmit_failed_image'), type: :duplicate_image + ) + error_sides << 'back' + end + unless error_sides.empty? + analytics.idv_doc_auth_failed_image_resubmitted( + side: error_sides.length == 2 ? 'both' : error_sides[0], **extra_attributes, + ) + end + end + def limit_if_rate_limited - return unless document_capture_session return unless rate_limited? errors.add(:limit, t('errors.doc_auth.rate_limited_heading'), type: :rate_limited) @@ -367,5 +401,53 @@ def track_event(response) failure_reason: response.errors&.except(:hints)&.presence, ) end + + ## + # Store failed image fingerprints in document_capture_session_result + # when client_response is not successful and not a network error + # ( http status except handled status 438, 439, 440 ) or doc_pii_response is not successful. + # @param [Object] client_response + # @param [Object] doc_pii_response + # @return [Object] latest failed fingerprints + def store_failed_images(client_response, doc_pii_response) + unless image_resubmission_check? + return { + front: [], + back: [], + } + end + # doc auth failed due to non network error or doc_pii is not valid + if client_response && !client_response.success? && !client_response.network_error? + errors_hash = client_response.errors&.to_h || {} + ## assume both sides' error presents or both sides' error missing + failed_front_fingerprint = extra_attributes[:front_image_fingerprint] + failed_back_fingerprint = extra_attributes[:back_image_fingerprint] + ## not both sides' error present nor both sides' error missing + ## equivalent to: only one side error presents + only_one_side_error = errors_hash[:front]&.present? ^ errors_hash[:back]&.present? + if only_one_side_error + ## find which side is missing + failed_front_fingerprint = nil unless errors_hash[:front]&.present? + failed_back_fingerprint = nil unless errors_hash[:back]&.present? + end + document_capture_session. + store_failed_auth_image_fingerprint(failed_front_fingerprint, failed_back_fingerprint) + elsif doc_pii_response && !doc_pii_response.success? + document_capture_session.store_failed_auth_image_fingerprint( + extra_attributes[:front_image_fingerprint], + extra_attributes[:back_image_fingerprint], + ) + end + # retrieve updated data from session + captured_result = document_capture_session&.load_result + { + front: captured_result&.failed_front_image_fingerprints || [], + back: captured_result&.failed_back_image_fingerprints || [], + } + end + + def image_resubmission_check? + IdentityConfig.store.doc_auth_check_failed_image_resubmission_enabled + end end end diff --git a/app/forms/openid_connect_token_form.rb b/app/forms/openid_connect_token_form.rb index 97fd3424092..ecdaac39e36 100644 --- a/app/forms/openid_connect_token_form.rb +++ b/app/forms/openid_connect_token_form.rb @@ -47,11 +47,12 @@ def submit def response if valid? id_token_builder = IdTokenBuilder.new(identity: identity, code: code) + @ttl = id_token_builder.ttl { access_token: identity.access_token, token_type: 'Bearer', - expires_in: id_token_builder.ttl, + expires_in: @ttl, id_token: id_token_builder.id_token, } else @@ -200,6 +201,7 @@ def extra_analytics_attributes code_digest: code ? Digest::SHA256.hexdigest(code) : nil, code_verifier_present: code_verifier.present?, service_provider_pkce: service_provider&.pkce, + ial: identity&.ial, } end diff --git a/app/javascript/packages/address-search/README.md b/app/javascript/packages/address-search/README.md index 39213a0f503..10ee4ef05e0 100644 --- a/app/javascript/packages/address-search/README.md +++ b/app/javascript/packages/address-search/README.md @@ -30,6 +30,7 @@ return( disabled={disabledAddressSearchCallback} handleLocationSelect={handleLocationSelect} locationsURL={LOCATIONS_URL} + noInPersonLocationsDisplay={noInPersonLocationsDisplay} onFoundLocations={setLocationResultsCallback} registerField={registerFieldCallback} resultsHeaderComponent={resultsHeaderComponent} diff --git a/app/javascript/packages/address-search/components/address-search.tsx b/app/javascript/packages/address-search/components/address-search.tsx index 4d116f48705..e69cce8a817 100644 --- a/app/javascript/packages/address-search/components/address-search.tsx +++ b/app/javascript/packages/address-search/components/address-search.tsx @@ -4,12 +4,14 @@ import { t } from '@18f/identity-i18n'; import InPersonLocations from './in-person-locations'; import AddressInput from './address-input'; import type { AddressSearchProps, LocationQuery, FormattedLocation } from '../types'; +import NoInPersonLocationsDisplay from './no-in-person-locations-display'; function AddressSearch({ addressSearchURL, disabled, handleLocationSelect, locationsURL, + noInPersonLocationsDisplay = NoInPersonLocationsDisplay, onFoundLocations, registerField, resultsHeaderComponent, @@ -46,6 +48,7 @@ function AddressSearch({ {locationResults && foundAddress && !isLoadingLocations && ( void; disabled?: boolean; locationsURL: string; + usStatesTerritories: [string, string][]; } export default function FullAddressSearchInput({ + usStatesTerritories, registerField = () => undefined, onFoundLocations = () => undefined, onLoadingLocations = () => undefined, @@ -81,8 +82,6 @@ export default function FullAddressSearchInput({ [addressValue, cityValue, stateValue, zipCodeValue], ); - const { usStatesTerritories } = useContext(InPersonContext); - return ( <> { const sandbox = useSandbox(); + const locationsURL = 'https://localhost:3000/locations/endpoint'; + const usStatesTerritories = [['Delware', 'DE']]; context('validates form', () => { it('displays an error for all required fields when input is empty', async () => { @@ -17,8 +18,9 @@ describe('FullAddressSearch', () => { const { findByText, findAllByText } = render( new Map() }}> { const { findByText, findByLabelText, findAllByText } = render( new Map() }}> { const { findByText, findByLabelText, queryByText } = render( new Map() }}> { let server: SetupServer; before(() => { server = setupServer( - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), ); server.listen(); }); @@ -128,8 +132,9 @@ describe('FullAddressSearch', () => { const { findByText, findByLabelText } = render( new Map() }}> (null); const [foundAddress, setFoundAddress] = useState(null); @@ -29,6 +31,7 @@ function FullAddressSearch({ {t('in_person_proofing.headings.po_search.location')}

{t('in_person_proofing.body.location.po_search.po_search_about')}

)} diff --git a/app/javascript/packages/address-search/components/in-person-locations.spec.tsx b/app/javascript/packages/address-search/components/in-person-locations.spec.tsx index fe95f52ac22..89971bc062b 100644 --- a/app/javascript/packages/address-search/components/in-person-locations.spec.tsx +++ b/app/javascript/packages/address-search/components/in-person-locations.spec.tsx @@ -1,30 +1,41 @@ import { render } from '@testing-library/react'; import { Alert } from '@18f/identity-components'; +import { screen } from '@testing-library/dom'; +import sinon from 'sinon'; import type { FormattedLocation } from './in-person-locations'; import InPersonLocations from './in-person-locations'; +function NoLocationsViewMock({ address }) { + return ( +
+

No PO found

+

{address}

+
+ ); +} + describe('InPersonLocations', () => { const locations: FormattedLocation[] = [ { - formattedCityStateZip: 'one', - distance: 'one', + name: 'test name', + streetAddress: '123 Test Address', + formattedCityStateZip: 'City, State 12345-1234', + distance: '0.2 miles', + weekdayHours: '9 AM - 5 PM', + saturdayHours: '9 AM - 6 PM', + sundayHours: 'Closed', id: 1, - name: 'one', - saturdayHours: 'one', - streetAddress: 'one', - sundayHours: 'one', - weekdayHours: 'one', isPilot: false, }, { - formattedCityStateZip: 'two', - distance: 'two', - id: 2, - name: 'two', - saturdayHours: 'two', - streetAddress: 'two', - sundayHours: 'two', - weekdayHours: 'two', + name: 'test name', + streetAddress: '456 Test Address', + formattedCityStateZip: 'City, State 12345-1234', + distance: '2.1 miles', + weekdayHours: '8 AM - 5 PM', + saturdayHours: '10 AM - 5 PM', + sundayHours: 'Closed', + id: 1, isPilot: false, }, ]; @@ -43,6 +54,7 @@ describe('InPersonLocations', () => { resultsHeaderComponent={alertComponent} locations={locations} onSelect={onSelect} + noInPersonLocationsDisplay={NoLocationsViewMock} />, ); @@ -52,7 +64,12 @@ describe('InPersonLocations', () => { it('renders results instructions when onSelect is passed', () => { const { getByText } = render( - , + , ); expect(getByText('in_person_proofing.body.location.po_search.results_instructions')).to.exist(); @@ -60,11 +77,68 @@ describe('InPersonLocations', () => { it('does not render results instructions when onSelect is not passed', () => { const { queryByText } = render( - , + , ); expect( queryByText('in_person_proofing.body.location.po_search.results_instructions'), ).to.not.exist(); }); + + context('when no locations are found', () => { + it('renders the passed in noLocations component w/ address', () => { + const onClick = sinon.stub(); + const { getByText } = render( + , + ); + + expect(getByText('No PO found')).to.exist(); + expect(getByText(address)).to.exist(); + expect(screen.getByTestId('no-results-found')).to.exist(); + }); + + it('does not render Post Office results', () => { + const onClick = sinon.stub(); + const { queryByText } = render( + , + ); + + expect(queryByText('in_person_proofing.body.location.po_search.results_instructions')).to.be + .null; + expect(queryByText('in_person_proofing.body.location.retail_hours_heading')).not.to.exist(); + }); + }); + + context('when at least 1 location is found', () => { + it('renders a list of Post Offices and does not render the passed in noInPersonLocationsDisplay component', () => { + const onClick = sinon.stub(); + const { queryByText } = render( + , + ); + + expect(queryByText('123 Test Address')).to.exist(); + expect(queryByText('456 Test Address')).to.exist(); + expect(queryByText('No PO found')).to.be.null; + }); + }); }); diff --git a/app/javascript/packages/address-search/components/in-person-locations.tsx b/app/javascript/packages/address-search/components/in-person-locations.tsx index 737f1d79adc..8873e76f134 100644 --- a/app/javascript/packages/address-search/components/in-person-locations.tsx +++ b/app/javascript/packages/address-search/components/in-person-locations.tsx @@ -2,7 +2,6 @@ import { ComponentType } from 'react'; import { t } from '@18f/identity-i18n'; import LocationCollection from './location-collection'; import LocationCollectionItem from './location-collection-item'; -import NoInPersonLocationsDisplay from './no-in-person-locations-display'; export interface FormattedLocation { formattedCityStateZip: string; @@ -20,6 +19,7 @@ interface InPersonLocationsProps { locations: FormattedLocation[] | null | undefined; onSelect; address: string; + noInPersonLocationsDisplay: ComponentType<{ address: string }>; resultsHeaderComponent?: ComponentType; } @@ -27,6 +27,7 @@ function InPersonLocations({ locations, onSelect, address, + noInPersonLocationsDisplay: NoInPersonLocationsDisplay, resultsHeaderComponent: HeaderComponent, }: InPersonLocationsProps) { const isPilot = locations?.some((l) => l.isPilot); diff --git a/app/javascript/packages/address-search/components/no-in-person-locations-display.tsx b/app/javascript/packages/address-search/components/no-in-person-locations-display.tsx index 82c3ce8b1b7..9513f3666b9 100644 --- a/app/javascript/packages/address-search/components/no-in-person-locations-display.tsx +++ b/app/javascript/packages/address-search/components/no-in-person-locations-display.tsx @@ -1,14 +1,18 @@ import { getAssetPath } from '@18f/identity-assets'; import { t } from '@18f/identity-i18n'; -function NoInPersonLocationsDisplay({ address }) { +interface NoInPersonLocationsDisplayProps { + address: string; +} + +function NoInPersonLocationsDisplay({ address }: NoInPersonLocationsDisplayProps) { return (
{t('image_description.info_pin_map')}
diff --git a/app/javascript/packages/document-capture/hooks/use-validated-usps-locations.spec.ts b/app/javascript/packages/address-search/hooks/use-validated-usps-locations.spec.ts similarity index 81% rename from app/javascript/packages/document-capture/hooks/use-validated-usps-locations.spec.ts rename to app/javascript/packages/address-search/hooks/use-validated-usps-locations.spec.ts index a711f27b229..6f055c6fe83 100644 --- a/app/javascript/packages/document-capture/hooks/use-validated-usps-locations.spec.ts +++ b/app/javascript/packages/address-search/hooks/use-validated-usps-locations.spec.ts @@ -3,7 +3,6 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import type { SetupServer } from 'msw/node'; import useValidatedUspsLocations from './use-validated-usps-locations'; -import { LOCATIONS_URL } from '../components/in-person-location-post-office-search-step'; const USPS_RESPONSE = [ { @@ -31,6 +30,7 @@ const USPS_RESPONSE = [ ]; describe('useValidatedUspsLocations', () => { + const locationsURL = 'https://localhost:3000/locations/endpoint'; let server: SetupServer; before(() => { @@ -44,13 +44,11 @@ describe('useValidatedUspsLocations', () => { beforeEach(() => { server.resetHandlers(); - server.use(rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json(USPS_RESPONSE)))); + server.use(rest.post(locationsURL, (_req, res, ctx) => res(ctx.json(USPS_RESPONSE)))); }); it('returns location results', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useValidatedUspsLocations(LOCATIONS_URL), - ); + const { result, waitForNextUpdate } = renderHook(() => useValidatedUspsLocations(locationsURL)); const { handleLocationSearch } = result.current; handleLocationSearch(new Event('submit'), '200 main', 'Endeavor', 'DE', '12345'); diff --git a/app/javascript/packages/document-capture/hooks/use-validated-usps-locations.ts b/app/javascript/packages/address-search/hooks/use-validated-usps-locations.ts similarity index 100% rename from app/javascript/packages/document-capture/hooks/use-validated-usps-locations.ts rename to app/javascript/packages/address-search/hooks/use-validated-usps-locations.ts diff --git a/app/javascript/packages/address-search/index.tsx b/app/javascript/packages/address-search/index.tsx index 4be29bd8694..e680b918900 100644 --- a/app/javascript/packages/address-search/index.tsx +++ b/app/javascript/packages/address-search/index.tsx @@ -1,6 +1,7 @@ import { snakeCase, formatLocations, transformKeys } from './utils'; import AddressInput from './components/address-input'; import AddressSearch from './components/address-search'; +import FullAddressSearch from './components/full-address-search'; import InPersonLocations from './components/in-person-locations'; import NoInPersonLocationsDisplay from './components/no-in-person-locations-display'; import { requestUspsLocations } from './hooks/use-usps-locations'; @@ -8,6 +9,7 @@ import { requestUspsLocations } from './hooks/use-usps-locations'; export { AddressInput, InPersonLocations, + FullAddressSearch, NoInPersonLocationsDisplay, formatLocations, snakeCase, diff --git a/app/javascript/packages/address-search/package.json b/app/javascript/packages/address-search/package.json index 585cbf93bfb..a42f25a526d 100644 --- a/app/javascript/packages/address-search/package.json +++ b/app/javascript/packages/address-search/package.json @@ -1,6 +1,6 @@ { "name": "@18f/identity-address-search", - "version": "2.2.0", + "version": "2.4.0", "type": "module", "private": false, "files": [ diff --git a/app/javascript/packages/address-search/types.d.ts b/app/javascript/packages/address-search/types.d.ts index 66e4b583aa7..9656c055113 100644 --- a/app/javascript/packages/address-search/types.d.ts +++ b/app/javascript/packages/address-search/types.d.ts @@ -58,6 +58,7 @@ interface AddressSearchProps { addressSearchURL: string; disabled: boolean; handleLocationSelect: ((e: any, id: number) => Promise) | null | undefined; + noInPersonLocationsDisplay?: ComponentType<{ address: string }>; resultsHeaderComponent?: ComponentType; locationsURL: string; onFoundLocations: Dispatch>; diff --git a/app/javascript/packages/address-search/webpack.config.cjs b/app/javascript/packages/address-search/webpack.config.cjs index a018e9914ab..3a671c043eb 100644 --- a/app/javascript/packages/address-search/webpack.config.cjs +++ b/app/javascript/packages/address-search/webpack.config.cjs @@ -17,6 +17,7 @@ module.exports = /** @type {import('webpack').Configuration} */ ({ }, resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'], + conditionNames: ['source'], }, externals: /^(?!(@18f\/identity-|\.))/, module: { diff --git a/app/javascript/packages/components/.gitignore b/app/javascript/packages/components/.gitignore new file mode 100644 index 00000000000..849ddff3b7e --- /dev/null +++ b/app/javascript/packages/components/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/app/javascript/packages/components/babel.config.json b/app/javascript/packages/components/babel.config.json new file mode 100644 index 00000000000..2ba02217015 --- /dev/null +++ b/app/javascript/packages/components/babel.config.json @@ -0,0 +1 @@ +{ "extends": "../../../../babel.config.js" } diff --git a/app/javascript/packages/components/package.json b/app/javascript/packages/components/package.json index cf5d703e535..4c7d5df31a7 100644 --- a/app/javascript/packages/components/package.json +++ b/app/javascript/packages/components/package.json @@ -1,10 +1,35 @@ { "name": "@18f/identity-components", - "private": true, - "version": "1.0.0", + "version": "1.0.0-beta0.2", + "type": "module", + "private": false, + "files": [ + "dist" + ], + "scripts": { + "prepublishOnly": "webpack" + }, + "exports": { + ".": { + "source": "./index.ts", + "default": "./dist/index.js" + } + }, + "license": "CC0-1.0", + "bugs": { + "url": "https://github.com/18f/identity-idp/issues" + }, + "homepage": "https://github.com/18f/identity-idp", "dependencies": { - "focus-trap": "^6.7.1", - "react": "^17.0.2", - "react-dom": "^17.0.2" + "focus-trap": "^6.7.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + }, + "repository": { + "type": "git", + "url": "https://github.com/18f/identity-idp.git", + "directory": "app/javascript/packages/components" } } diff --git a/app/javascript/packages/components/webpack.config.cjs b/app/javascript/packages/components/webpack.config.cjs new file mode 100644 index 00000000000..3a671c043eb --- /dev/null +++ b/app/javascript/packages/components/webpack.config.cjs @@ -0,0 +1,35 @@ +module.exports = /** @type {import('webpack').Configuration} */ ({ + mode: 'production', + target: ['node'], + entry: { + index: './', + }, + experiments: { + outputModule: true, + }, + output: { + module: true, + chunkFormat: false, + filename: '[name].js', + library: { + type: 'module', + }, + }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'], + conditionNames: ['source'], + }, + externals: /^(?!(@18f\/identity-|\.))/, + module: { + rules: [ + { + use: { + loader: 'babel-loader', + }, + }, + ], + }, + optimization: { + minimize: false, + }, +}); diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 5d4d17228c3..45eadb56963 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -62,6 +62,16 @@ interface ImageAnalyticsPayload { * capture functionality */ acuantCaptureMode?: AcuantCaptureMode; + + /** + * Fingerprint of the image, base64 encoded SHA-256 digest + */ + fingerprint: string | null; + + /** + * + */ + failedImageResubmission: boolean; } interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload { @@ -75,6 +85,7 @@ interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload { sharpnessScoreThreshold: number; isAssessedAsBlurry: boolean; assessment: AcuantImageAssessment; + isAssessedAsUnsupported: boolean; } interface AcuantCaptureProps { @@ -178,6 +189,31 @@ export function getNormalizedAcuantCaptureFailureMessage( } } +function getFingerPrint(file: File): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const dataBuffer = reader.result; + window.crypto.subtle + .digest('SHA-256', dataBuffer as ArrayBuffer) + .then((arrayBuffer) => { + const digestArray = new Uint8Array(arrayBuffer); + const strDigest = digestArray.reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ); + const base64String = window.btoa(strDigest); + const urlSafeBase64String = base64String + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + resolve(urlSafeBase64String); + }) + .catch(() => null); + }; + reader.readAsArrayBuffer(file); + }); +} function getImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> { let objectURL: string; return file.type.indexOf('image/') === 0 @@ -196,6 +232,22 @@ function getImageDimensions(file: File): Promise<{ width: number | null; height: : Promise.resolve({ width: null, height: null }); } +function getImageMetadata( + file: File, +): Promise<{ width: number | null; height: number | null; fingerprint: string | null }> { + const dimension = getImageDimensions(file); + const fingerprint = getFingerPrint(file); + return new Promise<{ width: number | null; height: number | null; fingerprint: string | null }>( + function (resolve) { + Promise.all([dimension, fingerprint]) + .then((results) => { + resolve({ width: results[0].width, height: results[0].height, fingerprint: results[1] }); + }) + .catch(() => ({ width: null, height: null, fingerprint: null })); + }, + ); +} + /** * Pauses default focus trap behaviors for a single tick. If a focus transition occurs during this * tick, the focus trap's deactivation will be overridden to prevent any default focus return, in @@ -289,6 +341,7 @@ function AcuantCapture( onResetFailedCaptureAttempts, failedSubmissionAttempts, forceNativeCamera, + failedSubmissionImageFingerprints, } = useContext(FailedCaptureAttemptsContext); const hasCapture = !isError && (isReady ? isCameraSupported : isMobile); @@ -330,17 +383,19 @@ function AcuantCapture( */ async function onUpload(nextValue: File | null) { let analyticsPayload: ImageAnalyticsPayload | undefined; + let hasFailed = false; if (nextValue) { - const { width, height } = await getImageDimensions(nextValue); - + const { width, height, fingerprint } = await getImageMetadata(nextValue); + hasFailed = failedSubmissionImageFingerprints[name]?.includes(fingerprint); analyticsPayload = getAddAttemptAnalyticsPayload({ width, height, + fingerprint, mimeType: nextValue.type, source: 'upload', size: nextValue.size, + failedImageResubmission: hasFailed, }); - trackEvent(`IdV: ${name} image added`, analyticsPayload); } @@ -472,6 +527,8 @@ function AcuantCapture( isAssessedAsBlurry, assessment, size: getDecodedBase64ByteSize(nextCapture.image.data), + fingerprint: null, + failedImageResubmission: false, }); trackEvent(`IdV: ${name} image added`, analyticsPayload); diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index d53a06cc9ac..9ec0fa2209d 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -117,6 +117,7 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { isFailedDocType: submissionError.isFailedDocType, captureHints: submissionError.hints, pii: submissionError.pii, + failedImageFingerprints: submissionError.failed_image_fingerprints, })(ReviewIssuesStep) : ReviewIssuesStep, title: t('errors.doc_auth.rate_limited_heading'), diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx index 5d8e200d622..984b91bb249 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx @@ -1,5 +1,6 @@ import { t } from '@18f/identity-i18n'; -import { FormError } from '@18f/identity-form-steps'; +import { FormError, FormStepsContext } from '@18f/identity-form-steps'; +import { useContext } from 'react'; import AcuantCapture from './acuant-capture'; /** @typedef {import('@18f/identity-form-steps').FormStepError<*>} FormStepError */ @@ -43,7 +44,7 @@ function DocumentSideAcuantCapture({ className, }) { const error = errors.find(({ field }) => field === side)?.error; - + const { changeStepCanComplete } = useContext(FormStepsContext); return ( + onChange={(nextValue, metadata) => { onChange({ [side]: nextValue, [`${side}_image_metadata`]: JSON.stringify(metadata), - }) - } + }); + if (metadata?.failedImageResubmission) { + onError(new Error(t('doc_auth.errors.doc.resubmit_failed_image')), { field: side }); + changeStepCanComplete(false); + } else { + changeStepCanComplete(true); + } + }} onCameraAccessDeclined={() => { onError(new CameraAccessDeclinedError(), { field: side }); onError(new CameraAccessDeclinedError(undefined, { isDetail: true })); diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx index eb56ce29695..267bd2668dd 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.spec.tsx @@ -9,7 +9,6 @@ import { usePropertyValue } from '@18f/identity-test-helpers'; import { ComponentType } from 'react'; import { InPersonContext } from '../context'; import InPersonLocationFullAddressEntryPostOfficeSearchStep from './in-person-location-full-address-entry-post-office-search-step'; -import { LOCATIONS_URL } from './in-person-location-post-office-search-step'; const USPS_RESPONSE = [ { @@ -39,13 +38,25 @@ const USPS_RESPONSE = [ const DEFAULT_PROPS = { toPreviousStep() {}, onChange() {}, - value: {}, registerField() {}, }; describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { + const usStatesTerritories: [string, string][] = [['Delware', 'DE']]; + const locationsURL = 'https://localhost:3000/locations/endpoint'; const wrapper: ComponentType = ({ children }) => ( - new Map() }}>{children} + + new Map() }}>{children} + ); let server: SetupServer; @@ -62,23 +73,12 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { beforeEach(() => { server.resetHandlers(); // todo: should we return USPS_RESPONSE here? - server.use( - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), - ); + server.use(rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }])))); }); it('renders the step', () => { const { getByRole } = render( - - , - , + , { wrapper }, ); @@ -87,21 +87,12 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { context('USPS request returns an error', () => { beforeEach(() => { - server.use(rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.status(500)))); + server.use(rest.post(locationsURL, (_req, res, ctx) => res(ctx.status(500)))); }); it('displays a try again error message', async () => { const { findByText, findByLabelText } = render( - - , - , + , { wrapper }, ); @@ -133,16 +124,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays validation error messages to the user if fields are empty', async () => { const { findAllByText, findByText } = render( - - , - , + , { wrapper }, ); @@ -156,16 +138,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays no post office results if a successful search is followed by an unsuccessful search', async () => { const { findByText, findByLabelText, queryByRole } = render( - - , - , + , { wrapper }, ); @@ -205,16 +178,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('clicking search again after first results do not clear results', async () => { const { findAllByText, findByText, findByLabelText } = render( - - , - , + , { wrapper }, ); @@ -253,16 +217,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays correct pluralization for a single location result', async () => { const { findByLabelText, findByText } = render( - - , - , + , { wrapper }, ); await userEvent.type( @@ -297,18 +252,9 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('displays correct pluralization for multiple location results', async () => { server.resetHandlers(); - server.use(rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json(USPS_RESPONSE)))); + server.use(rest.post(locationsURL, (_req, res, ctx) => res(ctx.json(USPS_RESPONSE)))); const { findByLabelText, findByText } = render( - - , - , + , { wrapper }, ); @@ -345,16 +291,7 @@ describe('InPersonLocationFullAddressEntryPostOfficeSearchStep', () => { it('allows user to select a location', async () => { const { findAllByText, findByLabelText, findByText, queryByText } = render( - - , - , + , { wrapper }, ); await userEvent.type( diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx index 1ce97987a37..fca72bf0a9c 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -1,21 +1,19 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'react'; import { request } from '@18f/identity-request'; import { forceRedirect } from '@18f/identity-url'; -import { transformKeys, snakeCase } from '@18f/identity-address-search'; +import { FullAddressSearch, transformKeys, snakeCase } from '@18f/identity-address-search'; import type { FormattedLocation } from '@18f/identity-address-search/types'; -import FullAddressSearch from './in-person-full-address-search'; import BackButton from './back-button'; import AnalyticsContext from '../context/analytics'; import { InPersonContext } from '../context'; import UploadContext from '../context/upload'; -import { LOCATIONS_URL } from './in-person-location-post-office-search-step'; function InPersonLocationFullAddressEntryPostOfficeSearchStep({ onChange, toPreviousStep, registerField, }) { - const { inPersonURL } = useContext(InPersonContext); + const { inPersonURL, locationsURL, usStatesTerritories } = useContext(InPersonContext); const [inProgress, setInProgress] = useState(false); const [autoSubmit, setAutoSubmit] = useState(false); const { trackEvent } = useContext(AnalyticsContext); @@ -60,7 +58,7 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ const selected = transformKeys(selectedLocation, snakeCase); setInProgress(true); try { - await request(LOCATIONS_URL, { + await request(locationsURL, { json: selected, method: 'PUT', }); @@ -96,8 +94,9 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ registerField={registerField} onFoundLocations={setLocationResults} disabled={disabledAddressSearch} - locationsURL={LOCATIONS_URL} + locationsURL={locationsURL} handleLocationSelect={handleLocationSelect} + usStatesTerritories={usStatesTerritories} /> diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx index e85f0d1575d..7be32c03b11 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx @@ -7,10 +7,8 @@ import { rest } from 'msw'; import type { SetupServer } from 'msw/node'; import { SWRConfig } from 'swr'; import { ComponentType } from 'react'; -import InPersonLocationPostOfficeSearchStep, { - ADDRESSES_URL, - LOCATIONS_URL, -} from './in-person-location-post-office-search-step'; +import { InPersonContext } from '../context'; +import InPersonLocationPostOfficeSearchStep from './in-person-location-post-office-search-step'; const DEFAULT_RESPONSE = [ { @@ -59,8 +57,22 @@ const DEFAULT_PROPS = { }; describe('InPersonLocationPostOfficeSearchStep', () => { + const usStatesTerritories: [string, string][] = [['Delware', 'DE']]; + const locationsURL = 'https://localhost:3000/locations/endpoint'; + const addressSearchURL = 'https://localhost:3000/addresses/endpoint'; const wrapper: ComponentType = ({ children }) => ( - new Map() }}>{children} + + new Map() }}>{children} + ); let server: SetupServer; @@ -80,7 +92,9 @@ describe('InPersonLocationPostOfficeSearchStep', () => { context('initial ArcGIS API request throws an error', () => { beforeEach(() => { - server.use(rest.post(ADDRESSES_URL, (_req, res, ctx) => res(ctx.json([]), ctx.status(422)))); + server.use( + rest.post(addressSearchURL, (_req, res, ctx) => res(ctx.json([]), ctx.status(422))), + ); }); it('displays a try again error message', async () => { @@ -106,10 +120,10 @@ describe('InPersonLocationPostOfficeSearchStep', () => { context('initial USPS API request throws an error', () => { beforeEach(() => { server.use( - rest.post(ADDRESSES_URL, (_req, res, ctx) => + rest.post(addressSearchURL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)), ), - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.status(500))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.status(500))), ); }); @@ -136,10 +150,10 @@ describe('InPersonLocationPostOfficeSearchStep', () => { context('initial API request is successful', () => { beforeEach(() => { server.use( - rest.post(ADDRESSES_URL, (_req, res, ctx) => + rest.post(addressSearchURL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)), ), - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), ); }); @@ -255,10 +269,10 @@ describe('InPersonLocationPostOfficeSearchStep', () => { it('displays correct pluralization for multiple location results', async () => { server.resetHandlers(); server.use( - rest.post(ADDRESSES_URL, (_req, res, ctx) => + rest.post(addressSearchURL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)), ), - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json(MULTI_LOCATION_RESPONSE))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.json(MULTI_LOCATION_RESPONSE))), ); const { findByLabelText, findByText } = render( , @@ -285,10 +299,10 @@ describe('InPersonLocationPostOfficeSearchStep', () => { context('subsequent network failures clear results', () => { beforeEach(() => { server.use( - rest.post(ADDRESSES_URL, (_req, res, ctx) => + rest.post(addressSearchURL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)), ), - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), ); }); @@ -310,7 +324,7 @@ describe('InPersonLocationPostOfficeSearchStep', () => { expect(result).to.exist(); server.use( - rest.post(ADDRESSES_URL, (_req, res, ctx) => + rest.post(addressSearchURL, (_req, res, ctx) => res( ctx.json([ { @@ -328,7 +342,7 @@ describe('InPersonLocationPostOfficeSearchStep', () => { ctx.status(200), ), ), - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.status(500))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.status(500))), ); await userEvent.type( @@ -347,10 +361,10 @@ describe('InPersonLocationPostOfficeSearchStep', () => { context('user deletes text from searchbox after location results load', () => { beforeEach(() => { server.use( - rest.post(ADDRESSES_URL, (_req, res, ctx) => + rest.post(addressSearchURL, (_req, res, ctx) => res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)), ), - rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + rest.post(locationsURL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), ); }); diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx index 237003d2dac..44e45fea73d 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx @@ -8,14 +8,8 @@ import AnalyticsContext from '../context/analytics'; import { InPersonContext } from '../context'; import UploadContext from '../context/upload'; -export const LOCATIONS_URL = new URL( - '/verify/in_person/usps_locations', - window.location.href, -).toString(); -export const ADDRESSES_URL = new URL('/api/addresses', window.location.href).toString(); - function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, registerField }) { - const { inPersonURL } = useContext(InPersonContext); + const { inPersonURL, locationsURL, addressSearchURL } = useContext(InPersonContext); const [inProgress, setInProgress] = useState(false); const [autoSubmit, setAutoSubmit] = useState(false); const { trackEvent } = useContext(AnalyticsContext); @@ -61,7 +55,7 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist const selected = transformKeys(selectedLocation, snakeCase); setInProgress(true); try { - await request(LOCATIONS_URL, { + await request(locationsURL, { json: selected, method: 'PUT', }); @@ -94,10 +88,10 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist return ( <> diff --git a/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx b/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx index f8440070c7a..65d4e7b955b 100644 --- a/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-outage-alert.spec.tsx @@ -8,6 +8,8 @@ describe('InPersonOutageAlert', () => { getByText = render( { const { queryByText } = render( { const { queryByText } = render( onFailedSubmissionAttempt(), []); + const { onFailedSubmissionAttempt, failedSubmissionImageFingerprints } = useContext( + FailedCaptureAttemptsContext, + ); + useEffect(() => onFailedSubmissionAttempt(failedImageFingerprints), []); + + useLayoutEffect(() => { + let frontMetaData: { fingerprint: string | null } = { fingerprint: null }; + try { + frontMetaData = JSON.parse( + typeof value.front_image_metadata === 'undefined' ? '{}' : value.front_image_metadata, + ); + } catch (e) {} + const frontHasFailed = !!failedSubmissionImageFingerprints?.front?.includes( + frontMetaData?.fingerprint ?? '', + ); + + let backMetaData: { fingerprint: string | null } = { fingerprint: null }; + try { + backMetaData = JSON.parse( + typeof value.back_image_metadata === 'undefined' ? '{}' : value.back_image_metadata, + ); + } catch (e) {} + const backHasFailed = !!failedSubmissionImageFingerprints?.back?.includes( + backMetaData?.fingerprint ?? '', + ); + if (frontHasFailed || backHasFailed) { + setSkipWarning(true); + } + }, []); + function onWarningPageDismissed() { trackEvent('IdV: Capture troubleshooting dismissed'); @@ -72,14 +103,15 @@ function ReviewIssuesStep({ // let FormSteps know, via FormStepsContext, whether this page // is ready to submit form values useEffect(() => { - changeStepCanComplete(!!hasDismissed); + changeStepCanComplete(!!hasDismissed && !skipWarning); }, [hasDismissed]); if (!hasDismissed && pii) { return ; } + // Show warning screen - if (!hasDismissed) { + if (!hasDismissed && !skipWarning) { // Warning(try again screen) return ( void; + onFailedSubmissionAttempt: (failedImageFingerprints: UploadedImageFingerprints) => void; /** * The maximum number of failed Acuant capture attempts @@ -58,6 +66,8 @@ interface FailedCaptureAttemptsContextInterface { * after maxCaptureAttemptsBeforeNativeCamera number of failed attempts */ forceNativeCamera: boolean; + + failedSubmissionImageFingerprints: UploadedImageFingerprints; } const DEFAULT_LAST_ATTEMPT_METADATA: CaptureAttemptMetadata = { @@ -76,6 +86,7 @@ const FailedCaptureAttemptsContext = createContext( DEFAULT_LAST_ATTEMPT_METADATA, @@ -98,13 +111,17 @@ function FailedCaptureAttemptsContextProvider({ useCounter(); const [failedSubmissionAttempts, incrementFailedSubmissionAttempts] = useCounter(); + const [failedSubmissionImageFingerprints, setFailedSubmissionImageFingerprints] = + useState(failedFingerprints); + function onFailedCaptureAttempt(metadata: CaptureAttemptMetadata) { incrementFailedCaptureAttempts(); setLastAttemptMetadata(metadata); } - function onFailedSubmissionAttempt() { + function onFailedSubmissionAttempt(failedOnes: UploadedImageFingerprints) { incrementFailedSubmissionAttempts(); + setFailedSubmissionImageFingerprints(failedOnes); } const forceNativeCamera = @@ -123,6 +140,7 @@ function FailedCaptureAttemptsContextProvider({ maxSubmissionAttemptsBeforeNativeCamera, lastAttemptMetadata, forceNativeCamera, + failedSubmissionImageFingerprints, }} > {children} @@ -132,3 +150,4 @@ function FailedCaptureAttemptsContextProvider({ export default FailedCaptureAttemptsContext; export { FailedCaptureAttemptsContextProvider as Provider }; +export { UploadedImageFingerprints }; diff --git a/app/javascript/packages/document-capture/context/in-person.ts b/app/javascript/packages/document-capture/context/in-person.ts index 818ada23e4c..3fcbb5ac2da 100644 --- a/app/javascript/packages/document-capture/context/in-person.ts +++ b/app/javascript/packages/document-capture/context/in-person.ts @@ -6,6 +6,16 @@ export interface InPersonContextProps { */ inPersonURL?: string; + /** + * Post Office location search endpoint URL + */ + locationsURL: string; + + /** + * Address search endpoint URL + */ + addressSearchURL: string; + /** * Whether the message indicating an outage should be displayed */ @@ -29,6 +39,8 @@ export interface InPersonContextProps { } const InPersonContext = createContext({ + locationsURL: '', + addressSearchURL: '', inPersonOutageMessageEnabled: false, inPersonFullAddressEntryEnabled: false, usStatesTerritories: [], diff --git a/app/javascript/packages/document-capture/context/upload.tsx b/app/javascript/packages/document-capture/context/upload.tsx index 1fc8192ead8..6f6879cfee7 100644 --- a/app/javascript/packages/document-capture/context/upload.tsx +++ b/app/javascript/packages/document-capture/context/upload.tsx @@ -55,7 +55,10 @@ export interface UploadSuccessResponse { */ isPending: boolean; } - +export interface ImageFingerprints { + front: string[] | null; + back: string[] | null; +} export interface UploadErrorResponse { /** * Whether request was successful. @@ -96,6 +99,11 @@ export interface UploadErrorResponse { * Whether the doc type is clearly not supported type. */ doc_type_supported: boolean; + + /** + * Record of failed image fingerprints + */ + failed_image_fingerprints: ImageFingerprints | null; } export type UploadImplementation = ( diff --git a/app/javascript/packages/document-capture/services/upload.ts b/app/javascript/packages/document-capture/services/upload.ts index f2ce10624ce..ed0c77e16ed 100644 --- a/app/javascript/packages/document-capture/services/upload.ts +++ b/app/javascript/packages/document-capture/services/upload.ts @@ -6,6 +6,7 @@ import type { UploadErrorResponse, UploadFieldError, UploadImplementation, + ImageFingerprints, } from '../context/upload'; /** @@ -44,6 +45,8 @@ export class UploadFormEntriesError extends FormError { pii?: PII; hints = false; + + failed_image_fingerprints: ImageFingerprints = { front: [], back: [] }; } /** @@ -121,6 +124,8 @@ const upload: UploadImplementation = async function (payload, { method = 'POST', error.isFailedDocType = !result.doc_type_supported; + error.failed_image_fingerprints = result.failed_image_fingerprints ?? { front: [], back: [] }; + throw error; } diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index 497b3e9ce56..94a6b96f50d 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -213,7 +213,6 @@ function getFieldActiveErrorFieldElement( fields: Record, ) { const error = errors.find(({ field }) => field && fields[field]?.element); - if (error) { return fields[error.field!].element || undefined; } diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 40d5a17fa5a..3448e037f5b 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -97,6 +97,8 @@ const App = composeComponents( { value: { inPersonURL, + locationsURL: new URL('/verify/in_person/usps_locations', window.location.href).toString(), + addressSearchURL: new URL('/api/addresses', window.location.href).toString(), inPersonOutageMessageEnabled: inPersonOutageMessageEnabled === 'true', inPersonOutageExpectedUpdateDate, inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', diff --git a/app/mailers/report_mailer.rb b/app/mailers/report_mailer.rb index f092b95a5ab..773ec8c0314 100644 --- a/app/mailers/report_mailer.rb +++ b/app/mailers/report_mailer.rb @@ -1,8 +1,12 @@ +require 'csv' + class ReportMailer < ActionMailer::Base include Mailable before_action :attach_images + layout 'tables_report', only: [:tables_report] + def deleted_user_accounts_report(email:, name:, issuers:, data:) @name = name @issuers = issuers @@ -30,4 +34,36 @@ def warn_error(email:, error:, env: Rails.env) @error = error mail(to: email, subject: "[#{env}] identity-idp error: #{error.class.name}") end + + # @param [String] email + # @param [String] subject + # @param [String] env name of current deploy environment + # @param [Array>>] tables + # an array of tables (which are arrays of rows (arrays of strings)) + # each table can have a first "row" that is a hash with options + # @option opts [Boolean] :float_as_percent whether or not to render floats as percents + # @option opts [Boolean] :title title of the table + def tables_report(email:, subject:, tables:, env: Identity::Hostdata.env || 'local') + @tables = tables.each_with_index.map do |table, index| + options = table.first.is_a?(Hash) ? table.shift : {} + + options[:title] ||= "Table #{index + 1}" + + [options, *table] + end + + @tables.each do |options_and_table| + options, *table = options_and_table + + title = "#{options[:title].parameterize}.csv" + + attachments[title] = CSV.generate do |csv| + table.each do |row| + csv << row + end + end + end + + mail(to: email, subject: "[#{env}] #{subject}") + end end diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 1889f7e0517..95e0b2bf218 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -4,23 +4,39 @@ class DocumentCaptureSession < ApplicationRecord belongs_to :user def load_result + return nil unless result_id.present? EncryptedRedisStructStorage.load(result_id, type: DocumentCaptureSessionResult) end def store_result_from_response(doc_auth_response) + session_result = load_result || DocumentCaptureSessionResult.new( + id: generate_result_id, + ) + session_result.success = doc_auth_response.success? + session_result.pii = doc_auth_response.pii_from_doc + session_result.attention_with_barcode = doc_auth_response.attention_with_barcode? EncryptedRedisStructStorage.store( - DocumentCaptureSessionResult.new( - id: generate_result_id, - success: doc_auth_response.success?, - pii: doc_auth_response.pii_from_doc, - attention_with_barcode: doc_auth_response.attention_with_barcode?, - ), + session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, ) self.ocr_confirmation_pending = doc_auth_response.attention_with_barcode? save! end + def store_failed_auth_image_fingerprint(front_image_fingerprint, back_image_fingerprint) + session_result = load_result || DocumentCaptureSessionResult.new( + id: generate_result_id, + ) + session_result.success = false + session_result.add_failed_front_image!(front_image_fingerprint) if front_image_fingerprint + session_result.add_failed_back_image!(back_image_fingerprint) if back_image_fingerprint + EncryptedRedisStructStorage.store( + session_result, + expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, + ) + save! + end + def load_doc_auth_async_result EncryptedRedisStructStorage.load(result_id, type: DocumentCaptureSessionAsyncResult) end diff --git a/app/models/user.rb b/app/models/user.rb index 653b3edd461..193a5adb8a1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -390,6 +390,17 @@ def has_devices? !recent_devices.empty? end + # Returns the number of times the user has signed in, corresponding to the `sign_in_before_2fa` + # event. + # + # A `since` time argument is required, to optimize performance based on database indices for + # querying a user's events. + # + # @param [ActiveSupport::TimeWithZone] since Time window to query user's events + def sign_in_count(since:) + events.where(event_type: :sign_in_before_2fa).where(created_at: since..).count + end + def second_last_signed_in_at events.where(event_type: 'sign_in_after_2fa'). order(created_at: :desc).limit(2).pluck(:created_at).second diff --git a/app/presenters/idv/address_presenter.rb b/app/presenters/idv/address_presenter.rb index 0e624f7d7cb..b42aa09590d 100644 --- a/app/presenters/idv/address_presenter.rb +++ b/app/presenters/idv/address_presenter.rb @@ -4,10 +4,6 @@ def initialize(pii:) @pii = pii end - def puerto_rico_address? - @pii[:state] == 'PR' - end - def pii @pii end diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb index edeee46a277..74ad43535fa 100644 --- a/app/presenters/image_upload_response_presenter.rb +++ b/app/presenters/image_upload_response_presenter.rb @@ -49,6 +49,7 @@ def as_json(*) json[:ocr_pii] = ocr_pii json[:result_failed] = doc_auth_result_failed? json[:doc_type_supported] = doc_type_supported? + json[:failed_image_fingerprints] = failed_fingerprints json end end @@ -80,4 +81,8 @@ def doc_type_supported? # default to true by assuming using supported doc type unless we clearly detect unsupported type @form_response.respond_to?(:id_type_supported?) ? @form_response.id_type_supported? : true end + + def failed_fingerprints + @form_response.extra[:failed_image_fingerprints] || { front: [], back: [] } + end end diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb index 37ce24c5408..4e76fd9d586 100644 --- a/app/presenters/two_factor_options_presenter.rb +++ b/app/presenters/two_factor_options_presenter.rb @@ -64,6 +64,14 @@ def skip_path after_mfa_setup_path if two_factor_enabled? && show_skip_additional_mfa_link? end + def skip_label + if user_has_dismissed_second_mfa_reminder? + t('links.cancel') + else + t('mfa.skip') + end + end + private def piv_cac_option @@ -114,4 +122,8 @@ def phishing_resistant_only? def mfa_policy @mfa_policy ||= MfaPolicy.new(user) end + + def user_has_dismissed_second_mfa_reminder? + user.second_mfa_reminder_dismissed_at.present? + end end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index ff4370f3215..87da9d45f7d 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -226,9 +226,9 @@ def backup_code_created(enabled_mfa_methods_count:, **extra) end # Tracks when the user visits the Backup Code Regenerate page. - # @param [Boolean] in_multi_mfa_selection_flow whether user is going through MFA selection Flow - def backup_code_regenerate_visit(in_multi_mfa_selection_flow:, **extra) - track_event('Backup Code Regenerate Visited', in_multi_mfa_selection_flow:, **extra) + # @param [Boolean] in_account_creation_flow whether user is going through creation flow + def backup_code_regenerate_visit(in_account_creation_flow:, **extra) + track_event('Backup Code Regenerate Visited', in_account_creation_flow:, **extra) end # Track user creating new BackupCodeSetupForm, record form submission Hash @@ -701,6 +701,15 @@ def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra) ) end + # @param [String] side the side of the image submission + def idv_doc_auth_failed_image_resubmitted(side:, **extra) + track_event( + 'IdV: failed doc image resubmitted', + side: side, + **extra, + ) + end + def idv_doc_auth_getting_started_submitted(**extra) track_event('IdV: doc auth getting_started submitted', **extra) end @@ -2291,7 +2300,7 @@ def idv_please_call_visited(proofing_components: nil, **extra) # The system encountered an error and the proofing results are missing def idv_proofing_resolution_result_missing(proofing_components: nil, **extra) track_event( - 'Proofing Resolution Result Missing', + 'IdV: proofing resolution result missing', proofing_components: proofing_components, **extra, ) @@ -2635,15 +2644,15 @@ def multi_factor_auth_added_phone(enabled_mfa_methods_count:, **extra) # Tracks when the user has added the MFA method piv_cac to their account # @param [Integer] enabled_mfa_methods_count number of registered mfa methods for the user - # @param [Boolean] in_multi_mfa_selection_flow whether user is going through MFA selection Flow - def multi_factor_auth_added_piv_cac(enabled_mfa_methods_count:, in_multi_mfa_selection_flow:, + # @param [Boolean] in_account_creation_flow whether user is going through creation flow + def multi_factor_auth_added_piv_cac(enabled_mfa_methods_count:, in_account_creation_flow:, **extra) track_event( 'Multi-Factor Authentication: Added PIV_CAC', { method_name: :piv_cac, enabled_mfa_methods_count:, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, **extra, }.compact, ) @@ -2651,14 +2660,14 @@ def multi_factor_auth_added_piv_cac(enabled_mfa_methods_count:, in_multi_mfa_sel # Tracks when the user has added the MFA method TOTP to their account # @param [Integer] enabled_mfa_methods_count number of registered mfa methods for the user - # @param [Boolean] in_multi_mfa_selection_flow whether user is going through MFA selection Flow - def multi_factor_auth_added_totp(enabled_mfa_methods_count:, in_multi_mfa_selection_flow:, + # @param [Boolean] in_account_creation_flow whether user is going through creation flow + def multi_factor_auth_added_totp(enabled_mfa_methods_count:, in_account_creation_flow:, **extra) track_event( 'Multi-Factor Authentication: Added TOTP', { method_name: :totp, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, enabled_mfa_methods_count:, **extra, }.compact, @@ -2690,17 +2699,17 @@ def multi_factor_auth_backup_code_download # Tracks when the user visits the backup code confirmation setup page # @param [Integer] enabled_mfa_methods_count number of registered mfa methods for the user - # @param [Boolean] in_multi_mfa_selection_flow tell whether its in MFA selection flow or not + # @param [Boolean] in_account_creation_flow whether user is going through creation flow def multi_factor_auth_enter_backup_code_confirmation_visit( enabled_mfa_methods_count:, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, **extra ) track_event( 'Multi-Factor Authentication: enter backup code confirmation visited', { enabled_mfa_methods_count:, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, **extra, }.compact, ) @@ -2859,13 +2868,13 @@ def multi_factor_auth_phone_setup(success:, # @param [Boolean] success Whether authenticator setup was successful # @param [Hash] errors Authenticator setup error reasons, if unsuccessful # @param [String] multi_factor_auth_method - # @param [Boolean] in_multi_mfa_selection_flow + # @param [Boolean] in_account_creation_flow whether user is going through account creation flow # @param [integer] enabled_mfa_methods_count def multi_factor_auth_setup( success:, multi_factor_auth_method:, enabled_mfa_methods_count:, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, errors: nil, **extra ) @@ -2874,7 +2883,7 @@ def multi_factor_auth_setup( success: success, errors: errors, multi_factor_auth_method: multi_factor_auth_method, - in_multi_mfa_selection_flow: in_multi_mfa_selection_flow, + in_account_creation_flow: in_account_creation_flow, enabled_mfa_methods_count: enabled_mfa_methods_count, **extra, ) @@ -3137,12 +3146,16 @@ def openid_connect_request_authorization( # @param [String] client_id # @param [String] user_id # @param [String] code_digest hash of "code" param - def openid_connect_token(client_id:, user_id:, code_digest:, **extra) + # @param [Integer, nil] expires_in time to expiration of token + # @param [Integer, nil] ial ial level of identity + def openid_connect_token(client_id:, user_id:, code_digest:, expires_in:, ial:, **extra) track_event( 'OpenID Connect: token', client_id: client_id, user_id: user_id, code_digest: code_digest, + expires_in: expires_in, + ial: ial, **extra, ) end @@ -3386,11 +3399,11 @@ def piv_cac_login(success:, errors:, **extra) # @identity.idp.previous_event_name User Registration: piv cac setup visited # Tracks when user's piv cac setup - # @param [Boolean] in_multi_mfa_selection_flow - def piv_cac_setup_visit(in_multi_mfa_selection_flow:, **extra) + # @param [Boolean] in_account_creation_flow + def piv_cac_setup_visit(in_account_creation_flow:, **extra) track_event( 'PIV CAC setup visited', - in_multi_mfa_selection_flow:, + in_account_creation_flow:, **extra, ) end @@ -3689,6 +3702,17 @@ def saml_auth_request( ) end + # User dismissed the second MFA reminder page + # @param [Boolean] opted_to_add Whether the user chose to add a method + def second_mfa_reminder_dismissed(opted_to_add:, **extra) + track_event('Second MFA Reminder Dismissed', opted_to_add:, **extra) + end + + # User visited the second MFA reminder page + def second_mfa_reminder_visit + track_event('Second MFA Reminder Visited') + end + # Tracks when security event is received # @param [Boolean] success # @param [String] error_code @@ -3881,12 +3905,12 @@ def telephony_otp_sent( # @param [Boolean] user_signed_up # @param [Boolean] totp_secret_present # @param [Integer] enabled_mfa_methods_count - # @param [Boolean] in_multi_mfa_selection_flow + # @param [Boolean] in_account_creation_flow def totp_setup_visit( user_signed_up:, totp_secret_present:, enabled_mfa_methods_count:, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, **extra ) track_event( @@ -3894,7 +3918,7 @@ def totp_setup_visit( user_signed_up:, totp_secret_present:, enabled_mfa_methods_count:, - in_multi_mfa_selection_flow:, + in_account_creation_flow:, **extra, ) end @@ -4138,6 +4162,7 @@ def user_registration_enter_email_visit # @param [Boolean] success # @param [Hash] mfa_method_counts # @param [Integer] enabled_mfa_methods_count + # @param [Boolean] second_mfa_reminder_conversion Whether it is a result of second MFA reminder. # @param [Hash] pii_like_keypaths # Tracks when a user has completed MFA setup def user_registration_mfa_setup_complete( @@ -4145,6 +4170,7 @@ def user_registration_mfa_setup_complete( mfa_method_counts:, enabled_mfa_methods_count:, pii_like_keypaths:, + second_mfa_reminder_conversion: nil, **extra ) track_event( @@ -4154,6 +4180,7 @@ def user_registration_mfa_setup_complete( mfa_method_counts: mfa_method_counts, enabled_mfa_methods_count: enabled_mfa_methods_count, pii_like_keypaths: pii_like_keypaths, + second_mfa_reminder_conversion:, **extra, }.compact, ) diff --git a/app/services/doc_auth/mock/doc_auth_mock_client.rb b/app/services/doc_auth/mock/doc_auth_mock_client.rb index de3061a925f..63e32360828 100644 --- a/app/services/doc_auth/mock/doc_auth_mock_client.rb +++ b/app/services/doc_auth/mock/doc_auth_mock_client.rb @@ -34,7 +34,6 @@ def create_document # rubocop:disable Lint/UnusedMethodArgument def post_front_image(image:, instance_id:) return mocked_response_for_method(__method__) if method_mocked?(__method__) - self.class.last_uploaded_front_image = image DocAuth::Response.new(success: true) end diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 56c76d99bce..84e517b21fc 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -71,6 +71,39 @@ def attention_with_barcode? parsed_alerts == [ATTENTION_WITH_BARCODE_ALERT] end + def self.create_image_error_response(status) + error = case status + when 438 + Errors::IMAGE_LOAD_FAILURE + when 439 + Errors::PIXEL_DEPTH_FAILURE + when 440 + Errors::IMAGE_SIZE_FAILURE + end + errors = { general: [error] } + message = [ + 'Unexpected HTTP response', + status, + ].join(' ') + exception = DocAuth::RequestError.new(message, status) + DocAuth::Response.new( + success: false, + errors: errors, + exception: exception, + extra: { vendor: 'Mock' }, + ) + end + + def self.create_network_error_response + errors = { network: true } + DocAuth::Response.new( + success: false, + errors: errors, + exception: Faraday::TimeoutError.new, + extra: { vendor: 'Mock' }, + ) + end + private def parsed_alerts diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index a652200b6a1..d7c3652e9e7 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -68,5 +68,10 @@ def first_error_message def attention_with_barcode? @attention_with_barcode end + + def network_error? + return false unless @errors + return !!@errors&.with_indifferent_access&.dig(:network) + end end end diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 320468a7968..6400dbcf592 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -6,8 +6,11 @@ :success, :pii, :attention_with_barcode, + :failed_front_image_fingerprints, + :failed_back_image_fingerprints, keyword_init: true, - allowed_members: [:id, :success, :attention_with_barcode], + allowed_members: [:id, :success, :attention_with_barcode, :failed_front_image_fingerprints, + :failed_back_image_fingerprints], ) do def self.redis_key_prefix 'dcs:result' @@ -16,4 +19,18 @@ def self.redis_key_prefix alias_method :success?, :success alias_method :attention_with_barcode?, :attention_with_barcode alias_method :pii_from_doc, :pii + + %w[front back].each do |side| + define_method("add_failed_#{side}_image!") do |fingerprint| + member_name = "failed_#{side}_image_fingerprints" + self[member_name] ||= [] + self[member_name] << fingerprint + end + + define_method("failed_#{side}_image?") do |fingerprint| + member_name = "failed_#{side}_image_fingerprints" + return false unless self[member_name]&.is_a?(Array) + return self[member_name]&.include?(fingerprint) + end + end end diff --git a/app/services/id_token_builder.rb b/app/services/id_token_builder.rb index adb8f950e45..368d4e8d0a9 100644 --- a/app/services/id_token_builder.rb +++ b/app/services/id_token_builder.rb @@ -48,7 +48,7 @@ def id_token_claims def timestamp_claims { - exp: @custom_expiration || expires, + exp: @custom_expiration || session_accessor.expires_at.to_i, iat: now.to_i, nbf: now.to_i, } diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index e952e9da614..6115ef4c9f0 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -16,6 +16,7 @@ class Session personal_key phone_for_mobile_flow pii + pii_from_doc previous_phone_step_params profile_id redo_document_capture diff --git a/app/services/out_of_band_session_accessor.rb b/app/services/out_of_band_session_accessor.rb index e3b779cc794..c361e660905 100644 --- a/app/services/out_of_band_session_accessor.rb +++ b/app/services/out_of_band_session_accessor.rb @@ -13,9 +13,21 @@ def initialize(session_uuid, session_store = nil) end def ttl + return 0 if expires_at.nil? + return (expires_at - Time.zone.now).to_i + end + + def expires_at + return @expires_at if defined?(@expires_at) uuid = Rack::Session::SessionId.new(session_uuid) - session_store.instance_eval do - with_redis_connection { |client| client.ttl(prefixed(uuid)) } + expires_at = session_store.instance_eval do + with_redis_connection { |client| client.expiretime(prefixed(uuid)) } + end + + if expires_at >= 0 + @expires_at = ActiveSupport::TimeZone['UTC'].at(expires_at).in_time_zone(Time.zone) + else + @expires_at = Time.zone.now end end diff --git a/app/services/decorated_session.rb b/app/services/service_provider_session_creator.rb similarity index 75% rename from app/services/decorated_session.rb rename to app/services/service_provider_session_creator.rb index dafe9f19bba..b85e540079f 100644 --- a/app/services/decorated_session.rb +++ b/app/services/service_provider_session_creator.rb @@ -1,4 +1,4 @@ -class DecoratedSession +class ServiceProviderSessionCreator def initialize(sp:, view_context:, sp_session:, service_provider_request:) @sp = sp @view_context = view_context @@ -6,16 +6,16 @@ def initialize(sp:, view_context:, sp_session:, service_provider_request:) @service_provider_request = service_provider_request end - def call + def create_session if sp - ServiceProviderSessionDecorator.new( + ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: sp_session, service_provider_request: service_provider_request, ) else - SessionDecorator.new(view_context: view_context) + NullServiceProviderSession.new(view_context: view_context) end end diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 37aaf93c85c..d35f0880bff 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -25,5 +25,5 @@ <% end %> <%= render(PageFooterComponent.new) do %> - <%= link_to t('links.cancel'), decorated_session.cancel_link_url %> + <%= link_to t('links.cancel'), decorated_sp_session.cancel_link_url %> <% end %> diff --git a/app/views/devise/sessions/_return_to_service_provider.html.erb b/app/views/devise/sessions/_return_to_service_provider.html.erb index bf968cedefa..03bbc9b2248 100644 --- a/app/views/devise/sessions/_return_to_service_provider.html.erb +++ b/app/views/devise/sessions/_return_to_service_provider.html.erb @@ -1,4 +1,4 @@ <%= link_to( - "‹ #{t('links.back_to_sp', sp: decorated_session.sp_name)}", + "‹ #{t('links.back_to_sp', sp: decorated_sp_session.sp_name)}", return_to_sp_cancel_path, ) %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 196360f07e3..812ff5db775 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,6 +1,6 @@ <% title t('titles.visitors.index') %> -<% if decorated_session.sp_name %> +<% if decorated_sp_session.sp_name %> <%= render 'sign_up/registrations/sp_registration_heading' %> <% end %> @@ -19,7 +19,7 @@ <% if @issuer_forced_reauthentication %>

- <%= t('account.login.forced_reauthentication_notice_html', sp_name: decorated_session.sp_name) %> + <%= t('account.login.forced_reauthentication_notice_html', sp_name: decorated_sp_session.sp_name) %>

<% end %> @@ -64,7 +64,7 @@ <% end %> <%= render PageFooterComponent.new do %> - <% if decorated_session.sp_name %> + <% if decorated_sp_session.sp_name %>
<%= render 'devise/sessions/return_to_service_provider' %>
diff --git a/app/views/idv/by_mail/letter_enqueued/show.html.erb b/app/views/idv/by_mail/letter_enqueued/show.html.erb index 9294f8ae88a..3922a682d29 100644 --- a/app/views/idv/by_mail/letter_enqueued/show.html.erb +++ b/app/views/idv/by_mail/letter_enqueued/show.html.erb @@ -26,14 +26,14 @@ <%= t('idv.messages.come_back_later_password_html') %>

- <% if decorated_session.sp_name.present? %> - <%= t('idv.messages.come_back_later_sp_html', sp: decorated_session.sp_name) %> + <% if decorated_sp_session.sp_name.present? %> + <%= t('idv.messages.come_back_later_sp_html', sp: decorated_sp_session.sp_name) %> <% else %> <%= t('idv.messages.come_back_later_no_sp_html', app_name: APP_NAME) %> <% end %>

- <% if decorated_session.sp_name.present? %> + <% if decorated_sp_session.sp_name.present? %> <%= link_to( t('idv.cancel.actions.exit', app_name: APP_NAME), return_to_sp_cancel_path(location: :come_back_later), diff --git a/app/views/idv/by_mail/request_letter/index.html.erb b/app/views/idv/by_mail/request_letter/index.html.erb index 7fad784e22f..52107ed33ef 100644 --- a/app/views/idv/by_mail/request_letter/index.html.erb +++ b/app/views/idv/by_mail/request_letter/index.html.erb @@ -13,7 +13,7 @@ <%= render StatusPageComponent.new(status: :warning) do |c| %> <% c.with_header { @presenter.title } %>

- <%= t('idv.messages.gpo.resend_timeframe') %> + <%= t('idv.messages.gpo.resend_timeframe_html') %>

<%= t('idv.messages.gpo.resend_code_warning') %> diff --git a/app/views/idv/document_capture/show.html.erb b/app/views/idv/document_capture/show.html.erb index 07dbb4153b6..817d3cc0c0e 100644 --- a/app/views/idv/document_capture/show.html.erb +++ b/app/views/idv/document_capture/show.html.erb @@ -2,7 +2,7 @@ 'idv/shared/document_capture', document_capture_session_uuid: document_capture_session_uuid, flow_path: 'standard', - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, failure_to_proof_url: failure_to_proof_url, acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, use_alternate_sdk: use_alternate_sdk, diff --git a/app/views/idv/hybrid_mobile/document_capture/show.html.erb b/app/views/idv/hybrid_mobile/document_capture/show.html.erb index 6940050a0eb..962a29d216b 100644 --- a/app/views/idv/hybrid_mobile/document_capture/show.html.erb +++ b/app/views/idv/hybrid_mobile/document_capture/show.html.erb @@ -2,7 +2,7 @@ 'idv/shared/document_capture', document_capture_session_uuid: document_capture_session_uuid, flow_path: 'hybrid', - sp_name: decorated_session.sp_name, + sp_name: decorated_sp_session.sp_name, failure_to_proof_url: failure_to_proof_url, acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, use_alternate_sdk: use_alternate_sdk, diff --git a/app/views/idv/not_verified/show.html.erb b/app/views/idv/not_verified/show.html.erb index 1721694124c..81da87ddea8 100644 --- a/app/views/idv/not_verified/show.html.erb +++ b/app/views/idv/not_verified/show.html.erb @@ -4,9 +4,9 @@ heading: t('idv.failure.verify.heading'), ) do %>

- <% if decorated_session.sp_name.present? %> + <% if decorated_sp_session.sp_name.present? %> <%= link_to( - t('idv.failure.verify.fail_link_html', sp_name: decorated_session.sp_name), + t('idv.failure.verify.fail_link_html', sp_name: decorated_sp_session.sp_name), return_to_sp_failure_to_proof_path( step: 'verify_info', location: request.params[:action], diff --git a/app/views/idv/phone_errors/_warning.html.erb b/app/views/idv/phone_errors/_warning.html.erb index 3c993d86915..1db68d18f5d 100644 --- a/app/views/idv/phone_errors/_warning.html.erb +++ b/app/views/idv/phone_errors/_warning.html.erb @@ -36,12 +36,12 @@ locals: text: t('idv.troubleshooting.options.verify_by_mail'), url: idv_request_letter_path, }, - decorated_session.sp_name && { + decorated_sp_session.sp_name && { url: return_to_sp_failure_to_proof_path( step: 'phone', location: local_assigns.fetch(:name, 'warning'), ), - text: t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_session.sp_name), + text: t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_sp_session.sp_name), new_tab: true, }, ].select(&:present?), diff --git a/app/views/idv/phone_errors/warning.html.erb b/app/views/idv/phone_errors/warning.html.erb index e53d56d6980..58176958201 100644 --- a/app/views/idv/phone_errors/warning.html.erb +++ b/app/views/idv/phone_errors/warning.html.erb @@ -46,7 +46,7 @@

<%= t('idv.failure.phone.warning.gpo.explanation') %> - <%= t('idv.failure.phone.warning.gpo.how_long_it_takes') %> + <%= t('idv.failure.phone.warning.gpo.how_long_it_takes_html') %>

diff --git a/app/views/idv/review/new.html.erb b/app/views/idv/review/new.html.erb index 53a36869575..eab16b36e86 100644 --- a/app/views/idv/review/new.html.erb +++ b/app/views/idv/review/new.html.erb @@ -1,4 +1,4 @@ -<% title t('titles.idv.review') %> +<% title @title %> <% content_for(:pre_flash_content) do %> <%= render StepIndicatorComponent.new( @@ -9,7 +9,7 @@ ) %> <% end %> -<%= render PageHeadingComponent.new.with_content(t('idv.titles.session.review', app_name: APP_NAME)) %> +<%= render PageHeadingComponent.new.with_content(@heading) %>

<%= t('idv.messages.review.message', app_name: APP_NAME) %> @@ -34,6 +34,17 @@ <%= link_to(t('idv.forgot_password.link_text'), idv_forgot_password_url, class: 'margin-left-1') %>

+ <% if @verifying_by_mail %> + <%= render AlertComponent.new( + type: :warning, + id: 'by-mail-password-warning', + class: 'margin-top-4', + ) do + t('idv.messages.review.by_mail_password_reminder_html') + end + %> + <% end %> + <%= f.submit t('forms.buttons.continue'), class: 'margin-top-5' %> <% end %> diff --git a/app/views/idv/session_errors/exception.html.erb b/app/views/idv/session_errors/exception.html.erb index 57296187ddc..cf8338fc047 100644 --- a/app/views/idv/session_errors/exception.html.erb +++ b/app/views/idv/session_errors/exception.html.erb @@ -12,12 +12,12 @@ text: t('idv.troubleshooting.options.contact_support', app_name: APP_NAME), new_tab: true, }, - decorated_session.sp_name && { + decorated_sp_session.sp_name && { url: return_to_sp_failure_to_proof_path( step: 'verify_info', location: request.params[:action], ), - text: t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_session.sp_name), + text: t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_sp_session.sp_name), new_tab: true, }, ].compact, diff --git a/app/views/idv/session_errors/rate_limited.html.erb b/app/views/idv/session_errors/rate_limited.html.erb index e7fe95ac22d..2e8e8cf638b 100644 --- a/app/views/idv/session_errors/rate_limited.html.erb +++ b/app/views/idv/session_errors/rate_limited.html.erb @@ -21,11 +21,11 @@

- <% if decorated_session.sp_name.present? %> + <% if decorated_sp_session.sp_name.present? %> <%= link_to( t( 'idv.failure.exit.with_sp', - app_name: APP_NAME, sp_name: decorated_session.sp_name, + app_name: APP_NAME, sp_name: decorated_sp_session.sp_name, ), return_to_sp_failure_to_proof_path( step: 'verify_id', diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 41b1b41d88d..6ca5755bbff 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -27,7 +27,7 @@ flow_path: flow_path, cancel_url: idv_cancel_path, failure_to_proof_url: failure_to_proof_url, - idv_in_person_url: (IdentityConfig.store.in_person_doc_auth_button_enabled && Idv::InPersonConfig.enabled_for_issuer?(decorated_session.sp_issuer)) ? idv_in_person_url : nil, + idv_in_person_url: (IdentityConfig.store.in_person_doc_auth_button_enabled && Idv::InPersonConfig.enabled_for_issuer?(decorated_sp_session.sp_issuer)) ? idv_in_person_url : nil, security_and_privacy_how_it_works_url: MarketingSite.security_and_privacy_how_it_works_url, in_person_full_address_entry_enabled: IdentityConfig.store.in_person_full_address_entry_enabled, in_person_outage_message_enabled: IdentityConfig.store.in_person_outage_message_enabled, diff --git a/app/views/idv/unavailable/show.html.erb b/app/views/idv/unavailable/show.html.erb index 9815916580a..0bbf62201da 100644 --- a/app/views/idv/unavailable/show.html.erb +++ b/app/views/idv/unavailable/show.html.erb @@ -5,8 +5,8 @@ <% c.with_header { t('idv.titles.unavailable') } %>

- <% if decorated_session.sp_name.present? %> - <%= t('idv.unavailable.idv_explanation.with_sp_html', sp: decorated_session.sp_name) %> + <% if decorated_sp_session.sp_name.present? %> + <%= t('idv.unavailable.idv_explanation.with_sp_html', sp: decorated_sp_session.sp_name) %> <% else %> <%= t('idv.unavailable.idv_explanation.without_sp') %> <% end %> diff --git a/app/views/idv/welcome/_welcome_default.html.erb b/app/views/idv/welcome/_welcome_default.html.erb index 1c516a5a7bb..80469d2a571 100644 --- a/app/views/idv/welcome/_welcome_default.html.erb +++ b/app/views/idv/welcome/_welcome_default.html.erb @@ -11,12 +11,12 @@ <%= render JavascriptRequiredComponent.new( header: t('idv.welcome.no_js_header'), - intro: t('idv.welcome.no_js_intro', sp_name: decorated_session.sp_name || APP_NAME), + intro: t('idv.welcome.no_js_intro', sp_name: decorated_sp_session.sp_name || APP_NAME), ) do %> <%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.welcome')) %>

- <%= t('doc_auth.info.welcome', sp_name: decorated_session.sp_name || APP_NAME) %> + <%= t('doc_auth.info.welcome', sp_name: decorated_sp_session.sp_name || APP_NAME) %>

<%= t('doc_auth.instructions.welcome') %>

@@ -81,9 +81,9 @@ text: t('idv.troubleshooting.options.learn_more_address_verification_options'), new_tab: true, }, - decorated_session.sp_name && { + decorated_sp_session.sp_name && { url: return_to_sp_failure_to_proof_url(step: 'welcome', location: 'missing_items'), - text: t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_session.sp_name), + text: t('idv.troubleshooting.options.get_help_at_sp', sp_name: decorated_sp_session.sp_name), new_tab: true, }, ].select(&:present?), diff --git a/app/views/layouts/tables_report.html.erb b/app/views/layouts/tables_report.html.erb new file mode 100644 index 00000000000..de2971adaa3 --- /dev/null +++ b/app/views/layouts/tables_report.html.erb @@ -0,0 +1,14 @@ + + + + + + <%= stylesheet_link_tag 'tables-report' %> + <%= @header || message.subject %> + + +
+ <%= yield %> +
+ + diff --git a/app/views/report_mailer/tables_report.html.erb b/app/views/report_mailer/tables_report.html.erb new file mode 100644 index 00000000000..d1928ccaebe --- /dev/null +++ b/app/views/report_mailer/tables_report.html.erb @@ -0,0 +1,33 @@ +<%# +- @tables: an array of tables, expects first "row" to be an options hash +%> +<% @tables.each do |table| %> + <% options, header, *rows = table %> + +

<%= options[:title] %>

+ + + + + <% header.each do |head| %> + + <% end %> + + + + <% rows.each do |row| %> + + <% row.each do |cell| %> + + <% end %> + + <% end %> + +
<%= head %>
> + <% if cell.is_a?(Float) && options[:float_as_percent] %> + <%= number_to_percentage(cell * 100, precision: 2) %> + <% else %> + <%= number_with_delimiter(cell) %> + <% end %> +
+<% end %> diff --git a/app/views/shared/_banner.html.erb b/app/views/shared/_banner.html.erb index abd643ee18f..933c4451593 100644 --- a/app/views/shared/_banner.html.erb +++ b/app/views/shared/_banner.html.erb @@ -66,7 +66,7 @@ <% if content_for?(:header) %> <%= yield(:header) %> <% else %> - <% if decorated_session.sp_name %> + <% if decorated_sp_session.sp_name %> <%= render 'shared/nav_branded' %> <% else %> <%= render 'shared/nav_lite' %> diff --git a/app/views/shared/_nav_branded.html.erb b/app/views/shared/_nav_branded.html.erb index 547bbf877c5..8dfcf6fe79c 100644 --- a/app/views/shared/_nav_branded.html.erb +++ b/app/views/shared/_nav_branded.html.erb @@ -1,3 +1,3 @@ <%= image_tag(asset_url('logo.svg'), height: 15, width: 111, alt: APP_NAME) %>
-<%= image_tag(decorated_session.sp_logo_url, height: 40, alt: decorated_session.sp_name) %> +<%= image_tag(decorated_sp_session.sp_logo_url, height: 40, alt: decorated_sp_session.sp_name) %> diff --git a/app/views/shared/_sp_alert.html.erb b/app/views/shared/_sp_alert.html.erb index 211785df9b2..ac2ff7601ef 100644 --- a/app/views/shared/_sp_alert.html.erb +++ b/app/views/shared/_sp_alert.html.erb @@ -1,4 +1,4 @@ -<% alert = decorated_session.sp_alert(section) %> +<% alert = decorated_sp_session.sp_alert(section) %> <% if alert %> <%= render AlertComponent.new(text_tag: 'div', class: 'margin-bottom-4') do %> <%= raw sanitize(alert, tags: %w[a b strong em br p ol ul li], attributes: %w[href target]) %> diff --git a/app/views/sign_up/registrations/_sp_registration_heading.html.erb b/app/views/sign_up/registrations/_sp_registration_heading.html.erb index 9729c698dba..1bebb5e7078 100644 --- a/app/views/sign_up/registrations/_sp_registration_heading.html.erb +++ b/app/views/sign_up/registrations/_sp_registration_heading.html.erb @@ -1,7 +1,7 @@
<%= image_tag(asset_url('user-access.svg'), width: '280', height: '91', alt: '') %>

- <%= decorated_session.sp_name %> + <%= decorated_sp_session.sp_name %> <%= t('headings.create_account_with_sp.sp_text', app_name: APP_NAME) %>

diff --git a/app/views/sign_up/registrations/new.html.erb b/app/views/sign_up/registrations/new.html.erb index 3c6e816de19..be7a81383ed 100644 --- a/app/views/sign_up/registrations/new.html.erb +++ b/app/views/sign_up/registrations/new.html.erb @@ -2,7 +2,7 @@ <%= render 'shared/sp_alert', section: 'sign_up' %> -<% if decorated_session.sp_name %> +<% if decorated_sp_session.sp_name %> <%= render 'sign_up/registrations/sp_registration_heading' %> <% end %> @@ -49,7 +49,7 @@ <%= f.submit t('forms.buttons.submit.default'), class: 'display-block margin-y-5' %> <% end %> -<%= render 'shared/cancel', link: decorated_session.cancel_link_url %> +<%= render 'shared/cancel', link: decorated_sp_session.cancel_link_url %>

<%= new_tab_link_to( diff --git a/app/views/users/authorization_confirmation/new.html.erb b/app/views/users/authorization_confirmation/new.html.erb index 1fe25a712b7..6e9a9e560cd 100644 --- a/app/views/users/authorization_confirmation/new.html.erb +++ b/app/views/users/authorization_confirmation/new.html.erb @@ -1,9 +1,9 @@ <% title t('titles.sign_up.confirmation') %> -<% if decorated_session.sp_name %> +<% if decorated_sp_session.sp_name %> <%= render 'sign_up/registrations/sp_registration_heading' %> <% else %> - <%= render PageHeadingComponent.new.with_content(decorated_session.new_session_heading) %> + <%= render PageHeadingComponent.new.with_content(decorated_sp_session.new_session_heading) %> <% end %>

diff --git a/app/views/users/emails/show.html.erb b/app/views/users/emails/show.html.erb index cb2ebc6adba..4a8be837f9e 100644 --- a/app/views/users/emails/show.html.erb +++ b/app/views/users/emails/show.html.erb @@ -19,7 +19,7 @@ <% end %>
-<%= render 'shared/cancel', link: decorated_session.cancel_link_url %> +<%= render 'shared/cancel', link: decorated_sp_session.cancel_link_url %>

<%= new_tab_link_to( diff --git a/app/views/users/rules_of_use/new.html.erb b/app/views/users/rules_of_use/new.html.erb index b4a16bd29ed..51dcd69d62a 100644 --- a/app/views/users/rules_of_use/new.html.erb +++ b/app/views/users/rules_of_use/new.html.erb @@ -32,4 +32,4 @@ <%= f.submit t('forms.buttons.continue'), class: 'margin-y-5' %> <% end %> -<%= render 'shared/cancel', link: decorated_session.cancel_link_url %> +<%= render 'shared/cancel', link: decorated_sp_session.cancel_link_url %> diff --git a/app/views/users/second_mfa_reminder/new.html.erb b/app/views/users/second_mfa_reminder/new.html.erb new file mode 100644 index 00000000000..1029f043cad --- /dev/null +++ b/app/views/users/second_mfa_reminder/new.html.erb @@ -0,0 +1,33 @@ +<% title t('users.second_mfa_reminder.heading') %> + +<%= render StatusPageComponent.new(status: :info, icon: :question) do |c| %> + <% c.with_header { t('users.second_mfa_reminder.heading') } %> + +

<%= t('users.second_mfa_reminder.description') %>

+ +
+
+ <%= render ButtonComponent.new( + action: ->(**tag_options, &block) do + button_to( + second_mfa_reminder_path, + **tag_options, + params: { add_method: true }, + &block + ) + end, + big: true, + full_width: true, + class: 'margin-bottom-2', + ).with_content(t('users.second_mfa_reminder.add_method')) %> + <%= render ButtonComponent.new( + action: ->(**tag_options, &block) do + button_to(second_mfa_reminder_path, **tag_options, &block) + end, + outline: true, + big: true, + full_width: true, + ).with_content(t('users.second_mfa_reminder.continue', sp_name: decorated_sp_session.sp_name || APP_NAME)) %> +
+
+<% end %> diff --git a/app/views/users/two_factor_authentication_setup/index.html.erb b/app/views/users/two_factor_authentication_setup/index.html.erb index 1506a1d37b3..3656ac0ab8d 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.erb +++ b/app/views/users/two_factor_authentication_setup/index.html.erb @@ -52,7 +52,7 @@ <% if @presenter.skip_path || !@presenter.two_factor_enabled? %> <%= render PageFooterComponent.new do %> <% if @presenter.skip_path %> - <%= link_to t('mfa.skip'), @presenter.skip_path %> + <%= link_to @presenter.skip_label, @presenter.skip_path %> <% elsif !@presenter.two_factor_enabled? %> <%= link_to t('links.cancel_account_creation'), sign_up_cancel_path %> <% end %> diff --git a/config/application.yml.default b/config/application.yml.default index 4f23277751d..b649acd9787 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -75,6 +75,7 @@ disallow_all_web_crawlers: true disposable_email_services: '[]' doc_auth_attempt_window_in_minutes: 360 doc_capture_polling_enabled: true +doc_auth_check_failed_image_resubmission_enabled: true doc_auth_client_glare_threshold: 50 doc_auth_client_sharpness_threshold: 50 doc_auth_s3_request_timeout: 5 @@ -201,6 +202,10 @@ max_piv_cac_per_account: 2 min_password_score: 3 minimum_wait_before_another_usps_letter_in_hours: 24 multi_region_kms_migration_jobs_enabled: true +multi_region_kms_migration_jobs_profile_count: 1000 +multi_region_kms_migration_jobs_profile_timeout: 120 +multi_region_kms_migration_jobs_user_count: 1000 +multi_region_kms_migration_jobs_user_timeout: 120 mx_timeout: 3 otp_delivery_blocklist_maxretry: 10 otp_valid_for: 10 @@ -285,6 +290,9 @@ rules_of_use_updated_at: '2022-01-19T00:00:00Z' # Production has a newer timesta s3_public_reports_enabled: false s3_reports_enabled: false saml_secret_rotation_enabled: false +second_mfa_reminder_account_age_in_days: 30 +second_mfa_reminder_enabled: true +second_mfa_reminder_sign_in_count: 10 seed_agreements_data: true service_provider_request_ttl_hours: 24 session_check_delay: 30 @@ -463,6 +471,7 @@ production: s3_reports_enabled: true saml_endpoint_configs: '[]' scrypt_cost: 10000$8$1$ + second_mfa_reminder_enabled: false secret_key_base: seed_agreements_data: false session_encryption_key: diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 845bbe1bb41..a7bf5ee9ed6 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -15,7 +15,7 @@ "type": "controller", "class": "Idv::CancellationsController", "method": "new", - "line": 14, + "line": 13, "file": "app/controllers/idv/cancellations_controller.rb", "rendered": { "name": "idv/cancellations/new", @@ -49,7 +49,7 @@ "type": "controller", "class": "Idv::CancellationsController", "method": "new", - "line": 14, + "line": 13, "file": "app/controllers/idv/cancellations_controller.rb", "rendered": { "name": "idv/cancellations/new", @@ -71,19 +71,19 @@ { "warning_type": "Dynamic Render Path", "warning_code": 15, - "fingerprint": "c39b10ff7d74fad282e9769c664c8ae50b1a79403169354b0654037488671bce", + "fingerprint": "ffc1a9fa8c18bd803bf353cbaebf0c8ca890b71574cedc4efded0dda941a4719", "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/idv/cancellations/new.html.erb", "line": 62, "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => SpinnerButtonComponent.new(:action => (lambda do\n button_to(idv_cancel_path(:step => params[:step], :location => \"cancel\"), { **tag_options }, &block)\n end), :method => :delete, :big => true, :wide => true, :outline => true, :form => ({ :data => ({ :form_steps_wait => \"\" }) })).with_content(CancellationsPresenter.new(:sp_name => decorated_session.sp_name, :url_options => url_options).exit_action_text), {})", + "code": "render(action => SpinnerButtonComponent.new(:action => (lambda do\n button_to(idv_cancel_path(:step => params[:step], :location => \"cancel\"), { **tag_options }, &block)\n end), :method => :delete, :big => true, :wide => true, :outline => true, :form => ({ :data => ({ :form_steps_wait => \"\" }) })).with_content(CancellationsPresenter.new(:sp_name => decorated_sp_session.sp_name, :url_options => url_options).exit_action_text), {})", "render_path": [ { "type": "controller", "class": "Idv::CancellationsController", "method": "new", - "line": 14, + "line": 13, "file": "app/controllers/idv/cancellations_controller.rb", "rendered": { "name": "idv/cancellations/new", @@ -103,6 +103,6 @@ "note": "" } ], - "updated": "2023-01-03 12:29:54 -0600", - "brakeman_version": "5.4.0" + "updated": "2023-09-14 11:53:14 -0400", + "brakeman_version": "6.0.1" } diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 78edbc75ad3..8125a5a2e17 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -196,9 +196,22 @@ args: -> { [Time.zone.today] }, }, # Job to backfill encrypted_pii_recovery_multi_region on profiles - multi_region_kms_migration_profile_migraiton: { + multi_region_kms_migration_profile_migration: { class: 'MultiRegionKmsMigration::ProfileMigrationJob', cron: cron_12m, + kwargs: { + profile_count: IdentityConfig.store.multi_region_kms_migration_jobs_profile_count, + statement_timeout: IdentityConfig.store.multi_region_kms_migration_jobs_profile_timeout, + }, + }, + # Job to backfill encrypted_pii_recovery_multi_region on users + multi_region_kms_migration_user_migration: { + class: 'MultiRegionKmsMigration::UserMigrationJob', + cron: cron_12m, + kwargs: { + user_count: IdentityConfig.store.multi_region_kms_migration_jobs_user_count, + statement_timeout: IdentityConfig.store.multi_region_kms_migration_jobs_user_timeout, + }, }, }.compact end diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index 896682efd6b..2afb91cbf5e 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -56,6 +56,8 @@ en: card_type: Try again with your driver’s license or state ID card. doc: doc_type_check: Other forms of ID are not accepted. + resubmit_failed_image: You already tried this image, and it failed. Please try + adding a different image. wrong_id_type: We only accept a driver’s license or a state ID card at this time. Other forms of ID are not accepted. dpi: @@ -223,7 +225,7 @@ en: - 'Verify by phone: We’ll call or text your phone number. This takes a few minutes.' - 'Verify by mail: We’ll mail a letter to your home - address. This takes about 3 to 7 business days.' + address. This takes 5 to 10 days.' welcome: 'You will need your:' tips: document_capture_header_text: 'For best results:' diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index c72483ac0ea..832e7facb0e 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -68,6 +68,8 @@ es: estatales. doc: doc_type_check: No se aceptan otras formas de identificación. + resubmit_failed_image: Ya intentó con esta imagen pero falló. Intente añadir una + imagen diferente. wrong_id_type: Solo aceptamos una licencia de conducir o un documento de identidad estatal. No se aceptan otras formas de identificación. dpi: @@ -257,7 +259,7 @@ es: - 'Verificar por teléfono: Le llamaremos o enviaremos un mensaje de texto a su número de teléfono. Esto lleva unos minutos' - 'Verificar por correo: Le enviaremos una carta a su - domicilio. Esto tarda entre 3 y 7 días laborables.' + domicilio. Esto tarda entre 5 y 10 días.' welcome: 'Necesitará su:' tips: document_capture_header_text: 'Para obtener los mejores resultados:' diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index bbc982b19ea..503890012fc 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -73,6 +73,8 @@ fr: par l’État. doc: doc_type_check: Les autres pièces d’identité ne sont pas acceptées. + resubmit_failed_image: Vous avez déjà essayé cette image et elle a échoué. + Veuillez essayer d’ajouter une image différente. wrong_id_type: Nous n’acceptons que les permis de conduire ou les cartes d’identité délivrées par l’État. Les autres pièces d’identité ne sont pas acceptées. @@ -266,8 +268,8 @@ fr: enverrons un SMS à votre numéro de téléphone. Cela prend quelques minutes.' - 'Vérification par courrier : Nous vous enverrons une - lettre à votre adresse personnelle. Cela prend de 3 à 7 jours - ouvrables.' + lettre à votre adresse personnelle. Cela prend 5 à 10 + jours.' welcome: 'Vous aurez besoin de votre:' tips: document_capture_header_text: 'Pour obtenir les meilleurs résultats:' diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 2d0af4d1d7c..6b499dbb8f6 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -102,8 +102,8 @@ en: gpo: button: Verify by mail heading: 'We couldn’t verify your identity by phone' - option_try_again_later_html: 'Cancel and start over again after %{time_left}' - option_verify_by_mail_html: 'Verify by mail, which typically takes 3-7 business days' + option_try_again_later_html: 'Cancel and start over again after %{time_left}' + option_verify_by_mail_html: 'Verify by mail, which takes 5 to 10 days' options_header: 'You can:' timeout: Our request to verify your information timed out. Please try again. warning: @@ -116,7 +116,7 @@ en: explanation: If you don’t have another phone number to try, verify by mail instead. heading: Verify by mail - how_long_it_takes: This typically takes 3 - 7 business days. + how_long_it_takes_html: This takes 5 to 10 days. heading: We couldn’t match you to this number learn_more_link: 'Learn more about what phone number to use' next_steps_html: 'Try another number that you use often and @@ -180,8 +180,8 @@ en: instructions: If you have your letter, enter the 10-character code from the letter you received. intro: - be_patient_html: Please note that letters typically take 3 to 7 business - days to arrive. Thank you for your patience. + be_patient_html: Please note that letters take up to 10 days to + arrive. Thank you for your patience. request_new_letter_link: request a new letter request_new_letter_prompt_html: If you haven’t received your letter, you may %{request_new_letter_link}. @@ -193,8 +193,8 @@ en: title: Confirm your account intro_html: '

If you have received your letter, please enter your one-time code below.

If your letter hasn’t arrived yet, please be patient - as letters typically take 3 to 7 business days to - arrive. Thank you for your patience.

' + as letters take up to 10 days to arrive. Thank you for + your patience.

' return_to_profile: Return to your profile title: Welcome back wrong_address: Not the right address? @@ -210,10 +210,9 @@ en: verified information, please %{link_html}. activated_link: contact us clear_and_start_over: Clear my information and start over - come_back_later_html:

Letters typically take 3 to 7 business - days to arrive through USPS First Class Mail.

Once your - letter arrives, sign in to %{app_name}, and enter your one-time code - when prompted.

+ come_back_later_html:

Letters take 5 to 10 days to arrive + via USPS First-Class Mail.

Once your letter arrives, sign in to + %{app_name}, and enter your one-time code when prompted.

come_back_later_no_sp_html: You can return to your %{app_name} account for now. come_back_later_password_html: Don’t forget your password.
If you reset your password, the one-time code in your letter will no @@ -231,9 +230,9 @@ en: resend_code_warning: If you request a new letter now, the one-time code from your current letter will remain valid for a limited time. You may still use the one-time code from either letter. - resend_timeframe: Letters typically take 3 to 7 business days to arrive. - timeframe_html: Letters are sent the next business day via USPS First Class Mail - and typically take 3 to 7 business days to arrive. + resend_timeframe_html: Letters take 5 to 10 days to arrive. + timeframe_html: Letters are sent the next business day via USPS First-Class Mail + and arrive in 5 to 10 days. gpo_reminder: body_html: You requested a letter to verify your identity on %{date_letter_was_sent}. You’ll need to enter the @@ -260,7 +259,8 @@ en: - Your primary number (the one you use the most often) return_to_profile: '‹ Return to your %{app_name} profile' review: - gpo_pending: We’ll send your letter once you re-enter your password. + by_mail_password_reminder_html: Remember your password. The verification + code in your letter won’t work if you reset your password later. message: '%{app_name} will encrypt your information with your password. This means that your information is secure and only you will be able to access or change it.' @@ -285,6 +285,7 @@ en: review: Review and submit session: review: Re-enter your %{app_name} password + review_letter: Re-enter your %{app_name} password to send your letter unavailable: 'We are working to resolve an error' troubleshooting: headings: diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 7dfebd40c00..002c6de32a4 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -108,9 +108,10 @@ es: gpo: button: Verificar por correo heading: No pudimos asociarlo a este número - option_try_again_later_html: 'Cancelar y empezar de nuevo transcurridas %{time_left}' - option_verify_by_mail_html: 'Verificar por correo, lo que suele demorar de 3 - a 7 días hábiles' + option_try_again_later_html: 'Cancelar y empezar de nuevo transcurridas + %{time_left}' + option_verify_by_mail_html: 'Verificar por correo, lo cual tarda entre 5 + y 10 días' options_header: 'Puede:' timeout: Nuestra solicitud para verificar tu información ha caducado. Vuelve a intentarlo. @@ -123,7 +124,7 @@ es: explanation: Si no dispones de otro número de teléfono para intentarlo, verifícalo por correo. heading: Verificar por correo - how_long_it_takes: Suele tardar entre 3 y 7 días laborables. + how_long_it_takes_html: Esto tarda entre 5 y 10 días. heading: No pudimos asociarlo a este número learn_more_link: 'Más información sobre qué número de teléfono usar' next_steps_html: 'Pruebe con otro número que utilice a menudo y @@ -187,8 +188,8 @@ es: instructions: Si ya tiene su carta, introduzca el código de 10 caracteres de la carta que recibió. intro: - be_patient_html: Tenga en cuenta que las cartas suelen tardar entre 3 y - 7 días laborables en llegar. Gracias por su paciencia. + be_patient_html: Tenga en cuenta que las cartas tardan hasta 10 + días en llegar. Gracias por su paciencia. request_new_letter_link: solicitar una nueva request_new_letter_prompt_html: Si no ha recibido su carta, puede %{request_new_letter_link}. @@ -200,8 +201,8 @@ es: title: Confirme su cuenta intro_html: '

Si ha recibido su carta, introduzca su código único a continuación.

Si su carta aún no ha llegado, tenga paciencia, ya - que las cartas suelen tardar de 3 a 7 días hábiles en - llegar. Gracias por su paciencia.

' + que las cartas tardan hasta 10 días en llegar. Gracias + por su paciencia.

' return_to_profile: Regrese a su perfil title: Bienvenido de nuevo wrong_address: ¿La dirección no es correcta? @@ -217,10 +218,10 @@ es: información verificada, por favor, %{link_html}. activated_link: Contáctenos clear_and_start_over: Borrar mi información y empezar de nuevo - come_back_later_html:

Las cartas suelen tardar de 3 a 7 días - hábiles en llegar a través de USPS First Class Mail.

Una - vez que reciba la carta, inicie sesión en %{app_name} e introduzca el - código único cuando se le solicite.

+ come_back_later_html:

Las cartas tardan entre 5 y 10 días en + llegar por USPS First-Class Mail.

Una vez que reciba la carta, + inicie sesión en %{app_name} e introduzca el código único cuando se le + solicite.

come_back_later_no_sp_html: Ahora puedes volver a tu cuenta de %{app_name}. come_back_later_password_html: No olvide su contraseña.
Si restablece su contraseña, el código único de su carta ya no @@ -239,10 +240,9 @@ es: vez de tu carta actual seguirá siendo válido durante un tiempo limitado. Puedes seguir utilizando el código de una sola vez de cualquiera de las dos cartas. - resend_timeframe: Las cartas suelen tardar entre 3 y 7 días hábiles en llegar. - timeframe_html: Las cartas se envían al día siguiente por First Class Mail de - USPS y suelen tardar entre 3 y 7 días hábiles en - llegar. + resend_timeframe_html: Las cartas tardan entre 5 y 10 días en llegar. + timeframe_html: Las cartas se envían al siguiente día hábil por USPS First-Class + Mail y tardan entre 5 y 10 días en llegar. gpo_reminder: body_html: El %{date_letter_was_sent} solicitaste una carta para verificar tu identidad. Deberás ingresar el código que contiene @@ -271,7 +271,9 @@ es: - Su número principal (el que utiliza con más frecuencia) return_to_profile: '‹ Volver a tu perfil de %{app_name}' review: - gpo_pending: Enviaremos tu carta una vez que hayas ingresado tu contraseña. + by_mail_password_reminder_html: Recuerde su contraseña. El código de + verificación de su carta no funcionará si restablece su contraseña más + tarde. message: '%{app_name} encriptará tu información con tu contraseña. Esto significa que tu información estará segura y solo tú podrás consultarla o modificarla.' @@ -297,6 +299,7 @@ es: review: Revise y envíe session: review: 'Vuelve a ingresar tu contraseña de %{app_name}' + review_letter: 'Vuelve a ingresar su contraseña de %{app_name} para enviar su carta' unavailable: Estamos trabajando para resolver un error troubleshooting: headings: diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index cdb49352b88..a9dfd7f1ce2 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -113,9 +113,8 @@ fr: gpo: button: Vérifier par courrier heading: Nous n’avons pas pu vérifier votre identité par téléphone - option_try_again_later_html: 'Annuler et recommencer après %{time_left}' - option_verify_by_mail_html: Vérifier par courrier, ce qui prend généralement de - trois à sept jours ouvrables. + option_try_again_later_html: 'Annuler et recommencer après %{time_left}' + option_verify_by_mail_html: Vérifier par courrier, qui nécessite 5 à 10 jours. options_header: 'Vous Pouvez :' timeout: Notre demande de vérification de vos renseignements a expiré. Veuillez réessayer. @@ -129,7 +128,7 @@ fr: explanation: Si vous n’avez pas d’autre numéro de téléphone à essayer, vérifiez plutôt par courrier. heading: Vérifier par courrier - how_long_it_takes: Cette procédure prend typiquement 3 à 7 jours ouvrables. + how_long_it_takes_html: Cela prend 5 à 10 jours. heading: Nous n’avons pas pu vous associer à ce numéro learn_more_link: 'Apprenez-en plus sur quel numéro de téléphone utiliser' next_steps_html: 'Essayez un autre numéro que vous utilisez @@ -195,9 +194,9 @@ fr: instructions: Si vous avez reçu votre lettre, veuillez entrer le code à 10 caractères figurant sur la lettre que vous avez reçue. intro: - be_patient_html: 'Veuillez noter que les lettres prennent généralement - entre trois à sept jours ouvrables pour arriver. Nous vous - remercions de votre patience.' + be_patient_html: 'Veuillez noter que les lettres mettent jusqu’à 10 + jours pour arriver. Nous vous remercions de votre + patience.' request_new_letter_link: demander une nouvelle request_new_letter_prompt_html: Si votre lettre n’est pas encore arrivée, vous pouvez en %{request_new_letter_link}. @@ -210,9 +209,9 @@ fr: title: Confirmez votre compte intro_html: '

Si vous avez reçu votre lettre, veuillez entrer votre code à usage unique ci-dessous.

Si votre lettre n’est pas encore - arrivée, veuillez être patient car les lettres prennent - généralement entre trois à sept jours ouvrables pour arriver. - Nous vous remercions de votre patience.

' + arrivée, veuillez être patient car les lettres mettent jusqu’à + 10 jours pour arriver. Nous vous remercions de votre + patience.

' return_to_profile: Retourner à votre profil title: Content de vous revoir wrong_address: Pas la bonne adresse? @@ -228,11 +227,10 @@ fr: information vérifiée, veuillez %{link_html}. activated_link: communiquer avec nous clear_and_start_over: Supprimer mes données et recommencer - come_back_later_html:

Les lettres prennent généralement trois à sept - jours ouvrables pour arriver par le biais du courriel de - première classe de l’USPS.

Une fois votre lettre arrivée, - connectez-vous à %{app_name} et entrez votre code à usage unique lorsque - vous y êtes invité.

+ come_back_later_html:

Les lettres mettent 5 à 10 jours pour + arriver par le courrier de première classe d’USPS.

Une fois votre + lettre arrivée, connectez-vous à %{app_name} et entrez votre code à + usage unique lorsque vous y êtes invité.

come_back_later_no_sp_html: Vous pouvez revenir à votre compte %{app_name} pour le moment. come_back_later_password_html: N’oubliez pas votre mot de @@ -254,11 +252,10 @@ fr: usage unique de votre lettre actuelle restera valable pendant une période limitée. Vous pouvez toujours utiliser le code à usage unique de l’une ou l’autre lettre. - resend_timeframe: Les lettres mettent généralement entre trois et sept jours - ouvrables pour arriver. - timeframe_html: Les lettres sont envoyées les jours ouvrables par courriel de - première classe de USPS et prennent généralement entre trois à - sept jours ouvrables pour être reçues. + resend_timeframe_html: Les lettres mettent 5 à 10 jours pour arriver. + timeframe_html: Les lettres sont envoyées le jour ouvrable suivant par le + courrier de première classe d’USPS et arrivent dans un délai de + 5 à 10 jours. gpo_reminder: body_html: Vous avez demandé une lettre pour vérifier votre identité le %{date_letter_was_sent}. Vous devrez saisir le code @@ -288,8 +285,9 @@ fr: - Votre numéro principal (celui que vous utilisez le plus souvent) return_to_profile: '‹ Revenir à votre profil %{app_name}' review: - gpo_pending: Nous vous enverrons votre lettre une fois que vous aurez - réintroduit votre mot de passe. + by_mail_password_reminder_html: Mémorisez votre mot de passe. Le code de + vérification contenu dans votre lettre ne fonctionnera pas si vous + réinitialisez votre mot de passe par la suite. message: '%{app_name} crypte vos informations avec votre mot de passe. Cela signifie que vos informations sont sécurisées et que vous seul pourrez y accéder ou les modifier.' @@ -316,6 +314,8 @@ fr: review: Réviser et soumettre session: review: 'Saisissez à nouveau votre mot de passe %{app_name}' + review_letter: 'Saisissez à nouveau votre mot de passe %{app_name} pour envoyer + votre lettre' unavailable: Nous travaillons à la résolution d’une erreur troubleshooting: headings: diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index 35f2574846d..0978956cad4 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -47,7 +47,7 @@ en: city_label: City is_searching_message: Searching for Post Office locations… none_found: Sorry, there are no participating Post Offices within 50 miles of - %{address}. + %{address} none_found_tip: You can search using a different address, or add photos of your ID to try and verify your identity online again. po_search_about: You can verify your identity in person at a local participating diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index 52c592f0445..e4e96b00edc 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -51,7 +51,7 @@ es: city_label: Ciudad is_searching_message: Buscando oficinas de correos… none_found: Lo sentimos, no hay Oficinas de Correos participantes en un radio de - 50 millas de la %{address}. + 50 millas de la %{address} none_found_tip: Puede buscar utilizando una dirección diferente, o añadir fotos de su documento de identidad para intentar verificar su identidad en línea de nuevo. diff --git a/config/locales/titles/en.yml b/config/locales/titles/en.yml index 4be6ec2f24c..665391a6dac 100644 --- a/config/locales/titles/en.yml +++ b/config/locales/titles/en.yml @@ -40,6 +40,7 @@ en: phone: Verify your phone number reset_password: Reset Password review: Re-enter your password + review_letter: Re-enter your password to send your letter verify_info: Verify your information mfa_setup: suggest_second_mfa: You’ve added your first authentication method! Add a second diff --git a/config/locales/titles/es.yml b/config/locales/titles/es.yml index 5349232ffcd..7c28884c7e6 100644 --- a/config/locales/titles/es.yml +++ b/config/locales/titles/es.yml @@ -40,6 +40,7 @@ es: phone: Verifique su número de teléfono reset_password: Restablecer la contraseña review: Vuelve a ingresar tu contraseña + review_letter: Vuelve a ingresar tu contraseña para enviar su carta verify_info: Verifica tu información mfa_setup: suggest_second_mfa: '¡Has agregado tu primer método de autenticación! Agrega un diff --git a/config/locales/titles/fr.yml b/config/locales/titles/fr.yml index ac64385f278..5e0ab0f3de1 100644 --- a/config/locales/titles/fr.yml +++ b/config/locales/titles/fr.yml @@ -40,6 +40,7 @@ fr: phone: Vérifiez votre numéro de téléphone reset_password: Réinitialisez le mot de passe review: Saisissez à nouveau votre mot de passe + review_letter: Saisissez à nouveau votre mot de passe pour envoyer votre lettre’ verify_info: Vérifiez vos informations mfa_setup: suggest_second_mfa: Vous avez ajouté votre première méthode d’authentification ! diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index 851efe31c36..4a54b871641 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -20,9 +20,9 @@ en: attempt_remaining_warning_html: one: You have %{count} attempt remaining. other: You have %{count} attempts remaining. - backup_code_header_text: Enter your backup security code - backup_code_prompt: You can use this security code once. After you enter it, - you’ll need to use a new key. + backup_code_header_text: Enter your backup code + backup_code_prompt: You can use this backup code once. After you submit it, + you’ll need to use a new backup code next time. backup_codes: add_another_authentication_option: '‹ Add another authentication option' instructions: If you don’t have access to another device, keep your backup codes diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index c01798427d0..ad5a263dcc3 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -20,9 +20,9 @@ es: attempt_remaining_warning_html: one: Le quedan %{count} intento. other: Le quedan %{count} intentos. - backup_code_header_text: Ingrese su código de seguridad de respaldo - backup_code_prompt: Puede utilizar este código de seguridad una vez. Después de - ingresarlo, deberá usar una nueva clave. + backup_code_header_text: Ingrese su código de respaldo + backup_code_prompt: Puede utilizar este código de respaldo una vez. Tendrá que + usar un nuevo código de respaldo la próxima vez después de que lo envíe. backup_codes: add_another_authentication_option: '‹ Añada otra opción de autenticación' instructions: Si no tiene acceso a otro dispositivo, guarde bien sus códigos de diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index 508db804c4f..6fc09347ad4 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -22,9 +22,10 @@ fr: attempt_remaining_warning_html: one: Il vous reste %{count} tentative. other: Il vous reste %{count} tentatives. - backup_code_header_text: Entrez votre code de sécurité de secours - backup_code_prompt: Vous pouvez utiliser ce code de sécurité une fois. Après - l’avoir entré, vous devrez utiliser une nouvelle clé. + backup_code_header_text: Entrez votre code de sauvegarde + backup_code_prompt: Vous pouvez utiliser ce code de sauvegarde une seule fois. + Après l’avoir envoyé, vous devrez utiliser un nouveau code de sauvegarde + la fois suivante backup_codes: add_another_authentication_option: '‹ Ajouter une autre option d’authentification' instructions: Si vous n’avez pas accès à un autre appareil, conservez vos codes diff --git a/config/locales/users/en.yml b/config/locales/users/en.yml index 5e54cdf9c2d..ef0a53f022c 100644 --- a/config/locales/users/en.yml +++ b/config/locales/users/en.yml @@ -36,6 +36,12 @@ en: overview_html: We’ve updated our %{link_html}. Please review and check the box below to continue. + second_mfa_reminder: + add_method: Add an authentication method + continue: Continue to %{sp_name} + description: Your account only has a single authentication method. Avoid being + locked out of your account by adding another authentication method. + heading: Improve your account security suspended_sign_in_account: contact_details: We couldn’t sign you in. Please call our contact center at %{contact_number}. diff --git a/config/locales/users/es.yml b/config/locales/users/es.yml index ff569db7d83..5aabdd789e1 100644 --- a/config/locales/users/es.yml +++ b/config/locales/users/es.yml @@ -37,6 +37,13 @@ es: overview_html: Actualizamos nuestro %{link_html}. Revise y marque la casilla a continuación para continuar. + second_mfa_reminder: + add_method: Agregar un método de autenticación + continue: Continúa con %{sp_name} + description: Su cuenta sólo tiene un método de autenticación. Para evitar que + bloqueen su cuenta, puede que necesites usar otro método de + autenticación. + heading: Aumente la seguridad de su cuenta suspended_sign_in_account: contact_details: No pudimos iniciar tu sesión. Por favor, llama a nuestro centro de contacto al %{contact_number}. diff --git a/config/locales/users/fr.yml b/config/locales/users/fr.yml index a4dcbd3cc0d..0cfc1594246 100644 --- a/config/locales/users/fr.yml +++ b/config/locales/users/fr.yml @@ -39,6 +39,13 @@ fr: overview_html: Nous avons mis à jour notre %{link_html}. Veuillez consulter et cocher la case ci-dessous pour continuer. + second_mfa_reminder: + add_method: Ajouter une méthode d’authentification + continue: Continuer vers %{sp_name} + description: Votre compte ne dispose que d’une seule méthode d’authentification. + Évitez le verrouillage de votre compte en ajoutant une autre méthode + d’authentification. + heading: Améliorer la sécurité de votre compte suspended_sign_in_account: contact_details: Nous n’avons pas pu vous connecter. Merci d’appeler notre centre de contact au %{contact_number}. diff --git a/config/locales/vendor_outage/en.yml b/config/locales/vendor_outage/en.yml index 63f636202ad..7a193962399 100644 --- a/config/locales/vendor_outage/en.yml +++ b/config/locales/vendor_outage/en.yml @@ -13,8 +13,8 @@ en: message_html: '%{sp_name_html} needs to make sure you are you — not someone pretending to be you.' options_html: - - Continue now and verify by mail, which takes 3 to 7 - business days. + - Continue now and verify by mail, which takes 5 to 10 + days. - Exit Login.gov and try again later. options_prompt: 'You can:' status_page_html: Unfortunately, we’re having technical difficulties right now. diff --git a/config/locales/vendor_outage/es.yml b/config/locales/vendor_outage/es.yml index b049686e16f..7fa8f731554 100644 --- a/config/locales/vendor_outage/es.yml +++ b/config/locales/vendor_outage/es.yml @@ -15,8 +15,8 @@ es: message_html: '%{sp_name_html} necesita asegurarse de que es usted realmente y no alguien que se hace pasar por usted.' options_html: - - Continuar ahora y verificar por correo, que tarda de 3 a 7 - días laborables. + - Continuar ahora y verificar por correo, lo cual tarda entre + 5 y 10 días. - Salir de Login.gov e inténtelo de nuevo más tarde. options_prompt: 'Usted puede:' status_page_html: Lamentablemente, estamos teniendo problemas técnicos. diff --git a/config/locales/vendor_outage/fr.yml b/config/locales/vendor_outage/fr.yml index 886f7a5d27a..74b3c946807 100644 --- a/config/locales/vendor_outage/fr.yml +++ b/config/locales/vendor_outage/fr.yml @@ -14,8 +14,8 @@ fr: message_html: '%{sp_name_html} doit s’assurer que c’est bien vous — et non quelqu’un qui se fait passer pour vous.' options_html: - - Continuer maintenant et effectuer la vérification par courrier, ce - qui prend de 3 à 7 jours ouvrables. + - Continuer maintenant et effectuer la vérification par courrier, + qui nécessite 5 à 10 jours. - Quitter Login.gov. options_prompt: 'Vous pouvez :' status_page_html: Malheureusement, nous avons actuellement des difficultés diff --git a/config/routes.rb b/config/routes.rb index 6b4fdebabde..defc3c7a8b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -207,6 +207,9 @@ get '/rules_of_use' => 'users/rules_of_use#new' post '/rules_of_use' => 'users/rules_of_use#create' + get '/second_mfa_reminder' => 'users/second_mfa_reminder#new' + post '/second_mfa_reminder' => 'users/second_mfa_reminder#create' + get '/piv_cac' => 'users/piv_cac_authentication_setup#new', as: :setup_piv_cac get '/piv_cac_error' => 'users/piv_cac_authentication_setup#error', as: :setup_piv_cac_error delete '/piv_cac' => 'users/piv_cac_authentication_setup#delete', as: :disable_piv_cac diff --git a/db/primary_migrate/20230831124437_add_sign_in_count_and_second_mfa_reminder_dismissed_at_to_users.rb b/db/primary_migrate/20230831124437_add_sign_in_count_and_second_mfa_reminder_dismissed_at_to_users.rb new file mode 100644 index 00000000000..8a4ac0c943f --- /dev/null +++ b/db/primary_migrate/20230831124437_add_sign_in_count_and_second_mfa_reminder_dismissed_at_to_users.rb @@ -0,0 +1,5 @@ +class AddSignInCountAndSecondMfaReminderDismissedAtToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :second_mfa_reminder_dismissed_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 7df4d6dbe7f..2e1b1e1b8ac 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_08_14_130423) do +ActiveRecord::Schema[7.0].define(version: 2023_08_31_124437) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -608,6 +608,7 @@ t.datetime "reinstated_at" t.string "encrypted_password_digest_multi_region" t.string "encrypted_recovery_code_digest_multi_region" + t.datetime "second_mfa_reminder_dismissed_at" t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true end diff --git a/lib/action_account.rb b/lib/action_account.rb index 48e97155da6..3a9afbbf34a 100644 --- a/lib/action_account.rb +++ b/lib/action_account.rb @@ -1,5 +1,6 @@ require_relative './script_base' +# rubocop:disable Metrics/BlockLength class ActionAccount attr_reader :argv, :stdout, :stderr @@ -149,13 +150,18 @@ def run(args:, config:) messages = [] users.each do |user| + profile_fraud_review_pending_at = nil + success = false + log_texts = [] if !user.fraud_review_pending? log_texts << log_text[:no_pending] elsif FraudReviewChecker.new(user).fraud_review_eligible? profile = user.fraud_review_pending_profile + profile_fraud_review_pending_at = profile.fraud_review_pending_at profile.reject_for_fraud(notify_user: true) + success = true log_texts << log_text[:rejected_for_fraud] else @@ -170,6 +176,19 @@ def run(args:, config:) messages:, ) end + ensure + if !success + analytics_error_hash = { message: log_texts.last } + end + + Analytics.new( + user: user, request: nil, session: {}, sp: nil, + ).fraud_review_rejected( + success:, + errors: analytics_error_hash, + exception: nil, + profile_fraud_review_pending_at: profile_fraud_review_pending_at, + ) end if config.include_missing? @@ -181,6 +200,14 @@ def run(args:, config:) messages:, ) end + Analytics.new( + user: AnonymousUser.new, request: nil, session: {}, sp: nil, + ).fraud_review_rejected( + success: false, + errors: { message: log_text[:missing_uuid] }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) end ScriptBase::Result.new( @@ -213,12 +240,17 @@ def run(args:, config:) messages = [] users.each do |user| + profile_fraud_review_pending_at = nil + success = false + log_texts = [] if !user.fraud_review_pending? log_texts << log_text[:no_pending] elsif FraudReviewChecker.new(user).fraud_review_eligible? profile = user.fraud_review_pending_profile + profile_fraud_review_pending_at = profile.fraud_review_pending_at profile.activate_after_passing_review + success = true if profile.active? event, _disavowal_token = UserEventCreator.new(current_user: user). @@ -242,6 +274,19 @@ def run(args:, config:) messages:, ) end + ensure + if !success + analytics_error_hash = { message: log_texts.last } + end + + Analytics.new( + user: user, request: nil, session: {}, sp: nil, + ).fraud_review_passed( + success:, + errors: analytics_error_hash, + exception: nil, + profile_fraud_review_pending_at: profile_fraud_review_pending_at, + ) end if config.include_missing? @@ -253,6 +298,14 @@ def run(args:, config:) messages:, ) end + Analytics.new( + user: AnonymousUser.new, request: nil, session: {}, sp: nil, + ).fraud_review_passed( + success: false, + errors: { message: log_text[:missing_uuid] }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) end ScriptBase::Result.new( @@ -288,3 +341,4 @@ def run(args:, config:) end end end +# rubocop:enable Metrics/BlockLength diff --git a/lib/identity_config.rb b/lib/identity_config.rb index ceed0812342..05eb4e4f4f6 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -174,6 +174,7 @@ def self.build_store(config_map) config.add(:disallow_all_web_crawlers, type: :boolean) config.add(:disposable_email_services, type: :json) config.add(:doc_auth_attempt_window_in_minutes, type: :integer) + config.add(:doc_auth_check_failed_image_resubmission_enabled, type: :boolean) config.add(:doc_auth_client_glare_threshold, type: :integer) config.add(:doc_auth_client_sharpness_threshold, type: :integer) config.add(:doc_auth_error_dpi_threshold, type: :integer) @@ -309,6 +310,10 @@ def self.build_store(config_map) config.add(:min_password_score, type: :integer) config.add(:minimum_wait_before_another_usps_letter_in_hours, type: :integer) config.add(:multi_region_kms_migration_jobs_enabled, type: :boolean) + config.add(:multi_region_kms_migration_jobs_profile_count, type: :integer) + config.add(:multi_region_kms_migration_jobs_profile_timeout, type: :integer) + config.add(:multi_region_kms_migration_jobs_user_count, type: :integer) + config.add(:multi_region_kms_migration_jobs_user_timeout, type: :integer) config.add(:mx_timeout, type: :integer) config.add(:newrelic_license_key, type: :string) config.add(:nonessential_email_banlist, type: :json) @@ -411,6 +416,9 @@ def self.build_store(config_map) config.add(:saml_endpoint_configs, type: :json, options: { symbolize_names: true }) config.add(:saml_secret_rotation_enabled, type: :boolean) config.add(:scrypt_cost, type: :string) + config.add(:second_mfa_reminder_account_age_in_days, type: :integer) + config.add(:second_mfa_reminder_enabled, type: :boolean) + config.add(:second_mfa_reminder_sign_in_count, type: :integer) config.add(:secret_key_base, type: :string) config.add(:seed_agreements_data, type: :boolean) config.add(:service_provider_request_ttl_hours, type: :integer) diff --git a/spec/controllers/concerns/mfa_setup_concern_spec.rb b/spec/controllers/concerns/mfa_setup_concern_spec.rb index ecd2000e05b..c5714409d3a 100644 --- a/spec/controllers/concerns/mfa_setup_concern_spec.rb +++ b/spec/controllers/concerns/mfa_setup_concern_spec.rb @@ -5,14 +5,42 @@ include MfaSetupConcern end - describe '#show_skip_additional_mfa_link?' do - let(:user) { create(:user, :fully_registered) } + let(:user) { create(:user, :fully_registered) } - subject(:show_skip_additional_mfa_link?) { controller.show_skip_additional_mfa_link? } + before do + allow(controller).to receive(:current_user).and_return(user) + end + + describe '#next_setup_path' do + subject(:next_setup_path) { controller.next_setup_path } - before do - allow(controller).to receive(:current_user).and_return(user) + context 'when user converts from second mfa reminder' do + let(:user) { create(:user, :fully_registered, :with_phone, :with_backup_code) } + + before do + stub_sign_in_before_2fa(user) + stub_analytics + controller.user_session[:second_mfa_reminder_conversion] = true + controller.user_session[:mfa_selections] = [] + end + + it 'tracks analytics event' do + next_setup_path + + expect(@analytics).to have_logged_event( + 'User Registration: MFA Setup Complete', + success: true, + mfa_method_counts: { phone: 1, backup_codes: 10 }, + enabled_mfa_methods_count: 2, + second_mfa_reminder_conversion: true, + in_account_creation_flow: false, + ) + end end + end + + describe '#show_skip_additional_mfa_link?' do + subject(:show_skip_additional_mfa_link?) { controller.show_skip_additional_mfa_link? } it 'returns true' do expect(show_skip_additional_mfa_link?).to eq(true) diff --git a/spec/controllers/concerns/second_mfa_reminder_concern_spec.rb b/spec/controllers/concerns/second_mfa_reminder_concern_spec.rb new file mode 100644 index 00000000000..e79b4691a2a --- /dev/null +++ b/spec/controllers/concerns/second_mfa_reminder_concern_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +RSpec.describe SecondMfaReminderConcern do + let(:test_class) do + Class.new do + include SecondMfaReminderConcern + + attr_reader :current_user + + def initialize(current_user:) + @current_user = current_user + end + end + end + let(:user) { build(:user) } + let(:instance) { test_class.new(current_user: user) } + + describe '#user_needs_second_mfa_reminder?' do + subject(:user_needs_second_mfa_reminder) { instance.user_needs_second_mfa_reminder? } + + context 'user has already dismissed second mfa reminder' do + let(:user) { build(:user, second_mfa_reminder_dismissed_at: Time.zone.now) } + + it { expect(user_needs_second_mfa_reminder).to eq(false) } + end + + context 'user has multiple mfas configured' do + let(:user) { build(:user, :with_phone, :with_piv_or_cac) } + + it { expect(user_needs_second_mfa_reminder).to eq(false) } + end + + context 'user has single mfa configured' do + let(:user) { build(:user, :with_phone) } + + it { expect(user_needs_second_mfa_reminder).to eq(false) } + + context 'user has signed in more times than the threshold for reminder' do + let(:user) do + user = create(:user, :with_phone) + 2.times { user.events.create(event_type: :sign_in_before_2fa, created_at: Time.zone.now) } + user + end + + before do + allow(IdentityConfig.store).to receive(:second_mfa_reminder_sign_in_count).and_return(2) + end + + it { expect(user_needs_second_mfa_reminder).to eq(true) } + end + + context 'user has exceeded account age threshold for reminder' do + let(:user) { build(:user, :with_phone, created_at: 11.days.ago) } + + before do + allow(IdentityConfig.store).to receive(:second_mfa_reminder_account_age_in_days). + and_return(10) + end + + it { expect(user_needs_second_mfa_reminder).to eq(true) } + end + + context 'user meets threshold but feature is disabled' do + let(:user) { build(:user, :with_phone, created_at: 11.days.ago) } + + before do + allow(IdentityConfig.store).to receive(:second_mfa_reminder_account_age_in_days). + and_return(10) + allow(IdentityConfig.store).to receive(:second_mfa_reminder_enabled).and_return(false) + end + + it { expect(user_needs_second_mfa_reminder).to eq(false) } + end + end + end +end diff --git a/spec/controllers/idv/address_controller_spec.rb b/spec/controllers/idv/address_controller_spec.rb index 53df0614f6d..ea6708b75d1 100644 --- a/spec/controllers/idv/address_controller_spec.rb +++ b/spec/controllers/idv/address_controller_spec.rb @@ -18,6 +18,7 @@ stub_analytics stub_idv_steps_before_verify_step(user) subject.idv_session.flow_path = 'standard' + subject.idv_session.pii_from_doc = pii_from_doc subject.user_session['idv/doc_auth'] = flow_session end @@ -55,7 +56,8 @@ end.to change { idv_session.address_edited }.from(nil).to eql(true) end - it 'updates pii_from_doc' do + it 'updates pii_from_doc in flow_session (even if nil)' do + flow_session[:pii_from_doc] = nil expect do put :update, params: params end.to change { flow_session[:pii_from_doc] }.to eql( @@ -71,6 +73,23 @@ ) end + it 'updates pii_from_doc in idv_session (even if nil)' do + subject.idv_session.pii_from_doc = nil + expect do + put :update, params: params + end.to change { idv_session.pii_from_doc }.to eql( + pii_from_doc.merge( + { + 'address1' => '1234 Main St', + 'address2' => 'Apt B', + 'city' => 'Beverly Hills', + 'state' => 'CA', + 'zipcode' => '90210', + }, + ), + ) + end + it 'logs an analytics event' do put :update, params: params expect(@analytics).to have_logged_event( diff --git a/spec/controllers/idv/document_capture_controller_spec.rb b/spec/controllers/idv/document_capture_controller_spec.rb index 28fa9d637b2..8e28c53f3ee 100644 --- a/spec/controllers/idv/document_capture_controller_spec.rb +++ b/spec/controllers/idv/document_capture_controller_spec.rb @@ -5,11 +5,7 @@ let(:document_capture_session_uuid) { 'fd14e181-6fb1-4cdc-92e0-ef66dad0df4e' } - let(:threatmetrix_session_id) { 'c90ae7a5-6629-4e77-b97c-f1987c2df7d0' } - - let(:flow_session) do - { threatmetrix_session_id: threatmetrix_session_id } - end + let(:flow_session) { {} } let(:user) { create(:user) } @@ -111,7 +107,7 @@ end end - context 'with pii in session' do + context 'with pii in flow session' do it 'redirects to ssn step' do flow_session[:pii_from_doc] = Idp::Constants::MOCK_IDV_APPLICANT get :show @@ -120,6 +116,15 @@ end end + context 'with pii in idv_session' do + it 'redirects to ssn step' do + subject.idv_session.pii_from_doc = Idp::Constants::MOCK_IDV_APPLICANT + get :show + + expect(response).to redirect_to(idv_ssn_url) + end + end + it 'does not use effective user outside of analytics_user in ApplicationControler' do allow(subject).to receive(:analytics_user).and_return(subject.current_user) expect(subject).not_to receive(:effective_user) diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 7b8472f0bf2..0de4acc241b 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -202,6 +202,7 @@ result_failed: false, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, }, ) end @@ -217,6 +218,7 @@ result_failed: false, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, } end diff --git a/spec/controllers/idv/link_sent_controller_spec.rb b/spec/controllers/idv/link_sent_controller_spec.rb index f201ca8a477..fb53f2d9c92 100644 --- a/spec/controllers/idv/link_sent_controller_spec.rb +++ b/spec/controllers/idv/link_sent_controller_spec.rb @@ -97,7 +97,7 @@ end end - context 'with pii in session' do + context 'with pii in flow_session' do it 'redirects to ssn step' do flow_session[:pii_from_doc] = Idp::Constants::MOCK_IDV_APPLICANT get :show @@ -105,6 +105,15 @@ expect(response).to redirect_to(idv_ssn_url) end end + + context 'with pii in idv_session' do + it 'redirects to ssn step' do + subject.idv_session.pii_from_doc = Idp::Constants::MOCK_IDV_APPLICANT + get :show + + expect(response).to redirect_to(idv_ssn_url) + end + end end describe '#update' do diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 439112cdba6..f1fa8747afa 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -156,6 +156,13 @@ def show expect(response).to render_template :new end + it 'sets the correct title and header' do + get :new + + expect(assigns(:title)).to eq(t('titles.idv.review')) + expect(assigns(:heading)).to eq(t('idv.titles.session.review', app_name: APP_NAME)) + end + it 'uses the correct step indicator step' do indicator_step = subject.step_indicator_step @@ -168,11 +175,20 @@ def show idv_session.address_verification_mechanism = 'gpo' end - it 'displays info message about sending letter' do + render_views + + it 'sets the correct title and header' do get :new - expect(flash.now[:info]).to eq( - t('idv.messages.review.gpo_pending'), + expect(assigns(:title)).to eq(t('titles.idv.review_letter')) + expect(assigns(:heading)).to eq(t('idv.titles.session.review_letter', app_name: APP_NAME)) + end + + it 'shows password reminder banner' do + get :new + + expect(response.body).to include( + t('idv.messages.review.by_mail_password_reminder_html'), ) end @@ -183,6 +199,17 @@ def show end end + context 'not in gpo flow' do + render_views + it 'does not show password reminder banner for non-gpo' do + get :new + + expect(response.body).not_to include( + t('idv.messages.review.by_mail_password_reminder_html'), + ) + end + end + it 'updates the doc auth log for the user for the encrypt view event' do unstub_analytics doc_auth_log = DocAuthLog.create(user_id: user.id) diff --git a/spec/controllers/idv/session_errors_controller_spec.rb b/spec/controllers/idv/session_errors_controller_spec.rb index 95e4e9da7e6..f677e43ed25 100644 --- a/spec/controllers/idv/session_errors_controller_spec.rb +++ b/spec/controllers/idv/session_errors_controller_spec.rb @@ -246,9 +246,9 @@ end it 'assigns sp_name' do - decorated_session = double - allow(decorated_session).to receive(:sp_name).and_return('Example SP') - allow(controller).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = double + allow(decorated_sp_session).to receive(:sp_name).and_return('Example SP') + allow(controller).to receive(:decorated_sp_session).and_return(decorated_sp_session) get action expect(assigns(:sp_name)).to eql('Example SP') end diff --git a/spec/controllers/idv/ssn_controller_spec.rb b/spec/controllers/idv/ssn_controller_spec.rb index d16a33c2bcd..6c62880c053 100644 --- a/spec/controllers/idv/ssn_controller_spec.rb +++ b/spec/controllers/idv/ssn_controller_spec.rb @@ -19,6 +19,7 @@ stub_sign_in(user) subject.user_session['idv/doc_auth'] = flow_session subject.idv_session.flow_path = 'standard' + subject.idv_session.pii_from_doc = Idp::Constants::MOCK_IDV_APPLICANT.dup stub_analytics stub_attempts_tracker allow(@analytics).to receive(:track_event) @@ -158,7 +159,10 @@ from(nil).to(ssn) end - context 'with a Puerto Rico address' do + context 'with a Puerto Rico address and pii_from_doc in flow_session' do + before do + subject.idv_session.pii_from_doc = nil + end it 'redirects to address controller after user enters their SSN' do flow_session[:pii_from_doc][:state] = 'PR' @@ -177,6 +181,25 @@ end end + context 'with a Puerto Rico address and pii_from_doc in idv_session' do + it 'redirects to address controller after user enters their SSN' do + subject.idv_session.pii_from_doc[:state] = 'PR' + + put :update, params: params + + expect(response).to redirect_to(idv_address_url) + end + + it 'redirects to the verify info controller if a user is updating their SSN' do + subject.idv_session.ssn = ssn + subject.idv_session.pii_from_doc[:state] = 'PR' + + put :update, params: params + + expect(response).to redirect_to(idv_verify_info_url) + end + end + it 'logs attempts api event' do expect(@irs_attempts_api_tracker).to receive(:idv_ssn_submitted).with( ssn: ssn, @@ -237,6 +260,7 @@ before do subject.idv_session.flow_path = 'standard' flow_session.delete(:pii_from_doc) + subject.idv_session.pii_from_doc = nil end it 'redirects to DocumentCaptureController on standard flow' do diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index 97cd1a4f177..83db0a89456 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -26,6 +26,7 @@ stub_attempts_tracker stub_idv_steps_before_verify_step(user) subject.idv_session.flow_path = 'standard' + subject.idv_session.pii_from_doc = Idp::Constants::MOCK_IDV_APPLICANT.dup subject.idv_session.ssn = Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN[:ssn] subject.user_session['idv/doc_auth'] = flow_session allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) @@ -88,25 +89,48 @@ context 'address line 2' do render_views - it 'With address2 in PII, shows address line 2 input' do - flow_session[:pii_from_doc][:address2] = 'APT 3E' - get :show + context 'pii_from_doc in flow_session' do + before do + subject.idv_session.pii_from_doc = nil + end + + it 'With address2 in PII, shows address line 2 input' do + flow_session[:pii_from_doc][:address2] = 'APT 3E' + get :show + + expect(response.body).to have_content(t('idv.form.address2')) + end + + it 'No address2 in PII, still shows address line 2 input' do + flow_session[:pii_from_doc][:address2] = nil - expect(response.body).to have_content(t('idv.form.address2')) + get :show + + expect(response.body).to have_content(t('idv.form.address2')) + end end - it 'No address2 in PII, still shows address line 2 input' do - flow_session[:pii_from_doc][:address2] = nil + context 'pii_from_doc in idv_session' do + it 'With address2 in PII, shows address line 2 input' do + subject.idv_session.pii_from_doc[:address2] = 'APT 3E' + get :show - get :show + expect(response.body).to have_content(t('idv.form.address2')) + end + + it 'No address2 in PII, still shows address line 2 input' do + subject.idv_session.pii_from_doc[:address2] = nil - expect(response.body).to have_content(t('idv.form.address2')) + get :show + + expect(response.body).to have_content(t('idv.form.address2')) + end end end context 'when the user has already verified their info' do it 'redirects to the review controller' do - controller.idv_session.resolution_successful = true + subject.idv_session.resolution_successful = true get :show @@ -392,15 +416,32 @@ expect(response).to redirect_to idv_verify_info_url end - it 'modifies pii as expected' do - app_id = 'hello-world' - sp = create(:service_provider, app_id: app_id) - sp_session = { issuer: sp.issuer } - allow(controller).to receive(:sp_session).and_return(sp_session) + context 'pii in flow_session' do + it 'modifies pii as expected' do + subject.idv_session.pii_from_doc = nil - put :update + app_id = 'hello-world' + sp = create(:service_provider, app_id: app_id) + sp_session = { issuer: sp.issuer } + allow(controller).to receive(:sp_session).and_return(sp_session) - expect(flow_session[:pii_from_doc][:uuid_prefix]).to eq app_id + put :update + + expect(flow_session[:pii_from_doc][:uuid_prefix]).to eq app_id + end + end + + context 'pii in idv_session' do + it 'modifies pii as expected' do + app_id = 'hello-world' + sp = create(:service_provider, app_id: app_id) + sp_session = { issuer: sp.issuer } + allow(controller).to receive(:sp_session).and_return(sp_session) + + put :update + + expect(subject.idv_session.pii_from_doc[:uuid_prefix]).to eq app_id + end end it 'updates DocAuthLog verify_submit_count' do diff --git a/spec/controllers/openid_connect/token_controller_spec.rb b/spec/controllers/openid_connect/token_controller_spec.rb index 8fc0b36c51a..8ae304586c8 100644 --- a/spec/controllers/openid_connect/token_controller_spec.rb +++ b/spec/controllers/openid_connect/token_controller_spec.rb @@ -61,6 +61,8 @@ code_digest: kind_of(String), code_verifier_present: false, service_provider_pkce: nil, + expires_in: 0, + ial: 1, }) action end @@ -90,6 +92,8 @@ code_verifier_present: false, service_provider_pkce: nil, error_details: hash_including(:grant_type), + expires_in: nil, + ial: 1, }) action diff --git a/spec/controllers/sign_out_controller_spec.rb b/spec/controllers/sign_out_controller_spec.rb index 26086f1c32e..32a1764884e 100644 --- a/spec/controllers/sign_out_controller_spec.rb +++ b/spec/controllers/sign_out_controller_spec.rb @@ -2,9 +2,9 @@ RSpec.describe SignOutController do describe '#destroy' do - it 'redirects to decorated_session.cancel_link_url with flash message' do + it 'redirects to decorated_sp_session.cancel_link_url with flash message' do stub_sign_in_before_2fa - allow(controller.decorated_session).to receive(:cancel_link_url).and_return('foo') + allow(controller.decorated_sp_session).to receive(:cancel_link_url).and_return('foo') get :destroy @@ -23,7 +23,7 @@ stub_sign_in_before_2fa stub_analytics stub_attempts_tracker - allow(controller.decorated_session).to receive(:cancel_link_url).and_return('foo') + allow(controller.decorated_sp_session).to receive(:cancel_link_url).and_return('foo') expect(@analytics). to receive(:track_event).with('Logout Initiated', hash_including(method: 'cancel link')) diff --git a/spec/controllers/sign_up/completions_controller_spec.rb b/spec/controllers/sign_up/completions_controller_spec.rb index a1dc9aef164..60095800142 100644 --- a/spec/controllers/sign_up/completions_controller_spec.rb +++ b/spec/controllers/sign_up/completions_controller_spec.rb @@ -11,7 +11,7 @@ end it 'redirects to account page when SP request URL is not present' do - user = create(:user) + user = create(:user, :fully_registered) stub_sign_in(user) subject.session[:sp] = { issuer: current_sp.issuer, @@ -22,7 +22,7 @@ end context 'IAL1' do - let(:user) { create(:user) } + let(:user) { create(:user, :fully_registered) } before do stub_sign_in(user) subject.session[:sp] = { @@ -39,11 +39,12 @@ 'User registration: agency handoff visited', ial2: false, ialmax: nil, - service_provider_name: subject.decorated_session.sp_name, + service_provider_name: subject.decorated_sp_session.sp_name, page_occurence: '', needs_completion_screen_reason: :new_sp, sp_request_requested_attributes: nil, sp_session_requested_attributes: [:email], + in_account_creation_flow: false, ) end @@ -54,7 +55,7 @@ context 'IAL2' do let(:user) do - create(:user, profiles: [create(:profile, :verified, :active)]) + create(:user, :fully_registered, profiles: [create(:profile, :verified, :active)]) end let(:pii) { { ssn: '123456789' } } @@ -75,11 +76,12 @@ 'User registration: agency handoff visited', ial2: true, ialmax: nil, - service_provider_name: subject.decorated_session.sp_name, + service_provider_name: subject.decorated_sp_session.sp_name, page_occurence: '', needs_completion_screen_reason: :new_sp, sp_request_requested_attributes: nil, sp_session_requested_attributes: [:email], + in_account_creation_flow: false, ) end @@ -90,7 +92,7 @@ context 'IALMax' do let(:user) do - create(:user, profiles: [create(:profile, :verified, :active)]) + create(:user, :fully_registered, profiles: [create(:profile, :verified, :active)]) end let(:pii) { { ssn: '123456789' } } @@ -112,11 +114,12 @@ 'User registration: agency handoff visited', ial2: false, ialmax: true, - service_provider_name: subject.decorated_session.sp_name, + service_provider_name: subject.decorated_sp_session.sp_name, page_occurence: '', needs_completion_screen_reason: :new_sp, sp_request_requested_attributes: nil, sp_session_requested_attributes: [:email], + in_account_creation_flow: false, ) end @@ -196,13 +199,15 @@ end context 'IAL1' do + let(:user) { create(:user, :fully_registered) } it 'tracks analytics' do - stub_sign_in + stub_sign_in(user) subject.session[:sp] = { ial2: false, issuer: 'foo', request_url: 'http://example.com', } + subject.user_session[:in_account_creation_flow] = true patch :update @@ -210,16 +215,17 @@ 'User registration: complete', ial2: false, ialmax: nil, - service_provider_name: subject.decorated_session.sp_name, + service_provider_name: subject.decorated_sp_session.sp_name, page_occurence: 'agency-page', needs_completion_screen_reason: :new_sp, sp_request_requested_attributes: nil, sp_session_requested_attributes: nil, + in_account_creation_flow: true, ) end it 'updates verified attributes' do - stub_sign_in + stub_sign_in(user) subject.session[:sp] = { issuer: 'foo', ial: 1, @@ -239,7 +245,7 @@ end it 'redirects to account page if the session request_url is removed' do - stub_sign_in + stub_sign_in(user) subject.session[:sp] = { ial2: false, issuer: 'foo', @@ -253,7 +259,7 @@ context 'IAL2' do it 'tracks analytics' do - user = create(:user, profiles: [create(:profile, :verified, :active)]) + user = create(:user, :fully_registered, profiles: [create(:profile, :verified, :active)]) stub_sign_in(user) sp = create(:service_provider, issuer: 'https://awesome') subject.session[:sp] = { @@ -262,6 +268,7 @@ request_url: 'http://example.com', requested_attributes: ['email'], } + subject.user_session[:in_account_creation_flow] = true patch :update @@ -269,11 +276,12 @@ 'User registration: complete', ial2: true, ialmax: nil, - service_provider_name: subject.decorated_session.sp_name, + service_provider_name: subject.decorated_sp_session.sp_name, page_occurence: 'agency-page', needs_completion_screen_reason: :new_sp, sp_request_requested_attributes: nil, sp_session_requested_attributes: ['email'], + in_account_creation_flow: true, ) end diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index 8e81bcd6240..cfa84217930 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -59,7 +59,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: false, } expect(@analytics).to receive(:track_event). @@ -143,7 +143,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: false, } stub_analytics @@ -214,7 +214,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: false, } stub_analytics @@ -280,7 +280,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: false, } stub_analytics @@ -439,6 +439,7 @@ context 'user enters a valid code' do before do subject.user_session[:mfa_selections] = ['sms'] + subject.user_session[:in_account_creation_flow] = true phone_configuration = MfaContext.new(subject.current_user).phone_configurations.last phone_id = phone_configuration.id parsed_phone = Phonelib.parse(phone_configuration.phone) @@ -457,7 +458,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: true, } expect(@analytics).to receive(:track_event). @@ -546,7 +547,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, } expect(@analytics).to have_received(:track_event). @@ -630,7 +631,7 @@ country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), enabled_mfa_methods_count: 0, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, } expect(@analytics).to have_received(:track_event). diff --git a/spec/controllers/two_factor_authentication/sms_opt_in_controller_spec.rb b/spec/controllers/two_factor_authentication/sms_opt_in_controller_spec.rb index 5af32a23f9f..1ee9d7e80d2 100644 --- a/spec/controllers/two_factor_authentication/sms_opt_in_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/sms_opt_in_controller_spec.rb @@ -14,8 +14,8 @@ before do stub_sign_in_before_2fa(user) stub_analytics - allow(controller).to receive(:decorated_session). - and_return(instance_double('SessionDecorator', sp_name: sp_name)) + allow(controller).to receive(:decorated_sp_session). + and_return(instance_double('NullServiceProviderSession', sp_name: sp_name)) end it 'tracks a visit event' do diff --git a/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb index cdf70de6e9a..20052aa7760 100644 --- a/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb @@ -123,73 +123,80 @@ controller.user_session[:webauthn_challenge] = webauthn_challenge end - it 'tracks a valid non-platform authenticator submission' do - create( - :webauthn_configuration, - user: controller.current_user, - credential_id: credential_id, - credential_public_key: credential_public_key, - ) - allow(WebauthnVerificationForm).to receive(:domain_name).and_return('localhost:3000') - webauthn_configuration = controller.current_user.webauthn_configurations.first - result = { - context: 'authentication', - multi_factor_auth_method: 'webauthn', - success: true, - webauthn_configuration_id: webauthn_configuration.id, - multi_factor_auth_method_created_at: webauthn_configuration.created_at.strftime('%s%L'), - } + context 'with a valid submission' do + let!(:webauthn_configuration) do + create( + :webauthn_configuration, + user: controller.current_user, + credential_id: credential_id, + credential_public_key: credential_public_key, + ) + controller.current_user.webauthn_configurations.first + end + let(:result) do + { + context: 'authentication', + multi_factor_auth_method: 'webauthn', + success: true, + webauthn_configuration_id: webauthn_configuration.id, + multi_factor_auth_method_created_at: webauthn_configuration.created_at.strftime('%s%L'), + } + end - expect(@analytics).to receive(:track_mfa_submit_event). - with(result) - expect(@analytics).to receive(:track_event). - with('User marked authenticated', authentication_type: :valid_2fa) + before do + allow(WebauthnVerificationForm).to receive(:domain_name).and_return('localhost:3000') + end - expect(@irs_attempts_api_tracker).to receive(:track_event).with( - :mfa_login_webauthn_roaming, - success: true, - ) + it 'tracks a valid submission' do + expect(@analytics).to receive(:track_mfa_submit_event). + with(result) + expect(@analytics).to receive(:track_event). + with('User marked authenticated', authentication_type: :valid_2fa) - patch :confirm, params: params + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :mfa_login_webauthn_roaming, + success: true, + ) - expect(subject.user_session[:auth_method]).to eq( - TwoFactorAuthenticatable::AuthMethod::WEBAUTHN, - ) - expect(subject.user_session[TwoFactorAuthenticatable::NEED_AUTHENTICATION]).to eq false - end + patch :confirm, params: params - it 'tracks a valid platform authenticator submission' do - create( - :webauthn_configuration, - :platform_authenticator, - user: controller.current_user, - credential_id: credential_id, - credential_public_key: credential_public_key, - ) - allow(WebauthnVerificationForm).to receive(:domain_name).and_return('localhost:3000') - webauthn_configuration = controller.current_user.webauthn_configurations.first - result = { - context: 'authentication', - multi_factor_auth_method: 'webauthn_platform', - success: true, - webauthn_configuration_id: webauthn_configuration.id, - multi_factor_auth_method_created_at: webauthn_configuration.created_at.strftime('%s%L'), - } - expect(@analytics).to receive(:track_mfa_submit_event). - with(result) - expect(@analytics).to receive(:track_event). - with('User marked authenticated', authentication_type: :valid_2fa) + expect(subject.user_session[:auth_method]).to eq( + TwoFactorAuthenticatable::AuthMethod::WEBAUTHN, + ) + expect(subject.user_session[TwoFactorAuthenticatable::NEED_AUTHENTICATION]).to eq false + end - expect(@irs_attempts_api_tracker).to receive(:track_event).with( - :mfa_login_webauthn_platform, - success: true, - ) + context 'with platform authenticator' do + let!(:webauthn_configuration) do + create( + :webauthn_configuration, + :platform_authenticator, + user: controller.current_user, + credential_id: credential_id, + credential_public_key: credential_public_key, + ) + controller.current_user.webauthn_configurations.first + end + let(:result) { super().merge(multi_factor_auth_method: 'webauthn_platform') } - patch :confirm, params: params - expect(subject.user_session[:auth_method]).to eq( - TwoFactorAuthenticatable::AuthMethod::WEBAUTHN_PLATFORM, - ) - expect(subject.user_session[TwoFactorAuthenticatable::NEED_AUTHENTICATION]).to eq false + it 'tracks a valid submission' do + expect(@analytics).to receive(:track_mfa_submit_event). + with(result) + expect(@analytics).to receive(:track_event). + with('User marked authenticated', authentication_type: :valid_2fa) + + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :mfa_login_webauthn_platform, + success: true, + ) + + patch :confirm, params: params + expect(subject.user_session[:auth_method]).to eq( + TwoFactorAuthenticatable::AuthMethod::WEBAUTHN_PLATFORM, + ) + expect(subject.user_session[TwoFactorAuthenticatable::NEED_AUTHENTICATION]).to eq false + end + end end it 'tracks an invalid submission' do diff --git a/spec/controllers/users/backup_code_setup_controller_spec.rb b/spec/controllers/users/backup_code_setup_controller_spec.rb index a3c4270bfc1..61a6fbcd6d1 100644 --- a/spec/controllers/users/backup_code_setup_controller_spec.rb +++ b/spec/controllers/users/backup_code_setup_controller_spec.rb @@ -32,11 +32,12 @@ pii_like_keypaths: [[:mfa_method_counts, :phone]], error_details: nil, enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, }) expect(@analytics).to receive(:track_event). with('Backup Code Created', { enabled_mfa_methods_count: 2, + in_account_creation_flow: false, }) expect(@irs_attempts_api_tracker).to receive(:track_event). with(:mfa_enroll_backup_code, success: true) @@ -152,7 +153,7 @@ get :edit expect(@analytics).to have_logged_event( 'Backup Code Regenerate Visited', - hash_including(in_multi_mfa_selection_flow: false), + hash_including(in_account_creation_flow: false), ) end end diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb index f65feba8996..43334bbbfe5 100644 --- a/spec/controllers/users/phone_setup_controller_spec.rb +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -22,6 +22,7 @@ stub_analytics stub_sign_in_before_2fa(user) subject.user_session[:mfa_selections] = ['voice'] + subject.user_session[:in_account_creation_flow] = true end it 'renders the index view' do diff --git a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb index bd2205b7cb9..78deccdc71b 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -109,7 +109,7 @@ stub_analytics expect(@analytics).to receive(:track_event). with('PIV CAC setup visited', { - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, enabled_mfa_methods_count: 1, }) diff --git a/spec/controllers/users/piv_cac_login_controller_spec.rb b/spec/controllers/users/piv_cac_login_controller_spec.rb index 05d53fd86bc..ca92bde1d75 100644 --- a/spec/controllers/users/piv_cac_login_controller_spec.rb +++ b/spec/controllers/users/piv_cac_login_controller_spec.rb @@ -13,7 +13,7 @@ it 'tracks the piv_cac setup' do expect(@analytics).to have_received(:track_event).with( 'PIV CAC setup visited', - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, ) end diff --git a/spec/controllers/users/second_mfa_reminder_controller_spec.rb b/spec/controllers/users/second_mfa_reminder_controller_spec.rb new file mode 100644 index 00000000000..6d5ed096253 --- /dev/null +++ b/spec/controllers/users/second_mfa_reminder_controller_spec.rb @@ -0,0 +1,114 @@ +require 'rails_helper' + +RSpec.describe Users::SecondMfaReminderController do + let(:user) do + create( + :user, + created_at: (IdentityConfig.store.second_mfa_reminder_account_age_in_days + 1).days.ago, + ) + end + + before do + stub_sign_in(user) if user + stub_analytics + end + + describe '#new' do + subject(:response) { get :new } + + it 'logs an event' do + response + + expect(@analytics).to have_logged_event('Second MFA Reminder Visited') + end + + context 'signed out' do + let(:user) { nil } + + it 'redirects to sign in' do + expect(response).to redirect_to(new_user_session_path) + end + + it 'does not log an event' do + expect(@analytics).not_to have_logged_event('Second MFA Reminder Visited') + end + end + end + + describe '#create' do + let(:params) {} + subject(:response) { post :create, params: } + + context 'user declined' do + let(:params) { {} } + + it 'logs an event' do + response + + expect(@analytics).to have_logged_event( + 'Second MFA Reminder Dismissed', + opted_to_add: false, + ) + end + + it 'updates user to acknowledge dismissal of prompt' do + freeze_time do + expect { response }.to change { user.reload.second_mfa_reminder_dismissed_at }. + from(nil).to(Time.zone.now) + end + end + + it 'redirects to after-signin path' do + expect(response).to redirect_to(account_path) + end + + it 'does not assign session value' do + response + + expect(controller.user_session[:second_mfa_reminder_conversion]).to be_nil + end + end + + context 'user opted to add' do + let(:params) { { add_method: true } } + + it 'logs an event' do + response + + expect(@analytics).to have_logged_event( + 'Second MFA Reminder Dismissed', + opted_to_add: true, + ) + end + + it 'updates user to acknowledge dismissal of prompt' do + freeze_time do + expect { response }.to change { user.reload.second_mfa_reminder_dismissed_at }. + from(nil).to(Time.zone.now) + end + end + + it 'redirects to authentication methods setup' do + expect(response).to redirect_to(authentication_methods_setup_path) + end + + it 'assigns session value' do + response + + expect(controller.user_session[:second_mfa_reminder_conversion]).to eq(true) + end + end + + context 'signed out' do + let(:user) { nil } + + it 'redirects to sign in' do + expect(response).to redirect_to(new_user_session_path) + end + + it 'does not log an event' do + expect(@analytics).not_to have_logged_event('Second MFA Reminder Dismissed') + end + end + end +end diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index d72572dfd87..be785f60bd8 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -42,7 +42,7 @@ user_signed_up: true, totp_secret_present: true, enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, } expect(@analytics). @@ -78,7 +78,7 @@ user_signed_up: false, totp_secret_present: true, enabled_mfa_methods_count: 0, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, } expect(@analytics). @@ -116,7 +116,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, enabled_mfa_methods_count: 0, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } @@ -153,7 +153,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, enabled_mfa_methods_count: 2, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } @@ -191,7 +191,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } @@ -230,7 +230,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } @@ -268,7 +268,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, enabled_mfa_methods_count: 0, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -290,6 +290,7 @@ allow(@irs_attempts_api_tracker).to receive(:track_event) subject.user_session[:new_totp_secret] = secret subject.user_session[:mfa_selections] = mfa_selections + subject.user_session[:in_account_creation_flow] = true patch :confirm, params: { name: name, code: generate_totp_code(secret) } end @@ -306,7 +307,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: true, pii_like_keypaths: [[:mfa_method_counts, :phone]], } @@ -331,7 +332,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, enabled_mfa_methods_count: 1, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: true, pii_like_keypaths: [[:mfa_method_counts, :phone]], } @@ -367,7 +368,7 @@ multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, enabled_mfa_methods_count: 0, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index 8cc1d3109d8..8fc08267900 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -114,7 +114,7 @@ def index user = create(:user, :with_phone, **with_default_phone) stub_sign_in_before_2fa(user) - time1 = Time.zone.local(2023, 12, 13, 0, 0, 0) + time1 = Time.zone.local(2022, 12, 13, 0, 0, 0) cookies.encrypted[:remember_device] = { value: RememberDeviceCookie.new(user_id: user.id, created_at: time1).to_json, expires: time1 + 10.seconds, diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index b2270d33f70..0109cffd0c1 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -53,7 +53,7 @@ platform_authenticator: false, errors: {}, enabled_mfa_methods_count: 0, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, success: true, ) @@ -97,7 +97,7 @@ multi_factor_auth_method: 'webauthn', success: true, errors: {}, - in_multi_mfa_selection_flow: false, + in_account_creation_flow: false, authenticator_data_flags: { up: true, uv: false, @@ -256,6 +256,11 @@ transports: 'usb', } end + + before do + controller.user_session[:in_account_creation_flow] = true + end + it 'should log expected events' do Funnel::Registration::AddMfa.call(user.id, 'phone', @analytics) expect(@analytics).to receive(:track_event). @@ -265,7 +270,7 @@ { enabled_mfa_methods_count: 1, errors: {}, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: true, mfa_method_counts: { webauthn: 1 }, multi_factor_auth_method: 'webauthn', pii_like_keypaths: [[:mfa_method_counts, :phone]], @@ -310,6 +315,11 @@ platform_authenticator: 'true', } end + + before do + controller.user_session[:in_account_creation_flow] = true + end + it 'should log expected events' do expect(@analytics).to receive(:track_event). with('User marked authenticated', { authentication_type: :valid_2fa_confirmation }) @@ -323,7 +333,7 @@ { enabled_mfa_methods_count: 1, errors: {}, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: true, mfa_method_counts: { webauthn_platform: 1 }, multi_factor_auth_method: 'webauthn_platform', pii_like_keypaths: [[:mfa_method_counts, :phone]], @@ -375,7 +385,7 @@ 'errors.webauthn_platform_setup.attestation_error', link: MarketingSite.contact_url, )] }, - in_multi_mfa_selection_flow: true, + in_account_creation_flow: false, mfa_method_counts: {}, multi_factor_auth_method: 'webauthn_platform', pii_like_keypaths: [[:mfa_method_counts, :phone]], diff --git a/spec/decorators/session_decorator_spec.rb b/spec/decorators/null_service_provider_session_spec.rb similarity index 82% rename from spec/decorators/session_decorator_spec.rb rename to spec/decorators/null_service_provider_session_spec.rb index f8e0ee38504..c36fc8b16c1 100644 --- a/spec/decorators/session_decorator_spec.rb +++ b/spec/decorators/null_service_provider_session_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' -RSpec.describe SessionDecorator do - subject { SessionDecorator.new } +RSpec.describe NullServiceProviderSession do + subject { NullServiceProviderSession.new } describe '#new_session_heading' do it 'returns the correct string' do @@ -33,9 +33,9 @@ it 'returns view_context.root url' do view_context = ActionController::Base.new.view_context allow(view_context).to receive(:root_url).and_return('http://www.example.com') - decorator = SessionDecorator.new(view_context: view_context) + null_sp_session = NullServiceProviderSession.new(view_context: view_context) - expect(decorator.cancel_link_url).to eq 'http://www.example.com' + expect(null_sp_session.cancel_link_url).to eq 'http://www.example.com' end end diff --git a/spec/decorators/service_provider_session_decorator_spec.rb b/spec/decorators/service_provider_session_spec.rb similarity index 93% rename from spec/decorators/service_provider_session_decorator_spec.rb rename to spec/decorators/service_provider_session_spec.rb index bc7acf9e58c..3afc96504d0 100644 --- a/spec/decorators/service_provider_session_decorator_spec.rb +++ b/spec/decorators/service_provider_session_spec.rb @@ -1,9 +1,9 @@ require 'rails_helper' -RSpec.describe ServiceProviderSessionDecorator do +RSpec.describe ServiceProviderSession do let(:view_context) { ActionController::Base.new.view_context } subject(:session_decorator) do - ServiceProviderSessionDecorator.new( + ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -20,8 +20,8 @@ and_return('/sign_up/enter_email') end - it 'has the same public API as SessionDecorator' do - SessionDecorator.public_instance_methods.each do |method| + it 'has the same public API as NullServiceProviderSession' do + NullServiceProviderSession.public_instance_methods.each do |method| expect( described_class.public_method_defined?(method), ).to be(true), "expected #{described_class} to have ##{method}" @@ -86,7 +86,7 @@ it 'returns the agency name if friendly name is not present' do sp = build_stubbed(:service_provider, friendly_name: nil) - subject = ServiceProviderSessionDecorator.new( + subject = ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -103,7 +103,7 @@ sp_logo = 'real_logo.svg' sp = build_stubbed(:service_provider, logo: sp_logo) - subject = ServiceProviderSessionDecorator.new( + subject = ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -118,7 +118,7 @@ it 'returns the default logo' do sp = build_stubbed(:service_provider, logo: nil) - subject = ServiceProviderSessionDecorator.new( + subject = ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -136,7 +136,7 @@ sp_logo = '18f.svg' sp = build_stubbed(:service_provider, logo: sp_logo) - subject = ServiceProviderSessionDecorator.new( + subject = ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -151,7 +151,7 @@ it 'returns the default logo' do sp = build_stubbed(:service_provider, logo: nil) - subject = ServiceProviderSessionDecorator.new( + subject = ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -166,7 +166,7 @@ it 'does not raise an exception' do sp = build_stubbed(:service_provider, logo: 'abc') - subject = ServiceProviderSessionDecorator.new( + subject = ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: {}, @@ -180,7 +180,7 @@ describe '#cancel_link_url' do subject(:decorator) do - ServiceProviderSessionDecorator.new( + ServiceProviderSession.new( sp: sp, view_context: view_context, sp_session: { request_id: 'foo' }, diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 1423b2ae404..fa42f7a32c6 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -12,6 +12,7 @@ confirmation_sent_at { 5.minutes.ago } end + created_at { Time.zone.now } accepted_terms_at { Time.zone.now if email } after(:build) do |user, evaluator| diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 3851f80b57e..77634db5a82 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -63,10 +63,10 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, getting_started_ab_test_bucket: :welcome_default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false }, 'Frontend: IdV: front image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'Frontend: IdV: back image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), getting_started_ab_test_bucket: :welcome_default @@ -168,10 +168,10 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, getting_started_ab_test_bucket: :welcome_default, skip_hybrid_handoff: nil, analytics_id: 'Doc Auth', irs_reproofing: false }, 'Frontend: IdV: front image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'Frontend: IdV: back image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), getting_started_ab_test_bucket: :welcome_default @@ -255,10 +255,10 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, getting_started_ab_test_bucket: :welcome_default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false }, 'Frontend: IdV: front image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'Frontend: IdV: back image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), getting_started_ab_test_bucket: :welcome_default diff --git a/spec/features/idv/cancel_spec.rb b/spec/features/idv/cancel_spec.rb index 6d97ce5d45e..2763a5798ea 100644 --- a/spec/features/idv/cancel_spec.rb +++ b/spec/features/idv/cancel_spec.rb @@ -132,7 +132,7 @@ it 'shows the user a cancellation message with the option to cancel and reset idv', :js do sp_name = 'Test SP' - allow_any_instance_of(ServiceProviderSessionDecorator).to receive(:sp_name). + allow_any_instance_of(ServiceProviderSession).to receive(:sp_name). and_return(sp_name) click_link t('links.cancel') diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 1b55ebcddf7..5bb0ca21b80 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -11,7 +11,7 @@ let(:sp_name) { 'Test SP' } before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) - allow_any_instance_of(ServiceProviderSessionDecorator).to receive(:sp_name).and_return(sp_name) + allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(sp_name) visit_idp_from_oidc_sp_with_ial2 diff --git a/spec/features/idv/doc_auth/getting_started_spec.rb b/spec/features/idv/doc_auth/getting_started_spec.rb index 557fb13c200..e4f2918a450 100644 --- a/spec/features/idv/doc_auth/getting_started_spec.rb +++ b/spec/features/idv/doc_auth/getting_started_spec.rb @@ -9,7 +9,7 @@ before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) - allow_any_instance_of(ServiceProviderSessionDecorator).to receive(:sp_name).and_return(sp_name) + allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(sp_name) stub_const('AbTests::IDV_GETTING_STARTED', FakeAbTestBucket.new) AbTests::IDV_GETTING_STARTED.assign_all(:getting_started) 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 0137b789e92..7e3eea50122 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -57,7 +57,7 @@ expect(current_path).to eq(idv_verify_info_path) check t('forms.ssn.show') expect(page).to have_content(DocAuthHelper::GOOD_SSN) - expect(page).to have_css('[role="status"]') # We verified your ID + expect(page).to have_css('[role="status"]') # We verified your ID end it 'document capture cannot be reached after submitting verify info step' do @@ -148,4 +148,115 @@ end end end + + shared_examples_for 'image re-upload allowed' do + it 'allows user to submit the same image again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3), + ) + DocAuth::Mock::DocAuthMockClient.reset! + attach_and_submit_images + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 2), + ) + expect(current_path).to eq(idv_ssn_path) + check t('forms.ssn.show') + end + end + + shared_examples_for 'image re-upload not allowed' do + it 'stops user submitting the same image again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3, attempts: 1), + ) + DocAuth::Mock::DocAuthMockClient.reset! + attach_images + # Error message without submit + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + end + end + + context 'error due to data issue with 2xx status code', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_error_unknown + attach_and_submit_images + click_try_again + end + it_behaves_like 'image re-upload not allowed' + end + + context 'error due to data issue with 4xx status code with trueid', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_trueid_http_non2xx_status(438) + attach_and_submit_images + click_try_again + end + + it_behaves_like 'image re-upload allowed' + end + + context 'error due to http status error but non 4xx status code with trueid', + allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_trueid_http_non2xx_status(500) + attach_and_submit_images + click_try_again + end + it_behaves_like 'image re-upload allowed' + end + + context 'error due to data issue with 4xx status code with assureid', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_http_4xx_status(440) + attach_and_submit_images + click_try_again + end + it_behaves_like 'image re-upload not allowed' + end + + context 'error due to data issue with 5xx status code with assureid', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_http_5xx_status + attach_and_submit_images + click_try_again + end + + it_behaves_like 'image re-upload allowed' + end + + context 'unknown error for acuant', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_error_unknown + attach_and_submit_images + click_try_again + end + + it_behaves_like 'image re-upload not allowed' + end end diff --git a/spec/features/idv/doc_auth/test_credentials_spec.rb b/spec/features/idv/doc_auth/test_credentials_spec.rb index 34976f8c287..849236666e8 100644 --- a/spec/features/idv/doc_auth/test_credentials_spec.rb +++ b/spec/features/idv/doc_auth/test_credentials_spec.rb @@ -61,6 +61,8 @@ def triggers_error_test_credentials_missing(credential_file, alert_message) it 'rate limits the user if invalid credentials submitted for max allowed attempts', allow_browser_log: true do + allow(IdentityConfig.store).to receive(:doc_auth_check_failed_image_resubmission_enabled). + and_return(false) max_attempts = IdentityConfig.store.doc_auth_max_attempts (max_attempts - 1).times do complete_document_capture_step_with_yml( 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 164b2b05952..21c127f564f 100644 --- a/spec/features/idv/doc_auth/verify_info_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_info_step_spec.rb @@ -359,7 +359,7 @@ and_return(nil) click_idv_continue - expect(fake_analytics).to have_logged_event('Proofing Resolution Result Missing') + expect(fake_analytics).to have_logged_event('IdV: proofing resolution result missing') expect(page).to have_content(t('idv.failure.timeout')) expect(page).to have_current_path(idv_verify_info_path) allow(DocumentCaptureSession).to receive(:find_by).and_call_original diff --git a/spec/features/idv/doc_auth/welcome_spec.rb b/spec/features/idv/doc_auth/welcome_spec.rb index 42334e19a77..23ca9a7629b 100644 --- a/spec/features/idv/doc_auth/welcome_spec.rb +++ b/spec/features/idv/doc_auth/welcome_spec.rb @@ -9,7 +9,7 @@ before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) - allow_any_instance_of(ServiceProviderSessionDecorator).to receive(:sp_name).and_return(sp_name) + allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(sp_name) visit_idp_from_sp_with_ial2(:oidc) sign_in_and_2fa_user diff --git a/spec/features/idv/steps/gpo_step_spec.rb b/spec/features/idv/steps/gpo_step_spec.rb index 48e80fc2b55..d5a4a649d22 100644 --- a/spec/features/idv/steps/gpo_step_spec.rb +++ b/spec/features/idv/steps/gpo_step_spec.rb @@ -13,6 +13,8 @@ and_return(minimum_wait_for_letter) allow(IdentityConfig.store).to receive(:gpo_max_profile_age_to_send_letter_in_days). and_return(max_days_before_resend_disabled) + allow(IdentityConfig.store).to receive(:second_mfa_reminder_account_age_in_days). + and_return(days_passed + 1) end it 'redirects to and completes the review step when the user chooses to verify by letter', :js do @@ -62,7 +64,7 @@ # Confirm that we show the correct content on # the GPO page for users requesting re-send expect(page).to have_content(t('idv.titles.mail.resend')) - expect(page).to have_content(t('idv.messages.gpo.resend_timeframe')) + expect(page).to have_content(strip_tags(t('idv.messages.gpo.resend_timeframe_html'))) expect(page).to have_content(t('idv.messages.gpo.resend_code_warning')) expect(page).to have_content(t('idv.buttons.mail.resend')) expect(page).to_not have_content(t('idv.messages.gpo.info_alert')) diff --git a/spec/features/remember_device/sp_expiration_spec.rb b/spec/features/remember_device/sp_expiration_spec.rb index e3fa086fc19..333ce427294 100644 --- a/spec/features/remember_device/sp_expiration_spec.rb +++ b/spec/features/remember_device/sp_expiration_spec.rb @@ -108,6 +108,8 @@ def visit_sp(protocol, aal) before do allow(IdentityConfig.store).to receive(:otp_delivery_blocklist_maxretry).and_return(1000) + allow(IdentityConfig.store).to receive(:second_mfa_reminder_account_age_in_days). + and_return([AAL1_REMEMBER_DEVICE_EXPIRATION, AAL2_REMEMBER_DEVICE_EXPIRATION].max.in_days + 2) ServiceProvider.find_by(issuer: OidcAuthHelper::OIDC_IAL1_ISSUER).update!( default_aal: aal, diff --git a/spec/features/two_factor_authentication/second_mfa_reminder_spec.rb b/spec/features/two_factor_authentication/second_mfa_reminder_spec.rb new file mode 100644 index 00000000000..b2d15ec3e93 --- /dev/null +++ b/spec/features/two_factor_authentication/second_mfa_reminder_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' + +RSpec.feature 'Second MFA Reminder' do + include OidcAuthHelper + + let(:service_provider) { ServiceProvider.find_by(issuer: OidcAuthHelper::OIDC_IAL1_ISSUER) } + let(:user) { create(:user, :fully_registered, :with_phone) } + + before do + allow(IdentityConfig.store).to receive(:second_mfa_reminder_sign_in_count).and_return(2) + allow(IdentityConfig.store).to receive(:second_mfa_reminder_account_age_in_days).and_return(5) + IdentityLinker.new(user, service_provider).link_identity(verified_attributes: %w[openid email]) + end + + context 'user with single mfa' do + it 'does not prompt the user on sign in' do + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + + expect(page).to have_current_path(account_path) + end + + context 'after sign in count threshold' do + before do + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + first(:button, t('links.sign_out')).click + end + + it 'prompts the user on sign in and allows them to continue', :js do + # This spec includes regression coverage for a scenario where the user would be redirected + # immediately to the partner, requiring CSP header overrides that are not enforced if not + # using the JavaScript driver. + + visit_idp_from_ial1_oidc_sp + sign_in_user(user) + + expect(page).to have_current_path(second_mfa_reminder_path) + + click_on t('users.second_mfa_reminder.continue', sp_name: service_provider.friendly_name) + + expect(current_url).to start_with(service_provider.redirect_uris.first) + end + end + + context 'after age threshold' do + before { travel 6.days } + + it 'prompts the user on sign in and allows them to add an authentication method' do + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + + click_on t('users.second_mfa_reminder.add_method') + + expect(page).to have_current_path(authentication_methods_setup_url) + end + end + + context 'user already acknowledged reminder' do + before do + travel 6.days + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + click_button t('users.second_mfa_reminder.continue', sp_name: APP_NAME) + first(:button, t('links.sign_out')).click + end + + it 'does not prompt the user on sign in' do + sign_in_user(user) + + expect(page).to have_current_path(account_path) + end + end + end + + context 'user with multiple mfas who would otherwise be candidate' do + let(:user) { create(:user, :fully_registered, :with_phone, :with_authentication_app) } + + before do + travel 6.days + end + + it 'does not prompt the user on sign in' do + sign_in_user(user) + fill_in_code_with_last_totp(user) + click_submit_default + + expect(page).to have_current_path(account_path) + end + end +end diff --git a/spec/features/users/sign_up_spec.rb b/spec/features/users/sign_up_spec.rb index 30a1381da8f..26d0b4dcb41 100644 --- a/spec/features/users/sign_up_spec.rb +++ b/spec/features/users/sign_up_spec.rb @@ -92,6 +92,44 @@ end end + context 'User in account creation logs in_account_creation_flow for proper analytic events' do + let(:fake_analytics) { FakeAnalytics.new } + before do + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + end + it 'logs analytic events for MFA selected with in account creation flow' do + sign_up_and_set_password + click_2fa_option('phone') + click_2fa_option('backup_code') + + click_continue + fill_in 'new_phone_form_phone', with: '703-555-1212' + click_send_one_time_code + + fill_in_code_with_last_phone_otp + click_submit_default + + expect(current_path).to eq backup_code_setup_path + + click_continue + + expect(page).to have_link(t('components.download_button.label')) + + click_continue + + expect(page).to have_content(t('notices.backup_codes_configured')) + + expect(fake_analytics).to have_logged_event( + 'Multi-Factor Authentication Setup', + success: true, + errors: nil, + multi_factor_auth_method: 'backup_codes', + in_account_creation_flow: true, + enabled_mfa_methods_count: 2, + ) + end + end + scenario 'renders an error when the telephony gem responds with an error' do allow(Telephony).to receive(:phone_info).and_return( Telephony::PhoneNumberInfo.new(carrier: 'Test', type: :test, error: nil), @@ -464,4 +502,8 @@ def clipboard_text end end end + + def click_2fa_option(option) + find("label[for='two_factor_options_form_selection_#{option}']").click + end end diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 20388b584e9..d1a95f84c5d 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -194,7 +194,6 @@ it 'logs analytics excluding invalid metadata' do form.submit - expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, @@ -269,6 +268,16 @@ response = form.submit expect(response.errors[:front]).to eq('glare') end + + it 'keeps fingerprints of failed image and triggers error when submit same image' do + form.submit + session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) + capture_result = session.load_result + expect(capture_result.failed_front_image_fingerprints).not_to match_array([]) + response = form.submit + expect(response.errors).to have_key(:front) + expect(response.errors).to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) + end end context 'PII validation from client response fails' do @@ -311,6 +320,27 @@ response = form.submit expect(response.errors[:doc_pii]).to eq('bad') end + + it 'keeps fingerprints of failed image and triggers error when submit same image' do + form.submit + session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) + capture_result = session.load_result + expect(capture_result.failed_front_image_fingerprints).not_to match_array([]) + response = form.submit + expect(response.errors).to have_key(:front) + expect(response.errors).to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) + expect(fake_analytics).to have_logged_event( + 'IdV: failed doc image resubmitted', + attempts: 1, + remaining_attempts: 3, + user_id: document_capture_session.user.uuid, + flow_path: anything, + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + getting_started_ab_test_bucket: :welcome_default, + side: 'both', + ) + end end describe 'encrypted document storage' do @@ -428,4 +458,74 @@ end end end + describe '#store_failed_images' do + let(:doc_pii_response) { instance_double(Idv::DocAuthFormResponse) } + let(:client_response) { instance_double(DocAuth::Response) } + context 'when client_response is not success and not network error' do + context 'when both sides error message missing' do + let(:errors) { {} } + it 'stores both sides as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(false) + allow(client_response).to receive(:errors).and_return(errors) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).not_to be_empty + end + end + context 'when both sides error message exist' do + let(:errors) { { front: 'blurry', back: 'dpi' } } + it 'stores both sides as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(false) + allow(client_response).to receive(:errors).and_return(errors) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).not_to be_empty + end + end + context 'when one sides error message exists' do + let(:errors) { { front: 'blurry', back: nil } } + it 'stores only the error side as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(false) + allow(client_response).to receive(:errors).and_return(errors) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).to be_empty + end + end + end + + context 'when client_response is not success and is network error' do + let(:errors) { {} } + context 'when doc_pii_response is success' do + it 'stores neither of the side as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(true) + allow(client_response).to receive(:errors).and_return(errors) + allow(doc_pii_response).to receive(:success?).and_return(true) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).to be_empty + expect(capture_result[:back]).to be_empty + end + end + context 'when doc_pii_response is failure' do + it 'stores both sides as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(true) + allow(client_response).to receive(:errors).and_return(errors) + allow(doc_pii_response).to receive(:success?).and_return(false) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).not_to be_empty + end + end + end + end end diff --git a/spec/forms/openid_connect_token_form_spec.rb b/spec/forms/openid_connect_token_form_spec.rb index 15880ab0576..e7a7174c453 100644 --- a/spec/forms/openid_connect_token_form_spec.rb +++ b/spec/forms/openid_connect_token_form_spec.rb @@ -381,6 +381,7 @@ code_digest: Digest::SHA256.hexdigest(code), code_verifier_present: false, service_provider_pkce: nil, + ial: 1, ) end end diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index 78d32e2e13b..d3a46c94e1b 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -4,7 +4,11 @@ import AcuantCapture, { getNormalizedAcuantCaptureFailureMessage, isAcuantCameraAccessFailure, } from '@18f/identity-document-capture/components/acuant-capture'; -import { AcuantContextProvider, AnalyticsContext } from '@18f/identity-document-capture'; +import { + AcuantContextProvider, + AnalyticsContext, + FailedCaptureAttemptsContextProvider, +} from '@18f/identity-document-capture'; import { createEvent, waitFor } from '@testing-library/dom'; import DeviceContext from '@18f/identity-document-capture/context/device'; @@ -794,6 +798,8 @@ describe('document-capture/components/acuant-capture', () => { attempt: sinon.match.number, size: sinon.match.number, acuantCaptureMode: 'AUTO', + fingerprint: null, + failedImageResubmission: false, }); expect(error).to.be.ok(); @@ -850,6 +856,8 @@ describe('document-capture/components/acuant-capture', () => { attempt: sinon.match.number, size: sinon.match.number, acuantCaptureMode: sinon.match.string, + fingerprint: null, + failedImageResubmission: false, }); expect(error).to.be.ok(); @@ -959,6 +967,8 @@ describe('document-capture/components/acuant-capture', () => { attempt: sinon.match.number, size: sinon.match.number, acuantCaptureMode: sinon.match.string, + fingerprint: null, + failedImageResubmission: false, }); }); @@ -1149,26 +1159,74 @@ describe('document-capture/components/acuant-capture', () => { it('logs metrics for manual upload', async () => { const trackEvent = sinon.stub(); + const onChange = sinon.stub(); + const { getByLabelText } = render( - - - + + + + + , ); - const input = getByLabelText('Image'); uploadFile(input, validUpload); + onChange.calls; + await new Promise((resolve) => onChange.callsFake(resolve)); + expect(trackEvent).to.be.calledOnce(); + expect(trackEvent).to.have.been.calledWith( + 'IdV: front image added', + sinon.match({ + width: sinon.match.number, + height: sinon.match.number, + fingerprint: sinon.match.string, + source: 'upload', + mimeType: 'image/jpeg', + size: sinon.match.number, + attempt: sinon.match.number, + acuantCaptureMode: 'AUTO', + }), + ); + }); - await expect(trackEvent).to.eventually.be.calledWith('IdV: test image added', { - height: sinon.match.number, - mimeType: 'image/jpeg', - source: 'upload', - width: sinon.match.number, - attempt: sinon.match.number, - size: sinon.match.number, - acuantCaptureMode: sinon.match.string, - }); + it('logs metrics for failed reupload', async () => { + const trackEvent = sinon.stub(); + const onChange = sinon.stub(); + const { getByLabelText } = render( + + + + + + + , + ); + const input = getByLabelText('Image'); + uploadFile(input, validUpload); + onChange.calls; + await new Promise((resolve) => onChange.callsFake(resolve)); + expect(trackEvent).to.be.calledOnce(); + expect(trackEvent).to.be.eventually.calledWith( + 'IdV: failed front image resubmitted', + sinon.match({ + width: sinon.match.number, + height: sinon.match.number, + fingerprint: sinon.match.string, + source: 'upload', + mimeType: 'image/jpeg', + size: sinon.match.number, + attempt: sinon.match.number, + acuantCaptureMode: 'AUTO', + }), + ); }); it('logs clicks', async () => { diff --git a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx index a5c5cb4d2a7..d160d95c5b4 100644 --- a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx @@ -1,7 +1,11 @@ import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; import { t } from '@18f/identity-i18n'; -import { DeviceContext, UploadContextProvider } from '@18f/identity-document-capture'; +import { + DeviceContext, + UploadContextProvider, + FailedCaptureAttemptsContextProvider, +} from '@18f/identity-document-capture'; import DocumentsStep from '@18f/identity-document-capture/components/documents-step'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; @@ -19,7 +23,14 @@ describe('document-capture/components/documents-step', () => { it('calls onChange callback with uploaded image', async () => { const onChange = sinon.stub(); - const { getByLabelText } = render(); + const { getByLabelText } = render( + + , + , + ); const file = await getFixtureFile('doc_auth_images/id-back.jpg'); await Promise.all([ diff --git a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx index 5984f8f72d4..0db08592fe9 100644 --- a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx @@ -4,6 +4,7 @@ import { ServiceProviderContextProvider, AnalyticsContext, InPersonContext, + FailedCaptureAttemptsContextProvider, } from '@18f/identity-document-capture'; import { I18n } from '@18f/identity-i18n'; import { I18nContext } from '@18f/identity-react-i18n'; @@ -154,7 +155,12 @@ describe('document-capture/components/review-issues-step', () => { it('calls onChange callback with uploaded image', async () => { const onChange = sinon.stub(); const { getByLabelText, getByRole } = render( - , + + , + , ); const file = await getFixtureFile('doc_auth_images/id-back.jpg'); await userEvent.click(getByRole('button', { name: 'idv.failure.button.warning' })); @@ -336,6 +342,49 @@ describe('document-capture/components/review-issues-step', () => { }); }); + it('skip renders initially with warning page when failed image is submitted again', () => { + const { findByRole, getByRole, getByText } = render( + One attempt remaining', + other: '%{count} attempts remaining', + }, + }, + }) + } + > + + + + , + ); + + expect(findByRole('button', { name: 'idv.failure.button.warning' })).not.to.exist; + expect(getByRole('heading', { name: 'doc_auth.headings.review_issues' })).to.be.ok; + expect(getByText('duplicate image')).to.be.ok; + }); + context('ial2 strict', () => { it('renders with front and back inputs', async () => { const { getByLabelText, getByRole } = render( diff --git a/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx b/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx index c40ae2483df..801d217bf14 100644 --- a/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx +++ b/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx @@ -24,6 +24,7 @@ describe('document-capture/context/failed-capture-attempts', () => { 'maxCaptureAttemptsBeforeNativeCamera', 'maxSubmissionAttemptsBeforeNativeCamera', 'lastAttemptMetadata', + 'failedSubmissionImageFingerprints', ]); expect(result.current.failedCaptureAttempts).to.equal(0); expect(result.current.failedSubmissionAttempts).to.equal(0); @@ -32,6 +33,7 @@ describe('document-capture/context/failed-capture-attempts', () => { expect(result.current.onResetFailedCaptureAttempts).to.be.a('function'); expect(result.current.maxCaptureAttemptsBeforeNativeCamera).to.be.a('number'); expect(result.current.lastAttemptMetadata).to.be.an('object'); + expect(result.current.failedSubmissionImageFingerprints).to.be.an('object'); }); describe('Provider', () => { @@ -83,10 +85,14 @@ describe('FailedCaptureAttemptsContext testing of forceNativeCamera logic', () = {children} ), }); - result.current.onFailedSubmissionAttempt(); + result.current.onFailedSubmissionAttempt({ front: ['abcdefg'], back: [] }); rerender(true); expect(result.current.failedSubmissionAttempts).to.equal(1); expect(result.current.forceNativeCamera).to.equal(false); + expect(result.current.failedSubmissionImageFingerprints).to.eql({ + front: ['abcdefg'], + back: [], + }); }); it('Updating failed captures to a number gte the maxCaptureAttemptsBeforeNativeCamera will set forceNativeCamera to true', () => { diff --git a/spec/javascript/packages/document-capture/services/upload-spec.js b/spec/javascript/packages/document-capture/services/upload-spec.js index 6bd72957a79..9a6733256ae 100644 --- a/spec/javascript/packages/document-capture/services/upload-spec.js +++ b/spec/javascript/packages/document-capture/services/upload-spec.js @@ -142,6 +142,44 @@ describe('document-capture/services/upload', () => { } }); + it('handles validation error due to resubmit failed message', async () => { + const endpoint = 'https://example.com'; + + const response = new Response( + JSON.stringify({ + success: false, + errors: [{ field: 'front', message: 'Using failed image' }], + remaining_attempts: 3, + hints: true, + result_failed: true, + ocr_pii: { first_name: 'Fakey', last_name: 'McFakerson', dob: '1938-10-06' }, + failed_image_fingerprints: { front: ['12345'], back: [] }, + }), + { status: 400 }, + ); + sandbox.stub(response, 'url').get(() => endpoint); + sandbox.stub(global, 'fetch').callsFake(() => Promise.resolve(response)); + + try { + await upload({}, { endpoint }); + throw new Error('This is a safeguard and should never be reached, since upload should error'); + } catch (error) { + expect(error).to.be.instanceOf(UploadFormEntriesError); + expect(error.remainingAttempts).to.equal(3); + expect(error.hints).to.be.true(); + expect(error.pii).to.deep.equal({ + first_name: 'Fakey', + last_name: 'McFakerson', + dob: '1938-10-06', + }); + expect(error.isFailedResult).to.be.true(); + expect(error.formEntryErrors[0]).to.be.instanceOf(UploadFormEntryError); + expect(error.formEntryErrors[0].field).to.equal('front'); + expect(error.formEntryErrors[0].message).to.equal('Using failed image'); + expect(error.failed_image_fingerprints).to.eql({ front: ['12345'], back: [] }); + } + }); + it('redirects error', async () => { const endpoint = 'https://example.com'; diff --git a/spec/lib/action_account_spec.rb b/spec/lib/action_account_spec.rb index f34e98f0573..e35f71204d1 100644 --- a/spec/lib/action_account_spec.rb +++ b/spec/lib/action_account_spec.rb @@ -135,12 +135,20 @@ let(:user) { create(:profile, :fraud_review_pending).user } let(:user_without_profile) { create(:user) } + let(:analytics) { FakeAnalytics.new } + + before do + allow(Analytics).to receive(:new).and_return(analytics) + end + let(:args) { [user.uuid, user_without_profile.uuid, 'uuid-does-not-exist'] } let(:include_missing) { true } let(:config) { ScriptBase::Config.new(include_missing:) } subject(:result) { subtask.run(args:, config:) } it 'Reject a user that has a pending review', aggregate_failures: true do + profile_fraud_review_pending_at = user.pending_profile.fraud_review_pending_at + expect(result.table).to match_array( [ ['uuid', 'status'], @@ -152,6 +160,28 @@ expect(result.subtask).to eq('review-reject') expect(result.uuids).to match_array([user.uuid, user_without_profile.uuid]) + + expect(analytics).to have_logged_event( + 'Fraud: Profile review rejected', + success: true, + errors: nil, + exception: nil, + profile_fraud_review_pending_at: profile_fraud_review_pending_at, + ) + expect(analytics).to have_logged_event( + 'Fraud: Profile review rejected', + success: false, + errors: { message: 'Error: User does not have a pending fraud review' }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) + expect(analytics).to have_logged_event( + 'Fraud: Profile review rejected', + success: false, + errors: { message: 'Error: Could not find user with that UUID' }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) end end end @@ -163,12 +193,20 @@ let(:user) { create(:profile, :fraud_review_pending).user } let(:user_without_profile) { create(:user) } + let(:analytics) { FakeAnalytics.new } + + before do + allow(Analytics).to receive(:new).and_return(analytics) + end + let(:args) { [user.uuid, user_without_profile.uuid, 'uuid-does-not-exist'] } let(:include_missing) { true } let(:config) { ScriptBase::Config.new(include_missing:) } subject(:result) { subtask.run(args:, config:) } it 'Pass a user that has a pending review', aggregate_failures: true do + profile_fraud_review_pending_at = user.pending_profile.fraud_review_pending_at + expect(result.table).to match_array( [ ['uuid', 'status'], @@ -180,6 +218,28 @@ expect(result.subtask).to eq('review-pass') expect(result.uuids).to match_array([user.uuid, user_without_profile.uuid]) + + expect(analytics).to have_logged_event( + 'Fraud: Profile review passed', + success: true, + errors: nil, + exception: nil, + profile_fraud_review_pending_at: profile_fraud_review_pending_at, + ) + expect(analytics).to have_logged_event( + 'Fraud: Profile review passed', + success: false, + errors: { message: 'Error: User does not have a pending fraud review' }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) + expect(analytics).to have_logged_event( + 'Fraud: Profile review passed', + success: false, + errors: { message: 'Error: Could not find user with that UUID' }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) end end end diff --git a/spec/lib/tasks/review_profile_spec.rb b/spec/lib/tasks/review_profile_spec.rb index 95cb1f2c97e..950cfa4e836 100644 --- a/spec/lib/tasks/review_profile_spec.rb +++ b/spec/lib/tasks/review_profile_spec.rb @@ -84,18 +84,41 @@ end context 'when the user has cancelled verification' do - it 'does not activate the profile' do - user.profiles.first.update!(gpo_verification_pending_at: user.created_at) + before do + user.pending_profile.deactivate(:encryption_error) + create(:profile, :verify_by_mail_pending, user: user) + end - expect { invoke_task }.to raise_error(RuntimeError) + it 'does not activate the profile' do + invoke_task - expect(user.reload.profiles.first.active).to eq(false) + expect(user.reload.active_profile).to be_nil end it 'logs an error to analytics' do - profile_fraud_review_pending_at = user.profiles.first.fraud_review_pending_at - user.profiles.first.update!(gpo_verification_pending_at: user.created_at) + invoke_task + + expect(analytics).to have_logged_event( + 'Fraud: Profile review passed', + success: false, + errors: { message: 'Error: User does not have a pending fraud review' }, + exception: nil, + profile_fraud_review_pending_at: nil, + ) + end + end + + context 'when the pending profile is in an invalid state and cannot be activated' do + before do + # Profiles should not be gpo_verification_pending and fraud_review_pending at the same time. + user.pending_profile.update!(gpo_verification_pending_at: 1.day.ago) + end + + it 'raises an error' do + expect { invoke_task }.to raise_error(RuntimeError) + end + it 'logs an exception' do expect { invoke_task }.to raise_error(RuntimeError) expect(analytics).to have_logged_event( @@ -103,7 +126,7 @@ success: false, errors: nil, exception: a_string_including('Attempting to activate profile with pending reason'), - profile_fraud_review_pending_at: profile_fraud_review_pending_at, + profile_fraud_review_pending_at: user.pending_profile.fraud_review_pending_at, ) end end diff --git a/spec/mailers/previews/report_mailer_preview.rb b/spec/mailers/previews/report_mailer_preview.rb index f3c71460244..81e776fd877 100644 --- a/spec/mailers/previews/report_mailer_preview.rb +++ b/spec/mailers/previews/report_mailer_preview.rb @@ -7,4 +7,30 @@ def warn_error ), ) end + + def tables_report + ReportMailer.tables_report( + email: 'test@example.com', + subject: 'Example Report', + tables: [ + [ + ['Some', 'String'], + ['a', 'b'], + ['c', 'd'], + ], + [ + { float_as_percent: true }, + [nil, 'Int', 'Float as Percent'], + ['Row 1', 1, 0.5], + ['Row 2', 1, 1.5], + ], + [ + { float_as_percent: false }, + [nil, 'Gigantic Int', 'Float as Float'], + ['Row 1', 100_000_000, 1.0], + ['Row 2', 123_456_789, 1.5], + ], + ], + ) + end end diff --git a/spec/mailers/previews/report_mailer_preview_spec.rb b/spec/mailers/previews/report_mailer_preview_spec.rb index fb1fc38697f..72368761d6d 100644 --- a/spec/mailers/previews/report_mailer_preview_spec.rb +++ b/spec/mailers/previews/report_mailer_preview_spec.rb @@ -9,4 +9,10 @@ expect { mailer_preview.warn_error }.to_not raise_error end end + + describe '#tables_report' do + it 'generates a tables_report email' do + expect { mailer_preview.tables_report }.to_not raise_error + end + end end diff --git a/spec/mailers/report_mailer_spec.rb b/spec/mailers/report_mailer_spec.rb index 9a7f076c638..5296cc6ec6c 100644 --- a/spec/mailers/report_mailer_spec.rb +++ b/spec/mailers/report_mailer_spec.rb @@ -52,4 +52,54 @@ expect(mail.text_part.body).to include('this is my test') end end + + describe '#tables_report' do + let(:env) { 'prod' } + + let(:mail) do + ReportMailer.tables_report( + email: 'foo@example.com', + subject: 'My Report', + env: env, + tables: [ + [ + ['Some', 'String'], + ['a', 'b'], + ['c', 'd'], + ], + [ + { float_as_percent: true, title: 'Custom Table 2' }, + ['Float', 'Int', 'Float'], + ['Row 1', 1, 0.5], + ['Row 2', 1, 1.5], + ], + [ + { float_as_percent: false, title: 'Custom Table 3' }, + ['Float As Percent', 'Gigantic Int', 'Float'], + ['Row 1', 100_000_000, 1.0], + ['Row 2', 123_456_789, 1.5], + ], + ], + ) + end + + it 'renders the tables in HTML and attaches them as CSVs', aggregate_failures: true do + doc = Nokogiri::HTML(mail.html_part.body.to_s) + + expect(doc.css('h2').map(&:text)).to eq(['Table 1', 'Custom Table 2', 'Custom Table 3']) + + _first_table, percent_table, float_table = doc.css('table') + + percent_cell = percent_table.at_css('tbody tr:nth-child(1) td:last-child') + expect(percent_cell.text.strip).to eq('50.00%') + expect(percent_cell['class']).to eq('table-number') + + float_cell = float_table.at_css('tbody tr:nth-child(1) td:last-child') + expect(float_cell.text.strip).to eq('1.0') + expect(percent_cell['class']).to eq('table-number') + + big_int_cell = float_table.at_css('tbody tr:nth-child(1) td:nth-child(2)') + expect(big_int_cell.text.strip).to eq('100,000,000') + end + end end diff --git a/spec/models/document_capture_session_spec.rb b/spec/models/document_capture_session_spec.rb index fdb50150e60..6d61bd49392 100644 --- a/spec/models/document_capture_session_spec.rb +++ b/spec/models/document_capture_session_spec.rb @@ -11,6 +11,12 @@ ) end + let(:failed_doc_auth_response) do + DocAuth::Response.new( + success: false, + ) + end + describe '#store_result_from_response' do it 'generates a result ID stores the result encrypted in redis' do record = DocumentCaptureSession.new @@ -114,4 +120,23 @@ end end end + + describe('#store_failed_auth_image_fingerprint') do + it 'stores image finger print' do + record = DocumentCaptureSession.new(result_id: SecureRandom.uuid) + + record.store_failed_auth_image_fingerprint( + 'fingerprint1', nil + ) + + result_id = record.result_id + key = EncryptedRedisStructStorage.key(result_id, type: DocumentCaptureSessionResult) + data = REDIS_POOL.with { |client| client.get(key) } + expect(data).to be_a(String) + result = record.load_result + expect(result.failed_front_image?('fingerprint1')).to eq(true) + expect(result.failed_front_image?(nil)).to eq(false) + expect(result.failed_back_image?(nil)).to eq(false) + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0a211ae5066..2cb1d792a04 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1490,6 +1490,20 @@ def it_should_not_send_survey end end + describe '#sign_in_count' do + it 'returns sign-in event count since the given time' do + freeze_time do + user = create(:user) + user.events.create(event_type: :sign_in_before_2fa, created_at: 1.day.ago) + user.events.create(event_type: :email_changed, created_at: 1.day.ago) + user.events.create(event_type: :sign_in_before_2fa, created_at: 2.days.ago) + user.events.create(event_type: :sign_in_before_2fa, created_at: 3.days.ago) + + expect(user.sign_in_count(since: 2.days.ago)).to eq(2) + end + end + end + describe '#second_last_signed_in_at' do it 'returns second most recent full authentication event' do user = create(:user) diff --git a/spec/presenters/image_upload_response_presenter_spec.rb b/spec/presenters/image_upload_response_presenter_spec.rb index 89de4ef42ee..03e81f90681 100644 --- a/spec/presenters/image_upload_response_presenter_spec.rb +++ b/spec/presenters/image_upload_response_presenter_spec.rb @@ -107,7 +107,9 @@ context 'rate limited' do let(:extra_attributes) do - { remaining_attempts: 0, flow_path: 'standard' } + { remaining_attempts: 0, + flow_path: 'standard', + failed_image_fingerprints: { back: [], front: ['12345'] } } end let(:form_response) do FormResponse.new( @@ -128,6 +130,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: ['12345'] }, } expect(presenter.as_json).to eq expected @@ -135,7 +138,9 @@ context 'hybrid flow' do let(:extra_attributes) do - { remaining_attempts: 0, flow_path: 'hybrid' } + { remaining_attempts: 0, + flow_path: 'hybrid', + failed_image_fingerprints: { back: [], front: ['12345'] } } end it 'returns hash of properties redirecting to capture_complete' do @@ -147,6 +152,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: ['12345'] }, } expect(presenter.as_json).to eq expected @@ -175,6 +181,7 @@ remaining_attempts: 3, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected @@ -201,6 +208,7 @@ remaining_attempts: 3, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, } expect(presenter.as_json).to eq expected @@ -237,6 +245,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, } expect(presenter.as_json).to eq expected @@ -253,6 +262,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected @@ -280,6 +290,7 @@ remaining_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected @@ -305,6 +316,7 @@ remaining_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected diff --git a/spec/presenters/two_factor_options_presenter_spec.rb b/spec/presenters/two_factor_options_presenter_spec.rb index 383fdeb3595..ff6aba336cc 100644 --- a/spec/presenters/two_factor_options_presenter_spec.rb +++ b/spec/presenters/two_factor_options_presenter_spec.rb @@ -105,6 +105,22 @@ end end + describe '#skip_label' do + subject(:skip_label) { presenter.skip_label } + + it 'is "Skip"' do + expect(skip_label).to eq(t('mfa.skip')) + end + + context 'user has dismissed second mfa reminder' do + let(:user) { build(:user, second_mfa_reminder_dismissed_at: Time.zone.now) } + + it 'is "Cancel"' do + expect(skip_label).to eq(t('links.cancel')) + end + end + end + describe '#show_skip_additional_mfa_link?' do it 'returns true' do expect(presenter.show_skip_additional_mfa_link?).to eq(true) diff --git a/spec/services/access_token_verifier_spec.rb b/spec/services/access_token_verifier_spec.rb index 9e620cc8465..6d419194bbf 100644 --- a/spec/services/access_token_verifier_spec.rb +++ b/spec/services/access_token_verifier_spec.rb @@ -51,7 +51,7 @@ let(:access_token) { identity.access_token } before do identity.save! - OutOfBandSessionAccessor.new(identity.rails_session_id).put_pii({}, 1) + OutOfBandSessionAccessor.new(identity.rails_session_id).put_pii({}, 5) end it 'is successful' do @@ -65,7 +65,7 @@ context 'with a valid access_token' do before do identity.save! - OutOfBandSessionAccessor.new(identity.rails_session_id).put_pii({}, 1) + OutOfBandSessionAccessor.new(identity.rails_session_id).put_pii({}, 5) end let(:access_token) { identity.access_token } diff --git a/spec/services/doc_auth/acuant/request_spec.rb b/spec/services/doc_auth/acuant/request_spec.rb index 913cbfb635e..d0ee51c2b95 100644 --- a/spec/services/doc_auth/acuant/request_spec.rb +++ b/spec/services/doc_auth/acuant/request_spec.rb @@ -86,6 +86,32 @@ def handle_http_response(http_response) expect(response.errors).to eq(network: true) expect(response.exception.message).to include('Unexpected HTTP response 404') end + + it 'returns a response of status 440 with an exception' do + stub_request(:get, full_url). + with(headers: request_headers). + to_return(body: 'test response body', status: 440) + allow(NewRelic::Agent).to receive(:notice_error) + + response = subject.fetch + + expect(response.success?).to eq(false) + expect(response.errors).to have_key(:general) + expect(response.exception.message).to include('Unexpected HTTP response 440') + end + + it 'returns a response of status 500 with an exception' do + stub_request(:get, full_url). + with(headers: request_headers). + to_return(body: 'test response body', status: 500) + allow(NewRelic::Agent).to receive(:notice_error) + + response = subject.fetch + + expect(response.success?).to eq(false) + expect(response.errors).to have_key(:network) + expect(response.exception.message).to include('Unexpected HTTP response 500') + end end context 'when the request resolves with retriable error then succeeds it only retries once' do diff --git a/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb b/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb index 3c0182409f9..4dd103ff2a2 100644 --- a/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb +++ b/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb @@ -22,5 +22,19 @@ expect(response.pii_from_doc).to_not be_empty expect(request_stub).to have_been_requested end + + it 'get general error for 4xx' do + stub_request(:get, url).to_return(status: 440) + response = described_class.new(config: config, instance_id: instance_id).fetch + expect(response.errors).to have_key(:general) + expect(response.network_error?).to eq(false) + end + + it 'get network error for 500' do + stub_request(:get, url).to_return(status: 500) + response = described_class.new(config: config, instance_id: instance_id).fetch + expect(response.errors).to have_key(:network) + expect(response.network_error?).to eq(true) + end end end diff --git a/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb b/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb index 1e9f1424b53..7ad5597fe51 100644 --- a/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb @@ -53,6 +53,21 @@ it_behaves_like 'a successful request' end + + context 'with non 200 http status code' do + let(:workflow) { 'test_workflow' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + it 'is a network error with 5xx status' do + stub_request(:post, full_url).to_return(body: '{}', status: 500) + response = subject.fetch + expect(response.network_error?).to eq(true) + end + it 'is not a network error with 440, 438, 439' do + stub_request(:post, full_url).to_return(body: '{}', status: 443) + response = subject.fetch + expect(response.network_error?).to eq(true) + end + end end def response_body diff --git a/spec/services/document_capture_session_result_spec.rb b/spec/services/document_capture_session_result_spec.rb index 11f724a4206..308c729e019 100644 --- a/spec/services/document_capture_session_result_spec.rb +++ b/spec/services/document_capture_session_result_spec.rb @@ -21,5 +21,19 @@ expect(loaded_result.pii).to eq(pii.deep_symbolize_keys) expect(loaded_result.attention_with_barcode?).to eq(false) end + it 'add fingerprint with EncryptedRedisStructStorage' do + result = DocumentCaptureSessionResult.new( + id: id, + success: success, + pii: pii, + attention_with_barcode: false, + ) + result.add_failed_front_image!('abcdefg') + expect(result.failed_front_image_fingerprints.is_a?(Array)).to eq(true) + expect(result.failed_front_image_fingerprints.length).to eq(1) + expect(result.failed_front_image?('abcdefg')).to eq(true) + expect(result.failed_front_image?(nil)).to eq(false) + expect(result.failed_back_image?(nil)).to eq(false) + end end end diff --git a/spec/services/id_token_builder_spec.rb b/spec/services/id_token_builder_spec.rb index 0e487fa8d60..6abb1b0807e 100644 --- a/spec/services/id_token_builder_spec.rb +++ b/spec/services/id_token_builder_spec.rb @@ -105,7 +105,7 @@ before { OutOfBandSessionAccessor.new(identity.rails_session_id).put_pii(nil, expiration) } it 'sets the expiration to the ttl of the session key in redis' do - expect(decoded_payload[:exp]).to eq(now.to_i + expiration) + expect(decoded_payload[:exp]).to be_within(3.seconds).of(now.to_i + expiration) end end diff --git a/spec/services/out_of_band_session_accessor_spec.rb b/spec/services/out_of_band_session_accessor_spec.rb index 23e678885d1..b4a319450b4 100644 --- a/spec/services/out_of_band_session_accessor_spec.rb +++ b/spec/services/out_of_band_session_accessor_spec.rb @@ -15,13 +15,7 @@ it 'returns the remaining time-to-live of the session data in redis' do store.put_pii({ first_name: 'Fakey' }, 5.minutes.to_i) - expect(store.ttl).to eq(5.minutes.to_i) - end - - it 'returns the remaining time-to-live of the session data in redis' do - store.put_pii({ first_name: 'Fakey' }, 5.minutes.to_i) - - expect(store.ttl).to eq(5.minutes.to_i) + expect(store.ttl).to be_within(1).of(5.minutes.to_i) end end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index e7f77073226..b89572cf6c5 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -229,6 +229,36 @@ def mock_doc_auth_attention_with_barcode ) end + def mock_doc_auth_trueid_http_non2xx_status(status) + network_error_response = instance_double( + Faraday::Response, + status: status, + body: '{}', + ) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :get_results, + response: DocAuth::LexisNexis::Responses::TrueIdResponse.new( + network_error_response, + DocAuth::LexisNexis::Config.new, + ), + ) + end + + # @param [Object] status one of 440, 438, 439 + def mock_doc_auth_acuant_http_4xx_status(status, method = :post_front_image) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: method, + response: DocAuth::Mock::ResultResponse.create_image_error_response(status), + ) + end + + def mock_doc_auth_acuant_http_5xx_status(method = :post_front_image) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: method, + response: DocAuth::Mock::ResultResponse.create_network_error_response, + ) + end + def mock_doc_auth_acuant_error_unknown failed_http_response = instance_double( Faraday::Response, diff --git a/spec/views/devise/passwords/new.html.erb_spec.rb b/spec/views/devise/passwords/new.html.erb_spec.rb index 678d837c097..8a9241e971b 100644 --- a/spec/views/devise/passwords/new.html.erb_spec.rb +++ b/spec/views/devise/passwords/new.html.erb_spec.rb @@ -18,13 +18,13 @@ allow_any_instance_of(ActionController::TestRequest).to receive(:path). and_return('/users/password/new') - @decorated_session = DecoratedSession.new( + @decorated_sp_session = ServiceProviderSessionCreator.new( sp: sp, view_context: view_context, sp_session: {}, service_provider_request: ServiceProviderRequestProxy.new, - ).call - allow(view).to receive(:decorated_session).and_return(@decorated_session) + ).create_session + allow(view).to receive(:decorated_sp_session).and_return(@decorated_sp_session) end it 'has a localized title' do @@ -51,10 +51,10 @@ expect(rendered).to have_xpath("//input[@autocorrect='off']") end - it 'has a cancel link that points to the decorated_session cancel_link_url' do + it 'has a cancel link that points to the decorated_sp_session cancel_link_url' do render - expect(rendered).to have_link(t('links.cancel'), href: @decorated_session.cancel_link_url) + expect(rendered).to have_link(t('links.cancel'), href: @decorated_sp_session.cancel_link_url) end it 'has sp alert for certain service providers' do diff --git a/spec/views/devise/sessions/new.html.erb_spec.rb b/spec/views/devise/sessions/new.html.erb_spec.rb index db81e0972b5..b85fdc8ee54 100644 --- a/spec/views/devise/sessions/new.html.erb_spec.rb +++ b/spec/views/devise/sessions/new.html.erb_spec.rb @@ -8,7 +8,7 @@ allow(view).to receive(:resource_name).and_return(:user) allow(view).to receive(:devise_mapping).and_return(Devise.mappings[:user]) allow(view).to receive(:controller_name).and_return('sessions') - allow(view).to receive(:decorated_session).and_return(SessionDecorator.new) + allow(view).to receive(:decorated_sp_session).and_return(NullServiceProviderSession.new) allow_any_instance_of(ActionController::TestRequest).to receive(:path). and_return('/') assign(:ial, 1) @@ -85,13 +85,13 @@ end before do view_context = ActionController::Base.new.view_context - @decorated_session = DecoratedSession.new( + @decorated_sp_session = ServiceProviderSessionCreator.new( sp: sp, view_context: view_context, sp_session: {}, service_provider_request: ServiceProviderRequest.new, - ).call - allow(view).to receive(:decorated_session).and_return(@decorated_session) + ).create_session + allow(view).to receive(:decorated_sp_session).and_return(@decorated_sp_session) allow(view_context).to receive(:sign_up_email_path). and_return('/sign_up/enter_email') end diff --git a/spec/views/idv/by_mail/letter_enqueued/show.html.erb_spec.rb b/spec/views/idv/by_mail/letter_enqueued/show.html.erb_spec.rb index bdac0574397..183a0b5e71d 100644 --- a/spec/views/idv/by_mail/letter_enqueued/show.html.erb_spec.rb +++ b/spec/views/idv/by_mail/letter_enqueued/show.html.erb_spec.rb @@ -5,9 +5,9 @@ let(:step_indicator_steps) { Idv::StepIndicatorConcern::STEP_INDICATOR_STEPS_GPO } before do - @decorated_session = instance_double(ServiceProviderSessionDecorator) - allow(@decorated_session).to receive(:sp_name).and_return(sp_name) - allow(view).to receive(:decorated_session).and_return(@decorated_session) + @decorated_sp_session = instance_double(ServiceProviderSession) + allow(@decorated_sp_session).to receive(:sp_name).and_return(sp_name) + allow(view).to receive(:decorated_sp_session).and_return(@decorated_sp_session) allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) end @@ -26,7 +26,7 @@ strip_tags( t( 'idv.messages.come_back_later_sp_html', - sp: @decorated_session.sp_name, + sp: @decorated_sp_session.sp_name, ), ), ) diff --git a/spec/views/idv/getting_started/show.html.erb_spec.rb b/spec/views/idv/getting_started/show.html.erb_spec.rb index a93404df492..14b22b96648 100644 --- a/spec/views/idv/getting_started/show.html.erb_spec.rb +++ b/spec/views/idv/getting_started/show.html.erb_spec.rb @@ -6,11 +6,11 @@ let(:user) { create(:user) } before do - @decorated_session = instance_double(ServiceProviderSessionDecorator) + @decorated_sp_session = instance_double(ServiceProviderSession) @sp_name = 'Login.gov' @title = t('doc_auth.headings.getting_started', sp_name: @sp_name) - allow(@decorated_session).to receive(:sp_name).and_return(sp_name) - allow(view).to receive(:decorated_session).and_return(@decorated_session) + allow(@decorated_sp_session).to receive(:sp_name).and_return(sp_name) + allow(view).to receive(:decorated_sp_session).and_return(@decorated_sp_session) allow(view).to receive(:user_fully_authenticated?).and_return(user_fully_authenticated) allow(view).to receive(:user_signing_up?).and_return(false) allow(view).to receive(:url_for).and_wrap_original do |method, *args, &block| diff --git a/spec/views/idv/phone_errors/_warning.html.erb_spec.rb b/spec/views/idv/phone_errors/_warning.html.erb_spec.rb index bb5166330e0..70ff3c0b36e 100644 --- a/spec/views/idv/phone_errors/_warning.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/_warning.html.erb_spec.rb @@ -6,8 +6,8 @@ let(:assigns) { {} } before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) render('idv/phone_errors/warning', assigns) { text } end diff --git a/spec/views/idv/phone_errors/failure.html.erb_spec.rb b/spec/views/idv/phone_errors/failure.html.erb_spec.rb index c1beaa6a718..1e9820a57e0 100644 --- a/spec/views/idv/phone_errors/failure.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/failure.html.erb_spec.rb @@ -10,8 +10,8 @@ end before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) assign(:gpo_letter_available, gpo_letter_available) allow(IdentityConfig.store).to receive(:idv_attempt_window_in_hours).and_return(timeout_hours) diff --git a/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb b/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb index 4341e6c3102..4a042c382f7 100644 --- a/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb @@ -5,8 +5,8 @@ let(:gpo_letter_available) { false } before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) assign(:gpo_letter_available, gpo_letter_available) render diff --git a/spec/views/idv/phone_errors/timeout.html.erb_spec.rb b/spec/views/idv/phone_errors/timeout.html.erb_spec.rb index f34f5fb7668..c17eea3c4f3 100644 --- a/spec/views/idv/phone_errors/timeout.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/timeout.html.erb_spec.rb @@ -5,8 +5,8 @@ let(:gpo_letter_available) { false } before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) assign(:gpo_letter_available, gpo_letter_available) render diff --git a/spec/views/idv/phone_errors/warning.html.erb_spec.rb b/spec/views/idv/phone_errors/warning.html.erb_spec.rb index e8587aac8ba..2f16b78cdb6 100644 --- a/spec/views/idv/phone_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/warning.html.erb_spec.rb @@ -11,8 +11,8 @@ let(:formatted_phone) { '+1 360-234-5678' } before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) assign(:gpo_letter_available, gpo_letter_available) assign(:remaining_attempts, remaining_attempts) assign(:country_code, country_code) @@ -98,9 +98,8 @@ end it 'says how long gpo takes' do - expect(rendered).to have_css( - 'strong', - text: t('idv.failure.phone.warning.gpo.how_long_it_takes'), + expect(rendered).to have_text( + strip_tags(t('idv.failure.phone.warning.gpo.how_long_it_takes_html')), ) end diff --git a/spec/views/idv/review/new.html.erb_spec.rb b/spec/views/idv/review/new.html.erb_spec.rb index cffe3952ad0..a2ca937750b 100644 --- a/spec/views/idv/review/new.html.erb_spec.rb +++ b/spec/views/idv/review/new.html.erb_spec.rb @@ -12,19 +12,49 @@ allow(view).to receive(:step_indicator_steps). and_return(Idv::StepIndicatorConcern::STEP_INDICATOR_STEPS) allow(view).to receive(:step_indicator_step).and_return(:secure_account) - - render end - it 'renders the correct content heading' do - expect(rendered).to have_content t('idv.titles.session.review', app_name: APP_NAME) + context 'user goes through phone finder' do + before do + @title = t('titles.idv.review') + @heading = t('idv.titles.session.review', app_name: APP_NAME) + render + end + + it 'has a localized title' do + expect(view).to receive(:title).with(t('titles.idv.review')) + + render + end + + it 'renders the correct content heading' do + expect(rendered).to have_content t('idv.titles.session.review', app_name: APP_NAME) + end + + it 'shows the step indicator' do + expect(view.content_for(:pre_flash_content)).to have_css( + '.step-indicator__step--current', + text: t('step_indicator.flows.idv.secure_account'), + ) + end end - it 'shows the step indicator' do - expect(view.content_for(:pre_flash_content)).to have_css( - '.step-indicator__step--current', - text: t('step_indicator.flows.idv.secure_account'), - ) + context 'user goes through verify by mail flow' do + before do + @title = t('titles.idv.review_letter') + @heading = t('idv.titles.session.review_letter', app_name: APP_NAME) + render + end + + it 'has a localized title' do + expect(view).to receive(:title).with(t('titles.idv.review_letter')) + + render + end + + it 'renders the correct content heading' do + expect(rendered).to have_content t('idv.titles.session.review_letter', app_name: APP_NAME) + end end end end diff --git a/spec/views/idv/session_errors/exception.html.erb_spec.rb b/spec/views/idv/session_errors/exception.html.erb_spec.rb index 91f903a182c..a10bd49ed4c 100644 --- a/spec/views/idv/session_errors/exception.html.erb_spec.rb +++ b/spec/views/idv/session_errors/exception.html.erb_spec.rb @@ -6,12 +6,12 @@ let(:try_again_path) { '/example/path' } before do - decorated_session = instance_double( - ServiceProviderSessionDecorator, + decorated_sp_session = instance_double( + ServiceProviderSession, sp_name: sp_name, sp_issuer: sp_issuer, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) assign(:try_again_path, try_again_path) diff --git a/spec/views/idv/session_errors/rate_limited.html.erb_spec.rb b/spec/views/idv/session_errors/rate_limited.html.erb_spec.rb index 93505d6bde6..b589e11e2d3 100644 --- a/spec/views/idv/session_errors/rate_limited.html.erb_spec.rb +++ b/spec/views/idv/session_errors/rate_limited.html.erb_spec.rb @@ -5,12 +5,12 @@ let(:sp_issuer) { nil } before do - decorated_session = instance_double( - ServiceProviderSessionDecorator, + decorated_sp_session = instance_double( + ServiceProviderSession, sp_name: sp_name, sp_issuer: sp_issuer, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) render end diff --git a/spec/views/idv/session_errors/warning.html.erb_spec.rb b/spec/views/idv/session_errors/warning.html.erb_spec.rb index 9485637d139..4d71bea1317 100644 --- a/spec/views/idv/session_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/session_errors/warning.html.erb_spec.rb @@ -7,8 +7,8 @@ let(:user_session) { {} } before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) allow(view).to receive(:user_session).and_return(user_session) assign(:remaining_attempts, remaining_attempts) diff --git a/spec/views/idv/shared/_document_capture.html.erb_spec.rb b/spec/views/idv/shared/_document_capture.html.erb_spec.rb index b16c51a8f85..2927bccf663 100644 --- a/spec/views/idv/shared/_document_capture.html.erb_spec.rb +++ b/spec/views/idv/shared/_document_capture.html.erb_spec.rb @@ -15,12 +15,12 @@ let(:acuant_version) { '1.3.3.7' } before do - decorated_session = instance_double( - ServiceProviderSessionDecorator, + decorated_sp_session = instance_double( + ServiceProviderSession, sp_name: sp_name, sp_issuer: sp_issuer, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) allow(view).to receive(:url_for).and_return('https://example.com/') allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?) do |issuer| diff --git a/spec/views/idv/shared/_error.html.erb_spec.rb b/spec/views/idv/shared/_error.html.erb_spec.rb index 763ff89e24a..a79ef410da8 100644 --- a/spec/views/idv/shared/_error.html.erb_spec.rb +++ b/spec/views/idv/shared/_error.html.erb_spec.rb @@ -21,8 +21,8 @@ end before do - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) - allow(view).to receive(:decorated_session).and_return(decorated_session) + decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) if step_indicator_steps allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) diff --git a/spec/views/idv/unavailable/show.html.erb_spec.rb b/spec/views/idv/unavailable/show.html.erb_spec.rb index 182fdd1ab20..bcc19508635 100644 --- a/spec/views/idv/unavailable/show.html.erb_spec.rb +++ b/spec/views/idv/unavailable/show.html.erb_spec.rb @@ -5,8 +5,8 @@ subject(:rendered) { render } before do - allow(view).to receive(:decorated_session).and_return( - instance_double(ServiceProviderSessionDecorator, sp_name: sp_name), + allow(view).to receive(:decorated_sp_session).and_return( + instance_double(ServiceProviderSession, sp_name: sp_name), ) end diff --git a/spec/views/idv/welcome/show.html.erb_spec.rb b/spec/views/idv/welcome/show.html.erb_spec.rb index 550c43ffe75..b9ab6b0aab2 100644 --- a/spec/views/idv/welcome/show.html.erb_spec.rb +++ b/spec/views/idv/welcome/show.html.erb_spec.rb @@ -7,9 +7,9 @@ let(:user) { create(:user) } before do - @decorated_session = instance_double(ServiceProviderSessionDecorator) - allow(@decorated_session).to receive(:sp_name).and_return(sp_name) - allow(view).to receive(:decorated_session).and_return(@decorated_session) + @decorated_sp_session = instance_double(ServiceProviderSession) + allow(@decorated_sp_session).to receive(:sp_name).and_return(sp_name) + allow(view).to receive(:decorated_sp_session).and_return(@decorated_sp_session) allow(view).to receive(:flow_session).and_return(flow_session) allow(view).to receive(:user_fully_authenticated?).and_return(user_fully_authenticated) allow(view).to receive(:user_signing_up?).and_return(false) diff --git a/spec/views/layouts/application.html.erb_spec.rb b/spec/views/layouts/application.html.erb_spec.rb index cb25c4db0e5..f82c57656ff 100644 --- a/spec/views/layouts/application.html.erb_spec.rb +++ b/spec/views/layouts/application.html.erb_spec.rb @@ -5,13 +5,13 @@ before do allow(view).to receive(:user_fully_authenticated?).and_return(true) - allow(view).to receive(:decorated_session).and_return( - DecoratedSession.new( + allow(view).to receive(:decorated_sp_session).and_return( + ServiceProviderSessionCreator.new( sp: nil, view_context: nil, sp_session: {}, service_provider_request: ServiceProviderRequestProxy.new, - ).call, + ).create_session, ) allow(view.request).to receive(:original_fullpath).and_return('/foobar') allow(view).to receive(:current_user).and_return(User.new) @@ -101,7 +101,7 @@ it 'renders a javascript page refresh' do allow(view).to receive(:user_fully_authenticated?).and_return(false) allow(view).to receive(:current_user).and_return(false) - allow(view).to receive(:decorated_session).and_return(SessionDecorator.new) + allow(view).to receive(:decorated_sp_session).and_return(NullServiceProviderSession.new) render expect(view).to render_template(partial: 'session_timeout/_expire_session') @@ -123,13 +123,13 @@ allow(view).to receive(:current_user).and_return(nil) allow(view).to receive(:page_with_trust?).and_return(false) allow(view).to receive(:user_fully_authenticated?).and_return(false) - allow(view).to receive(:decorated_session).and_return( - DecoratedSession.new( + allow(view).to receive(:decorated_sp_session).and_return( + ServiceProviderSessionCreator.new( sp: nil, view_context: nil, sp_session: {}, service_provider_request: nil, - ).call, + ).create_session, ) allow(IdentityConfig.store).to receive(:participate_in_dap).and_return(true) @@ -152,7 +152,7 @@ context 'current_user is present but is not fully authenticated' do before do allow(view).to receive(:user_fully_authenticated?).and_return(false) - allow(view).to receive(:decorated_session).and_return(SessionDecorator.new) + allow(view).to receive(:decorated_sp_session).and_return(NullServiceProviderSession.new) end it 'does not render the DAP analytics' do diff --git a/spec/views/shared/_banner.html.erb_spec.rb b/spec/views/shared/_banner.html.erb_spec.rb index a78a7ddde8a..1c7d5cf819a 100644 --- a/spec/views/shared/_banner.html.erb_spec.rb +++ b/spec/views/shared/_banner.html.erb_spec.rb @@ -6,13 +6,13 @@ :service_provider, logo: 'generic.svg', friendly_name: 'Best SP ever' ) - decorated_session = ServiceProviderSessionDecorator.new( + decorated_sp_session = ServiceProviderSession.new( sp: sp_with_logo, view_context: '', sp_session: {}, service_provider_request: nil, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) end it 'properly HTML escapes the secure notification' do diff --git a/spec/views/shared/_nav_branded.html.erb_spec.rb b/spec/views/shared/_nav_branded.html.erb_spec.rb index 2b223ee0fd8..dca15917aba 100644 --- a/spec/views/shared/_nav_branded.html.erb_spec.rb +++ b/spec/views/shared/_nav_branded.html.erb_spec.rb @@ -8,13 +8,13 @@ sp_with_logo = build_stubbed( :service_provider, logo: 'generic.svg', friendly_name: 'Best SP ever' ) - decorated_session = ServiceProviderSessionDecorator.new( + decorated_sp_session = ServiceProviderSession.new( sp: sp_with_logo, view_context: view_context, sp_session: {}, service_provider_request: nil, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) render end @@ -39,13 +39,13 @@ before do allow(IdentityConfig.store).to receive(:aws_logo_bucket).and_return(bucket) allow(FeatureManagement).to receive(:logo_upload_enabled?).and_return(true) - decorated_session = ServiceProviderSessionDecorator.new( + decorated_sp_session = ServiceProviderSession.new( sp: sp_with_s3_logo, view_context: view_context, sp_session: {}, service_provider_request: nil, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) render end @@ -58,13 +58,13 @@ context 'without a SP-logo configured' do before do sp_without_logo = build_stubbed(:service_provider, friendly_name: 'No logo no problem') - decorated_session = ServiceProviderSessionDecorator.new( + decorated_sp_session = ServiceProviderSession.new( sp: sp_without_logo, view_context: view_context, sp_session: {}, service_provider_request: nil, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) render end @@ -76,13 +76,13 @@ context 'service provider has a poorly configured logo' do before do sp = build_stubbed(:service_provider, logo: 'abc') - decorated_session = ServiceProviderSessionDecorator.new( + decorated_sp_session = ServiceProviderSession.new( sp:, view_context:, sp_session: {}, service_provider_request: nil, ) - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) end it 'does not raise an exception' do diff --git a/spec/views/sign_up/completions/show.html.erb_spec.rb b/spec/views/sign_up/completions/show.html.erb_spec.rb index 66e4e30bd35..6e5d66d3fcb 100644 --- a/spec/views/sign_up/completions/show.html.erb_spec.rb +++ b/spec/views/sign_up/completions/show.html.erb_spec.rb @@ -9,8 +9,8 @@ let(:completion_context) { :new_sp } let(:view_context) { ActionController::Base.new.view_context } - let(:decorated_session) do - ServiceProviderSessionDecorator.new( + let(:decorated_sp_session) do + ServiceProviderSession.new( sp: service_provider, view_context: view_context, sp_session: {}, @@ -32,7 +32,7 @@ before do @user = user @presenter = presenter - allow(view).to receive(:decorated_session).and_return(decorated_session) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) end it 'shows the app name, not the agency name' do diff --git a/spec/views/sign_up/registrations/new.html.erb_spec.rb b/spec/views/sign_up/registrations/new.html.erb_spec.rb index 8927e106967..08e4dc37cc2 100644 --- a/spec/views/sign_up/registrations/new.html.erb_spec.rb +++ b/spec/views/sign_up/registrations/new.html.erb_spec.rb @@ -23,13 +23,13 @@ allow_any_instance_of(ActionView::Base).to receive(:request_id). and_return(nil) - @decorated_session = DecoratedSession.new( + @decorated_sp_session = ServiceProviderSessionCreator.new( sp: sp, view_context: view_context, sp_session: {}, service_provider_request: ServiceProviderRequestProxy.new, - ).call - allow(view).to receive(:decorated_session).and_return(@decorated_session) + ).create_session + allow(view).to receive(:decorated_sp_session).and_return(@decorated_sp_session) end it 'has a localized title' do @@ -65,10 +65,10 @@ expect(rendered).to have_xpath("//input[@autocorrect='off']") end - it 'has a cancel link that points to the decorated_session cancel_link_url' do + it 'has a cancel link that points to the decorated_sp_session cancel_link_url' do render - expect(rendered).to have_link(t('links.cancel'), href: @decorated_session.cancel_link_url) + expect(rendered).to have_link(t('links.cancel'), href: @decorated_sp_session.cancel_link_url) end it 'includes a link to security / privacy page and privacy statement act' do diff --git a/spec/views/users/second_mfa_reminder/new.html.erb_spec.rb b/spec/views/users/second_mfa_reminder/new.html.erb_spec.rb new file mode 100644 index 00000000000..1fd096d59f8 --- /dev/null +++ b/spec/views/users/second_mfa_reminder/new.html.erb_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe 'users/second_mfa_reminder/new.html.erb' do + subject(:rendered) { render } + + let(:sp_name) {} + + before do + decorated_sp_session = double + allow(decorated_sp_session).to receive(:sp_name).and_return(sp_name) + allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) + end + + it 'renders with fallback app name for continue button' do + expect(rendered).to have_button(t('users.second_mfa_reminder.continue', sp_name: APP_NAME)) + end + + context 'with sp name' do + let(:sp_name) { 'Example SP' } + + it 'renders with sp name for continue button' do + expect(rendered).to have_button(t('users.second_mfa_reminder.continue', sp_name:)) + end + end +end