diff --git a/Gemfile.lock b/Gemfile.lock index 76879cacf2c..dbc6062bd78 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -462,7 +462,7 @@ GEM pg (1.5.4) pg_query (4.2.3) google-protobuf (>= 3.22.3) - phonelib (0.8.7) + phonelib (0.8.8) pkcs11 (0.3.4) premailer (1.21.0) addressable diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index 04c903ff7c6..37ee24f6f98 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -33,6 +33,7 @@ class FrontendLogController < ApplicationController 'IdV: personal key acknowledgment toggled' => :idv_personal_key_acknowledgment_toggled, 'IdV: prepare submitted' => :idv_in_person_prepare_submitted, 'IdV: prepare visited' => :idv_in_person_prepare_visited, + 'IdV: selfie image clicked' => :idv_selfie_image_clicked, 'IdV: switch_back submitted' => :idv_in_person_switch_back_submitted, 'IdV: switch_back visited' => :idv_in_person_switch_back_visited, 'IdV: user clicked sp link on ready to verify page' => :idv_in_person_ready_to_verify_sp_link_clicked, diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index 207630bda73..99ff49e8da4 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -63,6 +63,7 @@ def self.step_info idv_session.flow_path == 'standard' && ( # mobile idv_session.skip_hybrid_handoff || + idv_session.skip_doc_auth || !idv_session.selfie_check_required || # desktop but selfie not required idv_session.desktop_selfie_test_mode_enabled? ) diff --git a/app/controllers/idv/hybrid_handoff_controller.rb b/app/controllers/idv/hybrid_handoff_controller.rb index c50eae4f74d..829ff2abe7c 100644 --- a/app/controllers/idv/hybrid_handoff_controller.rb +++ b/app/controllers/idv/hybrid_handoff_controller.rb @@ -13,6 +13,8 @@ def show @upload_disabled = idv_session.selfie_check_required && !idv_session.desktop_selfie_test_mode_enabled? + @selfie_required = idv_session.selfie_check_required + analytics.idv_doc_auth_hybrid_handoff_visited(**analytics_arguments) Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]).call( diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index 4b899f33bc9..8004002d134 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -28,7 +28,7 @@ class AuthorizationController < ApplicationController def index if @authorize_form.ial2_or_greater? return redirect_to reactivate_account_url if user_needs_to_reactivate_account? - return redirect_to url_for_pending_profile_reason if user_has_pending_profile? + return redirect_to url_for_pending_profile_reason if user_has_usable_pending_profile? return redirect_to idv_url if identity_needs_verification? return redirect_to idv_url if selfie_needed? end @@ -47,6 +47,18 @@ def index private + def pending_profile_policy + @pending_profile_policy ||= PendingProfilePolicy.new( + user: current_user, + resolved_authn_context_result: resolved_authn_context_result, + biometric_comparison_requested: biometric_comparison_requested?, + ) + end + + def user_has_usable_pending_profile? + pending_profile_policy.user_has_usable_pending_profile? + end + def block_biometric_requests_in_production if biometric_comparison_requested? && !FeatureManagement.idv_allow_selfie_check? diff --git a/app/controllers/users/piv_cac_controller.rb b/app/controllers/users/piv_cac_controller.rb index a675022ee94..e5905967232 100644 --- a/app/controllers/users/piv_cac_controller.rb +++ b/app/controllers/users/piv_cac_controller.rb @@ -1,6 +1,7 @@ module Users class PivCacController < ApplicationController include ReauthenticationRequiredConcern + include PivCacConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -33,6 +34,7 @@ def destroy create_user_event(:piv_cac_disabled) revoke_remember_device(current_user) deliver_push_notification + clear_piv_cac_information flash[:success] = presenter.delete_success_alert_text redirect_to account_path diff --git a/app/javascript/packages/document-capture/components/acuant-capture.scss b/app/javascript/packages/document-capture/components/acuant-capture.scss index 451142b77d3..2ed7922a445 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.scss +++ b/app/javascript/packages/document-capture/components/acuant-capture.scss @@ -37,12 +37,4 @@ .document-capture-file-image--loading { @extend %pad-common-id-card; } - // Styles for the text that appears over the selfie capture screen to help users position their face for a good photo - .document-capture-selfie-feedback { - left: 50%; - top: 10%; - position: fixed; - transform: translateX(-50%); - z-index: 11; - } } diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index b273f2e4e0f..cc1e019a8fb 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -698,12 +698,16 @@ function AcuantCapture( onImageCaptureClose={onSelfieCaptureClosed} onImageCaptureFeedback={onImageCaptureFeedback} > - setIsCapturingEnvironment(false)} - imageCaptureText={imageCaptureText} - /> + + + )} - - + ); } -function AcuantSelfieCaptureCanvas({ - fullScreenRef, - onRequestClose, - fullScreenLabel, - imageCaptureText, -}) { +function AcuantSelfieCaptureCanvas({ imageCaptureText, onSelfieCaptureClosed }) { const { isReady } = useContext(AcuantContext); + const { t } = useI18n(); // The Acuant SDK script AcuantPassiveLiveness attaches to whatever element has // this id. It then uses that element as the root for the full screen selfie capture const acuantCaptureContainerId = 'acuant-face-capture-container'; - return isReady ? ( -
+ return ( + <> + {!isReady && } +

{imageCaptureText}

-
- ) : ( - + + ); } diff --git a/app/javascript/packages/document-capture/components/acuant-selfie-capture-canvas.scss b/app/javascript/packages/document-capture/components/acuant-selfie-capture-canvas.scss new file mode 100644 index 00000000000..2991c7ecc82 --- /dev/null +++ b/app/javascript/packages/document-capture/components/acuant-selfie-capture-canvas.scss @@ -0,0 +1,9 @@ +.document-capture-selfie-feedback { + color: color('white'); + background: color('black'); + left: 50%; + top: 10%; + position: fixed; + transform: translateX(-50%); + z-index: 11; +} diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx index c94c1504ea9..80efa33869b 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx @@ -7,6 +7,7 @@ import type { RegisterFieldCallback, } from '@18f/identity-form-steps'; import AcuantCapture from './acuant-capture'; +import SelfieCaptureContext from '../context/selfie-capture'; interface DocumentSideAcuantCaptureProps { side: 'front' | 'back' | 'selfie'; @@ -43,6 +44,9 @@ function DocumentSideAcuantCapture({ }: DocumentSideAcuantCaptureProps) { const error = errors.find(({ field }) => field === side)?.error; const { changeStepCanComplete } = useContext(FormStepsContext); + const { isSelfieCaptureEnabled, isSelfieDesktopTestMode } = useContext(SelfieCaptureContext); + const isUploadAllowed = isSelfieDesktopTestMode || !isSelfieCaptureEnabled; + return ( ); } diff --git a/app/javascript/packages/document-capture/context/acuant.tsx b/app/javascript/packages/document-capture/context/acuant.tsx index b3781ab67ba..ababf482dee 100644 --- a/app/javascript/packages/document-capture/context/acuant.tsx +++ b/app/javascript/packages/document-capture/context/acuant.tsx @@ -8,7 +8,6 @@ import SelfieCaptureContext from './selfie-capture'; /** * Global declarations */ -declare let AcuantJavascriptWebSdk: AcuantJavascriptWebSdkInterface; // As of 11.7.0, this is now a global object that is not on the window object. declare let AcuantCamera: AcuantCameraInterface; declare global { @@ -182,11 +181,11 @@ const getActualAcuantJavascriptWebSdk = (): AcuantJavascriptWebSdkInterface => { if (window.AcuantJavascriptWebSdk && typeof window.AcuantJavascriptWebSdk.start === 'function') { return window.AcuantJavascriptWebSdk; } - if (typeof AcuantJavascriptWebSdk === 'undefined') { + if (!window.AcuantJavascriptWebSdk) { // eslint-disable-next-line no-console console.error('AcuantJavascriptWebSdk is not defined in the global scope'); } - return AcuantJavascriptWebSdk; + return window.AcuantJavascriptWebSdk; }; /** diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx index 030f479b8b2..0411ea159e8 100644 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx @@ -1,6 +1,7 @@ -import { createContext, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; import type { ReactNode } from 'react'; import useCounter from '../hooks/use-counter'; +import SelfieCaptureContext from './selfie-capture'; interface CaptureAttemptMetadata { isAssessedAsGlare: boolean; @@ -125,6 +126,7 @@ function FailedCaptureAttemptsContextProvider({ useCounter(); const [failedSubmissionAttempts, incrementFailedSubmissionAttempts] = useCounter(); const [failedCameraPermissionAttempts, incrementFailedCameraPermissionAttempts] = useCounter(); + const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext); const [failedSubmissionImageFingerprints, setFailedSubmissionImageFingerprints] = useState(failedFingerprints); @@ -143,10 +145,12 @@ function FailedCaptureAttemptsContextProvider({ incrementFailedCameraPermissionAttempts(); } - const forceNativeCamera = + const hasExhaustedAttempts = failedCaptureAttempts >= maxCaptureAttemptsBeforeNativeCamera || failedSubmissionAttempts >= maxSubmissionAttemptsBeforeNativeCamera; + const forceNativeCamera = isSelfieCaptureEnabled ? false : hasExhaustedAttempts; + return ( ({ isSelfieCaptureEnabled: false, + isSelfieDesktopTestMode: false, }); SelfieCaptureContext.displayName = 'SelfieCaptureContext'; diff --git a/app/javascript/packages/document-capture/styles.scss b/app/javascript/packages/document-capture/styles.scss index 042b07e5189..b2a4c681187 100644 --- a/app/javascript/packages/document-capture/styles.scss +++ b/app/javascript/packages/document-capture/styles.scss @@ -1,5 +1,6 @@ @forward './components/acuant-capture'; @forward './components/acuant-capture-canvas'; +@forward './components/acuant-selfie-capture-canvas'; @forward './components/file-input'; @forward './components/location-collection-item'; @forward './components/optional-questions'; diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 3f752d0735c..68e690dd167 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -39,6 +39,7 @@ interface AppRootData { skipDocAuth: string; howToVerifyURL: string; uiExitQuestionSectionEnabled: string; + docAuthSelfieDesktopTestMode: string; } const appRoot = document.getElementById('document-capture-form')!; @@ -106,6 +107,7 @@ const { skipDocAuth, howToVerifyUrl, uiExitQuestionSectionEnabled = '', + docAuthSelfieDesktopTestMode, } = appRoot.dataset as DOMStringMap & AppRootData; let parsedUsStatesTerritories = []; @@ -177,6 +179,15 @@ const App = composeComponents( value: getServiceProvider(), }, ], + [ + SelfieCaptureContext.Provider, + { + value: { + isSelfieCaptureEnabled: getSelfieCaptureEnabled(), + isSelfieDesktopTestMode: String(docAuthSelfieDesktopTestMode) === 'true', + }, + }, + ], [ FailedCaptureAttemptsContextProvider, { @@ -192,14 +203,6 @@ const App = composeComponents( }, }, ], - [ - SelfieCaptureContext.Provider, - { - value: { - isSelfieCaptureEnabled: getSelfieCaptureEnabled(), - }, - }, - ], [ DocumentCapture, { diff --git a/app/models/profile.rb b/app/models/profile.rb index d9a6ab877fd..843c68f6460 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -207,6 +207,7 @@ def deactivate_for_fraud_review active: false, fraud_review_pending_at: Time.zone.now, fraud_rejection_at: nil, + in_person_verification_pending_at: nil, ) end diff --git a/app/policies/pending_profile_policy.rb b/app/policies/pending_profile_policy.rb new file mode 100644 index 00000000000..fc9d3a0c858 --- /dev/null +++ b/app/policies/pending_profile_policy.rb @@ -0,0 +1,40 @@ +class PendingProfilePolicy + def initialize(user:, resolved_authn_context_result:, biometric_comparison_requested:) + @user = user + @resolved_authn_context_result = resolved_authn_context_result + @biometric_comparison_requested = biometric_comparison_requested + end + + def user_has_usable_pending_profile? + if biometric_comparison_requested? + pending_biometric_profile? + else + pending_legacy_profile? || fraud_review_pending? + end + end + + private + + attr_reader :user, :resolved_authn_context_result, :biometric_comparison_requested + + def active_biometric_profile? + user.active_profile&.idv_level == 'unsupervised_with_selfie' + end + + def pending_biometric_profile? + user.pending_profile&.idv_level == 'unsupervised_with_selfie' + end + + def biometric_comparison_requested? + return false if !FeatureManagement.idv_allow_selfie_check? + resolved_authn_context_result.biometric_comparison? || biometric_comparison_requested + end + + def pending_legacy_profile? + user.pending_profile.present? && user.pending_profile&.idv_level != 'unsupervised_with_selfie' + end + + def fraud_review_pending? + user.fraud_review_pending? + end +end diff --git a/app/presenters/openid_connect_user_info_presenter.rb b/app/presenters/openid_connect_user_info_presenter.rb index f29698b24f8..83497940179 100644 --- a/app/presenters/openid_connect_user_info_presenter.rb +++ b/app/presenters/openid_connect_user_info_presenter.rb @@ -76,7 +76,7 @@ def x509_attributes { x509_subject: stringify_attr(x509_data.subject), x509_issuer: stringify_attr(x509_data.issuer), - x509_presented: x509_data.presented, + x509_presented:, } end @@ -154,6 +154,16 @@ def x509_session? identity.piv_cac_enabled? end + def x509_presented + if IdentityConfig.store.x509_presented_hash_attribute_requested_issuers.include?( + identity&.service_provider, + ) + x509_data.presented + else + !!x509_data.presented.raw + end + end + def active_profile identity.user&.active_profile end diff --git a/app/presenters/two_factor_options_presenter.rb b/app/presenters/two_factor_options_presenter.rb index 56a4567a328..b7e5ae9748d 100644 --- a/app/presenters/two_factor_options_presenter.rb +++ b/app/presenters/two_factor_options_presenter.rb @@ -67,7 +67,7 @@ def intro elsif phishing_resistant_only? t('two_factor_authentication.two_factor_aal3_choice_intro') else - t('mfa.info') + t('mfa.info', app_name: APP_NAME) end end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index df5c5d39819..2d734c7c2ae 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -624,7 +624,7 @@ def idv_acuant_sdk_loaded( success:, use_alternate_sdk:, liveness_checking_required:, - **_extra + **extra ) track_event( 'Frontend: IdV: Acuant SDK loaded', @@ -635,6 +635,7 @@ def idv_acuant_sdk_loaded( success: success, use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, + **extra, ) end # rubocop:enable Naming/VariableName,Naming/MethodParameterName @@ -665,8 +666,8 @@ def idv_address_submitted( end # User visited idv address page - def idv_address_visit - track_event('IdV: address visited') + def idv_address_visit(**extra) + track_event('IdV: address visited', **extra) end # @param [String] acuantCaptureMode @@ -723,7 +724,7 @@ def idv_back_image_added( use_alternate_sdk:, liveness_checking_required:, width:, - **_extra + **extra ) track_event( 'Frontend: IdV: back image added', @@ -752,6 +753,7 @@ def idv_back_image_added( use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, width: width, + **extra, ) end @@ -770,7 +772,7 @@ def idv_back_image_clicked( source:, use_alternate_sdk:, liveness_checking_required:, - **_extra + **extra ) track_event( 'Frontend: IdV: back image clicked', @@ -781,23 +783,26 @@ def idv_back_image_clicked( source: source, use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, + **extra, ) end # rubocop:enable Naming/VariableName,Naming/MethodParameterName # @param [String] liveness_checking_required Whether or not the selfie is required - def idv_barcode_warning_continue_clicked(liveness_checking_required:, **_extra) + def idv_barcode_warning_continue_clicked(liveness_checking_required:, **extra) track_event( 'Frontend: IdV: barcode warning continue clicked', liveness_checking_required: liveness_checking_required, + **extra, ) end # @param [String] liveness_checking_required Whether or not the selfie is required - def idv_barcode_warning_retake_photos_clicked(liveness_checking_required:, **_extra) + def idv_barcode_warning_retake_photos_clicked(liveness_checking_required:, **extra) track_event( 'Frontend: IdV: barcode warning retake photos clicked', liveness_checking_required: liveness_checking_required, + **extra, ) end @@ -856,7 +861,7 @@ def idv_capture_troubleshooting_dismissed( flow_path:, use_alternate_sdk:, liveness_checking_required:, - **_extra + **extra ) track_event( 'Frontend: IdV: Capture troubleshooting dismissed', @@ -865,6 +870,7 @@ def idv_capture_troubleshooting_dismissed( flow_path: flow_path, use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, + **extra, ) end @@ -954,10 +960,11 @@ def idv_doc_auth_link_sent_visited(**extra) track_event('IdV: doc auth link_sent visited', **extra) end - def idv_doc_auth_randomizer_defaulted + def idv_doc_auth_randomizer_defaulted(**extra) track_event( 'IdV: doc_auth random vendor error', error: 'document_capture_session_uuid_key missing', + **extra, ) end @@ -1275,7 +1282,7 @@ def idv_exit_optional_questions( flow_path:, ids:, use_alternate_sdk:, - **_extra + **extra ) track_event( 'Frontend: IdV: exit optional questions', @@ -1284,6 +1291,7 @@ def idv_exit_optional_questions( flow_path: flow_path, ids: ids, use_alternate_sdk: use_alternate_sdk, + **extra, ) end @@ -1394,7 +1402,7 @@ def idv_front_image_added( use_alternate_sdk:, liveness_checking_required:, width:, - **_extra + **extra ) track_event( 'Frontend: IdV: front image added', @@ -1423,6 +1431,7 @@ def idv_front_image_added( use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, width: width, + **extra, ) end @@ -1441,7 +1450,7 @@ def idv_front_image_clicked( source:, use_alternate_sdk:, liveness_checking_required: nil, - **_extra + **extra ) track_event( 'Frontend: IdV: front image clicked', @@ -1452,6 +1461,7 @@ def idv_front_image_clicked( source: source, use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, + **extra, ) end # rubocop:enable Naming/VariableName,Naming/MethodParameterName @@ -1565,7 +1575,7 @@ def idv_image_capture_failed( acuant_version:, flow_path:, use_alternate_sdk:, - **_extra + **extra ) track_event( 'Frontend: IdV: Image capture failed', @@ -1576,6 +1586,7 @@ def idv_image_capture_failed( acuant_version: acuant_version, flow_path: flow_path, use_alternate_sdk: use_alternate_sdk, + **extra, ) end # rubocop:enable Naming/VariableName,Naming/MethodParameterName @@ -2391,8 +2402,8 @@ def idv_in_person_usps_request_enroll_exception( end # User visits IdV - def idv_intro_visit - track_event('IdV: intro visited') + def idv_intro_visit(**extra) + track_event('IdV: intro visited', **extra) end # @param [String] enrollment_id @@ -2425,20 +2436,22 @@ def idv_letter_enqueued_visit(proofing_components: nil, **extra) def idv_link_sent_capture_doc_polling_complete( isCancelled:, isRateLimited:, - **_extra + **extra ) track_event( 'Frontend: IdV: Link sent capture doc polling complete', isCancelled: isCancelled, isRateLimited: isRateLimited, + **extra, ) end # rubocop:enable Naming/VariableName,Naming/MethodParameterName - def idv_link_sent_capture_doc_polling_started(**_extra) + def idv_link_sent_capture_doc_polling_started(**extra) track_event( 'Frontend: IdV: Link sent capture doc polling started', + **extra, ) end @@ -2497,8 +2510,8 @@ def idv_native_camera_forced( end # Tracks when user reaches verify errors due to being rejected due to fraud - def idv_not_verified_visited - track_event('IdV: Not verified visited') + def idv_not_verified_visited(**extra) + track_event('IdV: Not verified visited', **extra) end # Tracks if a user clicks the 'acknowledge' checkbox during personal @@ -2934,7 +2947,7 @@ def idv_selfie_image_added( source:, liveness_checking_required:, width:, - **_extra + **extra ) track_event( :idv_selfie_image_added, @@ -2948,6 +2961,38 @@ def idv_selfie_image_added( source: source, liveness_checking_required: liveness_checking_required, width: width, + **extra, + ) + end + # rubocop:enable Naming/VariableName,Naming/MethodParameterName + + # rubocop:disable Naming/VariableName,Naming/MethodParameterName, + # @param [Boolean] acuant_sdk_upgrade_a_b_testing_enabled + # @param [String] acuant_version + # @param [String] flow_path whether the user is in the hybrid or standard flow + # @param [Boolean] isDrop + # @param [String] source + # @param [String] use_alternate_sdk + # @param [Boolean] liveness_checking_required + def idv_selfie_image_clicked( + acuant_sdk_upgrade_a_b_testing_enabled:, + acuant_version:, + flow_path:, + isDrop:, + source:, + use_alternate_sdk:, + liveness_checking_required: nil, + **_extra + ) + track_event( + :idv_selfie_image_clicked, + acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, + acuant_version: acuant_version, + flow_path: flow_path, + isDrop: isDrop, + source: source, + use_alternate_sdk: use_alternate_sdk, + liveness_checking_required: liveness_checking_required, ) end # rubocop:enable Naming/VariableName,Naming/MethodParameterName @@ -3099,7 +3144,7 @@ def idv_warning_action_triggered( flow_path:, location:, use_alternate_sdk:, - **_extra + **extra ) track_event( 'Frontend: IdV: warning action triggered', @@ -3108,6 +3153,7 @@ def idv_warning_action_triggered( flow_path: flow_path, location: location, use_alternate_sdk: use_alternate_sdk, + **extra, ) end @@ -3132,7 +3178,7 @@ def idv_warning_shown( subheading:, use_alternate_sdk:, liveness_checking_required:, - **_extra + **extra ) track_event( 'Frontend: IdV: warning shown', @@ -3146,6 +3192,7 @@ def idv_warning_shown( subheading: subheading, use_alternate_sdk: use_alternate_sdk, liveness_checking_required: liveness_checking_required, + **extra, ) end @@ -4003,12 +4050,14 @@ def piv_cac_delete_submitted( # @identity.idp.previous_event_name PIV/CAC login # @param [Boolean] success # @param [Hash] errors + # @param [String,nil] key_id # tracks piv cac login event - def piv_cac_login(success:, errors:, **extra) + def piv_cac_login(success:, errors:, key_id:, **extra) track_event( :piv_cac_login, - success: success, - errors: errors, + success:, + errors:, + key_id:, **extra, ) end diff --git a/app/views/accounts/_webauthn_roaming.html.erb b/app/views/accounts/_webauthn_roaming.html.erb index 8672240e73a..7f098d10a04 100644 --- a/app/views/accounts/_webauthn_roaming.html.erb +++ b/app/views/accounts/_webauthn_roaming.html.erb @@ -1,7 +1,12 @@ -

+

<%= t('account.index.webauthn') %>

+ +

+ <%= t('two_factor_authentication.two_factor_choice_options.webauthn_info') %> +

+
<% MfaContext.new(current_user).webauthn_roaming_configurations.each do |configuration| %> <%= render ManageableAuthenticatorComponent.new( diff --git a/app/views/idv/hybrid_handoff/show.html.erb b/app/views/idv/hybrid_handoff/show.html.erb index fe09cd82f04..a7164548357 100644 --- a/app/views/idv/hybrid_handoff/show.html.erb +++ b/app/views/idv/hybrid_handoff/show.html.erb @@ -8,16 +8,56 @@ class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', ) %> <% end %> +<%# ============== Non Selfie Section ========== %> +<% if !@selfie_required %> -<%= render PageHeadingComponent.new do %> - <%= t('doc_auth.headings.hybrid_handoff') %> -<% end %> + <%= render PageHeadingComponent.new do %> + <%= t('doc_auth.headings.hybrid_handoff') %> + <% end %> -

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

+

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

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

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

+ <%= t('doc_auth.info.upload_from_phone') %> + <%= simple_form_for( + idv_phone_form, + as: :doc_auth, + url: url_for(type: :mobile, combined: true), + method: 'PUT', + html: { autocomplete: 'off', + id: 'form-to-submit-photos-through-mobile', + 'aria-label': t('forms.buttons.send_link') }, + ) do |f| %> + <%= render PhoneInputComponent.new( + form: f, + required: true, + delivery_methods: [:sms], + class: 'margin-bottom-4', + ) %> + <%= f.submit t('forms.buttons.send_link') %> + <% end %> +
+
+<%# ============== Selfie Section ========== %> +<% else %> +
<%= image_tag( asset_url('idv/phone-icon.svg'), @@ -27,12 +67,9 @@ ) %>
-
- <%= t('doc_auth.info.tag') %> -
-

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

+ <%= render PageHeadingComponent.new do %> + <%= t('doc_auth.headings.hybrid_handoff_selfie') %> + <% end %> <%= t('doc_auth.info.upload_from_phone') %> <%= simple_form_for( idv_phone_form, @@ -52,7 +89,8 @@ <%= f.submit t('forms.buttons.send_link') %> <% end %>
-
+
+<% end %> <% unless @upload_disabled %>
diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 07f8cd31453..02e22f02c8e 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -37,6 +37,7 @@ in_person_outage_expected_update_date: IdentityConfig.store.in_person_outage_expected_update_date, us_states_territories: us_states_territories, doc_auth_selfie_capture: FeatureManagement.idv_allow_selfie_check? && doc_auth_selfie_capture, + doc_auth_selfie_desktop_test_mode: IdentityConfig.store.doc_auth_selfie_desktop_test_mode, skip_doc_auth: skip_doc_auth, how_to_verify_url: idv_how_to_verify_url, ui_exit_question_section_enabled: IdentityConfig.store.doc_auth_exit_question_section_enabled, 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 eb9cdb8bdc7..da68a012db2 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.erb +++ b/app/views/users/two_factor_authentication_setup/index.html.erb @@ -12,7 +12,9 @@ <%= render PageHeadingComponent.new.with_content(@presenter.heading) %> -

<%= @presenter.intro %>

+

<%= @presenter.intro %>

+ +

<%= t('mfa.recommendation') %>

<% if @presenter.two_factor_enabled? %>

diff --git a/config/application.yml.default b/config/application.yml.default index ff46fd984aa..c681d2038ee 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -364,6 +364,7 @@ get_usps_proofing_results_job_request_delay_milliseconds: 1000 voice_otp_pause_time: '0.5s' voice_otp_speech_rate: 'slow' weekly_auth_funnel_report_config: '[]' +x509_presented_hash_attribute_requested_issuers: '[]' development: aamva_private_key: 123abc diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index a5d5dd2d1f0..119cb19b2da 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -7,6 +7,7 @@ en: document_capture_dialog: Document capture buttons: add_new_photos: Add new photos + close: Close continue: Continue take_or_upload_picture_html: 'Take photo or Upload photo' @@ -163,6 +164,7 @@ en: front: Front of your driver’s license or state ID how_to_verify: Choose how you want to verify your identity hybrid_handoff: How would you like to add your ID? + hybrid_handoff_selfie: Enter your phone number to switch devices interstitial: We are processing your images lets_go: How verifying your identity works no_ssn: Don’t have a Social Security number? @@ -225,10 +227,10 @@ en: your data is protected and only you will be able to access or change your information. selfie_capture_status: - face_close_to_border: TOO CLOSE TO THE FRAME - face_not_found: FACE NOT FOUND - face_too_small: FACE TOO SMALL - too_many_faces: TOO MANY FACES + face_close_to_border: Too close to the frame + face_not_found: Face not found + face_too_small: Face too small + too_many_faces: Too many faces ssn: We need your Social Security number to verify your name, date of birth and address. stepping_up_html: 'Verify your identity again and take a photo of yourself to diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index b6bd473dae3..80dbecd6dd7 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -7,6 +7,7 @@ es: document_capture_dialog: Captura del documento buttons: add_new_photos: Añadir nuevas fotos + close: Cerrar continue: Continuar take_or_upload_picture_html: 'Toma una foto o Sube una @@ -194,6 +195,7 @@ es: front: Anverso de su licencia de conducir o identificación estatal how_to_verify: Elija cómo quiere verificar su identidad hybrid_handoff: '¿Cómo desea añadir su documento de identidad?' + hybrid_handoff_selfie: Ingrese su número de celular para cambiar de dispositivo interstitial: Estamos procesando sus imágenes lets_go: Cómo funciona la verificación de su identidad no_ssn: ¿No tiene un número de Seguro Social? @@ -263,10 +265,10 @@ es: significa que sus datos están protegidos y solo usted podrá acceder o modificar su información. selfie_capture_status: - face_close_to_border: DEMASIADO CERCA DEL MARCO - face_not_found: NO SE DETECTÓ EL ROSTRO - face_too_small: ROSTRO DEMASIADO PEQUEÑO - too_many_faces: DEMASIADOS ROSTROS + face_close_to_border: Demasiado cerca del marco + face_not_found: No se detectó el rostro + face_too_small: Rostro demasiado pequeño + too_many_faces: Demasiados rostros ssn: Necesitamos su número de Seguro Social para validar su nombre, fecha de nacimiento y dirección. stepping_up_html: 'Verifica tu identidad de nuevo y tómate una foto para acceder diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 68ac732127a..dd58bc92ec7 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -7,6 +7,7 @@ fr: document_capture_dialog: Capture du document buttons: add_new_photos: Ajoutez de nouvelles photos + close: Fermer continue: Continuer take_or_upload_picture_html: 'Prendre une photo ou Télécharger une @@ -203,6 +204,7 @@ fr: front: Recto de votre permis de conduire ou de votre carte d’identité de l’État how_to_verify: Choisissez la manière dont vous souhaitez confirmer votre identité hybrid_handoff: Comment voulez-vous ajouter votre identifiant ? + hybrid_handoff_selfie: Saisissez votre numéro de téléphone pour changer d’appareil interstitial: Nous traitons vos images lets_go: Comment fonctionne la vérification de votre identité no_ssn: Vous n’avez pas de numéro de sécurité sociale? @@ -273,10 +275,10 @@ fr: chiffrage signifie que vos données sont protégées et que vous êtes le/la seul(e) à pouvoir accéder à vos informations ou les modifier. selfie_capture_status: - face_close_to_border: TROP PRÈS DU CADRE - face_not_found: VISAGE NON TROUVÉ - face_too_small: VISAGE TROP PETIT - too_many_faces: TROP DE VISAGES + face_close_to_border: Trop près du cadre + face_not_found: Visage non trouvé + face_too_small: Visage trop petit + too_many_faces: Trop de visages ssn: Nous avons besoin de votre numéro de sécurité sociale pour vérifier votre nom, date de naissance et adresse. stepping_up_html: 'Vérifiez à nouveau votre identité et prenez une photo de vous diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 7744e90b35d..7fae8fb0483 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -136,9 +136,12 @@ en: unique_name: That name is already taken. Please choose a different name. webauthn_setup: additional_methods_link: choose another authentication method - already_registered: Security key already registered. Please try a different security key. - general_error_html: We were unable to add the security key. Please try again or %{link_html}. - not_supported: Your browser doesn’t support security keys. Use the latest - version of Google Chrome, Microsoft Edge, Mozilla Firefox or Safari to - use security keys. - unique_name: That name is already taken. Please choose a different name. + already_registered: You have already linked this security key to your account. + Please try a different security key. + general_error_html: We were unable to add the security key. Check that your + security key is properly inserted and try again or %{link_html}. + not_supported: Your browser doesn’t support security keys. Update to the latest + version of Google Chrome, Microsoft Edge, Mozilla Firefox or Safari and + try again. + unique_name: That device nickname is already being used. Choose a different + device nickname. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 828cb9b30d3..48690f132b3 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -147,11 +147,13 @@ es: un nombre de dispositivo diferente. webauthn_setup: additional_methods_link: elija otro método de autenticación - already_registered: Clave de seguridad ya registrada. Por favor, intente una - clave de seguridad diferente. - general_error_html: No hemos podido añadir la clave de seguridad. Inténtelo de - nuevo o %{link_html}. - not_supported: Tu navegador no es compatible con llaves de seguridad. Utiliza la - última versión de Google Chrome, Microsoft Edge, Mozilla Firefox o - Safari para utilizar llaves de seguridad. - unique_name: El nombre ya fue escogido. Por favor, elija un nombre diferente. + already_registered: Ya vinculó esta clave de seguridad a su cuenta. Pruebe con + otra clave de seguridad. + general_error_html: No pudimos agregar la clave de seguridad. Revise si su clave + de seguridad se ingresó adecuadamente e inténtelo de nuevo, o bien + %{link_html}. + not_supported: Su navegador no es compatible con claves de seguridad. Actualice + a la última versión de Google Chrome, Microsoft Edge, Mozilla Firefox o + Safari. Después, inténtelo de nuevo. + unique_name: Este apodo de dispositivo ya está en uso. Elija otro apodo de + dispositivo. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index d9126b24188..e9ebe261aab 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -159,12 +159,14 @@ fr: unique_name: Ce nom d’appareil a été utilisé. Veuillez sélectionner un autre nom d’appareil. webauthn_setup: - additional_methods_link: choisir une autre méthode d’authentification - already_registered: Clé de sécurité déjà enregistrée. Veuillez essayer une clé - de sécurité différente. - general_error_html: Nous n’avons pas pu ajouter la clé de sécurité. Veuillez - réessayer ou %{link_html}. + additional_methods_link: choisissez une autre méthode d’authentification + already_registered: Vous avez déjà lié cette clé de sécurité à votre compte. + Veuillez essayer une autre clé de sécurité. + general_error_html: Nous n’avons pas pu ajouter la clé de sécurité. Vérifiez que + votre clé de sécurité est correctement insérée et réessayez ou + %{link_html}. not_supported: Votre navigateur ne prend pas en charge les clés de sécurité. - Utilisez la dernière version de Google Chrome, Microsoft Edge, Mozilla - Firefox ou Safari pour utiliser les clés de sécurité. - unique_name: Ce nom est déjà pris. Veuillez choisir un autre nom. + Installez la dernière version de Google Chrome, Microsoft Edge, Mozilla + Firefox ou Safari, puis réessayez. + unique_name: Ce pseudonyme d’appareil est déjà utilisé. Choisissez un autre + pseudonyme d’appareil. diff --git a/config/locales/mfa/en.yml b/config/locales/mfa/en.yml index 18aec7dc75d..622acceff0e 100644 --- a/config/locales/mfa/en.yml +++ b/config/locales/mfa/en.yml @@ -4,9 +4,10 @@ en: account_info: Adding another authentication method prevents you from getting locked out of your account if you lose one of your methods. add: Add another method - info: Add another layer of security by selecting a multi-factor authentication - method. We recommend you select at least two different options in case you - lose one of your methods. + info: Add an additional layer of protection to your %{app_name} account by + selecting a multi-factor authentication method. + recommendation: We recommend you select at least two different options in case + you lose one of your methods. second_method_warning: link: Add a second authentication method. text: You will have to delete your account and start over if you lose your only diff --git a/config/locales/mfa/es.yml b/config/locales/mfa/es.yml index f7fd720f821..7b760eb9bbc 100644 --- a/config/locales/mfa/es.yml +++ b/config/locales/mfa/es.yml @@ -4,9 +4,10 @@ es: account_info: Agregar otro método de autenticación evita que se le bloquee el acceso a su cuenta si pierde uno de sus métodos. add: Agregar otro método - info: Agregue otro nivel de seguridad seleccionando un método de autentificación - de varios factores. Le recomendamos seleccionar al menos dos opciones - diferentes en caso de que pierda uno de los métodos. + info: Añada una capa adicional de protección a su cuenta de %{app_name} al + seleccionar un método de autenticación multifactor. + recommendation: Le recomendamos seleccionar al menos dos opciones diferentes en + caso de que pierda uno de los métodos. second_method_warning: link: Agregue un segundo método de autenticación. text: Deberá eliminar su cuenta y comenzar de nuevo si pierde su único método de diff --git a/config/locales/mfa/fr.yml b/config/locales/mfa/fr.yml index 8d665f2b40e..a27213bc26c 100644 --- a/config/locales/mfa/fr.yml +++ b/config/locales/mfa/fr.yml @@ -4,10 +4,10 @@ fr: account_info: L’ajout d’une autre méthode d’authentification vous empêche d’être bloqué sur votre compte si vous perdez l’une de vos méthodes. add: Agregar otro método - info: Ajoutez une autre couche de sécurité en sélectionnant une méthode - d’authentification multi-facteurs. Nous vous recommandons de sélectionner - au moins deux options différentes au cas où vous perdriez l’une de vos - méthodes. + info: Ajoutez une couche de protection supplémentaire à votre compte %{app_name} + en sélectionnant une méthode d’authentification à plusieurs facteurs. + recommendation: Nous vous recommandons de sélectionner au moins deux options + différentes au cas où vous perdriez l’une de vos méthodes. second_method_warning: link: Ajoutez une deuxième méthode d’authentification. text: Vous devrez supprimer votre compte et recommencer si vous perdez votre diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index 1f763b1356c..4ef615402a5 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -180,8 +180,8 @@ en: piv_cac: Government employee ID piv_cac_info: PIV/CAC cards for government and military employees. Desktop only. webauthn: Security key - webauthn_info: A physical device, often shaped like a USB drive, that you plug - in to your device. + webauthn_info: Connect your physical security key to your device. You won’t need + to enter a code. webauthn_platform: Face or touch unlock webauthn_platform_info: Use your face or fingerprint to access your account without a one-time code. diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 1651fa5f32c..a45448f7cb4 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -191,8 +191,8 @@ es: piv_cac_info: Credenciales PIV/CAC para empleados gubernamentales y del ejército. Únicamente versión de escritorio. webauthn: Clave de seguridad - webauthn_info: Un dispositivo físico que suele tener la forma de una unidad USB - y se conecta a su dispositivo. + webauthn_info: Conecte su clave de seguridad física a su dispositivo. No + necesitará ingresar un código. webauthn_platform: Desbloqueo facial o táctil webauthn_platform_info: Use la cara o la huella digital para acceder a su cuenta sin un código de un solo uso. diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index e0fd90c4e2e..9e4b626de9b 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -199,8 +199,8 @@ fr: piv_cac_info: Cartes PIV/CAC pour les fonctionnaires et les militaires. Bureau uniquement. webauthn: Clef de sécurité - webauthn_info: Un appareil physique, souvent en forme de clé USB, que vous - branchez sur votre appareil. + webauthn_info: Connectez votre clé de sécurité physique à votre appareil. Vous + n’aurez pas besoin d’entrer un code. webauthn_platform: Déverrouillage facial ou tactile webauthn_platform_info: Utilisez votre visage ou votre empreinte digitale pour accéder à votre compte sans code à usage unique. diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 540c3cb6431..35e76c35f1d 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -503,6 +503,7 @@ def self.build_store(config_map) config.add(:voice_otp_speech_rate) config.add(:vtm_url) config.add(:weekly_auth_funnel_report_config, type: :json) + config.add(:x509_presented_hash_attribute_requested_issuers, type: :json) @key_types = config.key_types @unused_keys = config_map.keys - config.written_env.keys diff --git a/lib/reporting/fraud_metrics_lg99_report.rb b/lib/reporting/fraud_metrics_lg99_report.rb index 3f7a1bdbe40..734772e7447 100644 --- a/lib/reporting/fraud_metrics_lg99_report.rb +++ b/lib/reporting/fraud_metrics_lg99_report.rb @@ -104,7 +104,7 @@ def query format(<<~QUERY, params) fields name - , properties.user_id as user_id, + , properties.user_id as user_id | filter name in %{event_names} QUERY end diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 157393aa063..91e3ed90794 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -56,27 +56,6 @@ stub_analytics stub_attempts_tracker - expect(@analytics).to receive(:track_event).with( - 'IdV: doc auth image upload form submitted,', - success: false, - errors: { - front: ['Please fill in this field.'], - }, - error_details: { - front: { blank: true }, - }, - user_id: user.uuid, - submit_attempts: 1, - remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, - flow_path: 'standard', - ).exactly(0).times - - expect(@analytics).not_to receive(:track_event).with( - 'IdV: doc auth image upload vendor submitted', - any_args, - ) - expect(@irs_attempts_api_tracker).to receive(:track_event).with( :idv_document_upload_submitted, any_args, @@ -84,6 +63,14 @@ action + expect(@analytics).not_to have_logged_event( + 'IdV: doc auth image upload form submitted,', + ) + + expect(@analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor submitted', + ) + expect_funnel_update_counts(user, 0) end end @@ -117,26 +104,6 @@ stub_analytics stub_attempts_tracker - expect(@analytics).to receive(:track_event).with( - 'IdV: doc auth image upload form submitted', - success: false, - errors: { - front: [I18n.t('doc_auth.errors.not_a_file')], - }, - error_details: { - front: { not_a_file: true }, - }, - user_id: user.uuid, - submit_attempts: 1, - remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, - flow_path: 'standard', - front_image_fingerprint: nil, - back_image_fingerprint: an_instance_of(String), - selfie_image_fingerprint: nil, - liveness_checking_required: boolean, - ) - expect(@irs_attempts_api_tracker).to receive(:track_event).with( :idv_document_upload_submitted, { address: nil, @@ -153,13 +120,28 @@ success: false }, ) - expect(@analytics).not_to receive(:track_event).with( - 'IdV: doc auth image upload vendor submitted', - # Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - any_args, + action + + expect(@analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + success: false, + errors: { + front: [I18n.t('doc_auth.errors.not_a_file')], + }, + error_details: { + front: { not_a_file: true }, + }, + user_id: user.uuid, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + flow_path: 'standard', + front_image_fingerprint: nil, + back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, + liveness_checking_required: boolean, ) - action + expect(@analytics).not_to have_logged_event('IdV: doc auth image upload vendor submitted') expect_funnel_update_counts(user, 0) end @@ -253,26 +235,6 @@ stub_analytics stub_attempts_tracker - expect(@analytics).to receive(:track_event).with( - 'IdV: doc auth image upload form submitted', - success: false, - errors: { - limit: [I18n.t('errors.doc_auth.rate_limited_heading')], - }, - error_details: { - limit: { rate_limited: true }, - }, - user_id: user.uuid, - submit_attempts: IdentityConfig.store.doc_auth_max_attempts, - remaining_submit_attempts: 0, - pii_like_keypaths: pii_like_keypaths, - flow_path: 'standard', - front_image_fingerprint: an_instance_of(String), - back_image_fingerprint: an_instance_of(String), - selfie_image_fingerprint: nil, - liveness_checking_required: boolean, - ) - expect(@irs_attempts_api_tracker).to receive(:track_event).with( :idv_document_upload_rate_limited, ) @@ -295,12 +257,28 @@ success: false }, ) - expect(@analytics).not_to receive(:track_event).with( - 'IdV: doc auth image upload vendor submitted', - any_args, + action + + expect(@analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + success: false, + errors: { + limit: [I18n.t('errors.doc_auth.rate_limited_heading')], + }, + error_details: { + limit: { rate_limited: true }, + }, + user_id: user.uuid, + submit_attempts: IdentityConfig.store.doc_auth_max_attempts, + remaining_submit_attempts: 0, + flow_path: 'standard', + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, + liveness_checking_required: boolean, ) - action + expect(@analytics).not_to have_logged_event('IdV: doc auth image upload vendor submitted') expect_funnel_update_counts(user, 0) end @@ -405,14 +383,31 @@ stub_analytics stub_attempts_tracker - expect(@analytics).to receive(:track_event).with( + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :idv_document_upload_submitted, + success: true, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, + document_state: 'MT', + document_number: '1111111111111', + document_issued: '2019-12-31', + document_expiration: '2099-12-31', + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + date_of_birth: '1938-10-06', + address: '1 FAKE RD', + ) + + action + + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -420,7 +415,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, @@ -438,7 +433,6 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -467,7 +461,7 @@ workflow: an_instance_of(String), ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor pii validation', success: true, errors: {}, @@ -475,7 +469,6 @@ user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -484,24 +477,6 @@ classification_info: a_kind_of(Hash), ) - expect(@irs_attempts_api_tracker).to receive(:track_event).with( - :idv_document_upload_submitted, - success: true, - document_back_image_filename: nil, - document_front_image_filename: nil, - document_image_encryption_key: nil, - document_state: 'MT', - document_number: '1111111111111', - document_issued: '2019-12-31', - document_expiration: '2099-12-31', - first_name: 'FAKEY', - last_name: 'MCFAKERSON', - date_of_birth: '1938-10-06', - address: '1 FAKE RD', - ) - - action - expect_funnel_update_counts(user, 1) end @@ -606,14 +581,31 @@ stub_analytics stub_attempts_tracker - expect(@analytics).to receive(:track_event).with( + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :idv_document_upload_submitted, + success: false, + document_state: 'ND', + document_number: state_id_number, + document_issued: nil, + document_expiration: nil, + first_name: nil, + last_name: 'MCFAKERSON', + date_of_birth: '10/06/1938', + address: address1, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, + ) + + action + + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -621,7 +613,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, @@ -639,7 +631,6 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -667,7 +658,7 @@ vendor: nil, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor pii validation', success: false, errors: { @@ -680,7 +671,6 @@ user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -691,15 +681,24 @@ Back: hash_including(ClassName: 'Identification Card', CountryCode: 'USA'), ), ) + end + end + + context 'due to invalid State' do + let(:state) { 'Maryland' } + + it 'tracks state validation errors in analytics' do + stub_analytics + stub_attempts_tracker expect(@irs_attempts_api_tracker).to receive(:track_event).with( :idv_document_upload_submitted, success: false, - document_state: 'ND', + document_state: 'Maryland', document_number: state_id_number, document_issued: nil, document_expiration: nil, - first_name: nil, + first_name: 'FAKEY', last_name: 'MCFAKERSON', date_of_birth: '10/06/1938', address: address1, @@ -709,24 +708,14 @@ ) action - end - end - - context 'due to invalid State' do - let(:state) { 'Maryland' } - it 'tracks state validation errors in analytics' do - stub_analytics - stub_attempts_tracker - - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -734,7 +723,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, @@ -752,7 +741,6 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -780,7 +768,7 @@ vendor: nil, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor pii validation', success: false, errors: { @@ -793,7 +781,6 @@ user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -804,11 +791,23 @@ Back: hash_including(ClassName: 'Identification Card', CountryCode: 'USA'), ), ) + end + end + + context 'but doc_pii validation fails due to missing state_id_number' do + let(:state_id_number) { nil } + + it 'tracks state_id_number validation errors in analytics' do + stub_analytics + stub_attempts_tracker expect(@irs_attempts_api_tracker).to receive(:track_event).with( :idv_document_upload_submitted, success: false, - document_state: 'Maryland', + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, + document_state: 'ND', document_number: state_id_number, document_issued: nil, document_expiration: nil, @@ -816,30 +815,17 @@ last_name: 'MCFAKERSON', date_of_birth: '10/06/1938', address: address1, - document_back_image_filename: nil, - document_front_image_filename: nil, - document_image_encryption_key: nil, ) action - end - end - context 'but doc_pii validation fails due to missing state_id_number' do - let(:state_id_number) { nil } - - it 'tracks state_id_number validation errors in analytics' do - stub_analytics - stub_attempts_tracker - - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -847,7 +833,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, @@ -865,7 +851,6 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -893,7 +878,7 @@ vendor: nil, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor pii validation', success: false, errors: { @@ -906,7 +891,6 @@ user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -914,6 +898,15 @@ liveness_checking_required: boolean, classification_info: hash_including(:Front, :Back), ) + end + end + + context 'but doc_pii validation fails due to invalid DOB' do + let(:dob) { nil } + + it 'tracks dob validation errors in analytics' do + stub_analytics + stub_attempts_tracker expect(@irs_attempts_api_tracker).to receive(:track_event).with( :idv_document_upload_submitted, @@ -927,29 +920,19 @@ document_expiration: nil, first_name: 'FAKEY', last_name: 'MCFAKERSON', - date_of_birth: '10/06/1938', + date_of_birth: nil, address: address1, ) action - end - end - - context 'but doc_pii validation fails due to invalid DOB' do - let(:dob) { nil } - it 'tracks dob validation errors in analytics' do - stub_analytics - stub_attempts_tracker - - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -957,7 +940,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, @@ -975,7 +958,6 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -1003,7 +985,7 @@ vendor: nil, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor pii validation', success: false, errors: { @@ -1016,7 +998,6 @@ user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -1024,24 +1005,6 @@ liveness_checking_required: boolean, classification_info: hash_including(:Front, :Back), ) - - expect(@irs_attempts_api_tracker).to receive(:track_event).with( - :idv_document_upload_submitted, - success: false, - document_back_image_filename: nil, - document_front_image_filename: nil, - document_image_encryption_key: nil, - document_state: 'ND', - document_number: state_id_number, - document_issued: nil, - document_expiration: nil, - first_name: 'FAKEY', - last_name: 'MCFAKERSON', - date_of_birth: nil, - address: address1, - ) - - action end end end @@ -1075,14 +1038,15 @@ it 'tracks events' do stub_analytics - expect(@analytics).to receive(:track_event).with( + action + + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -1090,7 +1054,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: false, errors: { @@ -1110,7 +1074,6 @@ back: { glare: 99.99 }, }, doc_auth_result: nil, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -1138,8 +1101,6 @@ vendor: nil, ) - action - expect_funnel_update_counts(user, 1) end end @@ -1165,14 +1126,15 @@ it 'tracks events' do stub_analytics - expect(@analytics).to receive(:track_event).with( + action + + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), @@ -1180,7 +1142,7 @@ liveness_checking_required: boolean, ) - expect(@analytics).to receive(:track_event).with( + expect(@analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', success: false, errors: { @@ -1202,7 +1164,6 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, - pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), @@ -1231,8 +1192,6 @@ workflow: an_instance_of(String), ) - action - expect_funnel_update_counts(user, 1) end end diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index fd70342d6b9..36462739077 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -479,6 +479,84 @@ end end + context 'verified non-biometric profile with pending biometric profile' do + before do + allow(IdentityConfig.store).to receive(:openid_connect_redirect). + and_return('server_side') + IdentityLinker.new(user, service_provider).link_identity(ial: 3) + user.identities.last.update!( + verified_attributes: %w[birthdate family_name given_name verified_at], + ) + allow(controller).to receive(:pii_requested_but_locked?).and_return(false) + end + + context 'sp does not request biometrics' do + let(:selfie_capture_enabled) { true } + let(:user) { create(:profile, :active, :verified).user } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + end + + it 'redirects to the redirect_uri immediately when pii is unlocked if client-side redirect is disabled' do + create(:profile, :verify_by_mail_pending, :with_pii, idv_level: :unsupervised_with_selfie, user: user) + user.active_profile.idv_level = :legacy_unsupervised + + action + + expect(response).to redirect_to(/^#{params[:redirect_uri]}/) + expect(user.identities.last.verified_attributes).to eq(%w[birthdate family_name given_name verified_at]) + end + + it 'redirects to please call page if user has a fraudualent profile' do + create(:profile, :fraud_review_pending, :with_pii, idv_level: :unsupervised_with_selfie, user: user) + + action + + expect(response).to redirect_to(idv_please_call_url) + end + end + + context 'sp requests biometrics' do + let(:selfie_capture_enabled) { true } + let(:user) { create(:profile, :active, :verified).user } + + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(selfie_capture_enabled) + end + + context 'with biometric_comparison_required param' do + before do + params[:biometric_comparison_required] = 'true' + end + + it 'redirects to gpo enter code page' do + create(:profile, :verify_by_mail_pending, idv_level: :unsupervised_with_selfie, user: user) + + action + + expect(controller).to redirect_to(idv_verify_by_mail_enter_code_url) + end + end + + context 'with vectors of trust' do + before do + params[:vtr] = ['C1.C2.P1.Pb'].to_json + end + + it 'redirects to gpo enter code page' do + create(:profile, :verify_by_mail_pending, idv_level: :unsupervised_with_selfie, user: user) + + action + + expect(controller).to redirect_to(idv_verify_by_mail_enter_code_url) + end + end + end + end + context 'account is not already verified' do it 'redirects to have the user verify their account' do action diff --git a/spec/controllers/users/piv_cac_controller_spec.rb b/spec/controllers/users/piv_cac_controller_spec.rb index f407d211e1f..3b929b2e852 100644 --- a/spec/controllers/users/piv_cac_controller_spec.rb +++ b/spec/controllers/users/piv_cac_controller_spec.rb @@ -139,6 +139,12 @@ expect(flash[:success]).to eq(presenter.delete_success_alert_text) end + it 'removes the piv/cac information from the user session' do + controller.user_session[:decrypted_x509] = {} + response + expect(controller.user_session[:decrypted_x509]).to be_nil + end + it 'logs the submission attempt' do response diff --git a/spec/controllers/users/piv_cac_login_controller_spec.rb b/spec/controllers/users/piv_cac_login_controller_spec.rb index b3fea8d2cf2..4167e3b92a0 100644 --- a/spec/controllers/users/piv_cac_login_controller_spec.rb +++ b/spec/controllers/users/piv_cac_login_controller_spec.rb @@ -4,16 +4,13 @@ describe 'GET new' do before do stub_analytics - allow(@analytics).to receive(:track_event) end context 'without a token' do before { get :new } it 'tracks the piv cac login' do - expect(@analytics).to have_received(:track_event).with( - :piv_cac_login_visited, - ) + expect(@analytics).to have_logged_event(:piv_cac_login_visited) end it 'redirects to root url' do @@ -27,13 +24,11 @@ context 'an invalid token' do before { get :new, params: { token: token } } it 'tracks the login attempt' do - expect(@analytics).to have_received(:track_event).with( + expect(@analytics).to have_logged_event( :piv_cac_login, - { - errors: {}, - key_id: nil, - success: false, - }, + errors: {}, + key_id: nil, + success: false, ) end @@ -73,15 +68,13 @@ end it 'tracks the login attempt' do - expect(@analytics).to have_received(:track_event).with( + expect(@analytics).to have_logged_event( :piv_cac_login, - { - errors: { - type: 'user.not_found', - }, - key_id: nil, - success: false, + errors: { + type: 'user.not_found', }, + key_id: nil, + success: false, ) end @@ -112,13 +105,11 @@ end it 'tracks the login attempt' do - expect(@analytics).to have_received(:track_event).with( + expect(@analytics).to have_logged_event( :piv_cac_login, - { - errors: {}, - key_id: nil, - success: true, - }, + errors: {}, + key_id: nil, + success: true, ) end @@ -137,9 +128,9 @@ end it 'tracks the user_marked_authed event' do - expect(@analytics).to have_received(:track_event).with( + expect(@analytics).to have_logged_event( 'User marked authenticated', - { authentication_type: :valid_2fa }, + authentication_type: :valid_2fa, ) end @@ -164,9 +155,9 @@ describe 'it handles the otp_context' do it 'tracks the user_marked_authed event' do - expect(@analytics).to have_received(:track_event).with( + expect(@analytics).to have_logged_event( 'User marked authenticated', - { authentication_type: :valid_2fa }, + authentication_type: :valid_2fa, ) end diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index c791001e544..797e7136fa0 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -529,7 +529,8 @@ complete_doc_auth_steps_before_hybrid_handoff_step # we still have option to continue expect(page).to have_current_path(idv_hybrid_handoff_path) - expect(page).to have_content(t('doc_auth.headings.upload_from_phone')) + expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) + expect(page).not_to have_content(t('doc_auth.headings.hybrid_handoff')) expect(page).not_to have_content(t('doc_auth.info.upload_from_computer')) click_on t('forms.buttons.send_link') expect(page).to have_current_path(idv_link_sent_path) @@ -545,8 +546,9 @@ complete_doc_auth_steps_before_hybrid_handoff_step # we still have option to continue on handoff, since it's desktop no skip_hand_off expect(page).to have_current_path(idv_hybrid_handoff_path) + expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) + expect(page).not_to have_content(t('doc_auth.headings.hybrid_handoff')) expect(page).to have_content(t('doc_auth.info.upload_from_computer')) - expect(page).to have_content(t('doc_auth.headings.upload_from_phone')) click_on t('forms.buttons.upload_photos') expect(page).to have_current_path(idv_document_capture_url) expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) diff --git a/spec/features/idv/doc_auth/hybrid_handoff_spec.rb b/spec/features/idv/doc_auth/hybrid_handoff_spec.rb index 53f1bbd5248..f7ade66a6f0 100644 --- a/spec/features/idv/doc_auth/hybrid_handoff_spec.rb +++ b/spec/features/idv/doc_auth/hybrid_handoff_spec.rb @@ -11,8 +11,15 @@ let(:idv_send_link_attempt_window_in_minutes) do IdentityConfig.store.idv_send_link_attempt_window_in_minutes end + let(:doc_auth_selfie_capture_enabled) { false } + let(:biometric_comparison_required) { false } before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled). + and_return(doc_auth_selfie_capture_enabled) + if biometric_comparison_required + visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: biometric_comparison_required) + end sign_in_and_2fa_user allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker). @@ -209,4 +216,31 @@ expect(document_capture_session).to have_attributes(requested_at: a_kind_of(Time)) end end + + context 'on a desktop device and selfie is allowed' do + let(:doc_auth_selfie_capture_enabled) { true } + before do + complete_doc_auth_steps_before_hybrid_handoff_step + end + + describe 'when selfie is required by sp' do + let(:biometric_comparison_required) { true } + it 'has expected UI elements' do + mobile_form = find('#form-to-submit-photos-through-mobile') + expect(mobile_form).to have_name(t('forms.buttons.send_link')) + expect(page).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff_selfie')) + end + end + + describe 'when selfie is not required by sp' do + let(:biometric_comparison_required) { false } + it 'has expected UI elements' do + mobile_form = find('#form-to-submit-photos-through-mobile') + desktop_form = find('#form-to-submit-photos-through-desktop') + + expect(mobile_form).to have_name(t('forms.buttons.send_link')) + expect(desktop_form).to have_name(t('forms.buttons.upload_photos')) + end + end + end end diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 57681e3ec7d..237bb8f763a 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -10,135 +10,6 @@ allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) end - context 'ThreatMetrix review pending' do - let(:user) { user_with_2fa } - - before do - allow(IdentityConfig.store).to receive(:proofing_device_profiling).and_return(:enabled) - allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_org_id).and_return('test_org') - end - - it 'allows the user to continue down the happy path', allow_browser_log: true do - sign_in_and_2fa_user(user) - begin_in_person_proofing(user) - # prepare page - complete_prepare_step(user) - - # location page - complete_location_step - - # state ID page - complete_state_id_step(user) - - # ssn page - select 'Reject', from: :mock_profiling_result - complete_ssn_step(user) - - # verify page - expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) - expect(page).to have_content(t('headings.verify')) - expect(page).to have_current_path(idv_in_person_verify_info_path) - expect(page).to have_text(InPersonHelper::GOOD_FIRST_NAME) - expect(page).to have_text(InPersonHelper::GOOD_LAST_NAME) - expect(page).to have_text(InPersonHelper::GOOD_DOB_FORMATTED_EVENT) - expect(page).to have_text(InPersonHelper::GOOD_STATE_ID_NUMBER) - expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_ADDRESS1).twice - expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_ADDRESS2).twice - expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_CITY).twice - expect(page).to have_text( - Idp::Constants::MOCK_IDV_APPLICANT[:state_id_jurisdiction], - count: 3, - ) - expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_ZIPCODE).twice - expect(page).to have_text(DocAuthHelper::GOOD_SSN_MASKED) - complete_verify_step(user) - - # phone page - expect_in_person_step_indicator_current_step( - t('step_indicator.flows.idv.verify_phone_or_address'), - ) - expect(page).to have_content(t('titles.idv.phone')) - fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone) - click_idv_send_security_code - expect_in_person_step_indicator_current_step( - t('step_indicator.flows.idv.verify_phone_or_address'), - ) - - expect_in_person_step_indicator_current_step( - t('step_indicator.flows.idv.verify_phone_or_address'), - ) - fill_in_code_with_last_phone_otp - click_submit_default - - # password confirm page - expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) - expect(page).to have_content(t('idv.titles.session.enter_password', app_name: APP_NAME)) - complete_enter_password_step(user) - - # personal key page - expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) - expect(page).to have_content(t('titles.idv.personal_key')) - deadline = nil - freeze_time do - acknowledge_and_confirm_personal_key - deadline = (Time.zone.now + - IdentityConfig.store.in_person_enrollment_validity_in_days.days). - in_time_zone(Idv::InPerson::ReadyToVerifyPresenter::USPS_SERVER_TIMEZONE). - strftime(t('time.formats.event_date')) - end - - # ready to verify page - expect_in_person_step_indicator_current_step( - t('step_indicator.flows.idv.go_to_the_post_office'), - ) - expect_page_to_have_no_accessibility_violations(page) - enrollment_code = JSON.parse( - UspsInPersonProofing::Mock::Fixtures.request_enroll_response, - )['enrollmentCode'] - expect(page).to have_content(t('in_person_proofing.headings.barcode').tr(' ', ' ')) - expect(page).to have_content(Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code)) - expect(page).to have_content( - t('in_person_proofing.body.barcode.deadline', deadline: deadline), - ) - expect(page).to have_content('MILWAUKEE') - expect(page).to have_content('Sunday: Closed') - - # signing in again before completing in-person proofing at a post office - Capybara.reset_session! - sign_in_live_with_2fa(user) - visit_idp_from_sp_with_ial2(:oidc) - expect(page).to have_current_path(idv_in_person_ready_to_verify_path) - end - - it 'handles profiles in fraud review after 30 days', allow_browser_log: true do - sign_in_and_2fa_user(user) - begin_in_person_proofing(user) - complete_prepare_step(user) - complete_location_step - complete_state_id_step(user) - - # ssn page - select 'Review', from: :mock_profiling_result - complete_ssn_step(user) - complete_verify_step(user) - complete_phone_step(user) - complete_enter_password_step(user) - acknowledge_and_confirm_personal_key - - profile = InPersonEnrollment.last.profile - profile.deactivate_for_fraud_review - expect(profile.fraud_review_pending_at).to be_truthy - expect(profile.fraud_rejection_at).to eq(nil) - profile.update(fraud_review_pending_at: 31.days.ago) - FraudRejectionDailyJob.new.perform(Time.zone.now) - - # profile is rejected - profile.reload - expect(profile.fraud_review_pending_at).to be(nil) - expect(profile.fraud_rejection_at).to be_truthy - end - end - it 'works for a happy path', allow_browser_log: true do user = user_with_2fa @@ -586,8 +457,7 @@ complete_address_step(user, same_address_as_id: false) # ssn page - select 'Reject', from: :mock_profiling_result - complete_ssn_step(user) + complete_ssn_step(user, 'Reject') # verify page expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) diff --git a/spec/features/idv/in_person_threatmetrix_spec.rb b/spec/features/idv/in_person_threatmetrix_spec.rb new file mode 100644 index 00000000000..84bd913bcbb --- /dev/null +++ b/spec/features/idv/in_person_threatmetrix_spec.rb @@ -0,0 +1,316 @@ +require 'rails_helper' +require 'action_account' +require 'axe-rspec' + +RSpec.describe 'In Person Proofing Threatmetrix', js: true, allowed_extra_analytics: [:*] do + include InPersonHelper + + let(:sp) { :oidc } + let(:user) { user_with_2fa } + let(:enrollment_code) do + JSON.parse( + UspsInPersonProofing::Mock::Fixtures.request_enroll_response, + )['enrollmentCode'] + end + let(:stdout) { StringIO.new } + let(:stderr) { StringIO.new } + let(:argv) { ['review-pass', user.uuid, '--reason', 'INV1234'] } + let(:action_account) { ActionAccount.new(argv:, stdout:, stderr:) } + let(:review_pass) { ActionAccount::ReviewPass.new } + let(:review_reject) { ActionAccount::ReviewReject.new } + let(:include_missing) { true } + let(:config) { ScriptBase::Config.new(include_missing:, reason: 'INV1234') } + let(:enrollment) { InPersonEnrollment.last } + let(:profile) { enrollment.profile } + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_enforce_tmx).and_return(true) + ServiceProvider.find_by(issuer: service_provider_issuer(sp)). + update(in_person_proofing_enabled: true) + end + + def deactivate_profile_update_enrollment(status:) + # set fraud review to pending: + profile.deactivate_for_fraud_review + # update the enrollment: + enrollment.update( + status: status, + proofed_at: Time.zone.now, + status_check_completed_at: Time.zone.now, + ) + profile.reload + enrollment.reload + end + + shared_examples_for 'initially shows the user the barcode page' do + it 'shows the user the barcode page', allow_browser_log: true do + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.go_to_the_post_office'), + ) + expect(page).to have_content(t('in_person_proofing.headings.barcode').tr(' ', ' ')) + expect(page).to have_content(Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code)) + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + end + end + + shared_examples_for 'shows the user the Please Call screen' do + it 'shows the user the Please Call screen after passing IPP', allow_browser_log: true do + Capybara.reset_session! + sign_in_live_with_2fa(user) + expect(page).to have_current_path(account_path) + visit_idp_from_sp_with_ial2(sp) + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + + deactivate_profile_update_enrollment(status: :passed) + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + page.refresh + expect(page).to have_current_path(idv_please_call_path) + end + end + + context 'ThreatMetrix determination of Review' do + let(:tmx_status) { 'Review' } + + it 'allows the user to continue down the happy path', allow_browser_log: true do + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + # prepare page + complete_prepare_step(user) + + # location page + complete_location_step(user) + + # state ID page + complete_state_id_step(user) + + # ssn page + complete_ssn_step(user, 'Reject') + + # verify page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + expect(page).to have_content(t('headings.verify')) + expect(page).to have_current_path(idv_in_person_verify_info_path) + expect(page).to have_text(InPersonHelper::GOOD_FIRST_NAME) + expect(page).to have_text(InPersonHelper::GOOD_LAST_NAME) + expect(page).to have_text(InPersonHelper::GOOD_DOB_FORMATTED_EVENT) + expect(page).to have_text(InPersonHelper::GOOD_STATE_ID_NUMBER) + expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_ADDRESS1).twice + expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_ADDRESS2).twice + expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_CITY).twice + expect(page).to have_text( + Idp::Constants::MOCK_IDV_APPLICANT[:state_id_jurisdiction], + count: 3, + ) + expect(page).to have_text(InPersonHelper::GOOD_IDENTITY_DOC_ZIPCODE).twice + expect(page).to have_text(DocAuthHelper::GOOD_SSN_MASKED) + complete_verify_step(user) + + # phone page + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + expect(page).to have_content(t('titles.idv.phone')) + fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone) + click_idv_send_security_code + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + fill_in_code_with_last_phone_otp + click_submit_default + + # password confirm page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) + expect(page).to have_content(t('idv.titles.session.enter_password', app_name: APP_NAME)) + complete_enter_password_step(user) + + # personal key page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) + expect(page).to have_content(t('titles.idv.personal_key')) + deadline = nil + freeze_time do + acknowledge_and_confirm_personal_key + deadline = (Time.zone.now + + IdentityConfig.store.in_person_enrollment_validity_in_days.days). + in_time_zone(Idv::InPerson::ReadyToVerifyPresenter::USPS_SERVER_TIMEZONE). + strftime(t('time.formats.event_date')) + end + + # ready to verify page + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.go_to_the_post_office'), + ) + expect_page_to_have_no_accessibility_violations(page) + enrollment_code = JSON.parse( + UspsInPersonProofing::Mock::Fixtures.request_enroll_response, + )['enrollmentCode'] + expect(page).to have_content(t('in_person_proofing.headings.barcode').tr(' ', ' ')) + expect(page).to have_content(Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code)) + expect(page).to have_content( + t('in_person_proofing.body.barcode.deadline', deadline: deadline), + ) + expect(page).to have_content('MILWAUKEE') + expect(page).to have_content('Sunday: Closed') + + # signing in again before completing in-person proofing at a post office + Capybara.reset_session! + sign_in_live_with_2fa(user) + visit_idp_from_sp_with_ial2(:oidc) + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + end + + it 'handles profiles in fraud review after 30 days', allow_browser_log: true do + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + complete_prepare_step(user) + complete_location_step + complete_state_id_step(user) + + # ssn page + complete_ssn_step(user, tmx_status) + complete_verify_step(user) + complete_phone_step(user) + complete_enter_password_step(user) + acknowledge_and_confirm_personal_key + + profile = InPersonEnrollment.last.profile + profile.deactivate_for_fraud_review + expect(profile.fraud_review_pending_at).to be_truthy + expect(profile.fraud_rejection_at).to eq(nil) + profile.update(fraud_review_pending_at: 31.days.ago) + FraudRejectionDailyJob.new.perform(Time.zone.now) + + # profile is rejected + profile.reload + expect(profile.fraud_review_pending_at).to be(nil) + expect(profile.fraud_rejection_at).to be_truthy + end + + context 'Fraud decisioning is completed' do + before do + complete_entire_ipp_flow(user, tmx_status) + end + + context 'User passes IPP and passes TMX Review' do + it_behaves_like 'initially shows the user the barcode page' + + it_behaves_like 'shows the user the Please Call screen' + + it 'does not allow the user to restart the IPP flow', allow_browser_log: true do + deactivate_profile_update_enrollment(status: :passed) + + visit_idp_from_sp_with_ial2(sp) + expect(page).to have_current_path(idv_please_call_path) + page.visit('/verify/welcome') + expect(page).to have_current_path(idv_please_call_path) + page.visit('/verify/in_person/document_capture') + expect(page).to have_current_path(idv_please_call_path) + end + + it 'shows the user the successful verification screen after passing TMX review', + allow_browser_log: true do + deactivate_profile_update_enrollment(status: :passed) + visit_idp_from_sp_with_ial2(sp) + expect(page).to have_current_path(idv_please_call_path) + + expect do + review_pass.run(args: [user.uuid], config:) + end.to(change { ActionMailer::Base.deliveries.count }.by(1)) + page.visit('/verify/welcome') + expect(page).to have_current_path(idv_activated_path) + end + end + + context 'User passes IPP and fails TMX Review' do + it_behaves_like 'initially shows the user the barcode page' + + it_behaves_like 'shows the user the Please Call screen' + + it 'does not allow the user to restart the flow', allow_browser_log: true do + deactivate_profile_update_enrollment(status: :passed) + + # user revisits before fraud rejection + visit_idp_from_sp_with_ial2(sp) + expect(page).to have_current_path(idv_please_call_path) + + # reject the user + expect do + review_reject.run(args: [user.uuid], config:) + end.to(change { ActionMailer::Base.deliveries.count }.by(1)) + + # user revisits after fraud rejection + visit_idp_from_sp_with_ial2(sp) + expect(page).to have_current_path(idv_not_verified_path) + end + end + + # To be completed in a future ticket + # context 'User fails IPP and fails TMX review' do + # it_behaves_like 'initially shows the user the barcode page' + + # it 'allows the user to restart the flow', allow_browser_log: true do + # deactivate_profile_update_enrollment(status: :failed) + + # visit_idp_from_sp_with_ial2(sp) + # # user should not see the please call page as they failed ipp + # expect(page).not_to have_current_path(idv_please_call_path) + # # redo the flow: + # complete_entire_ipp_flow(user, tmx_status) + # expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + # end + # end + + # To be completed in a future ticket + # context 'User cancels IPP after being deactivated for TMX review', + # allow_browser_log: true do + # it 'allows the user to restart IPP' do + # # set fraud review to pending: + # profile.deactivate_for_fraud_review + # profile.reload + # + # # cancel the enrollment + # click_link t('links.cancel') + # # user can restart + # click_on t('idv.cancel.actions.start_over') + # + # # user should be redirected to the welcome path when they cancel + # expect(page).to have_current_path(idv_welcome_path) + # end + # end + end + end + + context 'ThreatMetrix determination of Reject' do + let(:tmx_status) { 'Reject' } + + before do + complete_entire_ipp_flow(user, tmx_status) + end + + context 'User passes IPP and rejects for TMX review' do + it_behaves_like 'initially shows the user the barcode page' + + it_behaves_like 'shows the user the Please Call screen' + + it 'does not allow the user to restart the IPP flow', allow_browser_log: true do + deactivate_profile_update_enrollment(status: :passed) + + # reject the user + expect do + review_reject.run(args: [user.uuid], config:) + end.to(change { ActionMailer::Base.deliveries.count }.by(1)) + + visit_idp_from_sp_with_ial2(sp) + expect(page).to have_current_path(idv_not_verified_path) + page.visit('/verify/welcome') + expect(page).to have_current_path(idv_not_verified_path) + page.visit('/verify/in_person/document_capture') + expect(page).to have_current_path(idv_not_verified_path) + end + end + end +end diff --git a/spec/features/webauthn/management_spec.rb b/spec/features/webauthn/management_spec.rb index 8517527cae2..b659c37e5f9 100644 --- a/spec/features/webauthn/management_spec.rb +++ b/spec/features/webauthn/management_spec.rb @@ -540,7 +540,7 @@ def expect_webauthn_platform_setup_error mock_press_button_on_hardware_key_on_setup expect(current_path).to eq webauthn_setup_path - expect(page).to have_content t('errors.webauthn_platform_setup.unique_name') + expect(page).to have_content t('errors.webauthn_setup.unique_name') end context 'with javascript enabled', :js do diff --git a/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx index 3558b5a2b7e..e862a1fcd79 100644 --- a/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx @@ -3,7 +3,7 @@ import { AcuantContext, DeviceContext } from '@18f/identity-document-capture'; import { render } from '../../../support/document-capture'; it('shows the loading spinner when the script hasnt loaded', () => { - const { getByRole, container } = render( + const { container } = render( @@ -11,12 +11,12 @@ it('shows the loading spinner when the script hasnt loaded', () => { , ); - expect(getByRole('dialog')).to.be.ok(); - expect(container.querySelector('#acuant-face-capture-container')).to.not.exist(); + expect(container.querySelector('#acuant-face-capture-container')).to.exist(); + expect(container.querySelector('.acuant-capture-canvas__spinner')).to.exist(); }); it('shows the Acuant div when the script has loaded', () => { - const { queryByRole, container } = render( + const { container } = render( @@ -24,6 +24,6 @@ it('shows the Acuant div when the script has loaded', () => { , ); - expect(queryByRole('dialog')).to.not.exist(); expect(container.querySelector('#acuant-face-capture-container')).to.exist(); + expect(container.querySelector('.acuant-capture-canvas__spinner')).not.to.exist(); }); diff --git a/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx b/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx index 1235587fefa..14b0783e3e8 100644 --- a/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx +++ b/spec/javascript/packages/document-capture/components/document-capture-abandon-spec.tsx @@ -41,6 +41,7 @@ describe('DocumentCaptureAbandon', () => { cancelURL: '/cancel', exitURL: '/exit', currentStep: 'document_capture', + accountURL: '', }} > { cancelURL: '/cancel', exitURL: '/exit', currentStep: 'document_capture', + accountURL: '', }} > { + const DEFAULT_PROPS = { + errors: [], + registerField: () => undefined, + value: '', + onChange: () => undefined, + onError: () => undefined, + }; + + context('when selfie is _not_ enabled', () => { + context('and using mobile', () => { + context('and doc_auth_selfie_desktop_test_mode is false', () => { + it('_does_ display a photo upload button', () => { + const { queryAllByText } = render( + + + + + + , + ); + + const takeOrUploadPictureText = queryAllByText( + 'doc_auth.buttons.take_or_upload_picture_html', + ); + expect(takeOrUploadPictureText).to.have.lengthOf(2); + }); + }); + + context('and doc_auth_selfie_desktop_test_mode is true', () => { + it('_does_ display a photo upload button', () => { + const { queryAllByText } = render( + + + + + + , + ); + + const takeOrUploadPictureText = queryAllByText( + 'doc_auth.buttons.take_or_upload_picture_html', + ); + expect(takeOrUploadPictureText).to.have.lengthOf(2); + }); + }); + }); + + context('and using desktop', () => { + context('and doc_auth_selfie_desktop_test_mode is false', () => { + it('shows a file pick area for each field', () => { + const { queryAllByText } = render( + + + + + + , + ); + + const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html'); + expect(uploadPictureText).to.have.lengthOf(2); + }); + }); + + context('and doc_auth_selfie_desktop_test_mode is true', () => { + it('shows a file pick area for each field', () => { + const { queryAllByText } = render( + + + + + + , + ); + + const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html'); + expect(uploadPictureText).to.have.lengthOf(2); + }); + }); + }); + }); + + context('when selfie _is_ enabled', () => { + context('and using mobile', () => { + context('and doc_auth_selfie_desktop_test_mode is false', () => { + it('does _not_ display a photo upload button', () => { + const { queryAllByText } = render( + + + + + + + , + ); + + const takePictureText = queryAllByText('doc_auth.buttons.take_picture'); + expect(takePictureText).to.have.lengthOf(3); + + const takeOrUploadPictureText = queryAllByText( + 'doc_auth.buttons.take_or_upload_picture_html', + ); + expect(takeOrUploadPictureText).to.have.lengthOf(0); + }); + }); + + context('and doc_auth_selfie_desktop_test_mode is true', () => { + it('does _not_ display a photo upload button', () => { + const { queryAllByText } = render( + + + + + + + , + ); + + const takePictureText = queryAllByText('doc_auth.buttons.take_picture'); + expect(takePictureText).to.have.lengthOf(3); + + const takeOrUploadPictureText = queryAllByText( + 'doc_auth.buttons.take_or_upload_picture_html', + ); + expect(takeOrUploadPictureText).to.have.lengthOf(3); + }); + }); + }); + + context('and using desktop', () => { + context('and doc_auth_selfie_desktop_test_mode is false', () => { + it('never loads these components', () => { + // noop + }); + }); + + context('and doc_auth_selfie_desktop_test_mode is true', () => { + it('shows a file pick area for each field', () => { + const { queryAllByText } = render( + + + + + + + , + ); + + const uploadPictureText = queryAllByText('doc_auth.forms.choose_file_html'); + expect(uploadPictureText).to.have.lengthOf(3); + }); + }); + }); + }); +}); 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 7f6f94e1ddc..bb323b10cad 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 @@ -1,7 +1,11 @@ import { useContext } from 'react'; import { renderHook } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; -import { DeviceContext, AnalyticsContext } from '@18f/identity-document-capture'; +import { + DeviceContext, + AnalyticsContext, + SelfieCaptureContext, +} from '@18f/identity-document-capture'; import { Provider as AcuantContextProvider } from '@18f/identity-document-capture/context/acuant'; import AcuantCapture from '@18f/identity-document-capture/components/acuant-capture'; import FailedCaptureAttemptsContext, { @@ -164,6 +168,59 @@ describe('FailedCaptureAttemptsContext testing of forceNativeCamera logic', () = expect(result.current.failedCaptureAttempts).to.equal(1); expect(result.current.forceNativeCamera).to.equal(false); }); + + describe('when selfie is enabled', () => { + it('forceNativeCamera is always false, no matter how many times any attempt fails', () => { + const trackEvent = sinon.spy(); + const { result, rerender } = renderHook(() => useContext(FailedCaptureAttemptsContext), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: true, + isAssessedAsBlurry: false, + }); + rerender(true); + expect(result.current.forceNativeCamera).to.equal(false); + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: false, + isAssessedAsBlurry: true, + }); + rerender(true); + expect(result.current.forceNativeCamera).to.equal(false); + result.current.onFailedCaptureAttempt({ + isAssessedAsGlare: false, + isAssessedAsBlurry: true, + }); + rerender({}); + expect(result.current.failedCaptureAttempts).to.equal(3); + expect(result.current.forceNativeCamera).to.equal(false); + + result.current.onFailedSubmissionAttempt(); + rerender(true); + expect(result.current.forceNativeCamera).to.equal(false); + result.current.onFailedSubmissionAttempt(); + rerender(true); + expect(result.current.forceNativeCamera).to.equal(false); + result.current.onFailedSubmissionAttempt(); + rerender({}); + expect(result.current.failedSubmissionAttempts).to.equal(3); + expect(result.current.forceNativeCamera).to.equal(false); + + expect(trackEvent).to.not.have.been.calledWith( + 'IdV: Native camera forced after failed attempts', + ); + }); + }); }); describe('maxCaptureAttemptsBeforeNativeCamera logging tests', () => { diff --git a/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx b/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx index e48dda792c9..cf57d166158 100644 --- a/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx @@ -6,7 +6,7 @@ describe('document-capture/context/feature-flag', () => { it('has expected default properties', () => { const { result } = renderHook(() => useContext(SelfieCaptureContext)); - expect(result.current).to.have.keys(['isSelfieCaptureEnabled']); + expect(result.current).to.have.keys(['isSelfieCaptureEnabled', 'isSelfieDesktopTestMode']); expect(result.current.isSelfieCaptureEnabled).to.be.a('boolean'); }); }); diff --git a/spec/javascript/support/document-capture.jsx b/spec/javascript/support/document-capture.jsx index 5259b26eded..f547b254396 100644 --- a/spec/javascript/support/document-capture.jsx +++ b/spec/javascript/support/document-capture.jsx @@ -1,5 +1,6 @@ import { render as baseRender, cleanup } from '@testing-library/react'; import sinon from 'sinon'; +// @ts-ignore import { UploadContextProvider } from '@18f/identity-document-capture'; /** @typedef {import('@testing-library/react').RenderOptions} BaseRenderOptions */ @@ -44,8 +45,12 @@ export function render(element, options = {}) { return baseRender(element, { ...baseRenderOptions, wrapper: ({ children }) => ( + // @ts-ignore - {baseWrapper({ children })} + { + // @ts-ignore + baseWrapper({ children }) + } ), }); @@ -57,10 +62,15 @@ export function useAcuant() { // resetting the global variables, since otherwise the component's effect unsubscribe will // attempt to reference globals that no longer exist. cleanup(); + // @ts-ignore delete window.AcuantJavascriptWebSdk; + // @ts-ignore delete window.AcuantCamera; + // @ts-ignore delete window.AcuantCameraUI; + // @ts-ignore delete window.AcuantPassiveLiveness; + // @ts-ignore delete window.loadAcuantSdk; }); @@ -75,6 +85,7 @@ export function useAcuant() { triggerCapture = sinon.stub(), } = {}) { window.AcuantJavascriptWebSdk = { + // @ts-ignore initialize: (_credentials, _endpoint, { onSuccess, onFail }) => isSuccess ? onSuccess() : onFail(401, 'Server returned a 401 (missing credentials).'), startWorkers: sinon.stub().callsArg(0), @@ -82,13 +93,16 @@ export function useAcuant() { REPEAT_FAIL_CODE: 'repeat-fail-code', SEQUENCE_BREAK_CODE: 'sequence-break-code', }; + // @ts-ignore window.AcuantCamera = { isCameraSupported, triggerCapture }; window.AcuantCameraUI = { start: sinon.stub().callsFake((...args) => { const camera = document.getElementById('acuant-camera'); const canvas = document.createElement('canvas'); canvas.id = 'acuant-ui-canvas'; + // @ts-ignore camera.appendChild(canvas); + // @ts-ignore camera.dispatchEvent(new window.CustomEvent('acuantcameracreated')); start(...args); }), @@ -97,7 +111,9 @@ export function useAcuant() { window.AcuantPassiveLiveness = { start: selfieStart, end: selfieEnd }; window.loadAcuantSdk = () => {}; const sdkScript = document.querySelector('[data-acuant-sdk]'); + // @ts-ignore sdkScript.onload(); + // @ts-ignore sdkScript.onload = null; }, }; diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index 549220074dc..1238aa72f16 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -1290,6 +1290,32 @@ ), ) end + + context 'when the enrollment has failed' do + before do + stub_request_failed_proofing_results + end + + it 'sends proofing failed email on response with failed status' do + user = pending_enrollment.user + + freeze_time do + expect do + job.perform(Time.zone.now) + end.to have_enqueued_mail(UserMailer, :in_person_failed).with( + params: { user: user, email_address: user.email_addresses.first }, + args: [{ enrollment: pending_enrollment }], + ) + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Success or failure email initiated', + hash_including( + email_type: 'Failed', + job_name: 'GetUspsProofingResultsJob', + ), + ) + end + end + end end it 'deactivates and sets fraud related fields of an expired enrollment' do diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index 8e7f9f5c946..02eeb838baa 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -1016,33 +1016,18 @@ # TODO: related: should we test against an active profile here? describe '#deactivate_for_fraud_review' do it 'sets fraud_review_pending to true and sets fraud_pending_reason' do - profile = create(:profile, user: user) - - expect(profile.activated_at).to be_nil - expect(profile.active).to eq(false) # ??? - expect(profile.deactivation_reason).to be_nil - expect(profile.fraud_review_pending?).to eq(false) # to change - expect(profile.gpo_verification_pending_at).to be_nil - expect(profile.in_person_verification_pending_at).to be_nil - expect(profile.initiating_service_provider).to be_nil - expect(profile.verified_at).to be_nil + profile = create(:profile, :in_person_verification_pending, user: user) profile.fraud_pending_reason = 'threatmetrix_review' - profile.deactivate_for_fraud_review - - expect(profile.activated_at).to be_nil - expect(profile.active).to eq(false) - expect(profile.deactivation_reason).to be_nil - expect(profile.fraud_review_pending?).to eq(true) # changed - expect(profile.gpo_verification_pending_at).to be_nil - expect(profile.in_person_verification_pending_at).to be_nil - expect(profile.initiating_service_provider).to be_nil - expect(profile.verified_at).to be_nil + expect { profile.deactivate_for_fraud_review }.to( + change { profile.fraud_review_pending? }.from(false).to(true). + and(change { profile.in_person_verification_pending_at }.to(nil)), + ) expect(profile).to_not be_active - expect(profile.fraud_review_pending?).to eq(true) expect(profile.fraud_rejection?).to eq(false) expect(profile.fraud_pending_reason).to eq('threatmetrix_review') + expect(profile.pending_reasons).to eq([:fraud_check_pending]) end end diff --git a/spec/policies/pending_profile_policy_spec.rb b/spec/policies/pending_profile_policy_spec.rb new file mode 100644 index 00000000000..bd579095011 --- /dev/null +++ b/spec/policies/pending_profile_policy_spec.rb @@ -0,0 +1,80 @@ +require 'rails_helper' + +RSpec.describe PendingProfilePolicy do + let(:user) { create(:user) } + let(:resolved_authn_context_result) do + AuthnContextResolver.new( + service_provider: nil, + vtr: vtr, + acr_values: acr_values, + ).resolve + end + let(:biometric_comparison_requested) { nil } + let(:vtr) { nil } + let(:acr_values) { nil } + + subject(:policy) do + described_class.new( + user: user, + resolved_authn_context_result: resolved_authn_context_result, + biometric_comparison_requested: biometric_comparison_requested, + ) + end + + describe '#user_has_usable_pending_profile?' do + context 'has an active non-biometric profile and biometric comparison is requested' do + let(:idv_level) { :unsupervised_with_selfie } + before do + create(:profile, :active, :verified, idv_level: :legacy_unsupervised, user: user) + create(:profile, :verify_by_mail_pending, idv_level: idv_level, user: user) + allow(FeatureManagement).to receive(:idv_allow_selfie_check?).and_return(true) + end + + context 'with resolved authn context result' do + let(:vtr) { ['C2.Pb'] } + + it 'has a usable pending profile' do + expect(policy.user_has_usable_pending_profile?).to eq(true) + end + end + + context 'with biometric_comparison_requested param set to true' do + let(:biometric_comparison_requested) { true } + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + + it 'has a usable pending profile' do + expect(policy.user_has_usable_pending_profile?).to eq(true) + end + end + end + + context 'no biometric comparison is requested' do + let(:idv_level) { :legacy_unsupervised } + let(:vtr) { ['C2'] } + context 'user has pending profile' do + before do + create(:profile, :verify_by_mail_pending, idv_level: idv_level, user: user) + end + + it { expect(policy.user_has_usable_pending_profile?).to eq(true) } + end + + context 'user has an active profile' do + before do + create(:profile, :active, :verified, idv_level: idv_level, user: user) + end + + it { expect(policy.user_has_usable_pending_profile?).to eq(false) } + end + + context 'user has active legacy profile with a pending fraud biometric profile' do + before do + create(:profile, :active, :verified, idv_level: idv_level, user: user) + create(:profile, :fraud_review_pending, idv_level: :unsupervised_with_selfie, user: user) + end + + it { expect(policy.user_has_usable_pending_profile?).to eq(true) } + end + end + end +end diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index ee44d0af503..20515a6c308 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -5,7 +5,7 @@ let(:rails_session_id) { SecureRandom.uuid } let(:scope) do - 'openid email all_emails address phone profile social_security_number x509:subject' + 'openid email all_emails address phone profile social_security_number x509' end let(:service_provider_ial) { 2 } let(:service_provider) { create(:service_provider, ial: service_provider_ial) } @@ -79,14 +79,18 @@ end context 'when a piv/cac was used as second factor' do + let(:x509_subject) { 'x509-subject' } + let(:presented) { true } + let(:issuer) { 'trusted issuer' } + let(:x509) do - { + X509::Attributes.new_from_hash( subject: x509_subject, - } + presented:, + issuer:, + ) end - let(:x509_subject) { 'x509-subject' } - before do OutOfBandSessionAccessor.new(rails_session_id).put_x509(x509, 5.minutes.to_i) end @@ -105,6 +109,8 @@ it 'returns x509 attributes' do aggregate_failures do expect(user_info[:x509_subject]).to eq(x509_subject) + expect(user_info[:x509_presented]).to eq(presented) + expect(user_info[:x509_issuer]).to eq(issuer) end end @@ -112,6 +118,27 @@ json = user_info.as_json expect(json['x509_subject']).to eq(x509_subject) + expect(json['x509_presented']).to eq(presented) + expect(json['x509_issuer']).to eq(issuer) + end + end + + context 'when the sp requested x509_presented scope before it was fixed to string' do + before do + expect(IdentityConfig.store).to receive( + :x509_presented_hash_attribute_requested_issuers, + ). + and_return([identity.service_provider]) + end + + it 'returns x509_presented as an (X509::Attribute' do + # This is guarding against partners who may have coded against + # a bug where we returning the wrong data type for x509_presented + aggregate_failures do + expect(user_info[:x509_subject]).to eq(x509_subject) + expect(user_info[:x509_presented].class).to eq(X509::Attribute) + expect(user_info[:x509_issuer]).to eq(issuer) + end end end end @@ -121,6 +148,8 @@ it 'returns no x509 attributes' do aggregate_failures do expect(user_info[:x509_subject]).to be_blank + expect(user_info[:x509_issuer]).to be_blank + expect(user_info[:x509_presented]).to be false end end end diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index 8cc9376fdd0..faf69edeceb 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -138,8 +138,9 @@ def complete_address_step(_user = nil, same_address_as_id: true) click_idv_continue end - def complete_ssn_step(_user = nil) + def complete_ssn_step(_user = nil, tmx_status = nil) fill_out_ssn_form_ok + select tmx_status.to_s, from: :mock_profiling_result unless tmx_status.nil? click_idv_continue end @@ -156,15 +157,27 @@ def complete_steps_before_state_id_step expect(page).to have_current_path(idv_in_person_step_path(step: :state_id), wait: 10) end - def complete_all_in_person_proofing_steps(user = user_with_2fa, same_address_as_id: true) + def complete_all_in_person_proofing_steps(user = user_with_2fa, tmx_status = nil, + same_address_as_id: true) complete_prepare_step(user) complete_location_step(user) complete_state_id_step(user, same_address_as_id: same_address_as_id) complete_address_step(user, same_address_as_id: same_address_as_id) unless same_address_as_id - complete_ssn_step(user) + complete_ssn_step(user, tmx_status) complete_verify_step(user) end + def complete_entire_ipp_flow(user = user_with_2fa, tmx_status = nil, same_address_as_id: true) + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + complete_all_in_person_proofing_steps(user, tmx_status, same_address_as_id: same_address_as_id) + click_idv_send_security_code + fill_in_code_with_last_phone_otp + click_submit_default + complete_enter_password_step(user) + acknowledge_and_confirm_personal_key + end + def expect_in_person_step_indicator_current_step(text) # Normally we're only concerned with the "current" step, but since some steps are shared between # flows, we also want to make sure that at least one of the in-person-specific steps exists in diff --git a/spec/views/idv/hybrid_handoff/show.html.erb_spec.rb b/spec/views/idv/hybrid_handoff/show.html.erb_spec.rb index eec29236ebf..8ee305e5127 100644 --- a/spec/views/idv/hybrid_handoff/show.html.erb_spec.rb +++ b/spec/views/idv/hybrid_handoff/show.html.erb_spec.rb @@ -12,22 +12,41 @@ } end - it 'has a form for starting mobile doc auth with an aria label tag' do - expect(rendered).to have_selector( - :xpath, - "//form[@aria-label=\"#{t('forms.buttons.send_link')}\"]", - ) - end + context 'when selfie is not required' do + before do + @selfie_required = false + end + it 'has a form for starting mobile doc auth with an aria label tag' do + expect(rendered).to have_selector( + :xpath, + "//form[@aria-label=\"#{t('forms.buttons.send_link')}\"]", + ) + end - it 'has a form for starting desktop doc auth with an aria label tag' do - expect(rendered).to have_selector( - :xpath, - "//form[@aria-label=\"#{t('forms.buttons.upload_photos')}\"]", - ) - end + it 'has a form for starting desktop doc auth with an aria label tag' do + expect(rendered).to have_selector( + :xpath, + "//form[@aria-label=\"#{t('forms.buttons.upload_photos')}\"]", + ) + end - it 'displays the expected headings from the "a" case' do - expect(rendered).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff')) - expect(rendered).to have_selector('h2', text: t('doc_auth.headings.upload_from_phone')) + it 'displays the expected headings from the "a" case' do + expect(rendered).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff')) + expect(rendered).to have_selector('h2', text: t('doc_auth.headings.upload_from_phone')) + end + end + context 'when selfie is required' do + before do + @selfie_required = true + end + it 'has a form for starting mobile doc auth with an aria label tag' do + expect(rendered).to have_selector( + :xpath, + "//form[@aria-label=\"#{t('forms.buttons.send_link')}\"]", + ) + end + it 'displays the expected headings from the "a" case' do + expect(rendered).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff_selfie')) + end end end diff --git a/tsconfig.json b/tsconfig.json index f02a216e7ee..24beb2ac6b1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "app/javascript/packs", "spec/javascript/spec_helper.d.ts", "spec/javascript/**/*.ts", + "spec/javascript/**/*.tsx", "./*.js", "scripts" ],