diff --git a/app/controllers/api/attempts/certs_controller.rb b/app/controllers/api/attempts/certs_controller.rb new file mode 100644 index 00000000000..a6068086ec1 --- /dev/null +++ b/app/controllers/api/attempts/certs_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Api + module Attempts + class CertsController < ApplicationController + prepend_before_action :skip_session_load + prepend_before_action :skip_session_expiration + skip_before_action :disable_caching + + JSON = AttemptsApiCertsPresenter.new.certs.freeze + + def index + expires_in 1.week, public: true + + render json: JSON + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 578d0bf95eb..3dbca5c6709 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -249,6 +249,7 @@ def fix_broken_personal_key_url def after_sign_in_path_for(_user) return rules_of_use_path if !current_user.accepted_rules_of_use_still_valid? return user_please_call_url if current_user.suspended? + return duplicate_profiles_detected_url if user_duplicate_profiles_detected? return manage_password_url if session[:redirect_to_change_password].present? return authentication_methods_setup_url if user_needs_sp_auth_method_setup? return fix_broken_personal_key_url if current_user.broken_personal_key? @@ -259,7 +260,6 @@ def after_sign_in_path_for(_user) return login_piv_cac_recommended_path if user_recommended_for_piv_cac? return second_mfa_reminder_url if user_needs_second_mfa_reminder? return backup_code_reminder_url if user_needs_backup_code_reminder? - return duplicate_profiles_detected_url if user_duplicate_profiles_detected? return sp_session_request_url_with_updated_params if sp_session.key?(:request_url) signed_in_url end @@ -547,7 +547,10 @@ def user_duplicate_profiles_detected? profile = current_user&.active_profile return false unless profile return false unless user_in_one_account_verification_bucket? - user_session[:duplicate_profile_ids].present? + DuplicateProfile.involving_profile( + profile_id: profile.id, + service_provider: current_sp&.issuer, + ).present? end def sp_eligible_for_one_account? @@ -560,4 +563,9 @@ def handle_banned_user sign_out redirect_to banned_user_url end + + def handle_duplicate_profile_user + return unless user_duplicate_profiles_detected? + redirect_to duplicate_profiles_detected_url + end end diff --git a/app/controllers/concerns/idv/choose_id_type_concern.rb b/app/controllers/concerns/idv/choose_id_type_concern.rb index 396a16d63a0..c19a1d9e27e 100644 --- a/app/controllers/concerns/idv/choose_id_type_concern.rb +++ b/app/controllers/concerns/idv/choose_id_type_concern.rb @@ -8,9 +8,17 @@ def chosen_id_type def set_passport_requested if chosen_id_type == 'passport' - document_capture_session.update!(passport_status: 'requested') + unless document_capture_session.passport_requested? + document_capture_session.update!( + passport_status: 'requested', + doc_auth_vendor: nil, + ) + end else - document_capture_session.update!(passport_status: 'not_requested') + document_capture_session.update!( + passport_status: 'not_requested', + doc_auth_vendor: nil, + ) end end diff --git a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb index 9fb313a9a61..343b3d5928a 100644 --- a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb +++ b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb @@ -4,34 +4,6 @@ module Idv module DocAuthVendorConcern include AbTestingConcern - # @returns[String] String identifying the vendor to use for doc auth. - def doc_auth_vendor - idv_session.bucketed_doc_auth_vendor ||= begin - bucket = nil - if socure_user_set.maxed_users? - bucket = choose_non_socure_bucket - elsif resolved_authn_context_result.facial_match? - bucket = ab_test_bucket(:DOC_AUTH_SELFIE_VENDOR) - elsif idv_session.passport_allowed - bucket = ab_test_bucket(:DOC_AUTH_PASSPORT_VENDOR) - else - bucket = ab_test_bucket(:DOC_AUTH_VENDOR) - end - - if bucket == :socure - if !add_user_to_socure_set - bucket = choose_non_socure_bucket # force to lexis_nexis if max user reached - end - end - - DocAuthRouter.doc_auth_vendor_for_bucket( - bucket, - selfie: resolved_authn_context_result.facial_match?, - passport_allowed: idv_session.passport_allowed, - ) - end - end - def doc_auth_vendor_enabled?(vendor) return true if IdentityConfig.store.doc_auth_vendor_default == vendor return false unless IdentityConfig.store.doc_auth_vendor_switching_enabled @@ -60,6 +32,12 @@ def doc_auth_selfie_vendor_enabled?(vendor) end end + def update_doc_auth_vendor(user: current_user) + if document_capture_session.doc_auth_vendor.blank? + document_capture_session.update!(doc_auth_vendor: bucketed_doc_auth_vendor(user)) + end + end + private def choose_non_socure_bucket @@ -74,17 +52,44 @@ def socure_user_set @socure_user_set ||= SocureUserSet.new end - def add_user_to_socure_set - uuid = current_user&.uuid - if uuid.nil? && defined?(document_capture_user) - uuid = document_capture_user&.uuid - end - - if uuid - return socure_user_set.add_user!(user_uuid: uuid) + def add_user_to_socure_set(user) + if user&.uuid + return socure_user_set.add_user!(user_uuid: user.uuid) end false end + + # @returns[String] String identifying the vendor to use for doc auth. + def bucketed_doc_auth_vendor(user) + @bucketed_doc_auth_vendor ||= begin + bucket = nil + if socure_user_set.maxed_users? + bucket = choose_non_socure_bucket + elsif resolved_authn_context_result.facial_match? + if document_capture_session.passport_requested? + bucket = choose_non_socure_bucket + else + bucket = ab_test_bucket(:DOC_AUTH_SELFIE_VENDOR, user:) + end + elsif document_capture_session.passport_requested? + bucket = ab_test_bucket(:DOC_AUTH_PASSPORT_VENDOR, user:) + else + bucket = ab_test_bucket(:DOC_AUTH_VENDOR, user:) + end + + if bucket == :socure + if !add_user_to_socure_set(user) + bucket = choose_non_socure_bucket # force to lexis_nexis if max user reached + end + end + + DocAuthRouter.doc_auth_vendor_for_bucket( + bucket, + selfie: resolved_authn_context_result.facial_match?, + passport_requested: document_capture_session.passport_requested?, + ) + end + end end end diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 79d2225cd45..31123300df3 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -130,10 +130,6 @@ def track_document_request_event(document_request:, document_response:, timer:) analytics.idv_socure_document_request_submitted(**analytics_hash) end - def choose_id_type_path - idv_choose_id_type_path - end - def doc_auth_upload_enabled? # false for now until we consolidate this method with desktop_selfie_test_mode_enabled false diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index 141f36884fd..f70a36af513 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -197,6 +197,7 @@ def async_state_done(current_async_state) [:errors, :ssn], [:errors, :state_id_jurisdiction], [:proofing_results, :context, :stages, :resolution, :errors, :ssn], + [:proofing_results, :context, :stages, :resolution, :reason_codes], [:proofing_results, :context, :stages, :residential_address, :errors, :ssn], [:proofing_results, :context, :stages, :threatmetrix, :response_body, :first_name], [:proofing_results, :context, :stages, :state_id, :state_id_jurisdiction], @@ -234,8 +235,7 @@ def async_state_done(current_async_state) def next_step_url return idv_request_letter_url if FeatureManagement.idv_by_mail_only? || - idv_session.gpo_request_letter_visited || - idv_session.gpo_letter_requested + idv_session.gpo_request_letter_visited idv_phone_url end diff --git a/app/controllers/concerns/idv_step_concern.rb b/app/controllers/concerns/idv_step_concern.rb index 0dfa2373e13..7838fcd8a0b 100644 --- a/app/controllers/concerns/idv_step_concern.rb +++ b/app/controllers/concerns/idv_step_concern.rb @@ -64,17 +64,32 @@ def confirm_hybrid_handoff_needed if params[:redo] idv_session.redo_document_capture = true end - # If we previously skipped hybrid handoff, keep doing that. # If hybrid flow is unavailable, skip it. # But don't store that we skipped it in idv_session, in case it is back to # available when the user tries to redo document capture. if idv_session.skip_hybrid_handoff? || !FeatureManagement.idv_allow_hybrid_flow? idv_session.flow_path = 'standard' - redirect_to vendor_document_capture_url + + if in_person_proofing_route_enabled? + redirect_to idv_how_to_verify_url + elsif idv_session.passport_allowed + redirect_to idv_choose_id_type_url + else + redirect_to vendor_document_capture_url + end end end + def in_person_proofing_route_enabled? + IdentityConfig.store.in_person_proofing_enabled && + IdentityConfig.store.in_person_proofing_opt_in_enabled && + IdentityConfig.store.in_person_doc_auth_button_enabled && + Idv::InPersonConfig.enabled_for_issuer?( + decorated_sp_session.sp_issuer, + ) + end + def vendor_document_capture_url case document_capture_session.doc_auth_vendor when Idp::Constants::Vendors::SOCURE, diff --git a/app/controllers/duplicate_profiles_detected_controller.rb b/app/controllers/duplicate_profiles_detected_controller.rb index bf05ce763a1..123ae3fd78e 100644 --- a/app/controllers/duplicate_profiles_detected_controller.rb +++ b/app/controllers/duplicate_profiles_detected_controller.rb @@ -6,44 +6,38 @@ class DuplicateProfilesDetectedController < ApplicationController def show @dupe_profiles_detected_presenter = DuplicateProfilesDetectedPresenter.new( - user: current_user, user_session: user_session, + user: current_user, + dupe_profile: dupe_profile, ) notify_users_of_duplicate_profile_sign_in analytics.one_account_duplicate_profiles_detected end - def do_not_recognize_profiles - analytics.one_account_unknown_profile_detected - - user_session.delete(:duplicate_profile_ids) - - redirect_to after_sign_in_path_for(current_user) - end - - def recognize_all_profiles - analytics.one_account_recognize_all_profiles - - user_session.delete(:duplicate_profile_ids) - redirect_to after_sign_in_path_for(current_user) - end - private def redirect_unless_user_has_active_duplicate_profile_confirmation if current_user&.active_profile.present? - if user_session[:duplicate_profile_ids].present? + if dupe_profile.present? return end end redirect_to root_url end + def dupe_profile + @dupe_profile ||= DuplicateProfile.involving_profile( + profile_id: current_user.active_profile.id, + service_provider: current_sp&.issuer, + ) + end + def notify_users_of_duplicate_profile_sign_in - return unless user_session[:duplicate_profile_ids].present? + return unless dupe_profile return if user_session[:dupe_profiles_notified] agency_name = current_sp.friendly_name || current_sp.agency&.name - user_session[:duplicate_profile_ids].each do |profile_id| + dupe_profile.profile_ids.each do |profile_id| + next if current_user.active_profile.id == profile_id profile = Profile.find(profile_id) AlertUserDuplicateProfileDiscoveredJob.perform_later( user: profile.user, diff --git a/app/controllers/idv/address_controller.rb b/app/controllers/idv/address_controller.rb index 0a61bd58524..95b7ec3ea3f 100644 --- a/app/controllers/idv/address_controller.rb +++ b/app/controllers/idv/address_controller.rb @@ -14,8 +14,7 @@ def new @address_form = build_address_form @presenter = AddressPresenter.new( - gpo_request_letter_visited: idv_session.gpo_request_letter_visited || - idv_session.gpo_letter_requested, + gpo_request_letter_visited: idv_session.gpo_request_letter_visited, address_update_request: address_update_request?, ) end @@ -81,8 +80,7 @@ def success def failure @presenter = AddressPresenter.new( - gpo_request_letter_visited: idv_session.gpo_request_letter_visited || - idv_session.gpo_letter_requested, + gpo_request_letter_visited: idv_session.gpo_request_letter_visited, address_update_request: address_update_request?, ) render :new @@ -119,7 +117,7 @@ def profile_params end def step_indicator_steps - if idv_session.gpo_request_letter_visited || idv_session.gpo_letter_requested + if idv_session.gpo_request_letter_visited return StepIndicatorConcern::STEP_INDICATOR_STEPS_GPO end diff --git a/app/controllers/idv/agreement_controller.rb b/app/controllers/idv/agreement_controller.rb index 6638c3ea708..4f1dd25ffbc 100644 --- a/app/controllers/idv/agreement_controller.rb +++ b/app/controllers/idv/agreement_controller.rb @@ -41,22 +41,10 @@ def update if result.success? idv_session.idv_consent_given_at = Time.zone.now + idv_session.opted_in_to_in_person_proofing = false + idv_session.skip_doc_auth_from_how_to_verify = false - if params[:skip_hybrid_handoff] - if in_person_proofing_route_enabled? - redirect_to idv_how_to_verify_url - elsif idv_session.passport_allowed - idv_session.opted_in_to_in_person_proofing = false - idv_session.skip_doc_auth_from_how_to_verify = false - redirect_to idv_choose_id_type_url - else - redirect_to idv_how_to_verify_url - end - else - idv_session.opted_in_to_in_person_proofing = false - idv_session.skip_doc_auth_from_how_to_verify = false - redirect_to idv_hybrid_handoff_url - end + redirect_to idv_hybrid_handoff_url else render :show end @@ -85,15 +73,6 @@ def analytics_arguments }.merge(ab_test_analytics_buckets) end - def in_person_proofing_route_enabled? - IdentityConfig.store.in_person_proofing_enabled && - IdentityConfig.store.in_person_proofing_opt_in_enabled && - IdentityConfig.store.in_person_doc_auth_button_enabled && - Idv::InPersonConfig.enabled_for_issuer?( - decorated_sp_session.sp_issuer, - ) - end - def skip_to_capture idv_session.flow_path = 'standard' diff --git a/app/controllers/idv/by_mail/request_letter_controller.rb b/app/controllers/idv/by_mail/request_letter_controller.rb index ff08b5a653b..13b22af9515 100644 --- a/app/controllers/idv/by_mail/request_letter_controller.rb +++ b/app/controllers/idv/by_mail/request_letter_controller.rb @@ -18,7 +18,6 @@ def index Funnel::DocAuth::RegisterStep.new(current_user.id, current_sp&.issuer) .call(:usps_address, :view, true) idv_session.gpo_request_letter_visited = true - idv_session.gpo_letter_requested = true analytics.idv_request_letter_visited end diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index 8042f425a0f..afc1ff7b81f 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -7,9 +7,11 @@ class DocumentCaptureController < ApplicationController include DocumentCaptureConcern include IdvStepConcern include StepIndicatorConcern + include DocAuthVendorConcern before_action :confirm_not_rate_limited, except: [:update, :direct_in_person] before_action :confirm_step_allowed, unless: -> { allow_direct_ipp? } + before_action :update_doc_auth_vendor, only: :show before_action :override_csp_to_allow_acuant before_action :set_usps_form_presenter before_action -> do @@ -87,7 +89,7 @@ def self.step_info def extra_view_variables { id_type: id_type_requested, - document_capture_session_uuid: document_capture_session_uuid, + document_capture_session_uuid:, mock_client: document_capture_session.doc_auth_vendor == 'mock', flow_path: 'standard', sp_name: decorated_sp_session.sp_name, @@ -96,7 +98,7 @@ def extra_view_variables skip_doc_auth_from_handoff: idv_session.skip_doc_auth_from_handoff, skip_doc_auth_from_socure: idv_session.skip_doc_auth_from_socure, opted_in_to_in_person_proofing: idv_session.opted_in_to_in_person_proofing, - choose_id_type_path: choose_id_type_path, + choose_id_type_path: idv_choose_id_type_path, doc_auth_selfie_capture: resolved_authn_context_result.facial_match?, doc_auth_upload_enabled: doc_auth_upload_enabled?, socure_errors_timeout_url: idv_socure_document_capture_errors_url(error_code: :timeout), diff --git a/app/controllers/idv/how_to_verify_controller.rb b/app/controllers/idv/how_to_verify_controller.rb index 867d1d00dc5..1e236b0759e 100644 --- a/app/controllers/idv/how_to_verify_controller.rb +++ b/app/controllers/idv/how_to_verify_controller.rb @@ -63,7 +63,7 @@ def self.step_info Idv::StepInfo.new( key: :how_to_verify, controller: self, - next_steps: [:choose_id_type, :hybrid_handoff, :document_capture], + next_steps: [:choose_id_type, :document_capture], preconditions: ->(idv_session:, user:) do self.enabled? && idv_session.idv_consent_given? && diff --git a/app/controllers/idv/hybrid_mobile/choose_id_type_controller.rb b/app/controllers/idv/hybrid_mobile/choose_id_type_controller.rb index 7898b2ad13f..24f852855c7 100644 --- a/app/controllers/idv/hybrid_mobile/choose_id_type_controller.rb +++ b/app/controllers/idv/hybrid_mobile/choose_id_type_controller.rb @@ -44,10 +44,7 @@ def update def redirect_if_passport_not_available unless document_capture_session.passport_allowed? - redirect_to correct_vendor_path( - document_capture_session.doc_auth_vendor, - in_hybrid_mobile: true, - ) + redirect_to idv_hybrid_mobile_document_capture_url end end diff --git a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb index a5996f8db4a..1436d15f27d 100644 --- a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb @@ -6,10 +6,14 @@ class DocumentCaptureController < ApplicationController include Idv::AvailabilityConcern include DocumentCaptureConcern include HybridMobileConcern + include DocAuthVendorConcern before_action :check_valid_document_capture_session before_action :override_csp_to_allow_acuant before_action :confirm_document_capture_needed, only: :show + before_action -> do + update_doc_auth_vendor(user: document_capture_user) + end, only: :show before_action :set_usps_form_presenter before_action -> do redirect_to_correct_vendor(Idp::Constants::Vendors::LEXIS_NEXIS, in_hybrid_mobile: true) @@ -61,10 +65,10 @@ def extra_view_variables id_type: id_type_requested, flow_path: 'hybrid', mock_client: document_capture_session.doc_auth_vendor == 'mock', - document_capture_session_uuid: document_capture_session_uuid, + document_capture_session_uuid:, failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), doc_auth_selfie_capture: resolved_authn_context_result.facial_match?, - choose_id_type_path: choose_id_type_path, + choose_id_type_path: idv_hybrid_mobile_choose_id_type_path, doc_auth_upload_enabled: doc_auth_upload_enabled?, skip_doc_auth_from_socure: @skip_doc_auth_from_socure, socure_errors_timeout_url: idv_hybrid_mobile_socure_document_capture_errors_url( diff --git a/app/controllers/idv/hybrid_mobile/entry_controller.rb b/app/controllers/idv/hybrid_mobile/entry_controller.rb index 905e16907ad..0e6094ca21c 100644 --- a/app/controllers/idv/hybrid_mobile/entry_controller.rb +++ b/app/controllers/idv/hybrid_mobile/entry_controller.rb @@ -19,10 +19,7 @@ def show return end - redirect_to correct_vendor_path( - document_capture_session.doc_auth_vendor, - in_hybrid_mobile: true, - ) + redirect_to idv_hybrid_mobile_document_capture_url end private diff --git a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb index 25006f5657a..5254be24539 100644 --- a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb @@ -9,10 +9,14 @@ class DocumentCaptureController < ApplicationController include Idv::HybridMobile::HybridMobileConcern include RenderConditionConcern include SocureErrorsConcern + include DocAuthVendorConcern check_or_render_not_found -> { IdentityConfig.store.socure_docv_enabled } before_action :check_valid_document_capture_session before_action :validate_step_not_completed, only: [:show] + before_action -> do + update_doc_auth_vendor(user: document_capture_user) + end, only: :show before_action -> do redirect_to_correct_vendor(Idp::Constants::Vendors::SOCURE, in_hybrid_mobile: true) end, only: :show diff --git a/app/controllers/idv/socure/document_capture_controller.rb b/app/controllers/idv/socure/document_capture_controller.rb index 4af90508cd6..6d3ba0768e7 100644 --- a/app/controllers/idv/socure/document_capture_controller.rb +++ b/app/controllers/idv/socure/document_capture_controller.rb @@ -7,6 +7,7 @@ class DocumentCaptureController < ApplicationController include IdvStepConcern include DocumentCaptureConcern include RenderConditionConcern + include DocAuthVendorConcern check_or_render_not_found -> { IdentityConfig.store.socure_docv_enabled } @@ -16,6 +17,7 @@ class DocumentCaptureController < ApplicationController end, only: :update before_action :confirm_step_allowed + before_action :update_doc_auth_vendor, only: :show before_action -> do redirect_to_correct_vendor(Idp::Constants::Vendors::SOCURE, in_hybrid_mobile: false) end, only: :show diff --git a/app/controllers/idv/welcome_controller.rb b/app/controllers/idv/welcome_controller.rb index 4b9771c7d1a..51463e68f0e 100644 --- a/app/controllers/idv/welcome_controller.rb +++ b/app/controllers/idv/welcome_controller.rb @@ -5,7 +5,6 @@ class WelcomeController < ApplicationController include Idv::AvailabilityConcern include IdvStepConcern include StepIndicatorConcern - include DocAuthVendorConcern include Idv::ChooseIdTypeConcern before_action :confirm_step_allowed @@ -14,7 +13,6 @@ class WelcomeController < ApplicationController before_action :update_passport_allowed, only: :show, if: -> { IdentityConfig.store.doc_auth_passports_enabled } - before_action :update_doc_auth_vendor def show idv_session.proofing_started_at ||= Time.zone.now.iso8601 @@ -47,7 +45,6 @@ def self.step_info undo_step: ->(idv_session:, user:) do idv_session.welcome_visited = nil idv_session.document_capture_session_uuid = nil - idv_session.bucketed_doc_auth_vendor = nil idv_session.passport_allowed = nil end, ) @@ -59,26 +56,14 @@ def analytics_arguments { step: 'welcome', analytics_id: 'Doc Auth', - doc_auth_vendor: idv_session.bucketed_doc_auth_vendor, passport_allowed: idv_session.passport_allowed, }.merge(ab_test_analytics_buckets) end def create_document_capture_session - existing_session = if idv_session.document_capture_session_uuid - DocumentCaptureSession.find_by( - uuid: idv_session.document_capture_session_uuid, - ) - end - - if existing_session - return - end - document_capture_session = DocumentCaptureSession.create!( user_id: current_user.id, issuer: sp_session[:issuer], - doc_auth_vendor:, passport_status:, ) idv_session.document_capture_session_uuid = document_capture_session.uuid @@ -90,13 +75,11 @@ def cancel_previous_in_person_enrollments ) end - def update_doc_auth_vendor - doc_auth_vendor - end - def update_passport_allowed - if !IdentityConfig.store.doc_auth_passports_enabled || - resolved_authn_context_result.facial_match? + if !IdentityConfig.store.doc_auth_passports_enabled || ( + resolved_authn_context_result.facial_match? && + !IdentityConfig.store.doc_auth_passport_selfie_enabled + ) idv_session.passport_allowed = nil return end @@ -109,7 +92,8 @@ def update_passport_allowed end def passport_status - if resolved_authn_context_result.facial_match? + if resolved_authn_context_result.facial_match? && + !IdentityConfig.store.doc_auth_passport_selfie_enabled idv_session.passport_allowed = nil end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index 38416870267..2f719e90e42 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -20,6 +20,7 @@ class AuthorizationController < ApplicationController before_action :check_sp_active, only: [:index] before_action :secure_headers_override, only: [:index] before_action :handle_banned_user + before_action :handle_duplicate_profile_user, only: :index before_action :bump_auth_count, only: :index before_action :redirect_to_sign_in, only: :index, unless: :user_signed_in? before_action :confirm_two_factor_authenticated, only: :index diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 4ec6e563e61..f703b22930a 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -25,6 +25,7 @@ class SamlIdpController < ApplicationController skip_before_action :verify_authenticity_token before_action :require_path_year before_action :handle_banned_user + before_action :handle_duplicate_profile_user, only: :auth before_action :bump_auth_count, only: :auth before_action :redirect_to_sign_in, only: :auth, unless: :user_signed_in? before_action :confirm_two_factor_authenticated, only: :auth diff --git a/app/models/duplicate_profile.rb b/app/models/duplicate_profile.rb index f7c3b0720bf..d34b0b0b2cd 100644 --- a/app/models/duplicate_profile.rb +++ b/app/models/duplicate_profile.rb @@ -1,4 +1,8 @@ # frozen_string_literal: true class DuplicateProfile < ApplicationRecord + def self.involving_profile(profile_id:, service_provider:) + where(service_provider: service_provider) + .where('? = ANY(profile_ids)', profile_id).first + end end diff --git a/app/models/profile.rb b/app/models/profile.rb index b6dc89b7d37..beab13a3555 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -256,6 +256,93 @@ def deactivate_due_to_ipp_expiration_during_fraud_review ) end + def deactivate_duplicate + raise 'Profile not active' unless active + raise 'Profile not a duplicate' unless DuplicateProfile.exists?(['? = ANY(profile_ids)', id]) + + transaction do + update!( + active: false, + fraud_review_pending_at: nil, + fraud_rejection_at: Time.zone.now, + ) + DuplicateProfile.where(['? = ANY(profile_ids)', id]).find_each do |duplicate_profile| + if duplicate_profile.profile_ids.length > 1 + duplicate_profile.profile_ids.delete(id) + duplicate_profile.save + else + duplicate_profile.update!( + closed_at: Time.zone.now, + self_serviced: false, + fraud_investigation_conclusive: true, + ) + end + + service_provider = ServiceProvider.find_sole_by(issuer: duplicate_profile.service_provider) + user.confirmed_email_addresses.each do |email_address| + mailer = UserMailer.with(user: user, email_address: email_address) + mailer.dupe_profile_account_review_complete_locked( + agency_name: service_provider.friendly_name, + ).deliver_now_or_later + end + end + end + end + + def clear_duplicate + raise 'Profile not active' unless active + raise 'Profile not a duplicate' unless DuplicateProfile.exists?(['? = ANY(profile_ids)', id]) + raise 'Profile has other duplicates' if DuplicateProfile.exists?( + ['? = ANY(profile_ids) AND cardinality(profile_ids) > 1', id], + ) + + transaction do + DuplicateProfile.where(['? = ANY(profile_ids)', id]).find_each do |duplicate_profile| + duplicate_profile.update!( + closed_at: Time.zone.now, + self_serviced: false, + fraud_investigation_conclusive: true, + ) + + service_provider = ServiceProvider.find_sole_by(issuer: duplicate_profile.service_provider) + user.confirmed_email_addresses.each do |email_address| + mailer = UserMailer.with(user: user, email_address: email_address) + mailer.dupe_profile_account_review_complete_success( + agency_name: service_provider.friendly_name, + ).deliver_now_or_later + end + end + end + end + + def close_inconclusive_duplicate + raise 'Profile not active' unless active + raise 'Profile not a duplicate' unless DuplicateProfile.exists?(['? = ANY(profile_ids)', id]) + + transaction do + DuplicateProfile.where(['? = ANY(profile_ids)', id]).find_each do |duplicate_profile| + if duplicate_profile.profile_ids.length > 1 + duplicate_profile.profile_ids.delete(id) + duplicate_profile.save + else + duplicate_profile.update!( + closed_at: Time.zone.now, + self_serviced: false, + fraud_investigation_conclusive: false, + ) + end + + service_provider = ServiceProvider.find_sole_by(issuer: duplicate_profile.service_provider) + user.confirmed_email_addresses.each do |email_address| + mailer = UserMailer.with(user: user, email_address: email_address) + mailer.dupe_profile_account_review_complete_unable( + agency_name: service_provider.friendly_name, + ).deliver_now_or_later + end + end + end + end + def reject_for_fraud(notify_user:) update!( active: false, diff --git a/app/presenters/attempts_api_certs_presenter.rb b/app/presenters/attempts_api_certs_presenter.rb new file mode 100644 index 00000000000..1ff65f3c114 --- /dev/null +++ b/app/presenters/attempts_api_certs_presenter.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class AttemptsApiCertsPresenter + def certs + { + keys: [key].compact, + } + end + + private + + def key + if IdentityConfig.store.attempts_api_signing_enabled + cert = OpenSSL::PKey::EC.new(signing_key) + { + alg: 'ES256', + use: 'sig', + }.merge(JWT::JWK::EC.new(OpenSSL::PKey::EC.new(cert.public_to_pem)).export) + end + end + + def signing_key + if IdentityConfig.store.attempts_api_signing_key.blank? + raise AttemptsApi::AttemptEvent::SigningKey::SigningKeyError, + 'Attempts API signing key is not configured' + end + + IdentityConfig.store.attempts_api_signing_key + end +end diff --git a/app/presenters/attempts_configuration_presenter.rb b/app/presenters/attempts_configuration_presenter.rb index 3360da685d1..61f2d274c53 100644 --- a/app/presenters/attempts_configuration_presenter.rb +++ b/app/presenters/attempts_configuration_presenter.rb @@ -9,7 +9,7 @@ class AttemptsConfigurationPresenter def configuration { issuer: root_url, - jwks_uri: api_openid_connect_certs_url, + jwks_uri: api_attempts_certs_url, delivery_methods_supported: [ DELIVERY_METHOD_POLL, ], diff --git a/app/presenters/duplicate_profiles_detected_presenter.rb b/app/presenters/duplicate_profiles_detected_presenter.rb index ddf9092179e..17d207d7199 100644 --- a/app/presenters/duplicate_profiles_detected_presenter.rb +++ b/app/presenters/duplicate_profiles_detected_presenter.rb @@ -3,11 +3,11 @@ class DuplicateProfilesDetectedPresenter include ActionView::Helpers::TranslationHelper - attr_reader :user, :user_session + attr_reader :user, :dupe_profile - def initialize(user:, user_session:) + def initialize(user:, dupe_profile:) @user = user - @user_session = user_session + @dupe_profile = dupe_profile end def heading @@ -15,8 +15,7 @@ def heading end def associated_profiles - profile_ids = [user.active_profile] + user_session[:duplicate_profile_ids] - profiles = Profile.where(id: profile_ids) + profiles = Profile.where(id: dupe_profile.profile_ids) profiles.map do |profile| dupe_user = profile.user email = dupe_user.last_sign_in_email_address.email diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d08374fd678..8e72b26f884 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -2392,14 +2392,12 @@ def idv_doc_auth_warning_visited(step_name:, remaining_submit_attempts:, **extra # User submits IdV welcome screen # @param [String] step Current IdV step # @param [String] analytics_id Current IdV flow identifier - # @param [String] doc_auth_vendor Vendor used for document capture # @param [Boolean] passport_allowed Whether passport is allowed for document capture # @param [Boolean] skip_hybrid_handoff Whether skipped hybrid handoff A/B test is active # @param [Boolean] opted_in_to_in_person_proofing User opted into in person proofing def idv_doc_auth_welcome_submitted( step:, analytics_id:, - doc_auth_vendor:, passport_allowed:, opted_in_to_in_person_proofing: nil, skip_hybrid_handoff: nil, @@ -2409,7 +2407,6 @@ def idv_doc_auth_welcome_submitted( 'IdV: doc auth welcome submitted', step:, analytics_id:, - doc_auth_vendor:, passport_allowed:, opted_in_to_in_person_proofing:, skip_hybrid_handoff:, @@ -2420,14 +2417,12 @@ def idv_doc_auth_welcome_submitted( # User visits IdV welcome screen # @param [String] step Current IdV step # @param [String] analytics_id Current IdV flow identifier - # @param [String] doc_auth_vendor Vendor used for document capture # @param [Boolean] passport_allowed Whether passport is allowed for document capture # @param [Boolean] skip_hybrid_handoff Whether skipped hybrid handoff A/B test is active # @param [Boolean] opted_in_to_in_person_proofing User opted into in person proofing def idv_doc_auth_welcome_visited( step:, analytics_id:, - doc_auth_vendor:, passport_allowed:, opted_in_to_in_person_proofing: nil, skip_hybrid_handoff: nil, @@ -2437,7 +2432,6 @@ def idv_doc_auth_welcome_visited( 'IdV: doc auth welcome visited', step:, analytics_id:, - doc_auth_vendor:, passport_allowed:, skip_hybrid_handoff:, opted_in_to_in_person_proofing:, @@ -6523,6 +6517,42 @@ def oidc_logout_visited( ) end + # Tracks when fraud clears duplicate profile + # @param [Boolean] success Whether the profile was successfully cleared + # @param [Hash] errors Errors resulting from clearing + def one_account_clear_duplicate_profile(success:, errors:, **extra) + track_event( + :one_account_clear_duplicate_profile, + success: success, + errors: errors, + **extra, + ) + end + + # Tracks when fraud clears duplicate profile + # @param [Boolean] success Whether the profile was successfully cleared + # @param [Hash] errors Errors resulting from clearing + def one_account_close_inconclusive_duplicate(success:, errors:, **extra) + track_event( + :one_account_close_inconclusive_duplicate, + success: success, + errors: errors, + **extra, + ) + end + + # Tracks when fraud deactivates duplicate profile + # @param [Boolean] success Whether the profile was successfully deactivated + # @param [Hash] errors Errors resulting from deactivation + def one_account_deactivate_duplicate_profile(success:, errors:, **extra) + track_event( + :one_account_deactivate_duplicate_profile, + success: success, + errors: errors, + **extra, + ) + end + # Tracks when user lands on page notifying them multiple profiles contain same information def one_account_duplicate_profiles_detected track_event(:one_account_duplicate_profiles_detected) diff --git a/app/services/attempts_api/attempt_event.rb b/app/services/attempts_api/attempt_event.rb index 0f1b52eaead..31e8464d8a6 100644 --- a/app/services/attempts_api/attempt_event.rb +++ b/app/services/attempts_api/attempt_event.rb @@ -24,7 +24,7 @@ def to_jwe(public_key:, issuer:) jwk = JWT::JWK.new(public_key) JWE.encrypt( - payload_json(issuer: issuer), + jwe_payload(payload_json: payload_json(issuer:)), public_key, typ: 'secevent+jwe', zip: 'DEF', @@ -36,6 +36,16 @@ def to_jwe(public_key:, issuer:) def self.from_jwe(jwe, private_key) decrypted_event = JWE.decrypt(jwe, private_key) + + if IdentityConfig.store.attempts_api_signing_enabled + decrypted_event = JWT.decode( + decrypted_event, + SigningKey.public_key, + true, + { algorithm: 'ES256' }, + ).first + end + parsed_event = JSON.parse(decrypted_event) event_type = parsed_event['events'].keys.first.split('/').last event_data = parsed_event['events'].values.first @@ -78,9 +88,36 @@ def event_data }.merge(event_metadata || {}) end + def jwe_payload(payload_json:) + if IdentityConfig.store.attempts_api_signing_enabled + JWT.encode(payload_json, SigningKey.private_key, 'ES256') + else + payload_json + end + end + def long_event_type dasherized_name = event_type.to_s.dasherize "https://schemas.login.gov/secevent/attempts-api/event-type/#{dasherized_name}" end + + module SigningKey + class SigningKeyError < StandardError; end + + def self.private_key + OpenSSL::PKey::EC.new(signing_key.private_to_pem) + end + + def self.public_key + OpenSSL::PKey::EC.new(signing_key.public_to_pem) + end + + def self.signing_key + raise SigningKeyError, 'Attempts API signing key is not configured' if + IdentityConfig.store.attempts_api_signing_key.blank? + + OpenSSL::PKey::EC.new(IdentityConfig.store.attempts_api_signing_key) + end + end end end diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 6584f2c69fa..58926eefa71 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -196,7 +196,7 @@ def self.client(vendor:, warn_notifier: nil) end # rubocop:enable Layout/LineLength - def self.doc_auth_vendor_for_bucket(bucket, selfie: false, passport_allowed: false) + def self.doc_auth_vendor_for_bucket(bucket, selfie: false, passport_requested: false) case bucket when :socure Idp::Constants::Vendors::SOCURE @@ -209,7 +209,7 @@ def self.doc_auth_vendor_for_bucket(bucket, selfie: false, passport_allowed: fal else # e.g., nil if selfie IdentityConfig.store.doc_auth_selfie_vendor_default - elsif passport_allowed + elsif passport_requested IdentityConfig.store.doc_auth_passport_vendor_default else IdentityConfig.store.doc_auth_vendor_default diff --git a/app/services/duplicate_profile_checker.rb b/app/services/duplicate_profile_checker.rb index 06c6e028f7b..7a0c1c14626 100644 --- a/app/services/duplicate_profile_checker.rb +++ b/app/services/duplicate_profile_checker.rb @@ -19,9 +19,17 @@ def check_for_duplicate_profiles associated_profiles = duplicate_ssn_finder.duplicate_facial_match_profiles( service_provider: sp.issuer, ) - if associated_profiles + if associated_profiles.present? ids = associated_profiles.map(&:id) - user_session[:duplicate_profile_ids] = ids + existing_profile = DuplicateProfile.involving_profile( + profile_id: profile.id, + service_provider: sp.issuer, + ) + if existing_profile + existing_profile.update(profile_ids: ids + [profile.id]) + else + DuplicateProfile.create(profile_ids: ids + [profile.id], service_provider: sp.issuer) + end end end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 3de68470db7..1e1f60b16db 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -9,7 +9,6 @@ module Idv # @attr flow_path [String, nil] # @attr go_back_path [String, nil] # @attr gpo_code_verified [Boolean, nil] - # @attr gpo_letter_requested [Boolean, nil] # @attr gpo_request_letter_visited [Boolean, nil] # @attr had_barcode_attention_error [Boolean, nil] # @attr had_barcode_read_failure [Boolean, nil] @@ -58,7 +57,6 @@ class Session flow_path go_back_path gpo_code_verified - gpo_letter_requested gpo_request_letter_visited had_barcode_attention_error had_barcode_read_failure diff --git a/app/services/proofing/resolution/result.rb b/app/services/proofing/resolution/result.rb index df937baa3de..e345ea766f5 100644 --- a/app/services/proofing/resolution/result.rb +++ b/app/services/proofing/resolution/result.rb @@ -13,6 +13,7 @@ class Result :failed_result_can_pass_with_additional_verification, :attributes_requiring_additional_verification, :reference, + :reason_codes, :vendor_workflow def initialize( success: nil, @@ -22,6 +23,7 @@ def initialize( transaction_id: '', customer_user_id: '', reference: '', + reason_codes: {}, failed_result_can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_workflow: nil, @@ -34,6 +36,7 @@ def initialize( @transaction_id = transaction_id @customer_user_id = customer_user_id @reference = reference + @reason_codes = reason_codes @failed_result_can_pass_with_additional_verification = failed_result_can_pass_with_additional_verification @attributes_requiring_additional_verification = @@ -60,6 +63,7 @@ def to_h timed_out: timed_out?, transaction_id: transaction_id, reference: reference, + reason_codes: reason_codes, can_pass_with_additional_verification: failed_result_can_pass_with_additional_verification, attributes_requiring_additional_verification: diff --git a/app/services/proofing/socure/id_plus/proofer.rb b/app/services/proofing/socure/id_plus/proofer.rb index 2d85337928f..27742f1db8a 100644 --- a/app/services/proofing/socure/id_plus/proofer.rb +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -68,12 +68,12 @@ def build_result_from_error(err) def build_result_from_response(response) Proofing::Resolution::Result.new( success: all_required_attributes_verified?(response), - errors: reason_codes_as_errors(response), exception: nil, vendor_name: VENDOR_NAME, verified_attributes: verified_attributes(response), transaction_id: response.reference_id, customer_user_id: response.customer_user_id, + reason_codes: reason_codes_as_errors(response), ) end diff --git a/app/views/idv/document_capture/show.html.erb b/app/views/idv/document_capture/show.html.erb index ffa08e464c3..2ef26bbad7c 100644 --- a/app/views/idv/document_capture/show.html.erb +++ b/app/views/idv/document_capture/show.html.erb @@ -1,20 +1,20 @@ <%= render( 'idv/shared/document_capture', - document_capture_session_uuid: document_capture_session_uuid, + document_capture_session_uuid:, flow_path: 'standard', - id_type: id_type, + id_type:, sp_name: decorated_sp_session.sp_name, - failure_to_proof_url: failure_to_proof_url, - acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, - use_alternate_sdk: use_alternate_sdk, - acuant_version: acuant_version, - opted_in_to_in_person_proofing: opted_in_to_in_person_proofing, - skip_doc_auth_from_how_to_verify: skip_doc_auth_from_how_to_verify, - skip_doc_auth_from_handoff: skip_doc_auth_from_handoff, - skip_doc_auth_from_socure: skip_doc_auth_from_socure, - socure_errors_timeout_url: socure_errors_timeout_url, - choose_id_type_path: choose_id_type_path, - doc_auth_selfie_capture: doc_auth_selfie_capture, - doc_auth_upload_enabled: doc_auth_upload_enabled, - mock_client: mock_client, + failure_to_proof_url:, + acuant_sdk_upgrade_a_b_testing_enabled:, + use_alternate_sdk:, + acuant_version:, + opted_in_to_in_person_proofing:, + skip_doc_auth_from_how_to_verify:, + skip_doc_auth_from_handoff:, + skip_doc_auth_from_socure:, + socure_errors_timeout_url:, + choose_id_type_path:, + doc_auth_selfie_capture:, + doc_auth_upload_enabled:, + mock_client:, ) %> diff --git a/app/views/idv/hybrid_mobile/document_capture/show.html.erb b/app/views/idv/hybrid_mobile/document_capture/show.html.erb index 402de56796a..2ae925cfc84 100644 --- a/app/views/idv/hybrid_mobile/document_capture/show.html.erb +++ b/app/views/idv/hybrid_mobile/document_capture/show.html.erb @@ -1,20 +1,20 @@ <%= render( 'idv/shared/document_capture', - document_capture_session_uuid: document_capture_session_uuid, + document_capture_session_uuid:, flow_path: 'hybrid', - id_type: id_type, + id_type:, sp_name: decorated_sp_session.sp_name, - failure_to_proof_url: failure_to_proof_url, - acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, - use_alternate_sdk: use_alternate_sdk, - acuant_version: acuant_version, + failure_to_proof_url:, + acuant_sdk_upgrade_a_b_testing_enabled:, + use_alternate_sdk:, + acuant_version:, opted_in_to_in_person_proofing: false, skip_doc_auth_from_how_to_verify: false, skip_doc_auth_from_handoff: nil, - skip_doc_auth_from_socure: skip_doc_auth_from_socure, - socure_errors_timeout_url: socure_errors_timeout_url, - choose_id_type_path: choose_id_type_path, - doc_auth_selfie_capture: doc_auth_selfie_capture, - doc_auth_upload_enabled: doc_auth_upload_enabled, + skip_doc_auth_from_socure:, + socure_errors_timeout_url:, + choose_id_type_path:, + doc_auth_selfie_capture:, + doc_auth_upload_enabled:, mock_client: mock_client, ) %> diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 48257ef5b66..faf16bc0aa4 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -12,8 +12,8 @@ flow: :idv, step: :document_capture, ), - id_type: id_type, - document_capture_session_uuid: document_capture_session_uuid, + id_type:, + document_capture_session_uuid:, endpoint: api_verify_images_url, status_endpoint: nil, glare_threshold: IdentityConfig.store.doc_auth_client_glare_threshold, @@ -21,30 +21,30 @@ status_poll_interval_ms: IdentityConfig.store.poll_rate_for_verify_in_seconds * 1000, max_capture_attempts_before_native_camera: doc_auth_selfie_capture ? ActiveSupport::Duration::SECONDS_PER_HOUR : IdentityConfig.store.doc_auth_max_capture_attempts_before_native_camera, max_submission_attempts_before_native_camera: doc_auth_selfie_capture ? ActiveSupport::Duration::SECONDS_PER_HOUR : IdentityConfig.store.doc_auth_max_submission_attempts_before_native_camera, - acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, - use_alternate_sdk: use_alternate_sdk, - acuant_version: acuant_version, - sp_name: sp_name, - flow_path: flow_path, + acuant_sdk_upgrade_a_b_testing_enabled:, + use_alternate_sdk:, + acuant_version:, + sp_name:, + flow_path:, cancel_url: idv_cancel_path(step: :document_capture), account_url: account_path, - failure_to_proof_url: failure_to_proof_url, - opted_in_to_in_person_proofing: opted_in_to_in_person_proofing, + failure_to_proof_url:, + opted_in_to_in_person_proofing:, idv_in_person_url: (IdentityConfig.store.in_person_doc_auth_button_enabled && Idv::InPersonConfig.enabled_for_issuer?(decorated_sp_session.sp_issuer)) ? idv_in_person_url : nil, security_and_privacy_how_it_works_url: MarketingSite.security_and_privacy_how_it_works_url, in_person_outage_message_enabled: IdentityConfig.store.in_person_outage_message_enabled, in_person_outage_expected_update_date: IdentityConfig.store.in_person_outage_expected_update_date, us_states_territories: @presenter.usps_states_territories, - choose_id_type_path: choose_id_type_path, + choose_id_type_path:, doc_auth_passports_enabled: IdentityConfig.store.doc_auth_passports_enabled, - doc_auth_selfie_capture: doc_auth_selfie_capture, + doc_auth_selfie_capture:, doc_auth_selfie_desktop_test_mode: IdentityConfig.store.doc_auth_selfie_desktop_test_mode, - doc_auth_upload_enabled: doc_auth_upload_enabled, - skip_doc_auth_from_how_to_verify: skip_doc_auth_from_how_to_verify, - skip_doc_auth_from_handoff: skip_doc_auth_from_handoff, - skip_doc_auth_from_socure: skip_doc_auth_from_socure, + doc_auth_upload_enabled:, + skip_doc_auth_from_how_to_verify:, + skip_doc_auth_from_handoff:, + skip_doc_auth_from_socure:, how_to_verify_url: idv_how_to_verify_url, - socure_errors_timeout_url: socure_errors_timeout_url, + socure_errors_timeout_url:, previous_step_url: @previous_step_url, locations_url: idv_in_person_usps_locations_url, sessions_url: api_internal_sessions_path, diff --git a/config/application.yml.default b/config/application.yml.default index 3764202bb72..6c52200c51b 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -45,6 +45,8 @@ async_stale_job_timeout_seconds: 300 async_wait_timeout_seconds: 60 attempts_api_enabled: false attempts_api_event_ttl_seconds: 3_600 +attempts_api_signing_enabled: false +attempts_api_signing_key: '' attribute_encryption_key: attribute_encryption_key_queue: '[]' available_locales: 'en,es,fr,zh' @@ -110,6 +112,7 @@ doc_auth_max_attempts: 5 doc_auth_max_capture_attempts_before_native_camera: 3 doc_auth_max_submission_attempts_before_native_camera: 3 doc_auth_mock_dos_api: false +doc_auth_passport_selfie_enabled: false doc_auth_passport_vendor_default: 'mock' doc_auth_passport_vendor_lexis_nexis_percent: 100 # note, LN is currently the default vendor doc_auth_passport_vendor_socure_percent: 0 diff --git a/config/locales/en.yml b/config/locales/en.yml index eb92bac5cef..6fb5ed4d7db 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1946,7 +1946,7 @@ user_mailer.dupe_profile.review_complete.success_heading: You can sign back in w user_mailer.dupe_profile.review_complete.success_info: We completed a review of your accounts and you can now sign back into %{sp_or_app_name} with this account. user_mailer.dupe_profile.review_complete.unable_heading: We are not able to sign you in user_mailer.dupe_profile.review_complete.unable_info: We completed a review of your accounts and are not able to sign you in. Please contact %{sp_or_app_name} for further assistance. -user_mailer.dupe_profile.sign_in.description: Someone just signed into a %{app_name} that had previously been verified with your personal information. For your safety we have restricted access on all accounts with matching information and access to %{sp_or_app_name}. +user_mailer.dupe_profile.sign_in.description: Someone just signed into a %{app_name} account that had previously been verified with your personal information. For your safety we have restricted access on all accounts with matching information and access to %{sp_or_app_name}. user_mailer.dupe_profile.sign_in.description2_html: If this was you, you should delete the duplicate account by following the %{steps_link_html} and use only one account for your security. If this wasn’t you, contact the %{help_center_link_html}. user_mailer.dupe_profile.sign_in.heading: Another account using your personal information has signed in user_mailer.dupe_profile.sign_in.help_center_link: '%{app_name} Help Center' diff --git a/config/locales/telephony/en.yml b/config/locales/telephony/en.yml index 6db941e30c7..26a8960e4d1 100644 --- a/config/locales/telephony/en.yml +++ b/config/locales/telephony/en.yml @@ -33,7 +33,7 @@ en: If this was you verifying another account, delete the duplicate account here: %{steps_link} If this wasn’t you, contact the help center here: %{help_center_link} dupe_profile_sign_in_attempted_notice: |- - Someone just signed into a %{app_name} that had previously been verified with your personal information. For your safety we have restricted access on all accounts with matching information and access to %{sp_or_app_name}. + Someone just signed into a %{app_name} account that had previously been verified with your personal information. For your safety we have restricted access on all accounts with matching information and access to %{sp_or_app_name}. If this was you verifying another account, delete the duplicate account here: %{steps_link} If this wasn’t you, contact the help center here: %{help_center_link} diff --git a/config/routes.rb b/config/routes.rb index 666effc56c1..67df9396884 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,7 @@ namespace :attempts do post '/poll' => 'events#poll', as: :poll get '/status' => 'events#status', as: :status + get '/certs' => 'certs#index', as: :certs end namespace :internal do diff --git a/docs/attempts-api/schemas/events/IdentityProofingEvents.yml b/docs/attempts-api/schemas/events/IdentityProofingEvents.yml index b27d98593c8..8ec2b4367f5 100644 --- a/docs/attempts-api/schemas/events/IdentityProofingEvents.yml +++ b/docs/attempts-api/schemas/events/IdentityProofingEvents.yml @@ -9,6 +9,8 @@ properties: $ref: './identity-proofing/IdvEnrollmentComplete.yml' idv-ipp-ready-to-verify-visit: $ref: './identity-proofing/IdvIppReadyToVerifyVisit.yml' + idv-image-retrieval-failed: + $ref: './identity-proofing/IdvImageRetrievalFailed.yml' idv-phone-otp-sent: $ref: './identity-proofing/IdvPhoneOtpSent.yml' idv-phone-otp-submitted: diff --git a/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploadSubmitted.yml b/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploadSubmitted.yml index 285e294d439..c372acc27d6 100644 --- a/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploadSubmitted.yml +++ b/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploadSubmitted.yml @@ -40,6 +40,9 @@ allOf: document_back_image_file_id: type: string description: The ID used to retrieve this image if needed + document_passport_image_file_id: + type: string + description: The ID used to retrieve this image if needed document_selfie_image_file_id: type: string description: The ID used to retrieve this image if needed @@ -49,6 +52,9 @@ allOf: document_back_image_encryption_key: type: string description: Randomly generated Base64-encoded key used to encrypt the back image file. + document_passport_image_encryption_key: + type: string + description: Randomly generated Base64-encoded key used to encrypt the passport image file if it exists. document_selfie_image_encryption_key: type: string description: Randomly generated Base64-encoded key used to encrypt the selfie image file if it exists. diff --git a/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploaded.yml b/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploaded.yml index 2b906b8363b..6ac45cf458c 100644 --- a/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploaded.yml +++ b/docs/attempts-api/schemas/events/identity-proofing/IdvDocumentUploaded.yml @@ -10,6 +10,9 @@ allOf: document_back_image_file_id: type: string description: If this image exists, the ID used to retrieve it if needed + document_passport_image_file_id: + type: string + description: If this image exists, the ID used to retrieve it if needed document_selfie_image_file_id: type: string description: If this image exists, the ID used to retrieve it if needed @@ -19,6 +22,9 @@ allOf: document_back_image_encryption_key: type: string description: Randomly generated Base64-encoded key used to encrypt the back image file if it exists. + document_passport_image_encryption_key: + type: string + description: Randomly generated Base64-encoded key used to encrypt the passport image file if it exists. document_selfie_image_encryption_key: type: string description: Randomly generated Base64-encoded key used to encrypt the selfie image file if it exists. diff --git a/lib/action_account.rb b/lib/action_account.rb index e756ae731ca..7cca6466a7c 100644 --- a/lib/action_account.rb +++ b/lib/action_account.rb @@ -46,6 +46,10 @@ def banner * #{basename} confirm-suspend-user uuid1 uuid2 * #{basename} clear-device-profiling-failure uuid1 uuid2 + + * #{basename} deactivate-duplicate uuid1 uuid2 + + * #{basename} close-inconclusive-duplicate uuid1 uuid2 Options: EOS end @@ -62,6 +66,9 @@ def subtask(name) 'reinstate-user' => ReinstateUser, 'confirm-suspend-user' => ConfirmSuspendUser, 'clear-device-profiling-failure' => ClearDeviceProfilingFailure, + 'deactivate-duplicate' => DeactivateDuplicate, + 'clear-duplicate' => ClearDuplicate, + 'close-inconclusive-duplicate' => CloseInconclusiveDuplicate, }[name] end @@ -74,21 +81,26 @@ def log_message(uuid:, log:, reason:, table:, messages:) def log_text { - no_pending: 'Error: User does not have a pending fraud review', - rejected_for_fraud: "User's profile has been deactivated due to fraud rejection.", - profile_activated: "User's profile has been activated and the user has been emailed.", + cleared_duplicate: "User's profile has been cleared and the user has been notified", + closed_inconclusive_duplicate: + 'User has been notified that the fraud investigation is inconclusive', + deactivated_duplicate: "User's profile has been deactivated and the user has been notified", + device_profiling_approved: 'Device profiling result has been updated to pass', + device_profiling_already_passed: 'Device profiling result already passed', + device_profiling_no_results_found: 'No device profiling results found for this user', error_activating: "There was an error activating the user's profile. Please try again.", - past_eligibility: 'User is past the 30 day review eligibility.', missing_uuid: 'Error: Could not find user with that UUID', - user_emailed: 'User has been emailed', - user_suspended: 'User has been suspended', - user_reinstated: 'User has been reinstated and the user has been emailed', + no_pending: 'Error: User does not have a pending fraud review', + past_eligibility: 'User is past the 30 day review eligibility.', + profile_activated: "User's profile has been activated and the user has been emailed.", + profile_not_active: "Error: User's profile is not active", + rejected_for_fraud: "User's profile has been deactivated due to fraud rejection.", + user_already_reinstated: 'User has already been reinstated', user_already_suspended: 'User has already been suspended', + user_emailed: 'User has been emailed', user_is_not_suspended: 'User is not suspended', - user_already_reinstated: 'User has already been reinstated', - device_profiling_approved: 'Device profiling result has been updated to pass', - device_profiling_already_passed: 'Device profiling result already passed', - device_profiling_no_results_found: 'No device profiling results found for this user', + user_reinstated: 'User has been reinstated and the user has been emailed', + user_suspended: 'User has been suspended', } end end @@ -391,6 +403,258 @@ def attempts_api_tracker(profile:) end end + class DeactivateDuplicate + include LogBase + def run(args:, config:) + uuids = args + + users = User.where(uuid: uuids).order(:uuid) + + table = [] + table << %w[uuid status reason] + + messages = [] + + users.each do |user| + profile = user.active_profile + success = false + + log_texts = [] + + if !profile + log_texts << log_text[:profile_not_active] + else + begin + profile.deactivate_duplicate + success = true + log_texts << log_text[:deactivated_duplicate] + rescue RuntimeError => error + log_texts << "Error: #{error.message}" + end + end + + log_texts.each do |text| + table, messages = log_message( + uuid: user.uuid, + log: text, + reason: config.reason, + table:, + messages:, + ) + end + ensure + if !success + analytics_error_hash = { message: log_texts.last } + end + + Analytics.new( + user: user, + request: nil, + session: {}, + sp: nil, + ).one_account_deactivate_duplicate_profile( + success:, + errors: analytics_error_hash, + ) + end + + missing_uuids = (uuids - users.map(&:uuid)) + + if config.include_missing? && !missing_uuids.empty? + missing_uuids.each do |missing_uuid| + table, messages = log_message( + uuid: missing_uuid, + log: log_text[:missing_uuid], + reason: config.reason, + table:, + messages:, + ) + end + Analytics.new( + user: AnonymousUser.new, request: nil, session: {}, sp: nil, + ).one_account_deactivate_duplicate_profile( + success: false, + errors: { message: log_text[:missing_uuid] }, + ) + end + + ScriptBase::Result.new( + subtask: 'deactivate-duplicate', + uuids: users.map(&:uuid), + messages:, + table:, + ) + end + end + + class ClearDuplicate + include LogBase + def run(args:, config:) + uuids = args + + users = User.where(uuid: uuids).order(:uuid) + + table = [] + table << %w[uuid status reason] + + messages = [] + + users.each do |user| + profile = user.active_profile + success = false + + log_texts = [] + + if !profile + log_texts << log_text[:profile_not_active] + else + begin + profile.clear_duplicate + success = true + log_texts << log_text[:cleared_duplicate] + rescue RuntimeError => error + log_texts << "Error: #{error.message}" + end + end + + log_texts.each do |text| + table, messages = log_message( + uuid: user.uuid, + log: text, + reason: config.reason, + table:, + messages:, + ) + end + ensure + if !success + analytics_error_hash = { message: log_texts.last } + end + + Analytics.new( + user: user, + request: nil, + session: {}, + sp: nil, + ).one_account_clear_duplicate_profile( + success:, + errors: analytics_error_hash, + ) + end + + missing_uuids = (uuids - users.map(&:uuid)) + + if config.include_missing? && !missing_uuids.empty? + missing_uuids.each do |missing_uuid| + table, messages = log_message( + uuid: missing_uuid, + log: log_text[:missing_uuid], + reason: config.reason, + table:, + messages:, + ) + end + Analytics.new( + user: AnonymousUser.new, request: nil, session: {}, sp: nil, + ).one_account_clear_duplicate_profile( + success: false, + errors: { message: log_text[:missing_uuid] }, + ) + end + + ScriptBase::Result.new( + subtask: 'clear-duplicate', + uuids: users.map(&:uuid), + messages:, + table:, + ) + end + end + + class CloseInconclusiveDuplicate + include LogBase + def run(args:, config:) + uuids = args + + users = User.where(uuid: uuids).order(:uuid) + + table = [] + table << %w[uuid status reason] + + messages = [] + + users.each do |user| + profile = user.active_profile + success = false + + log_texts = [] + + if !profile + log_texts << log_text[:profile_not_active] + else + begin + profile.close_inconclusive_duplicate + success = true + log_texts << log_text[:closed_inconclusive_duplicate] + rescue RuntimeError => error + log_texts << "Error: #{error.message}" + end + end + + log_texts.each do |text| + table, messages = log_message( + uuid: user.uuid, + log: text, + reason: config.reason, + table:, + messages:, + ) + end + ensure + if !success + analytics_error_hash = { message: log_texts.last } + end + + Analytics.new( + user: user, + request: nil, + session: {}, + sp: nil, + ).one_account_close_inconclusive_duplicate( + success:, + errors: analytics_error_hash, + ) + end + + missing_uuids = (uuids - users.map(&:uuid)) + + if config.include_missing? && !missing_uuids.empty? + missing_uuids.each do |missing_uuid| + table, messages = log_message( + uuid: missing_uuid, + log: log_text[:missing_uuid], + reason: config.reason, + table:, + messages:, + ) + end + Analytics.new( + user: AnonymousUser.new, request: nil, session: {}, sp: nil, + ).one_account_close_inconclusive_duplicate( + success: false, + errors: { message: log_text[:missing_uuid] }, + ) + end + + ScriptBase::Result.new( + subtask: 'close-inconclusive-duplicate', + uuids: users.map(&:uuid), + messages:, + table:, + ) + end + end + class SuspendUser include UserActions diff --git a/lib/identity_config.rb b/lib/identity_config.rb index fce12ef47eb..0efc8696995 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -63,6 +63,8 @@ def self.store config.add(:async_wait_timeout_seconds, type: :integer) config.add(:attempts_api_event_ttl_seconds, type: :integer) config.add(:attempts_api_enabled, type: :boolean) + config.add(:attempts_api_signing_key, type: :string) + config.add(:attempts_api_signing_enabled, type: :boolean) config.add(:attribute_encryption_key, type: :string) config.add(:attribute_encryption_key_queue, type: :json) config.add(:available_locales, type: :comma_separated_string_list) @@ -136,6 +138,7 @@ def self.store config.add(:doc_auth_passport_vendor_socure_percent, type: :integer) config.add(:doc_auth_passport_vendor_switching_enabled, type: :boolean) config.add(:doc_auth_selfie_desktop_test_mode, type: :boolean) + config.add(:doc_auth_passport_selfie_enabled, type: :boolean) config.add(:doc_auth_selfie_vendor_default, type: :string) config.add(:doc_auth_selfie_vendor_lexis_nexis_percent, type: :integer) config.add(:doc_auth_selfie_vendor_socure_percent, type: :integer) diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 543fda4c287..dd39d7a50b5 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -620,7 +620,9 @@ def index context 'when SP is not eligible for one account' do let(:issuer2) { 'wrong.com' } let(:sp) { create(:service_provider, ial: 2, issuer: issuer2) } - before do + let(:profile2) { create(:profile, :facial_match_proof) } + let(:profile) do + create(:profile, :active, user: user, initiating_service_provider_issuer: sp.issuer) end it 'returns false' do @@ -629,8 +631,7 @@ def index end it 'returns false even with duplicate profile confirmations' do - profile = create(:profile, :active, user: user) - create(:duplicate_profile_confirmation, profile: profile, confirmed_all: nil) + create(:duplicate_profile, profile_ids: [profile.id], service_provider: 'wrong-sp') get :index expect(response.body).to eq('false') @@ -656,7 +657,10 @@ def index end context 'when duplicate profile ids found in session' do before do - controller.user_session[:duplicate_profile_ids] = [active_profile.id] + create( + :duplicate_profile, profile_ids: [active_profile.id], + service_provider: sp.issuer + ) end it 'returns true' do diff --git a/spec/controllers/concerns/idv/choose_id_type_concern_spec.rb b/spec/controllers/concerns/idv/choose_id_type_concern_spec.rb index 769d47dc02a..60483331eda 100644 --- a/spec/controllers/concerns/idv/choose_id_type_concern_spec.rb +++ b/spec/controllers/concerns/idv/choose_id_type_concern_spec.rb @@ -10,7 +10,8 @@ let(:analytics) { FakeAnalytics.new } let(:step) { 'choose_id_type' } let(:context_analytics) { { step: step } } - let(:document_capture_session) { double(DocumentCaptureSession) } + let(:passport_status) { nil } + let(:document_capture_session) { create(:document_capture_session, passport_status:) } let(:parameters) do ActionController::Parameters.new( { @@ -38,10 +39,6 @@ end describe '#set_passport_requested' do - before do - allow(document_capture_session).to receive(:update!) - end - context 'when chosen_id_type is "passport"' do let(:id_type) { 'passport' } @@ -51,9 +48,7 @@ end it 'updates the document_capture_session passport status to "requested"' do - expect(document_capture_session).to have_received(:update!).with( - passport_status: 'requested', - ) + expect(document_capture_session.passport_requested?).to be true end end @@ -66,9 +61,21 @@ end it 'updates the document_capture_session passport status to "not_requested"' do - expect(document_capture_session).to have_received(:update!).with( - passport_status: 'not_requested', - ) + expect(document_capture_session.passport_status).to eq('not_requested') + end + + context 'when the document_capture_session doc_auth_vendor is already defined' do + let(:document_capture_session) do + create( + :document_capture_session, + passport_status:, + doc_auth_vendor: Idp::Constants::Vendors::SOCURE, + ) + end + + it 'sets the doc_auth_vendor to nil' do + expect(document_capture_session.doc_auth_vendor).to be_nil + end end end end @@ -108,10 +115,6 @@ end describe '#selected_id_type' do - before do - allow(document_capture_session).to receive(:passport_status).and_return(passport_status) - end - context 'when the document capture session passport status is "requested"' do let(:passport_status) { 'requested' } @@ -195,9 +198,10 @@ end context 'when the dos passport api is healthy' do + let(:passport_status) { 'requested' } + before do allow(response).to receive(:success?).and_return(true) - allow(document_capture_session).to receive(:passport_status).and_return('requested') end it 'returns expected local attributes' do diff --git a/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb b/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb index 189cfe300c3..5f89b916600 100644 --- a/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb +++ b/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb @@ -1,7 +1,8 @@ require 'rails_helper' RSpec.describe Idv::DocAuthVendorConcern, :controller do - let(:user) { create(:user) } + let(:document_capture_session) { create(:document_capture_session) } + let(:user) { document_capture_session.user } let(:socure_user_set) { Idv::SocureUserSet.new } let(:bucket) { :mock } let(:user_session) do @@ -25,21 +26,25 @@ REDIS_POOL.with { |client| client.flushdb } end - describe '#doc_auth_vendor' do - before do - allow(controller).to receive(:current_user).and_return(user) - allow(controller).to receive(:ab_test_bucket) - .with(:DOC_AUTH_VENDOR) - .and_return(bucket) - allow(controller).to receive(:idv_session).and_return(idv_session) - end + before do + stub_sign_in(user) + allow(controller).to receive(:current_user).and_return(user) + allow(controller).to receive(:ab_test_bucket) + .with(:DOC_AUTH_VENDOR, user:) + .and_return(bucket) + allow(controller).to receive(:idv_session).and_return(idv_session) + allow(controller).to receive(:document_capture_session) + .and_return(document_capture_session) + end + describe '#udpate_doc_auth_vendor' do context 'bucket is LexisNexis' do let(:bucket) { :lexis_nexis } it 'returns lexis nexis as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::LEXIS_NEXIS) - expect(controller.idv_session.bucketed_doc_auth_vendor) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) .to eq(Idp::Constants::Vendors::LEXIS_NEXIS) end end @@ -48,8 +53,9 @@ let(:bucket) { :mock } it 'returns mock as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) - expect(idv_session.bucketed_doc_auth_vendor) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) .to eq(Idp::Constants::Vendors::MOCK) end end @@ -59,23 +65,29 @@ context 'current user is undefined so use document_capture_session user' do it 'returns socure as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::SOCURE) - expect(controller.idv_session.bucketed_doc_auth_vendor) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) .to eq(Idp::Constants::Vendors::SOCURE) end it 'adds a user to the socure redis set' do - expect { controller.doc_auth_vendor }.to change { socure_user_set.count }.by(1) + expect { controller.update_doc_auth_vendor } + .to change { socure_user_set.count }.by(1) end end context 'current user is defined' do it 'returns socure as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::SOCURE) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::SOCURE) end it 'adds a user to the socure redis set' do - expect { controller.doc_auth_vendor }.to change { socure_user_set.count }.by(1) + expect { controller.update_doc_auth_vendor } + .to change { socure_user_set.count }.by(1) end end end @@ -97,7 +109,10 @@ end it 'returns mock as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::MOCK) end end @@ -108,7 +123,10 @@ end it 'returns socure as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::SOCURE) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::SOCURE) end context 'socure user set is maxed before user added' do @@ -118,7 +136,10 @@ end it 'returns mock as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::MOCK) end end end @@ -148,37 +169,48 @@ allow(controller).to receive(:resolved_authn_context_result) .and_return(resolved_authn_context) allow(controller).to receive(:ab_test_bucket) - .with(:DOC_AUTH_SELFIE_VENDOR) + .with(:DOC_AUTH_SELFIE_VENDOR, user:) .and_return(bucket) end it 'returns Socure as the vendor' do - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::SOCURE) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::SOCURE) end context 'Lexis Nexis is disabled' do before do allow(IdentityConfig.store) .to receive(:doc_auth_vendor_lexis_nexis_percent).and_return(0) + allow(controller).to receive(:ab_test_bucket).and_return(:socure) end context 'Socure user set is full after user bucketed' do before do allow_any_instance_of(Idv::SocureUserSet).to receive(:add_user!).and_return(false) + expect(controller).to receive(:ab_test_bucket).and_call_original end it 'returns enabled non socure bucket mock as the vendor' do - expect(controller).to receive(:ab_test_bucket) - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + controller.update_doc_auth_vendor + + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::MOCK) end end + context 'limit reached on allowed Socure users' do before do allow_any_instance_of(Idv::SocureUserSet).to receive(:maxed_users?).and_return(true) end it 'returns enabled non socure bucket mock as the vendor' do + controller.update_doc_auth_vendor + expect(controller).not_to receive(:ab_test_bucket) - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::MOCK) end end end @@ -188,10 +220,6 @@ describe '#doc_auth_vendor_enabled?' do let(:vendor) { Idp::Constants::Vendors::LEXIS_NEXIS } - before do - allow(controller).to receive(:idv_session).and_return(idv_session) - end - context 'doc_auth_vendor_switching is false' do before do allow(IdentityConfig.store) @@ -219,11 +247,15 @@ before do allow(IdentityConfig.store).to receive(:doc_auth_vendor_default) .and_return(Idp::Constants::Vendors::MOCK) - controller.idv_session.bucketed_doc_auth_vendor = Idp::Constants::Vendors::LEXIS_NEXIS + document_capture_session.update!(doc_auth_vendor: vendor) end + it 'lexis_nexis is still docauth vendor' do + controller.update_doc_auth_vendor + expect(DocAuthRouter).not_to receive(:doc_auth_vendor_for_bucket) - expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::LEXIS_NEXIS) + expect(document_capture_session.doc_auth_vendor) + .to eq(Idp::Constants::Vendors::LEXIS_NEXIS) end end end diff --git a/spec/controllers/concerns/idv_step_concern_spec.rb b/spec/controllers/concerns/idv_step_concern_spec.rb index fe472b1794c..8c6958b3b92 100644 --- a/spec/controllers/concerns/idv_step_concern_spec.rb +++ b/spec/controllers/concerns/idv_step_concern_spec.rb @@ -75,16 +75,65 @@ def show context 'previously skipped hybrid handoff' do before do idv_session.skip_hybrid_handoff = true - get :show end it 'sets flow_path to standard' do + get :show + expect(idv_session.flow_path).to eql('standard') end it 'redirects to document capture' do + get :show + expect(response).to redirect_to(idv_document_capture_url) end + + context 'when IPP proofing route is enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) + .and_return(true) + allow(IdentityConfig.store).to receive(:in_person_doc_auth_button_enabled) + .and_return(true) + allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).and_return(true) + end + + it 'redirects to how to verify' do + get :show + + expect(response).to redirect_to(idv_how_to_verify_url) + end + end + + context 'when passport is allowed' do + before do + idv_session.passport_allowed = true + end + + it 'redirects to choose id type' do + get :show + + expect(response).to redirect_to(idv_choose_id_type_url) + end + + context 'when IPP proofing route is enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) + .and_return(true) + allow(IdentityConfig.store).to receive(:in_person_doc_auth_button_enabled) + .and_return(true) + allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).and_return(true) + end + + it 'redirects to how to verify' do + get :show + + expect(response).to redirect_to(idv_how_to_verify_url) + end + end + end end context 'hybrid flow not available' do diff --git a/spec/controllers/duplicate_profiles_detected_controller_spec.rb b/spec/controllers/duplicate_profiles_detected_controller_spec.rb index dbac2d2ecd3..062c7bae47b 100644 --- a/spec/controllers/duplicate_profiles_detected_controller_spec.rb +++ b/spec/controllers/duplicate_profiles_detected_controller_spec.rb @@ -6,17 +6,24 @@ let(:current_sp) do create(:service_provider, issuer: 'test-sp', friendly_name: 'Test Service Provider') end + let(:dupe_profile) do + create( + :duplicate_profile, profile_ids: [user.active_profile.id, profile2.id], + service_provider: current_sp.issuer + ) + end before do stub_sign_in(user) stub_analytics - session[:duplicate_profile_ids] = [profile2.id] + dupe_profile allow(controller).to receive(:current_sp).and_return(current_sp) end describe '#show' do context 'when user is not authenticated with 2FA' do let(:user) { nil } + let(:dupe_profile) { nil } it 'redirects to sign in page' do get :show @@ -36,7 +43,7 @@ it 'initializes the DuplicateProfilesDetectedPresenter' do expect(DuplicateProfilesDetectedPresenter).to receive(:new) - .with(user: user, user_session: session) + .with(user: user, dupe_profile: dupe_profile) get :show end @@ -60,31 +67,6 @@ end end - describe '#do_not_recognize_profiles' do - before do - allow(controller).to receive(:user_session).and_return(session) - end - - it 'logs an event' do - post :do_not_recognize_profiles - - expect(@analytics).to have_logged_event( - :one_account_unknown_profile_detected, - ) - end - end - - describe '#recognize_all_profiles' do - before do - allow(controller).to receive(:user_session).and_return(session) - end - - it 'logs an analytics event' do - post :recognize_all_profiles - expect(@analytics).to have_logged_event - end - end - describe '#redirect_unless_user_has_active_duplicate_profile_confirmation' do context 'when user does not have an active profile' do before do diff --git a/spec/controllers/idv/agreement_controller_spec.rb b/spec/controllers/idv/agreement_controller_spec.rb index b61499d5cae..573b4c0a494 100644 --- a/spec/controllers/idv/agreement_controller_spec.rb +++ b/spec/controllers/idv/agreement_controller_spec.rb @@ -194,10 +194,10 @@ }.from(nil).to(true) end - it 'redirects to how to verify' do + it 'redirects to hybrid handoff' do put :update, params: params - expect(response).to redirect_to(idv_how_to_verify_url) + expect(response).to redirect_to(idv_hybrid_handoff_url) end it 'sets an idv_consent_given_at timestamp' do diff --git a/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb index fbef7c68578..5b33c9f7c57 100644 --- a/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/choose_id_type_controller_spec.rb @@ -45,10 +45,10 @@ describe '#show' do context 'passport not available' do let(:passport_status) { nil } - let(:idv_vendor) { Idp::Constants::Vendors::SOCURE } + it 'redirects to the vendor document capture' do get :show - expect(response).to redirect_to idv_hybrid_mobile_socure_document_capture_url + expect(response).to redirect_to idv_hybrid_mobile_document_capture_url end end context 'passport is available' do diff --git a/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb index 445f822c7e6..640092b0936 100644 --- a/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb @@ -80,7 +80,7 @@ let(:idv_vendor) { Idp::Constants::Vendors::SOCURE } it 'redirects to the first step' do - expect(response).to redirect_to idv_hybrid_mobile_socure_document_capture_url + expect(response).to redirect_to idv_hybrid_mobile_document_capture_url end end diff --git a/spec/controllers/idv/welcome_controller_spec.rb b/spec/controllers/idv/welcome_controller_spec.rb index a9d352a74fd..9fcd01b9110 100644 --- a/spec/controllers/idv/welcome_controller_spec.rb +++ b/spec/controllers/idv/welcome_controller_spec.rb @@ -74,7 +74,6 @@ { step: 'welcome', analytics_id: 'Doc Auth', - doc_auth_vendor: 'mock', } end @@ -214,7 +213,6 @@ { step: 'welcome', analytics_id: 'Doc Auth', - doc_auth_vendor: 'mock', } end diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index abb5a3d337b..b864b73402b 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -630,6 +630,34 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', ) end + + context 'when duplicate profiles are detected for user' do + let(:user2) do + create( + :profile, :active, :verified, proofing_components: { liveness_check: true } + ).user + end + let(:duplicate_profile) do + create( + :duplicate_profile, profile_ids: + [user.active_profile.id, user2.active_profile.id], service_provider: service_provider.issuer + ) + end + + before do + allow(IdentityConfig.store).to receive(:eligible_one_account_providers).and_return([service_provider.issuer]) + allow(controller).to receive(:user_in_one_account_verification_bucket?) + .and_return(true) + duplicate_profile + allow(controller).to receive(:user_signed_in?).and_return(true) + allow(controller).to receive(:current_user).and_return(user) + end + + it 'redirects user to duplicate profiles detected page' do + action + expect(response).to redirect_to(duplicate_profiles_detected_url) + end + end end context 'account is not already verified' do diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index a5af8ae2b59..592ec8f5dc2 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -933,6 +933,38 @@ def name_id_version(format_urn) expect(response).to redirect_to capture_password_url end end + + context 'user has a duplicate profile in another account' do + let(:user2) do + create( + :profile, :active, :verified, proofing_components: { liveness_check: true } + ).user + end + let(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: [user.active_profile.id, user2.active_profile.id], + service_provider: service_provider.issuer, + ) + end + + let(:service_provider) { build(:service_provider, issuer: ial2_settings.issuer) } + + before do + allow(IdentityConfig.store) + .to receive(:eligible_one_account_providers) + .and_return([service_provider.issuer]) + allow(controller).to receive(:user_in_one_account_verification_bucket?) + .and_return(true) + duplicate_profile + allow(controller).to receive(:current_user).and_return(user) + end + + it 'redirects user to duplicate profiles detected page' do + saml_get_auth(ial2_settings) + expect(response).to redirect_to(duplicate_profiles_detected_url) + end + end end context 'with IAL2 and the profile is reset' do diff --git a/spec/factories/duplicate_profiles.rb b/spec/factories/duplicate_profiles.rb new file mode 100644 index 00000000000..7554495081c --- /dev/null +++ b/spec/factories/duplicate_profiles.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :duplicate_profile do + profile_ids { [] } + service_provider { OidcAuthHelper::OIDC_FACIAL_MATCH_ISSUER } + created_at { Time.zone.now } + updated_at { Time.zone.now } + end +end diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 541ba235873..f077542efbc 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -87,6 +87,7 @@ timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', + reason_codes: {}, can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], vendor_name: 'ResolutionMock', @@ -112,6 +113,7 @@ errors: {}, exception: nil, reference: '', + reason_codes: {}, success: true, timed_out: false, transaction_id: '', @@ -125,6 +127,7 @@ errors: {}, exception: nil, reference: '', + reason_codes: {}, success: false, timed_out: false, transaction_id: '', @@ -165,6 +168,7 @@ residential_address: { errors: {}, exception: nil, reference: 'aaa-bbb-ccc', + reason_codes: {}, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', @@ -180,6 +184,7 @@ errors: {}, exception: nil, reference: '', + reason_codes: {}, success: false, timed_out: false, transaction_id: '', @@ -209,10 +214,10 @@ { 'IdV: intro visited' => {}, 'IdV: doc auth welcome visited' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth welcome submitted' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { step: 'agreement', analytics_id: 'Doc Auth' @@ -330,10 +335,10 @@ { 'IdV: intro visited' => {}, 'IdV: doc auth welcome visited' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth welcome submitted' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { step: 'agreement', analytics_id: 'Doc Auth' @@ -455,10 +460,10 @@ { 'IdV: intro visited' => {}, 'IdV: doc auth welcome visited' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth welcome submitted' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { step: 'agreement', analytics_id: 'Doc Auth' @@ -557,10 +562,10 @@ let(:in_person_path_events) do { 'IdV: doc auth welcome visited' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth welcome submitted' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { step: 'agreement', analytics_id: 'Doc Auth' @@ -696,10 +701,10 @@ { 'IdV: intro visited' => {}, 'IdV: doc auth welcome visited' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth welcome submitted' => { - step: 'welcome', analytics_id: 'Doc Auth', doc_auth_vendor: 'mock' + step: 'welcome', analytics_id: 'Doc Auth' }, 'IdV: doc auth agreement visited' => { step: 'agreement', analytics_id: 'Doc Auth' @@ -1112,7 +1117,7 @@ complete_doc_auth_steps_before_document_capture_step attach_images click_continue - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images diff --git a/spec/features/idv/doc_auth/choose_id_type_spec.rb b/spec/features/idv/doc_auth/choose_id_type_spec.rb index dcf519aceb8..5d3e936150e 100644 --- a/spec/features/idv/doc_auth/choose_id_type_spec.rb +++ b/spec/features/idv/doc_auth/choose_id_type_spec.rb @@ -20,6 +20,8 @@ .and_return(doc_auth_passports_percent) allow_any_instance_of(ServiceProviderSession).to receive(:sp_name) .and_return(ipp_service_provider) + allow(IdentityConfig.store).to receive(:doc_auth_passport_vendor_default) + .and_return(Idp::Constants::Vendors::SOCURE) stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) .to_return({ status: 200, body: { status: 'UP' }.to_json }) reload_ab_tests @@ -34,7 +36,7 @@ reload_ab_tests end - context 'desktop flow', :js do + context 'desktop flow', :js, :allow_browser_log do before do complete_doc_auth_steps_before_hybrid_handoff_step end @@ -52,12 +54,39 @@ ) choose(t('doc_auth.forms.id_type_preference.passport')) click_on t('forms.buttons.continue') - expect(page).to have_current_path(idv_document_capture_url) + expect(page).to have_current_path(idv_socure_document_capture_url) visit idv_choose_id_type_url expect(page).to have_checked_field( 'doc_auth_choose_id_type_preference_passport', visible: :all, ) + choose(t('doc_auth.forms.id_type_preference.drivers_license')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_document_capture_url) + end + + it 'shows choose id type screen and continues after license option' do + expect(page).to have_content(t('doc_auth.headings.upload_from_computer')) + click_on t('forms.buttons.upload_photos') + expect(page).to have_current_path(idv_choose_id_type_url) + expect(fake_analytics).to have_logged_event( + :passport_api_health_check, + step: 'choose_id_type', + success: true, + body: '{"status":"UP"}', + errors: {}, + ) + choose(t('doc_auth.forms.id_type_preference.drivers_license')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_document_capture_url) + visit idv_choose_id_type_url + expect(page).to have_checked_field( + 'doc_auth_choose_id_type_preference_drivers_license', + visible: :all, + ) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_socure_document_capture_url) end end diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index f009108fe2c..b4a9ee6602d 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -171,7 +171,7 @@ click_continue expect(page).to have_title(t('doc_auth.headings.selfie_capture')) expect(page).to have_content(t('doc_auth.tips.document_capture_selfie_text1')) - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images expect(page).to have_content(t('doc_auth.headings.capture_complete')) @@ -185,7 +185,7 @@ ), ) click_continue - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie( Rails.root.join( 'spec', 'fixtures', @@ -253,6 +253,7 @@ it 'happy path' do # Navigate to choose ID type page and select passport visit idv_choose_id_type_url + choose(t('doc_auth.forms.id_type_preference.passport')) click_on t('forms.buttons.continue') @@ -577,7 +578,7 @@ attach_images click_continue expect_doc_capture_selfie_subheader - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images @@ -592,6 +593,78 @@ expect(page).to have_current_path(idv_phone_url) end end + + context 'with a valid passport', driver: :headless_chrome_mobile do + let(:passports_enabled) { true } + let(:doc_auth_passport_selfie_enabled) { true } + let(:passport_image) do + Rails.root.join( + 'spec', 'fixtures', + 'passport_credential.yml' + ) + end + let(:fake_dos_api_endpoint) { 'http://fake_dos_api_endpoint/' } + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) + .and_return(true) + allow_any_instance_of(ServiceProvider).to receive( + :in_person_proofing_enabled, + ).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) + allow(IdentityConfig.store).to receive(:dos_passport_mrz_endpoint) + .and_return(fake_dos_api_endpoint) + allow(IdentityConfig.store).to receive(:doc_auth_passport_selfie_enabled) + .and_return(doc_auth_passport_selfie_enabled) + stub_request(:post, fake_dos_api_endpoint) + .to_return_json({ status: 200, body: { response: 'YES' } }) + stub_health_check_settings + stub_health_check_endpoints_success + reload_ab_tests + end + + it 'proceeds to the next page with valid info, including a selfie image' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_hybrid_handoff_step + + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + + expect(page).to have_current_path(idv_document_capture_url, wait: 10) + + click_button t('doc_auth.buttons.take_picture') + expect(page).to have_content(t('doc_auth.headings.document_capture_passport')) + + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_passport_image(passport_image) + click_continue + expect_doc_capture_selfie_subheader + click_button t('doc_auth.buttons.take_picture') + attach_selfie + submit_images + + expect(page).to have_content(t('doc_auth.headings.capture_complete')) + fill_out_ssn_form_ok + click_idv_continue + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + expect(page).to have_content(t('doc_auth.headings.address')) + fill_in 'idv_form_address1', with: '123 Main St' + fill_in 'idv_form_city', with: 'Nowhere' + select 'Virginia', from: 'idv_form_state' + fill_in 'idv_form_zipcode', with: '66044' + click_idv_continue + expect(page).to have_current_path(idv_verify_info_path) + expect(page).to have_content('VA') + expect(page).to have_content('123 Main St') + expect(page).to have_content('Nowhere') + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + end end context 'with ipp enabled' do @@ -640,7 +713,7 @@ perform_in_browser(:mobile) do use_id_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') click_continue - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') click_idv_submit_default expect(page).not_to have_content(t('doc_auth.headings.capture_complete')) expect(page).not_to have_content(t('doc_auth.errors.rate_limited_heading')) @@ -874,7 +947,7 @@ attach_images click_continue expect_doc_capture_selfie_subheader - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images diff --git a/spec/features/idv/doc_auth/redo_document_capture_spec.rb b/spec/features/idv/doc_auth/redo_document_capture_spec.rb index 90e9c292b12..f86ff3ec0e5 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -157,7 +157,7 @@ click_continue expect(page).to have_title(t('doc_auth.headings.selfie_capture')) expect(page).to have_content(t('doc_auth.tips.document_capture_selfie_text1')) - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images expect(page).to have_content(t('doc_auth.headings.capture_complete')) @@ -171,7 +171,7 @@ ), ) click_continue - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie( Rails.root.join( 'spec', 'fixtures', @@ -433,7 +433,7 @@ attach_images click_continue expect_doc_capture_selfie_subheader - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images @@ -457,7 +457,7 @@ perform_in_browser(:mobile) do use_id_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') click_continue - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') click_idv_submit_default expect(page).not_to have_content(t('doc_auth.headings.capture_complete')) expect(page).not_to have_content(t('doc_auth.errors.rate_limited_heading')) @@ -689,7 +689,7 @@ attach_images click_continue expect_doc_capture_selfie_subheader - click_button 'Take photo' + click_button t('doc_auth.buttons.take_picture') attach_selfie submit_images diff --git a/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb index a743d64b581..08d426e3b94 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_choose_id_type_spec.rb @@ -12,6 +12,8 @@ allow(IdentityConfig.store).to receive(:doc_auth_passports_enabled).and_return(true) allow(IdentityConfig.store).to receive(:doc_auth_passports_percent).and_return(100) allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return('mock') + allow(IdentityConfig.store).to receive(:doc_auth_passport_vendor_default) + .and_return(Idp::Constants::Vendors::SOCURE) stub_request(:get, IdentityConfig.store.dos_passport_composite_healthcheck_endpoint) .to_return({ status: 200, body: { status: 'UP' }.to_json }) allow(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| @@ -41,12 +43,16 @@ expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) choose(t('doc_auth.forms.id_type_preference.passport')) click_on t('forms.buttons.continue') - expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).to have_current_path(idv_hybrid_mobile_socure_document_capture_url) visit idv_hybrid_mobile_choose_id_type_url expect(page).to have_checked_field( 'doc_auth_choose_id_type_preference_passport', visible: :all, ) + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose(t('doc_auth.forms.id_type_preference.drivers_license')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) end end @@ -72,6 +78,9 @@ 'doc_auth_choose_id_type_preference_drivers_license', visible: :all, ) + choose(t('doc_auth.forms.id_type_preference.passport')) + click_on t('forms.buttons.continue') + expect(page).to have_current_path(idv_hybrid_mobile_socure_document_capture_url) end end diff --git a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb index 99f942f9949..064667c8d25 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb @@ -4,6 +4,7 @@ include IdvHelper include IdvStepHelper include DocAuthHelper + include DocCaptureHelper include AbTestsHelper include PassportApiHelpers @@ -304,6 +305,88 @@ verify_phone_otp end end + + context 'selfie is required' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_passport_selfie_enabled) + .and_return(true) + end + + it 'works with valid passport data' do + user = nil + + perform_in_browser(:desktop) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + + user = sign_up_and_2fa_ial1_user + + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + + expect(page).to have_content(t('doc_auth.headings.text_message')) + expect(page).to have_content(t('doc_auth.info.you_entered')) + expect(page).to have_content('+1 415-555-0199') + + # Confirm that Continue button is not shown when polling is enabled + expect(page).not_to have_content(t('doc_auth.buttons.continue')) + end + + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_choose_id_type_url) + choose_id_type(:passport) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + attach_passport_image(passport_image) + click_continue + expect_doc_capture_selfie_subheader + click_button t('doc_auth.buttons.take_picture') + attach_selfie + submit_images + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + # Confirm app disallows jumping back to DocumentCapture page + visit idv_hybrid_mobile_document_capture_url + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + end + + perform_in_browser(:desktop) do + expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) + expect(page).to have_current_path(idv_ssn_path) + + fill_out_ssn_form_ok + click_idv_continue + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) + expect(page).to have_content(t('doc_auth.headings.address')) + fill_in 'idv_form_address1', with: '123 Main St' + fill_in 'idv_form_city', with: 'Nowhere' + select 'Virginia', from: 'idv_form_state' + fill_in 'idv_form_zipcode', with: '66044' + click_idv_continue + expect(page).to have_current_path(idv_verify_info_path) + expect(page).to have_content('VA') + expect(page).to have_content('123 Main St') + expect(page).to have_content('Nowhere') + complete_verify_step + + prefilled_phone = page.find(id: 'idv_phone_form_phone').value + + expect( + PhoneFormatter.format(prefilled_phone), + ).to eq( + PhoneFormatter.format(user.default_phone_configuration.phone), + ) + + fill_out_phone_form_ok + verify_phone_otp + end + end + end end context 'invalid passport data', js: true do diff --git a/spec/jobs/socure_shadow_mode_proofing_job_spec.rb b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb index aa5970102e3..daa56c87287 100644 --- a/spec/jobs/socure_shadow_mode_proofing_job_spec.rb +++ b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb @@ -269,7 +269,8 @@ socure_result: { attributes_requiring_additional_verification: [], can_pass_with_additional_verification: false, - errors: { + errors: {}, + reason_codes: { 'I123' => 'Person is over seven feet tall.', 'R890' => 'Help! I am trapped in a reason code factory!', }, @@ -403,8 +404,8 @@ expect(analytics).to have_logged_event( :idv_socure_shadow_mode_proofing_result, satisfy do |attributes| - errors = attributes.dig(:socure_result, :errors) - expect(errors).to include( + reason_codes = attributes.dig(:socure_result, :reason_codes) + expect(reason_codes).to include( 'I000' => '[unknown]', ) end, diff --git a/spec/lib/action_account_spec.rb b/spec/lib/action_account_spec.rb index c11e8b52c33..7dd62cf6c9a 100644 --- a/spec/lib/action_account_spec.rb +++ b/spec/lib/action_account_spec.rb @@ -491,4 +491,286 @@ end end end + + describe ActionAccount::DeactivateDuplicate do + subject(:subtask) { ActionAccount::DeactivateDuplicate.new } + + describe '#run' do + let(:analytics) { FakeAnalytics.new } + + before do + allow(Analytics).to receive(:new).and_return(analytics) + end + + let(:include_missing) { true } + let(:config) { ScriptBase::Config.new(include_missing:, reason: 'INV1234') } + let(:args) { [user.uuid] } + subject(:result) { subtask.run(args:, config:) } + + context 'when the user has no active profile' do + let(:user) do + create( + :profile, + :deactivated, + ).user + end + + it 'reports that the profile is not active' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, "Error: User's profile is not active", 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_deactivate_duplicate_profile, + success: false, + errors: { message: "Error: User's profile is not active" }, + ) + end + end + + context 'when the profile has not been flagged as a duplicate' do + let(:user) do + create( + :profile, + :active, + ).user + end + + it 'reports that the profile has not been flagged as a duplicate' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, 'Error: Profile not a duplicate', 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_deactivate_duplicate_profile, + success: false, + errors: { message: 'Error: Profile not a duplicate' }, + ) + end + end + + context 'when the profile has been flagged as a duplicate' do + let(:profile) do + create( + :profile, + :active, + ) + end + let(:user) { profile.user } + let!(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: [user.profiles.active.sole.id], + ) + end + + it 'deactivates the profile for duplicate' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, "User's profile has been deactivated and the user has been notified", + 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_deactivate_duplicate_profile, + success: true, + ) + expect(profile.reload).not_to be_active + end + end + end + end + + describe ActionAccount::ClearDuplicate do + subject(:subtask) { ActionAccount::ClearDuplicate.new } + + describe '#run' do + let(:analytics) { FakeAnalytics.new } + + before do + allow(Analytics).to receive(:new).and_return(analytics) + end + + let(:include_missing) { true } + let(:config) { ScriptBase::Config.new(include_missing:, reason: 'INV1234') } + let(:args) { [user.uuid] } + subject(:result) { subtask.run(args:, config:) } + + context 'when the user has no active profile' do + let(:user) do + create( + :profile, + :deactivated, + ).user + end + + it 'reports that the profile is not active' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, "Error: User's profile is not active", 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_clear_duplicate_profile, + success: false, + errors: { message: "Error: User's profile is not active" }, + ) + end + end + + context 'when the profile has not been flagged as a duplicate' do + let(:user) do + create( + :profile, + :active, + ).user + end + + it 'reports that the profile has not been flagged as a duplicate' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, 'Error: Profile not a duplicate', 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_clear_duplicate_profile, + success: false, + errors: { message: 'Error: Profile not a duplicate' }, + ) + end + end + + context 'when the profile has been flagged as a duplicate' do + let(:profile) do + create( + :profile, + :active, + ) + end + let(:user) { profile.user } + let!(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: [user.profiles.active.sole.id], + ) + end + + it 'clears the profile for duplicate' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, "User's profile has been cleared and the user has been notified", + 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_clear_duplicate_profile, + success: true, + ) + expect(profile.reload).to be_active + end + end + end + end + + describe ActionAccount::CloseInconclusiveDuplicate do + subject(:subtask) { ActionAccount::CloseInconclusiveDuplicate.new } + + describe '#run' do + let(:analytics) { FakeAnalytics.new } + + before do + allow(Analytics).to receive(:new).and_return(analytics) + end + + let(:include_missing) { true } + let(:config) { ScriptBase::Config.new(include_missing:, reason: 'INV1234') } + let(:args) { [user.uuid] } + subject(:result) { subtask.run(args:, config:) } + + context 'when the user has no active profile' do + let(:user) do + create( + :profile, + :deactivated, + ).user + end + + it 'reports that the profile is not active' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, "Error: User's profile is not active", 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_close_inconclusive_duplicate, + success: false, + errors: { message: "Error: User's profile is not active" }, + ) + end + end + + context 'when the profile has not been flagged as a duplicate' do + let(:user) do + create( + :profile, + :active, + ).user + end + + it 'reports that the profile has not been flagged as a duplicate' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, 'Error: Profile not a duplicate', 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_close_inconclusive_duplicate, + success: false, + errors: { message: 'Error: Profile not a duplicate' }, + ) + end + end + + context 'when the profile has been flagged as a duplicate' do + let(:profile) do + create( + :profile, + :active, + ) + end + let(:user) { profile.user } + let!(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: [user.profiles.active.sole.id], + ) + end + + it 'logs an event and leaves the profile active' do + expect(result.table).to match_array( + [ + ['uuid', 'status', 'reason'], + [user.uuid, 'User has been notified that the fraud investigation is inconclusive', + 'INV1234'], + ], + ) + expect(analytics).to have_logged_event( + :one_account_close_inconclusive_duplicate, + success: true, + ) + expect(profile.reload).to be_active + end + end + end + end end diff --git a/spec/models/duplicate_profile_spec.rb b/spec/models/duplicate_profile_spec.rb new file mode 100644 index 00000000000..342db4d0d36 --- /dev/null +++ b/spec/models/duplicate_profile_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe DuplicateProfile, type: :model do + let(:service_provider) { create(:service_provider, issuer: 'test-sp') } + let(:profile_id) { 123 } + let(:other_profile_id) { 456 } + let!(:matching_profile) do + create( + :duplicate_profile, service_provider: service_provider.issuer, + profile_ids: [profile_id, other_profile_id] + ) + end + let!(:non_matching_service) do + create( + :duplicate_profile, service_provider: 'other-sp', + profile_ids: [profile_id, other_profile_id] + ) + end + let!(:non_matching_profile) do + create(:duplicate_profile, service_provider: service_provider.issuer, profile_ids: [999, 888]) + end + + it 'returns record matching both service_provider and profile_id' do + result = described_class.involving_profile( + profile_id: profile_id, + service_provider: service_provider.issuer, + ) + + expect(result).to eq(matching_profile) + expect(result).not_to eq(non_matching_service) + expect(result).not_to eq(non_matching_profile) + end + + it 'returns nil result when profile_id is not duplicate profile' do + result = described_class.involving_profile( + profile_id: 777, + service_provider: service_provider.issuer, + ) + + expect(result).to eq(nil) + end +end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index d1555ddd05a..4bb12ec6b0b 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -1177,6 +1177,210 @@ end end + describe '#deactivate_duplicate' do + context 'when the profile is not active' do + let(:profile) { create(:profile, :deactivated) } + it 'raises an exception' do + expect { profile.deactivate_duplicate }.to raise_error('Profile not active') + end + end + + context 'when the profile is active' do + let(:profile) { create(:profile, :active) } + context 'when the profile is not identified as a duplicate' do + it 'raises an exception' do + expect { profile.deactivate_duplicate }.to raise_error('Profile not a duplicate') + end + end + + context 'when the profile is identified as a duplicate' do + let!(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: profile_ids, + ) + end + + context 'when the profile is the only one in its duplicate set' do + let(:profile_ids) { [profile.id] } + + it 'deactivates the profile', :freeze_time do + profile.deactivate_duplicate + expect(profile).to_not be_active + expect(profile.fraud_rejection_at).to eq(Time.zone.now) + end + + it 'closes the case as resolved by fraud', :freeze_time do + profile.deactivate_duplicate + duplicate_profile.reload + expect(duplicate_profile.profile_ids).to include(profile.id) + expect(duplicate_profile.closed_at).to eq(Time.zone.now) + expect(duplicate_profile.self_serviced).to be(false) + expect(duplicate_profile.fraud_investigation_conclusive).to be(true) + end + + it 'notifies the user' do + expect { profile.deactivate_duplicate } + .to(change { ActionMailer::Base.deliveries.count }.by(1)) + end + end + + context 'when there are other profiles in the duplicate set' do + let(:second_profile) { create(:profile, :active) } + let(:profile_ids) { [profile.id, second_profile.id] } + + it 'deactivates the profile', :freeze_time do + profile.deactivate_duplicate + expect(profile).to_not be_active + expect(profile.fraud_rejection_at).to eq(Time.zone.now) + end + + it 'does not close the case', :freeze_time do + profile.deactivate_duplicate + duplicate_profile.reload + expect(duplicate_profile.profile_ids).not_to include(profile.id) + expect(duplicate_profile.closed_at).to be(nil) + expect(duplicate_profile.self_serviced).to be(nil) + expect(duplicate_profile.fraud_investigation_conclusive).to be(nil) + end + + it 'notifies the user' do + expect { profile.deactivate_duplicate } + .to(change { ActionMailer::Base.deliveries.count }.by(1)) + end + end + end + end + end + + describe '#clear_duplicate' do + context 'when the profile is not active' do + let(:profile) { create(:profile, :deactivated) } + it 'raises an exception' do + expect { profile.clear_duplicate }.to raise_error('Profile not active') + end + end + + context 'when the profile is active' do + let(:profile) { create(:profile, :active) } + context 'when the profile is not identified as a duplicate' do + it 'raises an exception' do + expect { profile.clear_duplicate }.to raise_error('Profile not a duplicate') + end + end + + context 'when the profile is identified as a duplicate' do + let!(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: profile_ids, + ) + end + + context 'when the profile is the only one in its duplicate set' do + let(:profile_ids) { [profile.id] } + + it 'leaves the profile as active', :freeze_time do + profile.clear_duplicate + expect(profile).to be_active + end + + it 'closes the case as resolved by fraud', :freeze_time do + profile.clear_duplicate + duplicate_profile.reload + expect(duplicate_profile.profile_ids).to include(profile.id) + expect(duplicate_profile.closed_at).to eq(Time.zone.now) + expect(duplicate_profile.self_serviced).to be(false) + expect(duplicate_profile.fraud_investigation_conclusive).to be(true) + end + + it 'notifies the user' do + expect { profile.clear_duplicate } + .to(change { ActionMailer::Base.deliveries.count }.by(1)) + end + end + + context 'when there are other profiles in the duplicate set' do + let(:second_profile) { create(:profile, :active) } + let(:profile_ids) { [profile.id, second_profile.id] } + + it 'raises an exception' do + expect { profile.clear_duplicate }.to raise_error('Profile has other duplicates') + end + end + end + end + end + + describe '#close_inconclusive_duplicate' do + context 'when the profile is not active' do + let(:profile) { create(:profile, :deactivated) } + it 'raises an exception' do + expect { profile.close_inconclusive_duplicate }.to raise_error('Profile not active') + end + end + + context 'when the profile is active' do + let(:profile) { create(:profile, :active) } + context 'when the profile is not identified as a duplicate' do + it 'raises an exception' do + expect { profile.close_inconclusive_duplicate } + .to raise_error('Profile not a duplicate') + end + end + + context 'when the profile is identified as a duplicate' do + let!(:duplicate_profile) do + create( + :duplicate_profile, + profile_ids: profile_ids, + ) + end + + context 'when the profile is the only one in its duplicate set' do + let(:profile_ids) { [profile.id] } + + it 'leaves the profile as active', :freeze_time do + profile.close_inconclusive_duplicate + expect(profile).to be_active + end + + it 'closes the case as inconclusive', :freeze_time do + profile.close_inconclusive_duplicate + duplicate_profile.reload + expect(duplicate_profile.closed_at).to eq(Time.zone.now) + expect(duplicate_profile.self_serviced).to be(false) + expect(duplicate_profile.fraud_investigation_conclusive).to be(false) + end + end + + context 'when there are other profiles in the duplicate set' do + let(:second_profile) { create(:profile, :active) } + let(:profile_ids) { [profile.id, second_profile.id] } + + it 'leaves the profile as active', :freeze_time do + profile.close_inconclusive_duplicate + expect(profile).to be_active + end + + it 'does not close the case', :freeze_time do + profile.close_inconclusive_duplicate + duplicate_profile.reload + expect(duplicate_profile.profile_ids).not_to include(profile.id) + expect(duplicate_profile.closed_at).to be(nil) + expect(duplicate_profile.self_serviced).to be(nil) + expect(duplicate_profile.fraud_investigation_conclusive).to be(nil) + end + + it 'notifies the user' do + expect { profile.close_inconclusive_duplicate } + .to(change { ActionMailer::Base.deliveries.count }.by(1)) + end + end + end + end + end + describe '#profile_age_in_seconds' do it 'logs the time since the created_at timestamp', :freeze_time do profile = create(:profile, created_at: 5.minutes.ago) diff --git a/spec/presenters/attempts_api_certs_presenter_spec.rb b/spec/presenters/attempts_api_certs_presenter_spec.rb new file mode 100644 index 00000000000..c2a8c90c8c4 --- /dev/null +++ b/spec/presenters/attempts_api_certs_presenter_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe AttemptsApiCertsPresenter do + let(:signing_key) { OpenSSL::PKey::EC.generate('prime256v1') } + subject(:presenter) { described_class.new } + + describe '#certs' do + describe 'when attempts signing is enabled' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_enabled).and_return(true) + end + + describe 'when the attempts signing key is present' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_key).and_return( + signing_key.to_pem, + ) + end + it 'renders the attempts api signing key as a JWK set' do + json = presenter.certs + + expect(json[:keys].size).to eq(1) + expect(json[:keys].first[:alg]).to eq 'ES256' + expect(json[:keys].first[:use]).to eq 'sig' + end + end + + describe 'when the attempts signing key is not present' do + it 'raises a SigningKeyError' do + expect { presenter.certs }.to raise_error( + AttemptsApi::AttemptEvent::SigningKey::SigningKeyError, + 'Attempts API signing key is not configured', + ) + end + end + end + + describe 'when attempts signing is not enabled' do + it 'renders an empty JWK set' do + json = presenter.certs + + expect(json[:keys]).to eq [] + end + end + end +end diff --git a/spec/presenters/attempts_configuration_presenter_spec.rb b/spec/presenters/attempts_configuration_presenter_spec.rb index a0a2d252b8e..192e15ab27c 100644 --- a/spec/presenters/attempts_configuration_presenter_spec.rb +++ b/spec/presenters/attempts_configuration_presenter_spec.rb @@ -11,7 +11,7 @@ it 'includes information about the RISC integration' do aggregate_failures do expect(configuration[:issuer]).to eq(root_url) - expect(configuration[:jwks_uri]).to eq(api_openid_connect_certs_url) + expect(configuration[:jwks_uri]).to eq(api_attempts_certs_url) expect(configuration[:delivery_methods_supported]) .to eq([AttemptsConfigurationPresenter::DELIVERY_METHOD_POLL]) diff --git a/spec/presenters/duplicate_profiles_detected_presenter_spec.rb b/spec/presenters/duplicate_profiles_detected_presenter_spec.rb index 20dfb8a046b..2953c4a0bdf 100644 --- a/spec/presenters/duplicate_profiles_detected_presenter_spec.rb +++ b/spec/presenters/duplicate_profiles_detected_presenter_spec.rb @@ -2,15 +2,25 @@ RSpec.describe DuplicateProfilesDetectedPresenter do let(:user) { create(:user, :proofed_with_selfie) } - let(:user_session) { {} } - let(:presenter) { described_class.new(user: user, user_session: user_session) } + let(:dupe_profile) do + create( + :duplicate_profile, + profile_ids: [user.active_profile.id, profile2.id], + service_provider: 'test-sp', + ) + end + let(:presenter) { described_class.new(user: user, dupe_profile: dupe_profile) } let(:profile2) { create(:profile, :facial_match_proof) } describe '#associated_profiles' do context 'when multiple duplicate profiles were found for user' do let(:profile3) { create(:profile, :facial_match_proof) } - before do - user_session[:duplicate_profile_ids] = [profile2.id, profile3.id] + let(:dupe_profile) do + create( + :duplicate_profile, + profile_ids: [user.active_profile.id, profile2.id, profile3.id], + service_provider: 'test-sp', + ) end it 'should return multiple elements and user element' do @@ -19,9 +29,6 @@ end context 'when a single duplicate profiles were found for user' do - before do - user_session[:duplicate_profile_ids] = [profile2.id] - end it 'should return user element and other profile' do expect(presenter.associated_profiles.count).to eq(2) end diff --git a/spec/services/attempts_api/attempt_event_spec.rb b/spec/services/attempts_api/attempt_event_spec.rb index 3ed0446151e..9a182ee3981 100644 --- a/spec/services/attempts_api/attempt_event_spec.rb +++ b/spec/services/attempts_api/attempt_event_spec.rb @@ -4,6 +4,10 @@ let(:attempts_api_private_key) { OpenSSL::PKey::RSA.new(2048) } let(:attempts_api_public_key) { attempts_api_private_key.public_key } + let(:signing_key) { OpenSSL::PKey::EC.generate('prime256v1') } + let(:singing_private_key) { signing_key.private_to_pem } + let(:signing_public_key) { OpenSSL::PKey::EC.new(signing_key.public_to_pem) } + let(:jti) { 'test-unique-id' } let(:iat) { Time.zone.now.to_i } let(:event_type) { 'test-event' } @@ -24,47 +28,157 @@ end describe '#to_jwe' do - it 'returns a JWE for the event' do - jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + describe 'attempts event singing is enabled' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_enabled).and_return(true) + end + describe 'when the attempts signing key is present' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_key).and_return( + signing_key.to_pem, + ) + end + it 'returns a JWE for the event' do + jwe = subject.to_jwe( + issuer: service_provider.issuer, + public_key: attempts_api_public_key, + ) + + header_str, *_rest = JWE::Serialization::Compact.decode(jwe) + headers = JSON.parse(header_str) - header_str, *_rest = JWE::Serialization::Compact.decode(jwe) - headers = JSON.parse(header_str) + expect(headers['alg']).to eq('RSA-OAEP') + expect(headers['kid']).to eq(JWT::JWK.new(attempts_api_public_key).kid) - expect(headers['alg']).to eq('RSA-OAEP') - expect(headers['kid']).to eq(JWT::JWK.new(attempts_api_public_key).kid) + decrypted_jwe_payload = JWE.decrypt(jwe, attempts_api_private_key) + decoded_jwe_payload = JWT.decode( + decrypted_jwe_payload, + signing_public_key, + true, + { algorithm: 'ES256' }, + ) - decrypted_jwe_payload = JWE.decrypt(jwe, attempts_api_private_key) + token = JSON.parse(decoded_jwe_payload.first) - token = JSON.parse(decrypted_jwe_payload) + expect(token['iss']).to eq(Rails.application.routes.url_helpers.root_url) + expect(token['jti']).to eq(jti) + expect(token['iat']).to eq(iat) + expect(token['aud']).to eq(service_provider.issuer) - expect(token['iss']).to eq(Rails.application.routes.url_helpers.root_url) - expect(token['jti']).to eq(jti) - expect(token['iat']).to eq(iat) - expect(token['aud']).to eq(service_provider.issuer) + event_key = 'https://schemas.login.gov/secevent/attempts-api/event-type/test-event' + event_data = token['events'][event_key] - event_key = 'https://schemas.login.gov/secevent/attempts-api/event-type/test-event' - event_data = token['events'][event_key] + expect(event_data['subject']).to eq( + 'subject_type' => 'session', 'session_id' => 'test-session-id', + ) + expect(event_data['foo']).to eq('bar') + expect(event_data['occurred_at']).to eq(occurred_at.to_f) + end + end - expect(event_data['subject']).to eq( - 'subject_type' => 'session', 'session_id' => 'test-session-id', - ) - expect(event_data['foo']).to eq('bar') - expect(event_data['occurred_at']).to eq(occurred_at.to_f) + describe 'when the attempts signing key is not present' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_key).and_return('') + end + it 'raises an error' do + expect do + subject.to_jwe( + issuer: service_provider.issuer, + public_key: attempts_api_public_key, + ) + end.to raise_error( + AttemptsApi::AttemptEvent::SigningKey::SigningKeyError, + 'Attempts API signing key is not configured', + ) + end + end + end + + describe 'attempts event signing is not enabled' do + it 'returns a JWE for the event' do + jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + + header_str, *_rest = JWE::Serialization::Compact.decode(jwe) + headers = JSON.parse(header_str) + + expect(headers['alg']).to eq('RSA-OAEP') + expect(headers['kid']).to eq(JWT::JWK.new(attempts_api_public_key).kid) + + decrypted_jwe_payload = JWE.decrypt(jwe, attempts_api_private_key) + + token = JSON.parse(decrypted_jwe_payload) + + expect(token['iss']).to eq(Rails.application.routes.url_helpers.root_url) + expect(token['jti']).to eq(jti) + expect(token['iat']).to eq(iat) + expect(token['aud']).to eq(service_provider.issuer) + + event_key = 'https://schemas.login.gov/secevent/attempts-api/event-type/test-event' + event_data = token['events'][event_key] + + expect(event_data['subject']).to eq( + 'subject_type' => 'session', 'session_id' => 'test-session-id', + ) + expect(event_data['foo']).to eq('bar') + expect(event_data['occurred_at']).to eq(occurred_at.to_f) + end end end describe '.from_jwe' do - it 'returns an event decrypted from the JWE' do - jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + describe 'when attempts signing is not enabled' do + it 'returns an event decrypted from the JWE' do + jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + + decoded_event = described_class.from_jwe(jwe, attempts_api_private_key) + + expect(decoded_event.jti).to eq(subject.jti) + expect(decoded_event.iat).to eq(subject.iat) + expect(decoded_event.event_type).to eq(subject.event_type) + expect(decoded_event.session_id).to eq(subject.session_id) + expect(decoded_event.occurred_at).to eq(subject.occurred_at) + expect(decoded_event.event_metadata).to eq(subject.event_metadata.symbolize_keys) + end + end + + describe 'when attempts signing is enabled' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_enabled).and_return(true) + end + + describe 'when the attempts signing key is present' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_key).and_return( + signing_key.to_pem, + ) + end + it 'returns an event decrypted from the JWE' do + jwe = subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + + decoded_event = described_class.from_jwe(jwe, attempts_api_private_key) - decrypted_event = described_class.from_jwe(jwe, attempts_api_private_key) + expect(decoded_event.jti).to eq(subject.jti) + expect(decoded_event.iat).to eq(subject.iat) + expect(decoded_event.event_type).to eq(subject.event_type) + expect(decoded_event.session_id).to eq(subject.session_id) + expect(decoded_event.occurred_at).to eq(subject.occurred_at) + expect(decoded_event.event_metadata).to eq(subject.event_metadata.symbolize_keys) + end + end - expect(decrypted_event.jti).to eq(subject.jti) - expect(decrypted_event.iat).to eq(subject.iat) - expect(decrypted_event.event_type).to eq(subject.event_type) - expect(decrypted_event.session_id).to eq(subject.session_id) - expect(decrypted_event.occurred_at).to eq(subject.occurred_at) - expect(decrypted_event.event_metadata).to eq(subject.event_metadata.symbolize_keys) + describe 'when the attempts signing key is not present' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_signing_key).and_return('') + end + it 'raises an error' do + expect do + subject.to_jwe(issuer: service_provider.issuer, public_key: attempts_api_public_key) + end.to raise_error( + AttemptsApi::AttemptEvent::SigningKey::SigningKeyError, + 'Attempts API signing key is not configured', + ) + end + end end end end diff --git a/spec/services/duplicate_profile_checker_spec.rb b/spec/services/duplicate_profile_checker_spec.rb index 2ba069741df..51e96507b2b 100644 --- a/spec/services/duplicate_profile_checker_spec.rb +++ b/spec/services/duplicate_profile_checker_spec.rb @@ -41,8 +41,11 @@ sp: sp, ) dupe_profile_checker.check_for_duplicate_profiles - - expect(session[:duplicate_profile_ids]).to be(nil) + dupe_profile_object = DuplicateProfile.involving_profile( + profile_id: profile.id, + service_provider: sp.issuer, + ) + expect(dupe_profile_object).to eq(nil) end end @@ -56,27 +59,28 @@ ) end let!(:profile2) do - profile = create( + create( :profile, :active, :facial_match_proof, user: user2, initiating_service_provider_issuer: sp.issuer, ) - profile.encrypt_pii(active_pii, user2.password) - profile.save end before do session[:encrypted_profiles] = { profile.id.to_s => SessionEncryptor.new.kms_encrypt(active_pii.to_json), } + + allow_any_instance_of(Idv::DuplicateSsnFinder) + .to receive(:duplicate_facial_match_profiles) + .and_return([profile2]) end it 'creates a new duplicate profile confirmation entry' do allow(IdentityConfig.store).to receive(:eligible_one_account_providers) .and_return([sp.issuer]) - expect(session[:duplicate_profile_ids]).to be(nil) dupe_profile_checker = DuplicateProfileChecker.new( user: user, @@ -84,7 +88,12 @@ sp: sp, ) dupe_profile_checker.check_for_duplicate_profiles - expect(session[:duplicate_profile_ids]).to eq([user2.profiles.last.id]) + + dupe_profile_objects = DuplicateProfile.involving_profile( + profile_id: profile.id, + service_provider: sp.issuer, + ) + expect(dupe_profile_objects.profile_ids).to eq([profile2.id, profile.id]) end end end @@ -108,7 +117,11 @@ ) dupe_profile_checker.check_for_duplicate_profiles - expect(session[:duplicate_profile_ids]).to be(nil) + dupe_profile_objects = DuplicateProfile.involving_profile( + profile_id: profile.id, + service_provider: sp.issuer, + ) + expect(dupe_profile_objects).to eq(nil) end end @@ -122,7 +135,12 @@ ) dupe_profile_checker.check_for_duplicate_profiles - expect(session[:duplicate_profile_ids]).to be(nil) + dupe_profile_objects = DuplicateProfile.involving_profile( + profile_id: profile.id, + service_provider: sp.issuer, + ) + + expect(dupe_profile_objects).to eq(nil) end end end diff --git a/spec/services/proofing/mock/resolution_mock_client_spec.rb b/spec/services/proofing/mock/resolution_mock_client_spec.rb index 4214551886a..a11fcb583ee 100644 --- a/spec/services/proofing/mock/resolution_mock_client_spec.rb +++ b/spec/services/proofing/mock/resolution_mock_client_spec.rb @@ -23,6 +23,7 @@ exception: nil, timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -47,6 +48,7 @@ exception: nil, timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -71,6 +73,7 @@ exception: nil, timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -95,6 +98,7 @@ exception: nil, timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -119,6 +123,7 @@ exception: RuntimeError.new('Failed to contact proofing vendor'), timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -143,6 +148,7 @@ exception: RuntimeError.new('Failed to contact proofing vendor'), timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -167,6 +173,7 @@ exception: Proofing::TimeoutError.new('resolution mock timeout'), timed_out: true, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, @@ -193,6 +200,7 @@ ), timed_out: false, reference: reference, + reason_codes: {}, transaction_id: transaction_id, vendor_name: 'ResolutionMock', can_pass_with_additional_verification: false, diff --git a/spec/services/proofing/socure/id_plus/proofer_spec.rb b/spec/services/proofing/socure/id_plus/proofer_spec.rb index 469610514be..ee06b37742b 100644 --- a/spec/services/proofing/socure/id_plus/proofer_spec.rb +++ b/spec/services/proofing/socure/id_plus/proofer_spec.rb @@ -71,7 +71,7 @@ end it 'reports reason codes as errors' do - expect(result.errors).to eql( + expect(result.reason_codes).to eql( { 'I905' => '[unknown]', 'I914' => '[unknown]', diff --git a/spec/support/features/document_capture_step_helper.rb b/spec/support/features/document_capture_step_helper.rb index c5415aa6dae..ad6de9e2c60 100644 --- a/spec/support/features/document_capture_step_helper.rb +++ b/spec/support/features/document_capture_step_helper.rb @@ -30,7 +30,9 @@ def attach_liveness_images( ) attach_images(file) click_continue - click_button 'Take photo' if page.has_button? 'Take photo' + if page.has_button? t('doc_auth.buttons.take_picture') + click_button t('doc_auth.buttons.take_picture') + end attach_selfie end