From 9aea0977de509c470279801dfc135c0ae4f0f4c8 Mon Sep 17 00:00:00 2001 From: Amir Reavis-Bey Date: Wed, 31 Jan 2024 13:48:09 -0500 Subject: [PATCH 01/25] LG-12041: Prevent user from resubmitting a selfie that already failed portrait matching (#9976) * init add selfie to failed images * rebase merge conflict resolved * remove comments * rebase merge conflict resolved * resolve failing tests * happy linting changelog: Upcoming Features, Document Authentication, Store fingerprint of failed selfie images and prevent user from reusing same image * add selfie to mocked response * test #store_failed_auth_data with selfie * remove guar dto store back/front fingerprints if doc auth is successful b/ failure could be doc_pii * remove irrelavant test * add feature test for resubmitting same failed selfie during doc auth * update comment * add sleep to allow warning to appear * fail selie validation to test selfie resubmisssion * feature test doc auth vs portrait match pass/fail scenarios and ability to reload a failed image * add liveness_enabled * remove unneeded comment * test storing failed images when pii validation fails * remove selfie status helper function * remove selfie_status_from_response helper * DocAuthResponse instance double to stub selfie_status * remove comma typo --- app/forms/idv/api_image_upload_form.rb | 61 +- app/helpers/application_helper.rb | 6 - app/models/document_capture_session.rb | 13 +- .../image_upload_response_presenter.rb | 2 +- app/services/analytics_events.rb | 6 +- .../lexis_nexis/responses/true_id_response.rb | 2 +- app/services/doc_auth/response.rb | 7 +- .../document_capture_session_result.rb | 12 +- .../idv/image_uploads_controller_spec.rb | 32 +- spec/features/idv/analytics_spec.rb | 18 +- .../doc_auth/redo_document_capture_spec.rb | 205 ++++- .../ial2_test_portrait_match_failure.yml | 17 + .../ial2_test_portrait_match_success.yml | 16 + ...response_failure_with_face_match_pass.json | 740 ++++++++++++++++++ spec/forms/idv/api_image_upload_form_spec.rb | 193 ++++- spec/helpers/application_helper_spec.rb | 23 - spec/models/document_capture_session_spec.rb | 50 +- .../image_upload_response_presenter_spec.rb | 20 +- .../responses/true_id_response_spec.rb | 2 + spec/support/doc_auth_image_fixtures.rb | 16 + spec/support/features/doc_auth_helper.rb | 64 ++ spec/support/lexis_nexis_fixtures.rb | 4 + 22 files changed, 1414 insertions(+), 95 deletions(-) create mode 100644 spec/fixtures/ial2_test_portrait_match_failure.yml create mode 100644 spec/fixtures/ial2_test_portrait_match_success.yml create mode 100644 spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 37399c27cbd..7227ac29c2e 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -155,6 +155,7 @@ def extra_attributes @extra_attributes[:front_image_fingerprint] = front_image_fingerprint @extra_attributes[:back_image_fingerprint] = back_image_fingerprint + @extra_attributes[:selfie_image_fingerprint] = selfie_image_fingerprint @extra_attributes[:liveness_checking_required] = liveness_checking_required @extra_attributes end @@ -175,6 +176,16 @@ def back_image_fingerprint end end + def selfie_image_fingerprint + return unless liveness_checking_required + return @selfie_image_fingerprint if @selfie_image_fingerprint + + if readable?(:selfie) + @selfie_image_fingerprint = + Digest::SHA256.urlsafe_base64digest(selfie_image_bytes) + end + end + def remaining_attempts rate_limiter.remaining_count if document_capture_session end @@ -262,6 +273,15 @@ def validate_duplicate_images side: error_sides.length == 2 ? 'both' : error_sides[0], **extra_attributes, ) end + + if capture_result&.failed_selfie_image?(selfie_image_fingerprint) + errors.add( + :selfie, t('doc_auth.errors.doc.resubmit_failed_image'), type: :duplicate_image + ) + analytics.idv_doc_auth_failed_image_resubmitted( + side: 'selfie', **extra_attributes, + ) + end end def limit_if_rate_limited @@ -439,35 +459,39 @@ def store_failed_images(client_response, doc_pii_response) return { front: [], back: [], + selfie: [], } end # doc auth failed due to non network error or doc_pii is not valid if client_response && !client_response.success? && !client_response.network_error? errors_hash = client_response.errors&.to_h || {} - ## assume both sides' error presents or both sides' error missing - failed_front_fingerprint = extra_attributes[:front_image_fingerprint] - failed_back_fingerprint = extra_attributes[:back_image_fingerprint] - ## not both sides' error present nor both sides' error missing - ## equivalent to: only one side error presents - only_one_side_error = errors_hash[:front]&.present? ^ errors_hash[:back]&.present? - if only_one_side_error - ## find which side is missing - failed_front_fingerprint = nil unless errors_hash[:front]&.present? - failed_back_fingerprint = nil unless errors_hash[:back]&.present? + failed_front_fingerprint = nil + failed_back_fingerprint = nil + if errors_hash[:front] || errors_hash[:back] + if errors_hash[:front] + failed_front_fingerprint = extra_attributes[:front_image_fingerprint] + end + if errors_hash[:back] + failed_back_fingerprint = extra_attributes[:back_image_fingerprint] + end + elsif !client_response.doc_auth_success? + failed_front_fingerprint = extra_attributes[:front_image_fingerprint] + failed_back_fingerprint = extra_attributes[:back_image_fingerprint] end - document_capture_session. - store_failed_auth_data( - front_image_fingerprint: failed_front_fingerprint, - back_image_fingerprint: failed_back_fingerprint, - doc_auth_success: client_response.doc_auth_success?, - selfie_status: selfie_status_from_response(client_response), - ) + document_capture_session.store_failed_auth_data( + front_image_fingerprint: failed_front_fingerprint, + back_image_fingerprint: failed_back_fingerprint, + selfie_image_fingerprint: extra_attributes[:selfie_image_fingerprint], + doc_auth_success: client_response.doc_auth_success?, + selfie_status: client_response.selfie_status, + ) elsif doc_pii_response && !doc_pii_response.success? document_capture_session.store_failed_auth_data( front_image_fingerprint: extra_attributes[:front_image_fingerprint], back_image_fingerprint: extra_attributes[:back_image_fingerprint], + selfie_image_fingerprint: extra_attributes[:selfie_image_fingerprint], doc_auth_success: client_response.doc_auth_success?, - selfie_status: selfie_status_from_response(client_response), + selfie_status: client_response.selfie_status, ) end # retrieve updated data from session @@ -475,6 +499,7 @@ def store_failed_images(client_response, doc_pii_response) { front: captured_result&.failed_front_image_fingerprints || [], back: captured_result&.failed_back_image_fingerprints || [], + selfie: captured_result&.failed_selfie_image_fingerprints || [], } end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3e434470734..7b0eb6c382a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -60,10 +60,4 @@ def cancel_link_text def desktop_device? !BrowserCache.parse(request.user_agent).mobile? end - - def selfie_status_from_response(client_response) - return client_response.selfie_status if client_response.respond_to?(:selfie_status) - - :not_processed - end end diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index dd36db9aae5..94ca97fe105 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -19,7 +19,7 @@ def store_result_from_response(doc_auth_response) session_result.attention_with_barcode = doc_auth_response.attention_with_barcode? session_result.selfie_check_performed = doc_auth_response.selfie_check_performed? session_result.doc_auth_success = doc_auth_response.doc_auth_success? - session_result.selfie_status = selfie_status_from_response(doc_auth_response) + session_result.selfie_status = doc_auth_response.selfie_status EncryptedRedisStructStorage.store( session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, @@ -28,8 +28,8 @@ def store_result_from_response(doc_auth_response) save! end - def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, doc_auth_success:, - selfie_status:) + def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, + selfie_image_fingerprint:, doc_auth_success:, selfie_status:) session_result = load_result || DocumentCaptureSessionResult.new( id: generate_result_id, ) @@ -37,8 +37,11 @@ def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, do session_result.captured_at = Time.zone.now session_result.doc_auth_success = doc_auth_success session_result.selfie_status = selfie_status - session_result.add_failed_front_image!(front_image_fingerprint) if front_image_fingerprint - session_result.add_failed_back_image!(back_image_fingerprint) if back_image_fingerprint + + session_result.add_failed_front_image!(front_image_fingerprint) + session_result.add_failed_back_image!(back_image_fingerprint) + session_result.add_failed_selfie_image!(selfie_image_fingerprint) if selfie_status == :fail + EncryptedRedisStructStorage.store( session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb index cc1b8d9f7ca..3078377f680 100644 --- a/app/presenters/image_upload_response_presenter.rb +++ b/app/presenters/image_upload_response_presenter.rb @@ -89,7 +89,7 @@ def doc_type_supported? end def failed_fingerprints - @form_response.extra[:failed_image_fingerprints] || { front: [], back: [] } + @form_response.extra[:failed_image_fingerprints] || { front: [], back: [], selfie: [] } end def show_selfie_failures diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index eff2e191771..d97eb786034 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -1018,7 +1018,7 @@ def idv_doc_auth_submitted_image_upload_form( # @param [Boolean] attention_with_barcode # @param [Boolean] doc_type_supported # @param [Boolean] doc_auth_success - # @param [Boolean] selfie_success + # @param [String] selfie_status # @param [String] vendor # @param [String] conversation_id # @param [String] reference @@ -1066,7 +1066,7 @@ def idv_doc_auth_submitted_image_upload_vendor( attention_with_barcode: nil, doc_type_supported: nil, doc_auth_success: nil, - selfie_success: nil, + selfie_status: nil, vendor: nil, conversation_id: nil, reference: nil, @@ -1102,7 +1102,7 @@ def idv_doc_auth_submitted_image_upload_vendor( attention_with_barcode:, doc_type_supported:, doc_auth_success:, - selfie_success:, + selfie_status:, vendor:, conversation_id:, reference:, diff --git a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb index d117fa72582..9c3b96748c0 100644 --- a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb +++ b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb @@ -221,7 +221,6 @@ def response_info def create_response_info alerts = parsed_alerts log_alert_formatter = DocAuth::ProcessedAlertToLogAlertFormatter.new - { transaction_status: transaction_status, transaction_reason_code: transaction_reason_code, @@ -235,6 +234,7 @@ def create_response_info image_metrics: parse_image_metrics, address_line2_present: !pii_from_doc[:address2].blank?, classification_info: classification_info, + liveness_enabled: @liveness_checking_enabled, } end diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index d5e7506688c..9be626d4ce1 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -76,7 +76,7 @@ def to_h selfie_live: selfie_live?, selfie_quality_good: selfie_quality_good?, doc_auth_success: doc_auth_success?, - selfie_status: selfie_status_from_response(self), + selfie_status: selfie_status, }.merge(extra) end @@ -103,5 +103,10 @@ def doc_auth_success? # to be implemented by concrete subclass false end + + def selfie_status + # to be implemented by concrete subclass + :not_processed + end end end diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 3a8c527afee..5d09e275242 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -8,13 +8,15 @@ :attention_with_barcode, :failed_front_image_fingerprints, :failed_back_image_fingerprints, + :failed_selfie_image_fingerprints, :captured_at, :selfie_check_performed, :doc_auth_success, :selfie_status, :selfie_success, keyword_init: true, allowed_members: [:id, :success, :attention_with_barcode, :failed_front_image_fingerprints, - :failed_back_image_fingerprints, :captured_at, :selfie_check_performed, - :doc_auth_success, :selfie_status, :selfie_success] + :failed_back_image_fingerprints, :failed_selfie_image_fingerprints, + :captured_at, :selfie_check_performed, :doc_auth_success, :selfie_status, + :selfie_success] ) do def self.redis_key_prefix 'dcs:result' @@ -28,11 +30,13 @@ def selfie_status alias_method :attention_with_barcode?, :attention_with_barcode alias_method :pii_from_doc, :pii - %w[front back].each do |side| + %w[front back selfie].each do |side| define_method(:"add_failed_#{side}_image!") do |fingerprint| member_name = "failed_#{side}_image_fingerprints" self[member_name] ||= [] - self[member_name] << fingerprint + if fingerprint && !self[member_name].include?(fingerprint) + self[member_name] << fingerprint + end end define_method(:"failed_#{side}_image?") do |fingerprint| diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 4ee86c00895..f0ab25f4dad 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -132,6 +132,7 @@ flow_path: 'standard', front_image_fingerprint: nil, back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -203,7 +204,7 @@ result_failed: false, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { front: [], back: [] }, + failed_image_fingerprints: { front: [], back: [], selfie: [] }, }, ) end @@ -219,7 +220,7 @@ result_failed: false, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { front: [], back: [] }, + failed_image_fingerprints: { front: [], back: [], selfie: [] }, } end @@ -267,6 +268,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -410,6 +412,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -436,6 +439,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -452,7 +456,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, @@ -470,6 +473,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: a_kind_of(Hash), ) @@ -607,6 +611,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -633,6 +638,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -649,7 +655,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, @@ -672,6 +677,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: hash_including( Front: hash_including(ClassName: 'Identification Card', CountryCode: 'USA'), @@ -717,6 +723,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -743,6 +750,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -759,7 +767,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, @@ -782,6 +789,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: hash_including( Front: hash_including(ClassName: 'Identification Card', CountryCode: 'USA'), @@ -827,6 +835,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -853,6 +862,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -869,7 +879,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, @@ -892,6 +901,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: hash_including(:Front, :Back), ) @@ -934,6 +944,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -960,6 +971,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -976,7 +988,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, @@ -999,6 +1010,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: hash_including(:Front, :Back), ) @@ -1064,6 +1076,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -1092,6 +1105,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -1108,7 +1122,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, @@ -1152,6 +1165,7 @@ flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -1182,6 +1196,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, doc_auth_success: boolean, selfie_status: :not_processed, @@ -1198,7 +1213,6 @@ processed_alerts: nil, product_status: nil, reference: nil, - selfie_success: nil, transaction_reason_code: nil, transaction_status: nil, vendor: nil, diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index b68ae4480a6..87209d678c5 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -69,10 +69,10 @@ width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean + success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false @@ -177,10 +177,10 @@ width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'hybrid', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'hybrid', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean + success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'hybrid', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'hybrid', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false @@ -282,10 +282,10 @@ width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean + success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, skip_hybrid_handoff: nil, analytics_id: 'Doc Auth', irs_reproofing: false @@ -369,7 +369,7 @@ width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean + success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'standard', attention_with_barcode: true, doc_auth_result: 'Attention'), 'IdV: verify in person troubleshooting option clicked' => { @@ -500,10 +500,10 @@ width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean + success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, skip_hybrid_handoff: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false 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 fae43a730d8..825b8a0c0c0 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -153,6 +153,37 @@ end end + shared_examples_for 'selfie image re-upload not allowed' do + it 'stops user submitting the same images again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3, attempts: 1), + ) + DocAuth::Mock::DocAuthMockClient.reset! + expect(page).not_to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + attach_selfie + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + count: 1, + ) + + attach_images + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + count: 3, + ) + end + end + shared_examples_for 'inline error for 4xx status shown' do |status| it "shows inline error for status #{status}" do error = case status @@ -251,6 +282,7 @@ and_return(true) allow_any_instance_of(FederatedProtocols::Oidc). to receive(:biometric_comparison_required?).and_return(true) + allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:fail) start_idv_from_sp sign_in_and_2fa_user complete_doc_auth_steps_before_document_capture_step @@ -261,10 +293,181 @@ click_try_again sleep(10) end - it_behaves_like 'image re-upload not allowed' + + it_behaves_like 'selfie image re-upload not allowed' + it 'shows current existing header' do expect_doc_capture_page_header(t('doc_auth.headings.review_issues')) end end + + context 'when doc auth is success and portait match fails', allow_browser_log: true do + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) + + start_idv_from_sp + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_success_face_match_fail + attach_images + attach_selfie + submit_images + click_try_again + sleep(10) + end + + it 'stops user submitting the same images again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3, attempts: 1), + ) + DocAuth::Mock::DocAuthMockClient.reset! + expect(page).not_to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + + attach_selfie + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + count: 1, + ) + + attach_images + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + count: 1, + ) + end + end + + context 'when doc auth fails and portrait match pass', allow_browser_log: true do + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) + + start_idv_from_sp + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_failure_face_match_pass + attach_images + attach_selfie + submit_images + click_try_again + sleep(10) + end + + it 'stops user submitting the same images again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3, attempts: 1), + ) + DocAuth::Mock::DocAuthMockClient.reset! + expect(page).not_to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + + attach_selfie + expect(page).not_to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + + attach_images + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + count: 2, + ) + end + end + + context 'when doc auth and portrait match fail', allow_browser_log: true do + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) + allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:fail) + start_idv_from_sp + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_error_unknown + attach_images + attach_selfie + submit_images + click_try_again + sleep(10) + end + + it_behaves_like 'selfie image re-upload not allowed' + end + + context 'when pii validation fails', allow_browser_log: true do + before do + expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). + and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) + pii = Idp::Constants::MOCK_IDV_APPLICANT.dup + pii.delete(:address1) + allow_any_instance_of(DocAuth::LexisNexis::Responses::TrueIdResponse). + to receive(:pii_from_doc).and_return(pii) + start_idv_from_sp + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_pass_face_match_pass_no_address1 + attach_images + attach_selfie + submit_images + click_try_again + sleep(10) + end + + it 'stops user submitting the same images again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3, attempts: 1), + ) + DocAuth::Mock::DocAuthMockClient.reset! + expect(page).not_to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + + attach_selfie + expect(page).not_to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + + attach_images + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + count: 2, + ) + end + end end end diff --git a/spec/fixtures/ial2_test_portrait_match_failure.yml b/spec/fixtures/ial2_test_portrait_match_failure.yml new file mode 100644 index 00000000000..be7ba2e55e1 --- /dev/null +++ b/spec/fixtures/ial2_test_portrait_match_failure.yml @@ -0,0 +1,17 @@ +document: + first_name: 'John' + last_name: 'Doe' + address1: 1800 F Street + address2: Apt 3 + city: Bayside + state: NY + zipcode: '11364' + dob: 10/06/1938 + phone: +1 314-555-1212 + state_id_jurisdiction: 'ND' + state_id_number: '1111111111111' +doc_auth_result: Passed +failed_alert: [] +portrait_match_results: + FaceMatchResult: Fail + FaceErrorMessage: 'Liveness: PoorQuality' \ No newline at end of file diff --git a/spec/fixtures/ial2_test_portrait_match_success.yml b/spec/fixtures/ial2_test_portrait_match_success.yml new file mode 100644 index 00000000000..70b2ec5af5d --- /dev/null +++ b/spec/fixtures/ial2_test_portrait_match_success.yml @@ -0,0 +1,16 @@ +document: + first_name: 'John' + last_name: 'Doe' + address1: 1800 F Street + address2: Apt 3 + city: Bayside + state: NY + zipcode: '11364' + dob: 10/06/1938 + phone: +1 314-555-1212 + state_id_jurisdiction: 'ND' + state_id_number: '1111111111111' +doc_auth_result: Passed +failed_alert: [] +portrait_match_results: + FaceMatchResult: Pass \ No newline at end of file diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json new file mode 100644 index 00000000000..d82cd53bb5a --- /dev/null +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json @@ -0,0 +1,740 @@ +{ + "Status": { + "ConversationId": "31000406185568", + "RequestId": "708870588", + "TransactionStatus": "failed", + "TransactionReasonCode": { + "Code": "failed_true_id", + "Description": "Failed: TrueID document authentication" + }, + "Reference": "Reference1", + "ServerInfo": "bctlsidmapp01.risk.regn.net" + }, + "Products": [ + { + "ProductType": "TrueID", + "ExecutedStepName": "True_ID_Step", + "ProductConfigurationName": "AndreV3_TrueID_Flow", + "ProductStatus": "pass", + "ParameterDetails": [ + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocumentName", + "Values": [{"Value": "Connecticut (CT) Driver License"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocAuthResult", + "Values": [{"Value": "Failed"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssuerCode", + "Values": [{"Value": "CT"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssuerName", + "Values": [{"Value": "Connecticut"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssuerType", + "Values": [{"Value": "StateProvince"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocClassCode", + "Values": [{"Value": "DriversLicense"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocClass", + "Values": [{"Value": "DriversLicense"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocClassName", + "Values": [{"Value": "Drivers License"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIsGeneric", + "Values": [{"Value": "false"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssue", + "Values": [{"Value": "2009"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocIssueType", + "Values": [{"Value": "Driver License"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DocSize", + "Values": [{"Value": "ID1"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ClassificationMode", + "Values": [{"Value": "Automatic"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "OrientationChanged", + "Values": [{"Value": "false"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "PresentationChanged", + "Values": [{"Value": "false"}] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "Side", + "Values":[ + {"Value": "Front"}, + {"Value": "Back"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "GlareMetric", + "Values": [ + {"Value": "100"}, + {"Value": "100"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "SharpnessMetric", + "Values":[ + {"Value": "50"}, + {"Value": "56"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "IsTampered", + "Values":[ + {"Value": "0"}, + {"Value": "0"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "IsCropped", + "Values":[ + {"Value": "1"}, + {"Value": "1"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "HorizontalResolution", + "Values":[ + {"Value": "418"}, + {"Value": "420"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "VerticalResolution", + "Values":[ + {"Value": "418"}, + {"Value": "420"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "Light", + "Values":[ + {"Value": "White"}, + {"Value": "White"} + ] + }, + { + "Group": "IMAGE_METRICS_RESULT", + "Name": "MimeType", + "Values":[ + {"Value": "image/vnd.ms-photo"}, + {"Value": "image/vnd.ms-photo"} + ] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "FullName", + "Values": [{"Value": "EVAN M MOZINGO"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Sex", + "Values": [{"Value": "Male"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Age", + "Values": [{"Value": "30"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DOB_Year", + "Values": [{"Value": "1989"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DOB_Month", + "Values": [{"Value": "9"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "DOB_Day", + "Values": [{"Value": "11"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ExpirationDate_Year", + "Values": [{"Value": "2017"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ExpirationDate_Month", + "Values": [{"Value": "9"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "ExpirationDate_Day", + "Values": [{"Value": "11"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Portrait", + "Values": [{"Value": "/9j/4AAQSkZJRgABAQEBogGiAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9\nPDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhC\nY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAAR\nCAI1AagDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA\nAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK\nFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG\nh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl\n5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA\nAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk\nNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE\nhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk\n5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDvOlVr2QRwjOeeOKtGqmok/ZwB6jmluNu2\npkzbmbcOcimEFgQykVKRgDpTQSDkkjHr0o9mP61IhiTy3KgfK3OaWVdwAGfYipdpUeYr8Z5B\nGaeyjdnGaXsx/WplSOHzThl2qvRQetOaz3jKnB9Ksxrt471E1yc7UGOTnn0o9mH1qaKcqOow\nycDrg9aiVv3i7Uz61baRm4AzmkkiUehOKr2RP1yYTNyAFIyASRWeYpPNOVOCeuOlX1kUrhk6\ncD3pnGSCPeq9iH12aK+XjJwhZfam+ZLliEOPp1q2MFscZ96axIJCn5fY8U/ZIX16ZBlin3Tg\ne3WkzuOAMAdR71PkClwWPzfnR7JC+vVCDgMPl57UjrzvCAMeBz0qdsAfSmgADFHsg+vVCBQT\n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\nRxR5agcDGKKKLABRc520UUUAf//Z"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_1_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_2_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_3_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_4_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_5_AlertName", + "Values": [{"Value": "1D Control Number Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_6_AlertName", + "Values": [{"Value": "2D Barcode Content"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_7_AlertName", + "Values": [{"Value": "Control Number Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_8_AlertName", + "Values": [{"Value": "Document Expired"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_9_AlertName", + "Values": [{"Value": "2D Barcode Read"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_10_AlertName", + "Values": [{"Value": "Birth Date Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_11_AlertName", + "Values": [{"Value": "Birth Date Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_12_AlertName", + "Values": [{"Value": "Document Classification"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_13_AlertName", + "Values": [{"Value": "Document Crosscheck Aggregation"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_14_AlertName", + "Values": [{"Value": "Document Number Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_15_AlertName", + "Values": [{"Value": "Expiration Date Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_16_AlertName", + "Values": [{"Value": "Expiration Date Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_17_AlertName", + "Values": [{"Value": "Full Name Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_18_AlertName", + "Values": [{"Value": "Issue Date Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_19_AlertName", + "Values": [{"Value": "Issue Date Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_20_AlertName", + "Values": [{"Value": "Layout Valid"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_21_AlertName", + "Values": [{"Value": "Sex Crosscheck"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_22_AlertName", + "Values": [{"Value": "Visible Color Response"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_23_AlertName", + "Values": [{"Value": "Visible Pattern"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_24_AlertName", + "Values": [{"Value": "Visible Photo Characteristics"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_1_AuthenticationResult", + "Values": [{ + "Value": "Failed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_2_AuthenticationResult", + "Values": [{ + "Value": "Failed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_3_AuthenticationResult", + "Values": [{ + "Value": "Failed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_4_AuthenticationResult", + "Values": [{ + "Value": "Failed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_5_AuthenticationResult", + "Values": [{ + "Value": "Failed", + "Detail": "Checks whether the 1D barcode is indicative of known-fake documents." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_6_AuthenticationResult", + "Values": [{ + "Value": "Failed", + "Detail": "Checked the contents of the two-dimensional barcode on the document." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_7_AuthenticationResult", + "Values": [{ + "Value": "Caution", + "Detail": "Compare the machine-readable control number field to the human-readable control number field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_8_AuthenticationResult", + "Values": [{ + "Value": "Attention", + "Detail": "Checked if the document is expired." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_9_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified that the two-dimensional barcode on the document was read successfully." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_10_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compare the machine-readable birth date field to the human-readable birth date field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_11_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified that the birth date is valid." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_12_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified that the type of document is supported and is able to be fully authenticated." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_13_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compared the machine-readable fields to the human-readable fields." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_14_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compare the machine-readable document number field to the human-readable document number field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_15_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compare the machine-readable expiration date field to the human-readable expiration date field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_16_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified that the expiration date is valid." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_17_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compare the machine-readable full name field to the human-readable full name field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_18_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compare the machine-readable issue date field to the human-readable issue date field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_19_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified that the issue date is valid." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_20_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified that the layout of the document is correct." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_21_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Compare the machine-readable sex field to the human-readable sex field." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_22_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified the color response of an element on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_23_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verified the presence of a pattern on the visible image." + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_24_AuthenticationResult", + "Values": [{ + "Value": "Passed", + "Detail": "Verifies the visible characteristics of the Photo" + }] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_1_Regions", + "Values": [{"Value": "Stylized DOB Label"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_2_Regions", + "Values": [{"Value": "Expires Label"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_3_Regions", + "Values": [{"Value": "Inches"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_4_Regions", + "Values": [{"Value": "Background Center"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_20_Regions", + "Values":[ + {"Value": "2D Barcode Bottom Left"}, + {"Value": "2D Barcode Top Left"} + ] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_22_Regions", + "Values": [{"Value": "Background Salmon"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_23_Regions", + "Values": [{"Value": "Background Boat"}] + }, + { + "Group": "AUTHENTICATION_RESULT", + "Name": "Alert_24_Regions", + "Values": [{"Value": "Photo"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_FullName", + "Values": [{"Value": "EVAN M MOZINGO"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Surname", + "Values": [{"Value": "MOZINGO"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_GivenName", + "Values": [{"Value": "EVAN M"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_FirstName", + "Values": [{"Value": "EVAN"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_MiddleName", + "Values": [{"Value": "M"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DOB_Year", + "Values": [{"Value": "1989"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DOB_Month", + "Values": [{"Value": "9"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DOB_Day", + "Values": [{"Value": "11"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DocumentClassName", + "Values": [{"Value": "Drivers License"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_DocumentNumber", + "Values": [{"Value": "096760377"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_ExpirationDate_Year", + "Values": [{"Value": "2017"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_ExpirationDate_Month", + "Values": [{"Value": "9"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_xpirationDate_Day", + "Values": [{"Value": "11"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssuingStateCode", + "Values": [{"Value": "CT"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssuingStateName", + "Values": [{"Value": "Connecticut"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Address", + "Values": [{"Value": "1 MUDRY FARM RD
BRISTOL, CT 02809-2366"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_AddressLine1", + "Values": [{"Value": "1 MUDRY FARM RD"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_City", + "Values": [{"Value": "BRISTOL"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_State", + "Values": [{"Value": "CT"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_PostalCode", + "Values": [{"Value": "02809-2366"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Sex", + "Values": [{"Value": "M"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_ControlNumber", + "Values": [{"Value": "demo a11230009"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_Height", + "Values": [{"Value": "5' 9\""}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssueDate_Year", + "Values": [{"Value": "2010"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssueDate_Month", + "Values": [{"Value": "10"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_IssueDate_Day", + "Values": [{"Value": "12"}] + }, + { + "Group": "IDAUTH_FIELD_DATA", + "Name": "Fields_LicenseClass", + "Values": [{"Value": "D"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceMatchResult", + "Values": [{"Value": "Pass"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceMatchScore", + "Values": [{"Value": "96"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceStatusCode", + "Values": [{"Value": "1"}] + }, + { + "Group": "PORTRAIT_MATCH_RESULT", + "Name": "FaceErrorMessage", + "Values": [{"Value": "Successful. Liveness: Live"}] + } + ] + }, + { + "ProductType": "TrueID_Decision", + "ExecutedStepName": "Decision", + "ProductConfigurationName": "TRUEID_FAIL", + "ProductStatus": "fail", + "ProductReason": { + "Code": "failed_true_id", + "Description": "Failed: TrueID document authentication" + } + } + ] +} diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index ad79ed7cf8b..9e936aab656 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -124,6 +124,7 @@ flow_path: anything, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) @@ -164,6 +165,7 @@ vendor_request_time_in_ms: a_kind_of(Float), front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, doc_type_supported: boolean, liveness_checking_required: boolean, log_alert_results: nil, @@ -173,7 +175,6 @@ reference: nil, selfie_live: boolean, selfie_quality_good: boolean, - selfie_success: nil, doc_auth_success: boolean, selfie_status: anything, vendor: nil, @@ -187,10 +188,119 @@ expect(response).to be_a_kind_of DocAuth::Response expect(response.success?).to eq(true) + expect(response.doc_auth_success?).to eq(true) + expect(response.selfie_status).to eq(:not_processed) expect(response.errors).to eq({}) expect(response.attention_with_barcode?).to eq(false) expect(response.pii_from_doc).to eq(Idp::Constants::MOCK_IDV_APPLICANT) end + + context 'when liveness check is required' do + let(:liveness_checking_required) { true } + let(:back_image) { DocAuthImageFixtures.portrait_match_success_yaml } + let(:selfie_image) { DocAuthImageFixtures.selfie_image_multipart } + it 'logs analytics' do + expect(irs_attempts_api_tracker).to receive(:idv_document_upload_submitted).with( + { + address: '1800 F Street', + date_of_birth: '1938-10-06', + document_back_image_filename: nil, + document_expiration: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, + document_issued: nil, + document_number: '1111111111111', + document_state: 'NY', + first_name: 'John', + last_name: 'Doe', + success: true, + }, + ) + + form.submit + + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + success: true, + errors: {}, + attempts: 1, + remaining_attempts: 3, + user_id: document_capture_session.user.uuid, + flow_path: anything, + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: an_instance_of(String), + liveness_checking_required: boolean, + ) + + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload vendor submitted', + address_line2_present: nil, + alert_failure_count: nil, + async: false, + attempts: 1, + attention_with_barcode: false, + billed: true, + client_image_metrics: { + back: { + height: 20, + mimeType: 'image/png', + source: 'upload', + width: 20, + }, + front: { + height: 40, + mimeType: 'image/png', + source: 'upload', + width: 40, + }, + }, + conversation_id: nil, + decision_product_status: nil, + doc_auth_result: 'Passed', + errors: {}, + exception: nil, + flow_path: anything, + image_metrics: nil, + liveness_checking_required: boolean, + log_alert_results: nil, + portrait_match_results: anything, + processed_alerts: nil, + product_status: nil, + reference: nil, + remaining_attempts: 3, + state: 'NY', + state_id_type: nil, + success: true, + user_id: document_capture_session.user.uuid, + vendor_request_time_in_ms: a_kind_of(Float), + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: an_instance_of(String), + doc_type_supported: boolean, + selfie_live: boolean, + selfie_quality_good: boolean, + doc_auth_success: boolean, + selfie_status: :success, + vendor: nil, + transaction_status: nil, + transaction_reason_code: nil, + ) + end + + it 'returns the expected response' do + response = form.submit + + expect(response).to be_a_kind_of DocAuth::Response + expect(response.success?).to eq(true) + expect(response.doc_auth_success?).to eq(true) + expect(response.selfie_check_performed?).to eq(true) + expect(response.selfie_status).to eq(:success) + expect(response.errors).to eq({}) + expect(response.attention_with_barcode?).to eq(false) + # expect(response.pii_from_doc).to eq(Idp::Constants::MOCK_IDV_APPLICANT) + end + end end context 'image data returns unknown errors' do @@ -240,6 +350,7 @@ flow_path: anything, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, ) end @@ -275,10 +386,14 @@ end context 'posting images to client fails' do + let(:errors) do + { front: 'glare' } + end + let(:failed_response) do DocAuth::Response.new( success: false, - errors: { front: 'glare' }, + errors: errors, extra: { remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1 }, ) end @@ -293,6 +408,8 @@ expect(response).to be_a_kind_of DocAuth::Response expect(response.success?).to eq(false) + expect(response.doc_auth_success?).to eq(false) + expect(response.selfie_status).to eq(:not_processed) expect(response.attention_with_barcode?).to eq(false) expect(response.pii_from_doc).to eq({}) expect(response.doc_auth_success?).to eq(false) @@ -335,6 +452,40 @@ ), ) end + + context 'selfie is checked for liveness' do + let(:liveness_checking_required) { true } + let(:selfie_image) { DocAuthImageFixtures.selfie_image_multipart } + let(:errors) do + { selfie: 'glare' } + end + let(:back_image) { DocAuthImageFixtures.portrait_match_fail_yaml } + + before do + allow(failed_response).to receive(:doc_auth_success?).and_return(true) + allow(failed_response).to receive(:selfie_status).and_return(:fail) + end + + it 'includes client response errors' do + response = form.submit + expect(response.errors[:front]).to be_nil + expect(response.errors[:back]).to be_nil + expect(response.errors[:selfie]).to eq('glare') + end + + it 'keeps fingerprints of failed image and triggers error when submit same image' do + form.submit + session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) + capture_result = session.load_result + expect(capture_result.failed_front_image_fingerprints).to match_array([]) + expect(capture_result.failed_back_image_fingerprints).to match_array([]) + expect(capture_result.failed_selfie_image_fingerprints.length).to eq(1) + response = form.submit + expect(response.errors).to have_key(:selfie) + expect(response.errors). + to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) + end + end end context 'PII validation from client response fails' do @@ -382,7 +533,8 @@ form.submit session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) capture_result = session.load_result - expect(capture_result.failed_front_image_fingerprints).not_to match_array([]) + expect(capture_result.failed_front_image_fingerprints.length).to eq(1) + expect(capture_result.failed_back_image_fingerprints.length).to eq(1) response = form.submit expect(response.errors).to have_key(:front) expect(response.errors).to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) @@ -394,10 +546,41 @@ flow_path: anything, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, liveness_checking_required: boolean, side: 'both', ) end + + context 'when selfie is checked for liveness' do + let(:selfie_image) { DocAuthImageFixtures.selfie_image_multipart } + let(:back_image) { DocAuthImageFixtures.portrait_match_success_yaml } + it 'keeps fingerprints of failed image and triggers error when submit same image' do + form.submit + session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) + capture_result = session.load_result + expect(capture_result.failed_front_image_fingerprints.length).to eq(1) + expect(capture_result.failed_back_image_fingerprints.length).to eq(1) + expect(capture_result.failed_selfie_image_fingerprints).to be_nil + response = form.submit + expect(response.errors).to have_key(:front) + expect(response.errors).to have_key(:back) + expect(response.errors). + to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) + expect(fake_analytics).to have_logged_event( + 'IdV: failed doc image resubmitted', + attempts: 1, + remaining_attempts: 3, + user_id: document_capture_session.user.uuid, + flow_path: anything, + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + selfie_image_fingerprint: nil, + liveness_checking_required: boolean, + side: 'both', + ) + end + end end describe 'encrypted document storage' do @@ -526,6 +709,7 @@ allow(client_response).to receive(:network_error?).and_return(false) allow(client_response).to receive(:errors).and_return(errors) allow(client_response).to receive(:doc_auth_success?).and_return(false) + allow(client_response).to receive(:selfie_status).and_return(:not_processed) form.send(:validate_form) capture_result = form.send(:store_failed_images, client_response, doc_pii_response) expect(capture_result[:front]).not_to be_empty @@ -539,6 +723,7 @@ allow(client_response).to receive(:network_error?).and_return(false) allow(client_response).to receive(:errors).and_return(errors) allow(client_response).to receive(:doc_auth_success?).and_return(false) + allow(client_response).to receive(:selfie_status).and_return(:not_processed) form.send(:validate_form) capture_result = form.send(:store_failed_images, client_response, doc_pii_response) expect(capture_result[:front]).not_to be_empty @@ -552,6 +737,7 @@ allow(client_response).to receive(:network_error?).and_return(false) allow(client_response).to receive(:errors).and_return(errors) allow(client_response).to receive(:doc_auth_success?).and_return(false) + allow(client_response).to receive(:selfie_status).and_return(:not_processed) form.send(:validate_form) capture_result = form.send(:store_failed_images, client_response, doc_pii_response) expect(capture_result[:front]).not_to be_empty @@ -581,6 +767,7 @@ allow(client_response).to receive(:errors).and_return(errors) allow(client_response).to receive(:doc_auth_success?).and_return(false) allow(doc_pii_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:selfie_status).and_return(:not_processed) form.send(:validate_form) capture_result = form.send(:store_failed_images, client_response, doc_pii_response) expect(capture_result[:front]).not_to be_empty diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8fae8f15415..6f8fa036d4f 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -96,27 +96,4 @@ end end end - - describe '#selfie_status_from_response' do - context 'the response does not have selfie_status method defined on it' do - let(:success) { true } - let(:errors) { {} } - let(:exception) { nil } - let(:pii_from_doc) { {} } - let(:attention_with_barcode) { false } - let(:response) do - DocAuth::Response.new( - success: success, - errors: errors, - exception: exception, - pii_from_doc: pii_from_doc, - attention_with_barcode: attention_with_barcode, - ) - end - - it 'returns :not_processed' do - expect(selfie_status_from_response(response)).to eq(:not_processed) - end - end - end end diff --git a/spec/models/document_capture_session_spec.rb b/spec/models/document_capture_session_spec.rb index 406a02a7929..4deee580d0a 100644 --- a/spec/models/document_capture_session_spec.rb +++ b/spec/models/document_capture_session_spec.rb @@ -95,6 +95,7 @@ record.store_failed_auth_data( front_image_fingerprint: 'fingerprint1', back_image_fingerprint: nil, + selfie_image_fingerprint: nil, doc_auth_success: false, selfie_status: :not_processed, ) @@ -110,12 +111,13 @@ expect(result.selfie_status).to eq(:not_processed) end - it 'saves failed image finterprints' do + it 'saves failed image fingerprints' do record = DocumentCaptureSession.new(result_id: SecureRandom.uuid) record.store_failed_auth_data( front_image_fingerprint: 'fingerprint1', back_image_fingerprint: nil, + selfie_image_fingerprint: nil, doc_auth_success: false, selfie_status: :not_processed, ) @@ -124,6 +126,7 @@ record.store_failed_auth_data( front_image_fingerprint: 'fingerprint2', back_image_fingerprint: 'fingerprint3', + selfie_image_fingerprint: nil, doc_auth_success: false, selfie_status: :not_processed, ) @@ -132,14 +135,59 @@ expect(old_result.failed_front_image?('fingerprint1')).to eq(true) expect(old_result.failed_front_image?('fingerprint2')).to eq(false) expect(old_result.failed_back_image?('fingerprint3')).to eq(false) + expect(old_result.failed_selfie_image_fingerprints).to be_nil expect(old_result.doc_auth_success).to eq(false) expect(old_result.selfie_status).to eq(:not_processed) expect(new_result.failed_front_image?('fingerprint1')).to eq(true) expect(new_result.failed_front_image?('fingerprint2')).to eq(true) expect(new_result.failed_back_image?('fingerprint3')).to eq(true) + expect(new_result.failed_selfie_image_fingerprints).to be_nil expect(new_result.doc_auth_success).to eq(false) expect(new_result.selfie_status).to eq(:not_processed) + + old_result = new_result + + record.store_failed_auth_data( + front_image_fingerprint: 'fingerprint2', + back_image_fingerprint: 'fingerprint3', + selfie_image_fingerprint: 'fingerprint4', + doc_auth_success: false, + selfie_status: :fail, + ) + new_result = record.load_result + + expect(old_result.failed_front_image?('fingerprint1')).to eq(true) + expect(old_result.failed_front_image?('fingerprint2')).to eq(true) + expect(old_result.failed_back_image?('fingerprint3')).to eq(true) + expect(old_result.failed_selfie_image_fingerprints).to be_nil + expect(old_result.doc_auth_success).to eq(false) + expect(old_result.selfie_status).to eq(:not_processed) + + expect(new_result.failed_front_image_fingerprints.length).to eq(2) + expect(new_result.failed_back_image_fingerprints.length).to eq(1) + expect(new_result.failed_selfie_image?('fingerprint4')).to eq(true) + expect(new_result.doc_auth_success).to eq(false) + expect(new_result.selfie_status).to eq(:fail) + end + + context 'when selfie is successful' do + it 'does not add selfie to failed image fingerprints' do + record = DocumentCaptureSession.new(result_id: SecureRandom.uuid) + + record.store_failed_auth_data( + front_image_fingerprint: 'fingerprint1', + back_image_fingerprint: 'fingerprint2', + selfie_image_fingerprint: 'fingerprint3', + doc_auth_success: false, + selfie_status: :pass, + ) + result = record.load_result + + expect(result.failed_front_image?('fingerprint1')).to eq(true) + expect(result.failed_back_image?('fingerprint2')).to eq(true) + expect(result.failed_selfie_image_fingerprints).to be_nil + end end end end diff --git a/spec/presenters/image_upload_response_presenter_spec.rb b/spec/presenters/image_upload_response_presenter_spec.rb index 03e81f90681..58e36fa5134 100644 --- a/spec/presenters/image_upload_response_presenter_spec.rb +++ b/spec/presenters/image_upload_response_presenter_spec.rb @@ -109,7 +109,7 @@ let(:extra_attributes) do { remaining_attempts: 0, flow_path: 'standard', - failed_image_fingerprints: { back: [], front: ['12345'] } } + failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] } } end let(:form_response) do FormResponse.new( @@ -130,7 +130,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { back: [], front: ['12345'] }, + failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -140,7 +140,7 @@ let(:extra_attributes) do { remaining_attempts: 0, flow_path: 'hybrid', - failed_image_fingerprints: { back: [], front: ['12345'] } } + failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] } } end it 'returns hash of properties redirecting to capture_complete' do @@ -152,7 +152,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { back: [], front: ['12345'] }, + failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -181,7 +181,7 @@ remaining_attempts: 3, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { back: [], front: [] }, + failed_image_fingerprints: { back: [], front: [], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -208,7 +208,7 @@ remaining_attempts: 3, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { front: [], back: [] }, + failed_image_fingerprints: { front: [], back: [], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -245,7 +245,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { front: [], back: [] }, + failed_image_fingerprints: { front: [], back: [], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -262,7 +262,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, - failed_image_fingerprints: { back: [], front: [] }, + failed_image_fingerprints: { back: [], front: [], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -290,7 +290,7 @@ remaining_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, - failed_image_fingerprints: { back: [], front: [] }, + failed_image_fingerprints: { back: [], front: [], selfie: [] }, } expect(presenter.as_json).to eq expected @@ -316,7 +316,7 @@ remaining_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, - failed_image_fingerprints: { back: [], front: [] }, + failed_image_fingerprints: { back: [], front: [], selfie: [] }, } expect(presenter.as_json).to eq expected diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index d71d3005ba8..1ce36b10b1b 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -137,6 +137,7 @@ selfie_status: :not_processed, selfie_live: true, selfie_quality_good: true, + liveness_enabled: false, ) passed_alerts = response_hash.dig(:processed_alerts, :passed) passed_alerts.each do |alert| @@ -380,6 +381,7 @@ def get_decision_product(resp) selfie_status: :fail, selfie_live: true, selfie_quality_good: false, + liveness_enabled: false, ) end it 'produces appropriate errors with document tampering' do diff --git a/spec/support/doc_auth_image_fixtures.rb b/spec/support/doc_auth_image_fixtures.rb index 4063e925fe8..188e3f3d4d7 100644 --- a/spec/support/doc_auth_image_fixtures.rb +++ b/spec/support/doc_auth_image_fixtures.rb @@ -47,6 +47,22 @@ def self.error_yaml_no_db_multipart Rack::Test::UploadedFile.new(path, Mime[:yaml]) end + def self.portrait_match_success_yaml + path = File.join( + File.dirname(__FILE__), + '../fixtures/ial2_test_portrait_match_success.yml', + ) + Rack::Test::UploadedFile.new(path, Mime[:yaml]) + end + + def self.portrait_match_fail_yaml + path = File.join( + File.dirname(__FILE__), + '../fixtures/ial2_test_portrait_match_failure.yml', + ) + Rack::Test::UploadedFile.new(path, Mime[:yaml]) + end + def self.fixture_path(filename) File.join( File.dirname(__FILE__), diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index a5f8464ceda..4c763b6ab86 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -252,6 +252,70 @@ def mock_doc_auth_attention_with_barcode ) end + def mock_doc_auth_success_face_match_fail + failure_response = instance_double( + Faraday::Response, + status: 200, + body: LexisNexisFixtures.true_id_response_with_face_match_fail, + ) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :get_results, + response: DocAuth::LexisNexis::Responses::TrueIdResponse.new( + failure_response, + DocAuth::LexisNexis::Config.new, + true, # liveness_checking_enabled + ), + ) + end + + def mock_doc_auth_failure_face_match_fail + failure_response = instance_double( + Faraday::Response, + status: 200, + body: LexisNexisFixtures.true_id_response_failure_no_liveness, + ) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :get_results, + response: DocAuth::LexisNexis::Responses::TrueIdResponse.new( + failure_response, + DocAuth::LexisNexis::Config.new, + true, # liveness_checking_enabled + ), + ) + end + + def mock_doc_auth_failure_face_match_pass + failure_response = instance_double( + Faraday::Response, + status: 200, + body: LexisNexisFixtures.true_id_response_failure_with_face_match_pass, + ) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :get_results, + response: DocAuth::LexisNexis::Responses::TrueIdResponse.new( + failure_response, + DocAuth::LexisNexis::Config.new, + true, # liveness_checking_enabled + ), + ) + end + + def mock_doc_auth_pass_face_match_pass_no_address1 + response = instance_double( + Faraday::Response, + status: 200, + body: LexisNexisFixtures.true_id_response_success_with_liveness, + ) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :get_results, + response: DocAuth::LexisNexis::Responses::TrueIdResponse.new( + response, + DocAuth::LexisNexis::Config.new, + true, # liveness_checking_enabled + ), + ) + end + def mock_doc_auth_trueid_http_non2xx_status(status) network_error_response = instance_double( Faraday::Response, diff --git a/spec/support/lexis_nexis_fixtures.rb b/spec/support/lexis_nexis_fixtures.rb index d1929aed7ce..922cf8026d2 100644 --- a/spec/support/lexis_nexis_fixtures.rb +++ b/spec/support/lexis_nexis_fixtures.rb @@ -168,6 +168,10 @@ def true_id_response_with_face_match_fail read_fixture_file_at_path('true_id/true_id_response_with_face_match_fail.json') end + def true_id_response_failure_with_face_match_pass + read_fixture_file_at_path('true_id/true_id_response_failure_with_face_match_pass.json') + end + def true_id_response_failure_no_liveness read_fixture_file_at_path('true_id/true_id_response_failure_no_liveness.json') end From 39cdd8d4dc00e5e6829d6c8f35a8d4dcbad8fbce Mon Sep 17 00:00:00 2001 From: Charley Ferguson Date: Wed, 31 Jan 2024 14:21:52 -0500 Subject: [PATCH 02/25] LG-11893: Selfie Liveness Errors on the FE (#9975) changelog: User-Facing Improvements, In-Person Proofing, Change error messages when selfie upload fails. --- .../document-capture-review-issues.tsx | 4 + .../components/document-capture-warning.tsx | 29 +++++-- .../components/document-capture.tsx | 2 + .../components/review-issues-step.tsx | 4 + .../components/unknown-error.tsx | 38 ++++++++- .../document-capture/context/upload.tsx | 10 +++ .../document-capture/services/upload.ts | 9 +++ app/services/doc_auth/errors.rb | 5 +- app/services/doc_auth/selfie_concern.rb | 10 +-- app/services/doc_auth_router.rb | 7 ++ config/locales/doc_auth/en.yml | 5 ++ config/locales/doc_auth/es.yml | 5 ++ config/locales/doc_auth/fr.yml | 6 ++ config/locales/errors/en.yml | 1 + config/locales/errors/es.yml | 1 + config/locales/errors/fr.yml | 1 + .../idv/doc_auth/document_capture_spec.rb | 78 +++++++++++++++++++ .../ial2_test_credential_no_liveness.yml | 20 +++++ .../ial2_test_credential_poor_quality.yml | 20 +++++ .../mock/doc_auth_mock_client_spec.rb | 8 +- 20 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 spec/fixtures/ial2_test_credential_no_liveness.yml create mode 100644 spec/fixtures/ial2_test_credential_poor_quality.yml diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx index 84c74fbcba0..4a08856d737 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx @@ -17,6 +17,7 @@ import type { ReviewIssuesStepValue } from './review-issues-step'; interface DocumentCaptureReviewIssuesProps extends FormStepComponentProps { isFailedDocType: boolean; + isFailedSelfieLivenessOrQuality: boolean; remainingAttempts: number; captureHints: boolean; hasDismissed: boolean; @@ -24,6 +25,7 @@ interface DocumentCaptureReviewIssuesProps extends FormStepComponentProps undefined, @@ -52,6 +54,8 @@ function DocumentCaptureReviewIssues({ unknownFieldErrors={unknownFieldErrors} remainingAttempts={remainingAttempts} isFailedDocType={isFailedDocType} + isFailedSelfieLivenessOrQuality={isFailedSelfieLivenessOrQuality} + altIsFailedSelfieDontIncludeAttempts altFailedDocTypeMsg={isFailedDocType ? t('doc_auth.errors.doc.wrong_id_type_html') : null} hasDismissed={hasDismissed} /> diff --git a/app/javascript/packages/document-capture/components/document-capture-warning.tsx b/app/javascript/packages/document-capture/components/document-capture-warning.tsx index c7944a15eb5..80347039384 100644 --- a/app/javascript/packages/document-capture/components/document-capture-warning.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-warning.tsx @@ -2,6 +2,7 @@ import { Cancel } from '@18f/identity-verify-flow'; import { useI18n, HtmlTextWithStrongNoWrap } from '@18f/identity-react-i18n'; import { useContext, useEffect, useRef } from 'react'; import { FormStepError } from '@18f/identity-form-steps'; +import type { I18n } from '@18f/identity-i18n'; import Warning from './warning'; import DocumentCaptureTroubleshootingOptions from './document-capture-troubleshooting-options'; import UnknownError from './unknown-error'; @@ -11,6 +12,7 @@ import AnalyticsContext from '../context/analytics'; interface DocumentCaptureWarningProps { isFailedDocType: boolean; isFailedResult: boolean; + isFailedSelfieLivenessOrQuality: boolean; remainingAttempts: number; actionOnClick?: () => void; unknownFieldErrors: FormStepError<{ front: string; back: string }>[]; @@ -19,9 +21,25 @@ interface DocumentCaptureWarningProps { const DISPLAY_ATTEMPTS = 3; +type GetHeadingArguments = { + isFailedDocType: boolean; + isFailedSelfieLivenessOrQuality: boolean; + t: typeof I18n.prototype.t; +}; +function getHeading({ isFailedDocType, isFailedSelfieLivenessOrQuality, t }: GetHeadingArguments) { + if (isFailedDocType) { + return t('errors.doc_auth.doc_type_not_supported_heading'); + } + if (isFailedSelfieLivenessOrQuality) { + return t('errors.doc_auth.selfie_not_live_or_poor_quality_heading'); + } + return t('errors.doc_auth.rate_limited_heading'); +} + function DocumentCaptureWarning({ isFailedDocType, isFailedResult, + isFailedSelfieLivenessOrQuality, remainingAttempts, actionOnClick, unknownFieldErrors = [], @@ -32,15 +50,13 @@ function DocumentCaptureWarning({ const { trackEvent } = useContext(AnalyticsContext); const nonIppOrFailedResult = !inPersonURL || isFailedResult; - const heading = isFailedDocType - ? t('errors.doc_auth.doc_type_not_supported_heading') - : t('errors.doc_auth.rate_limited_heading'); + const heading = getHeading({ isFailedDocType, isFailedSelfieLivenessOrQuality, t }); const actionText = nonIppOrFailedResult ? t('idv.failure.button.warning') : t('idv.failure.button.try_online'); - const subheading = !nonIppOrFailedResult && !isFailedDocType && ( -

{t('errors.doc_auth.rate_limited_subheading')}

- ); + const subheading = !nonIppOrFailedResult && + !isFailedDocType && + !isFailedSelfieLivenessOrQuality &&

{t('errors.doc_auth.rate_limited_subheading')}

; const subheadingRef = useRef(null); const errorMessageDisplayedRef = useRef(null); @@ -79,6 +95,7 @@ function DocumentCaptureWarning({ unknownFieldErrors={unknownFieldErrors} remainingAttempts={remainingAttempts} isFailedDocType={isFailedDocType} + isFailedSelfieLivenessOrQuality={isFailedSelfieLivenessOrQuality} hasDismissed={hasDismissed} /> diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index c591d0e3472..cb2f3d813e5 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -115,6 +115,8 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { remainingAttempts: submissionError.remainingAttempts, isFailedResult: submissionError.isFailedResult, isFailedDocType: submissionError.isFailedDocType, + isFailedSelfieLivenessOrQuality: + submissionError.selfieNotLive || submissionError.selfieNotGoodQuality, captureHints: submissionError.hints, pii: submissionError.pii, failedImageFingerprints: submissionError.failed_image_fingerprints, diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx index 82e777548f7..5e2c7f82e2e 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -39,6 +39,7 @@ interface ReviewIssuesStepProps extends FormStepComponentProps { unknownFieldErrors: FormStepError<{ front: string; back: string }>[]; isFailedDocType: boolean; + isFailedSelfieLivenessOrQuality: boolean; remainingAttempts: number; altFailedDocTypeMsg?: string | null; + altIsFailedSelfieDontIncludeAttempts?: boolean; hasDismissed: boolean; } @@ -24,11 +26,27 @@ function formatIdTypeMsg({ altFailedDocTypeMsg, acceptedIdUrl }) { }); } +type GetErrorArguments = { + unknownFieldErrors: FormStepError<{ front: string; back: string }>[]; +}; +function getError({ unknownFieldErrors }: GetErrorArguments) { + const errs = + !!unknownFieldErrors && + // Errors where the field than is not 'front' or 'back'. In practice this means the field + // should be from the 'general' field in the "IdV: doc auth image upload vendor submitted" event + unknownFieldErrors.filter((error) => !['front', 'back'].includes(error.field!)); + const err = errs.length !== 0 ? errs[0].error : null; + + return err; +} + function UnknownError({ unknownFieldErrors = [], isFailedDocType = false, + isFailedSelfieLivenessOrQuality = false, remainingAttempts, altFailedDocTypeMsg = null, + altIsFailedSelfieDontIncludeAttempts = false, hasDismissed, }: UnknownErrorProps) { const { t } = useI18n(); @@ -45,10 +63,8 @@ function UnknownError({ location: 'document_capture_review_issues', }); - const errs = - !!unknownFieldErrors && - unknownFieldErrors.filter((error) => !['front', 'back'].includes(error.field!)); - const err = errs.length !== 0 ? errs[0].error : null; + const err = getError({ unknownFieldErrors }); + if (isFailedDocType && !!altFailedDocTypeMsg) { return (

{formatIdTypeMsg({ altFailedDocTypeMsg, acceptedIdUrl })}

@@ -64,6 +80,20 @@ function UnknownError({

); } + if (isFailedSelfieLivenessOrQuality && err) { + return ( + <> +

{err.message}

+

+ {!altIsFailedSelfieDontIncludeAttempts && ( + + )} +

+ + ); + } if (err && !hasDismissed) { return

{err.message}

; } diff --git a/app/javascript/packages/document-capture/context/upload.tsx b/app/javascript/packages/document-capture/context/upload.tsx index 6f6879cfee7..5a80aa66c1d 100644 --- a/app/javascript/packages/document-capture/context/upload.tsx +++ b/app/javascript/packages/document-capture/context/upload.tsx @@ -100,6 +100,16 @@ export interface UploadErrorResponse { */ doc_type_supported: boolean; + /* + * Whether the selfie passed the liveness check from trueid + */ + selfie_live?: boolean; + + /* + * Whether the selfie passed the quality check from trueid. + */ + selfie_quality_good?: boolean; + /** * Record of failed image fingerprints */ diff --git a/app/javascript/packages/document-capture/services/upload.ts b/app/javascript/packages/document-capture/services/upload.ts index ed0c77e16ed..f124210d35d 100644 --- a/app/javascript/packages/document-capture/services/upload.ts +++ b/app/javascript/packages/document-capture/services/upload.ts @@ -42,6 +42,10 @@ export class UploadFormEntriesError extends FormError { isFailedDocType = false; + selfieNotLive = false; + + selfieNotGoodQuality = false; + pii?: PII; hints = false; @@ -124,6 +128,11 @@ const upload: UploadImplementation = async function (payload, { method = 'POST', error.isFailedDocType = !result.doc_type_supported; + error.selfieNotLive = result.selfie_live === undefined ? false : !result.selfie_live; + + error.selfieNotGoodQuality = + result.selfie_quality_good === undefined ? false : !result.selfie_quality_good; + error.failed_image_fingerprints = result.failed_image_fingerprints ?? { front: [], back: [] }; throw error; diff --git a/app/services/doc_auth/errors.rb b/app/services/doc_auth/errors.rb index 98068b64158..a8bdf9c514a 100644 --- a/app/services/doc_auth/errors.rb +++ b/app/services/doc_auth/errors.rb @@ -29,6 +29,7 @@ module Errors SELFIE_FAILURE = 'selfie_failure' SELFIE_NOT_LIVE = 'selfie_not_live' SELFIE_POOR_QUALITY = 'selfie_poor_quality' + SELFIE_NOT_LIVE_POOR_QUALITY_FIELD = 'selfie_not_live_poor_quality' SEX_CHECK = 'sex_check' VISIBLE_COLOR_CHECK = 'visible_color_check' VISIBLE_PHOTO_CHECK = 'visible_photo_check' @@ -120,8 +121,8 @@ module Errors # TODO, theses messages need modifying # Liveness, use general error for now SELFIE_FAILURE => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: false }, - SELFIE_NOT_LIVE => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: false }, - SELFIE_POOR_QUALITY => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: false }, + SELFIE_NOT_LIVE => { long_msg: SELFIE_NOT_LIVE, field_msg: SELFIE_NOT_LIVE_POOR_QUALITY_FIELD, hints: false }, + SELFIE_POOR_QUALITY => { long_msg: SELFIE_POOR_QUALITY, field_msg: SELFIE_NOT_LIVE_POOR_QUALITY_FIELD, hints: false }, } # rubocop:enable Layout/LineLength end diff --git a/app/services/doc_auth/selfie_concern.rb b/app/services/doc_auth/selfie_concern.rb index 43f29dec079..ec8eab06ced 100644 --- a/app/services/doc_auth/selfie_concern.rb +++ b/app/services/doc_auth/selfie_concern.rb @@ -4,25 +4,25 @@ module SelfieConcern def selfie_live? portait_error = get_portrait_error(portrait_match_results) return true if portait_error.nil? || portait_error.blank? - return error_is_not_live(portait_error) + return !error_is_not_live(portait_error) end def selfie_quality_good? portait_error = get_portrait_error(portrait_match_results) return true if portait_error.nil? || portait_error.blank? - return error_is_poor_quality(portait_error) + return !error_is_poor_quality(portait_error) end def error_is_success(error_message) - return error_message != ERROR_TEXTS[:success] + error_message == ERROR_TEXTS[:success] end def error_is_not_live(error_message) - return error_message != ERROR_TEXTS[:not_live] + return error_message == ERROR_TEXTS[:not_live] end def error_is_poor_quality(error_message) - return error_message != ERROR_TEXTS[:poor_quality] + error_message == ERROR_TEXTS[:poor_quality] end private diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index b5f380ce177..80f934c221e 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -54,6 +54,13 @@ module DocAuthRouter # i18n-tasks-use t('doc_auth.errors.alerts.ref_control_number_check') DocAuth::Errors::REF_CONTROL_NUMBER_CHECK => 'doc_auth.errors.alerts.ref_control_number_check', + # i18n-tasks-use t('doc_auth.errors.alerts.selfie_not_live') + DocAuth::Errors::SELFIE_NOT_LIVE => 'doc_auth.errors.alerts.selfie_not_live', + # i18n-tasks-use t('doc_auth.errors.alerts.selfie_poor_quality') + DocAuth::Errors::SELFIE_POOR_QUALITY => 'doc_auth.errors.alerts.selfie_poor_quality', + # i18n-tasks-use t('doc_auth.errors.alerts.selfie_not_live_poor_quality') + DocAuth::Errors::SELFIE_NOT_LIVE_POOR_QUALITY_FIELD => + 'doc_auth.errors.alerts.selfie_not_live_poor_quality', # i18n-tasks-use t('doc_auth.errors.alerts.sex_check') DocAuth::Errors::SEX_CHECK => 'doc_auth.errors.alerts.sex_check', # i18n-tasks-use t('doc_auth.errors.alerts.visible_color_check') diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index 40b0f6a3a9a..788b09b34ba 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -38,6 +38,11 @@ en: the picture. Try taking new pictures. issue_date_checks: We couldn’t read the issue date on your ID. Try taking new pictures. ref_control_number_check: We couldn’t read the control number barcode. Try taking new pictures. + selfie_not_live: 'Try taking a photo of yourself again. Make sure your whole + face is clear and visible in the photo.' + selfie_not_live_poor_quality: 'We couldn’t verify the photo of yourself. Try taking a new picture.' + selfie_poor_quality: 'Try taking a photo of yourself again. Make sure your whole + face is clear and visible in the photo.' sex_check: We couldn’t read the sex on your ID. Try taking new pictures. visible_color_check: We couldn’t verify your ID. It might have moved when you took the picture, or the picture is too dark. Try taking new pictures diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index ce537f99f04..122fd64e7d4 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -48,6 +48,11 @@ es: identidad. Intente tomar nuevas fotos. ref_control_number_check: No pudimos leer el código de barras del número de control. Intente tomar nuevas fotografías. + selfie_not_live: 'Intenta volver a tomarte una foto. Asegúrate de que tu rostro + completo esté claro y visible en la foto.' + selfie_not_live_poor_quality: 'No pudimos verificar su foto. Trate de tomarse otra foto.' + selfie_poor_quality: 'Intenta volver a tomarte una foto. Asegúrate de que tu + rostro completo esté claro y visible en la foto.' sex_check: No pudimos leer el sexo en su documento de identidad. Intente tomar nuevas fotos. visible_color_check: No pudimos verificar su documento de identidad. Puede que diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 1c12cc52be6..67298dcd2c2 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -51,6 +51,12 @@ fr: d’identité. Essayez de prendre de nouvelles photos. ref_control_number_check: Nous n’avons pas pu lire le code-barres du numéro de contrôle. Essayez de prendre de nouvelles photos. + selfie_not_live: 'Rééssayez de vous prendre en photo. Assurez-vous que lensemble + de votre visage est clair et visible sur la photo.' + selfie_not_live_poor_quality: 'Nous n’avons pas réussi à vérifier votre photo. + Essayez à nouveau avec une nouvelle photo.' + selfie_poor_quality: 'Rééssayez de vous prendre en photo. Assurez-vous que + l’ensemble de votre visage est clair et visible sur la photo.' sex_check: Nous n’avons pas pu lire le sexe sur votre pièce d’identité. Essayez de prendre de nouvelles photos. visible_color_check: Nous n’avons pas pu vérifier votre pièce d’identité. Elle a diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index d5798e70027..f95ad00e9d6 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -34,6 +34,7 @@ en: rate_limited_text_html: 'For your security, we limit the number of times you can attempt to verify a document online. Try again in %{timeout}.' + selfie_not_live_or_poor_quality_heading: We could not verify the photo of yourself send_link_limited: You tried too many times, please try again in %{timeout}. You can also go back and choose to use your computer instead. enter_code: diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 970491697c4..69149b91f0b 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -36,6 +36,7 @@ es: rate_limited_text_html: 'Por su seguridad, limitamos el número de veces que puede intentar verificar un documento en línea. Inténtelo de nuevo en %{timeout}.' + selfie_not_live_or_poor_quality_heading: No pudimos verificar tu foto send_link_limited: Ha intentado demasiadas veces, por favor, inténtelo de nuevo en %{timeout}. También puede retroceder y elegir utilizar su computadora como alternativa. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index a62e9a84798..94656f325cc 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -40,6 +40,7 @@ fr: rate_limited_text_html: 'Pour votre sécurité, nous limitons le nombre de fois où vous pouvez tenter de vérifier un document en ligne. Veuillez réessayer dans %{timeout}.' + selfie_not_live_or_poor_quality_heading: Nous n’avons pas pu vérifier votre photo send_link_limited: Vous avez essayé trop de fois, veuillez réessayer dans %{timeout}. Vous pouvez également revenir en arrière et choisir d’utiliser votre ordinateur à la place. diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 340aa1fdbb4..a84172e862d 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -250,6 +250,84 @@ end end + context 'selfie with no liveness or poor quality is uploaded', allow_browser_log: true do + it 'try again and page show no liveness inline error message' do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_document_capture_step + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_no_liveness.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_no_liveness.yml' + ), + ) + submit_images + message = strip_tags(t('errors.doc_auth.selfie_not_live_or_poor_quality_heading')) + expect(page).to have_content(message) + detail_message = strip_tags(t('doc_auth.errors.alerts.selfie_not_live')) + security_message = strip_tags( + t( + 'idv.warning.attempts_html', + count: IdentityConfig.store.doc_auth_max_attempts - 1, + ), + ) + expect(page).to have_content(detail_message << "\n" << security_message) + review_issues_header = strip_tags( + t('errors.doc_auth.selfie_not_live_or_poor_quality_heading'), + ) + expect(page).to have_content(review_issues_header) + expect(page).to have_current_path(idv_document_capture_path) + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + inline_error = strip_tags(t('doc_auth.errors.alerts.selfie_not_live_poor_quality')) + expect(page).to have_content(inline_error) + end + + it 'try again and page show poor quality inline error message' do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_document_capture_step + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_poor_quality.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_poor_quality.yml' + ), + ) + submit_images + message = strip_tags(t('errors.doc_auth.selfie_not_live_or_poor_quality_heading')) + expect(page).to have_content(message) + detail_message = strip_tags(t('doc_auth.errors.alerts.selfie_poor_quality')) + security_message = strip_tags( + t( + 'idv.warning.attempts_html', + count: IdentityConfig.store.doc_auth_max_attempts - 1, + ), + ) + expect(page).to have_content(detail_message << "\n" << security_message) + review_issues_header = strip_tags( + t('errors.doc_auth.selfie_not_live_or_poor_quality_heading'), + ) + expect(page).to have_content(review_issues_header) + expect(page).to have_current_path(idv_document_capture_path) + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + inline_error = strip_tags(t('doc_auth.errors.alerts.selfie_not_live_poor_quality')) + expect(page).to have_content(inline_error) + end + end + context 'when selfie check is not enabled (flag off, and/or in production)' do let(:selfie_check_enabled) { false } it 'proceeds to the next page with valid info, excluding a selfie image' do diff --git a/spec/fixtures/ial2_test_credential_no_liveness.yml b/spec/fixtures/ial2_test_credential_no_liveness.yml new file mode 100644 index 00000000000..850f6a33ca8 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_no_liveness.yml @@ -0,0 +1,20 @@ +portrait_match_results: + # returns the portrait match result + FaceMatchResult: Fail + # returns the liveness result + FaceErrorMessage: 'Liveness: NotLive' +doc_auth_result: Passed +document: + first_name: Jane + last_name: Doe + middle_name: Q + address1: 1800 F Street + address2: Apt 3 + city: Bayside + state: NY + zipcode: '11364' + dob: 10/06/1938 + phone: +1 314-555-1212 + state_id_jurisdiction: 'ND' + state_id_number: 'S59397998' + state_id_type: drivers_license diff --git a/spec/fixtures/ial2_test_credential_poor_quality.yml b/spec/fixtures/ial2_test_credential_poor_quality.yml new file mode 100644 index 00000000000..11519ae47b5 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_poor_quality.yml @@ -0,0 +1,20 @@ +portrait_match_results: + # returns the portrait match result + FaceMatchResult: Fail + # returns the liveness result + FaceErrorMessage: 'Liveness: PoorQuality' +doc_auth_result: Passed +document: + first_name: Jane + last_name: Doe + middle_name: Q + address1: 1800 F Street + address2: Apt 3 + city: Bayside + state: NY + zipcode: '11364' + dob: 10/06/1938 + phone: +1 314-555-1212 + state_id_jurisdiction: 'ND' + state_id_number: 'S59397998' + state_id_type: drivers_license diff --git a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb index c39d2435a1d..a22023c982a 100644 --- a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb +++ b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb @@ -343,7 +343,9 @@ errors = post_images_response.errors expect(errors.keys).to contain_exactly(:general, :hints, :selfie) - expect(errors[:selfie]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(errors[:selfie]).to contain_exactly( + DocAuth::Errors::SELFIE_NOT_LIVE_POOR_QUALITY_FIELD, + ) end end @@ -373,7 +375,9 @@ errors = post_images_response.errors expect(errors.keys).to contain_exactly(:general, :hints, :selfie) - expect(errors[:selfie]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(errors[:selfie]).to contain_exactly( + DocAuth::Errors::SELFIE_NOT_LIVE_POOR_QUALITY_FIELD, + ) end end From 250625eb8d235b06deea9d49cd1b7543aa417162 Mon Sep 17 00:00:00 2001 From: "Luis H. Matos" Date: Wed, 31 Jan 2024 16:01:02 -0600 Subject: [PATCH 03/25] Lg 12117 summarize multiple apps (#10008) * LG-12117 Condense Reuse Rate Report changelog: Internal, Reporting, Condensing Reuse Rate Report rows --- .../reporting/account_reuse_report.rb | 26 ++- .../reporting/account_reuse_report_spec.rb | 171 +++++++++++++++--- 2 files changed, 165 insertions(+), 32 deletions(-) diff --git a/app/services/reporting/account_reuse_report.rb b/app/services/reporting/account_reuse_report.rb index 4bb534270c5..f8c08b7ad65 100644 --- a/app/services/reporting/account_reuse_report.rb +++ b/app/services/reporting/account_reuse_report.rb @@ -65,15 +65,20 @@ def initialize( def update_details( num_entities: nil, entity_type: nil, - num_idv_users: nil, num_all_users: nil + num_all_users: nil, all_percent: nil, + num_idv_users: nil, idv_percent: nil ) self.num_entities = num_entities if !num_entities.nil? self.entity_type = entity_type if !entity_type.nil? + self.num_all_users = num_all_users if !num_all_users.nil? + + self.all_percent = all_percent if !all_percent.nil? + self.num_idv_users = num_idv_users if !num_idv_users.nil? - self.num_all_users = num_all_users if !num_all_users.nil? + self.idv_percent = idv_percent if !idv_percent.nil? self end @@ -173,6 +178,23 @@ def update_from_results(results:, total_registered:, total_proofed:) end end end + + results.each_with_index do |details_section, section_index| + details_section.select { |details| details.num_entities >= 10 }. + reduce do |summary_row, captured_row| + # Delete any rows after the first captured_row (which becomes the summary_row) + details_section.delete(captured_row) if captured_row != summary_row + summary_row.update_details( + num_entities: "10-#{captured_row.num_entities}", + entity_type: summary_row.entity_type, + num_all_users: summary_row.num_all_users + captured_row.num_all_users, + all_percent: summary_row.all_percent + captured_row.all_percent, + num_idv_users: summary_row.num_idv_users + captured_row.num_idv_users, + idv_percent: summary_row.idv_percent + captured_row.idv_percent, + ) + end + end + self.details_section = results self diff --git a/spec/services/reporting/account_reuse_report_spec.rb b/spec/services/reporting/account_reuse_report_spec.rb index 7c399430e44..4afc8d19e1f 100644 --- a/spec/services/reporting/account_reuse_report_spec.rb +++ b/spec/services/reporting/account_reuse_report_spec.rb @@ -14,12 +14,25 @@ let(:in_query) { report_date - 12.days } let(:out_of_query) { report_date + 12.days } - let(:agency) { create(:agency, name: 'The Agency') } + let(:agency1) { create(:agency, name: 'The Agency') } let(:agency2) { create(:agency, name: 'The Other Agency') } let(:sp_a) { 'a' } let(:sp_b) { 'b' } let(:sp_c) { 'c' } let(:sp_d) { 'd' } + let(:sp_e) { 'e' } + let(:sp_f) { 'f' } + let(:sp_g) { 'g' } + let(:sp_h) { 'h' } + let(:sp_i) { 'i' } + let(:sp_j) { 'j' } + let(:sp_k) { 'k' } + let(:sp_l) { 'l' } + + let(:agency1_apps) { [sp_a, sp_d, sp_e, sp_h, sp_i, sp_l] } + let(:all_agency_apps) do + [sp_a, sp_b, sp_c, sp_d, sp_e, sp_f, sp_g, sp_h, sp_i, sp_j, sp_k, sp_l] + end before do create( @@ -27,7 +40,7 @@ issuer: sp_a, iaa: 'iaa123', friendly_name: 'The App', - agency: agency, + agency: agency1, ) create( :service_provider, @@ -48,7 +61,63 @@ issuer: sp_d, iaa: 'iaa321', friendly_name: 'The Other First App', - agency: agency, + agency: agency1, + ) + create( + :service_provider, + issuer: sp_e, + iaa: 'iaa123', + friendly_name: 'App E', + agency: agency1, + ) + create( + :service_provider, + issuer: sp_f, + iaa: 'iaa456', + friendly_name: 'App F', + agency: agency2, + ) + create( + :service_provider, + issuer: sp_g, + iaa: 'iaa789', + friendly_name: 'App G', + agency: agency2, + ) + create( + :service_provider, + issuer: sp_h, + iaa: 'iaa321', + friendly_name: 'App H', + agency: agency1, + ) + create( + :service_provider, + issuer: sp_i, + iaa: 'iaa123', + friendly_name: 'App I', + agency: agency1, + ) + create( + :service_provider, + issuer: sp_j, + iaa: 'iaa456', + friendly_name: 'App J', + agency: agency2, + ) + create( + :service_provider, + issuer: sp_k, + iaa: 'iaa789', + friendly_name: 'App K', + agency: agency2, + ) + create( + :service_provider, + issuer: sp_l, + iaa: 'iaa321', + friendly_name: 'App L', + agency: agency1, ) # Seed the database with data to be queried @@ -61,57 +130,72 @@ # User 6 has 2 SPs and only 1 shows up in the query # User 7 has 1 SP and 1 shows up in the query # User 8 has 1 SP and 0 show up in the query + # User 9 has 2 SPs and only 1 shows up in the query + # User 10 has 2 SPs and 0 show up in the query + # User 11 has 2 SPs and 0 show up in the query + # User 12 has 1 SP and 1 shows up in the query + # User 13 has 1 SP and 0 show up in the query + # User 14 has 1 SP and 0 show up in the query + # User 15 has 12 SPs and 12 show up in the query + # User 16 has 11 SPs and 11 show up in the query + # User 17 has 11 SPs and 11 show up in the query + # User 18 has 10 SPs and 10 show up in the query + # User 19 has 10 SPs and 10 show up in the query + # User 20 has 10 SPs and 9 show up in the query # # This will give 1 user with 3 SPs/apps and 3 users with 2 SPs/apps for the IDV app report # This will give 4 users with 3 SPs/apps and 5 users with 2 SPs/apps for the ALL app report - # This will give 3 users with 2 agencies for the IDV agency report - # This will give 7 users with 2 agencies for the ALL agency report + # This will give 1 user with 9 SPs/apps for the ALL app report and 0 for the IDV app report + # This will give 6 users with 10-12 SPs/apps for the ALL app report + # This will give 5 users with 10-12 SPs/apps for the IDV app report + # This will give 9 users with 2 agencies for the IDV agency report + # This will give 13 users with 2 agencies for the ALL agency report users_to_query = [ { id: 1, # 3 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b, sp_c], - sp_timestamp: [in_query, in_query, in_query] }, + sp: all_agency_apps.first(3), + sp_timestamp: Array.new(3) { in_query } }, { id: 2, # 3 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b, sp_c], + sp: all_agency_apps.first(3), sp_timestamp: [in_query, in_query, out_of_query] }, { id: 3, # 3 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b, sp_c], + sp: all_agency_apps.first(3), sp_timestamp: [in_query, out_of_query, out_of_query] }, { id: 4, # 3 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b, sp_c], + sp: all_agency_apps.first(3), sp_timestamp: [in_query, out_of_query, out_of_query] }, { id: 5, # 3 apps, 2 agencies created_timestamp: out_of_query, - sp: [sp_a, sp_b, sp_c], - sp_timestamp: [out_of_query, out_of_query, out_of_query] }, + sp: all_agency_apps.first(3), + sp_timestamp: Array.new(3) { out_of_query } }, { id: 6, # 2 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b], - sp_timestamp: [in_query, in_query] }, + sp: all_agency_apps.first(2), + sp_timestamp: Array.new(2) { in_query } }, { id: 7, # 2 apps, 1 agency created_timestamp: in_query, - sp: [sp_a, sp_d], - sp_timestamp: [in_query, in_query] }, + sp: agency1_apps.first(2), + sp_timestamp: Array.new(2) { in_query } }, { id: 8, # 2 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b], + sp: all_agency_apps.first(2), sp_timestamp: [in_query, out_of_query] }, { id: 9, # 2 apps, 1 agency created_timestamp: in_query, - sp: [sp_a, sp_d], + sp: agency1_apps.first(2), sp_timestamp: [in_query, out_of_query] }, { id: 10, # 2 apps, 2 agencies created_timestamp: in_query, - sp: [sp_a, sp_b], - sp_timestamp: [out_of_query, out_of_query] }, + sp: all_agency_apps.first(2), + sp_timestamp: Array.new(2) { out_of_query } }, { id: 11, # 2 apps, 2 agencies created_timestamp: out_of_query, - sp: [sp_a, sp_b], - sp_timestamp: [out_of_query, out_of_query] }, + sp: all_agency_apps.first(2), + sp_timestamp: Array.new(2) { out_of_query } }, { id: 12, created_timestamp: in_query, sp: [sp_a], @@ -124,6 +208,31 @@ created_timestamp: out_of_query, sp: [sp_a], sp_timestamp: [out_of_query] }, + { id: 15, # 12 apps, 2 agencies + created_timestamp: in_query, + sp: all_agency_apps, + sp_timestamp: Array.new(12) { in_query } }, + { id: 16, # 11 apps, 2 agencies + created_timestamp: in_query, + sp: all_agency_apps.first(11), + sp_timestamp: Array.new(11) { in_query } }, + { id: 17, # 11 apps, 2 agencies + created_timestamp: in_query, + sp: all_agency_apps.first(11), + sp_timestamp: Array.new(11) { in_query } }, + { id: 18, # 10 apps, 2 agencies + created_timestamp: in_query, + sp: all_agency_apps.first(10), + sp_timestamp: Array.new(10) { in_query } }, + { id: 19, # 10 apps, 2 agencies + created_timestamp: in_query, + sp: all_agency_apps.first(10), + sp_timestamp: Array.new(10) { in_query } }, + { id: 20, # 10 apps, 2 agencies + created_timestamp: in_query, + sp: all_agency_apps.first(10), + sp_timestamp: Array.new(9) { in_query } + Array.new(1) { out_of_query } }, + ] users_to_query.each do |user| @@ -139,8 +248,8 @@ end # Create active profiles for total_proofed_identities - # These 13 profiles will yield 10 active profiles in the results - (1..10).each do |_| + # These 20 profiles will yield 10 active profiles in the results + 10.times do create( :profile, :active, @@ -148,7 +257,7 @@ user: create(:user, :fully_registered, registered_at: in_query), ) end - (1..3).each do |_| + 10.times do create( :profile, :active, @@ -162,11 +271,13 @@ it 'has the correct results' do expected_csv = [ ['Metric', 'Num. all users', '% of accounts', 'Num. IDV users', '% of accounts'], - ['2 apps', 5, 5 / 13.0, 3, 0.3], - ['3 apps', 4, 4 / 13.0, 1, 0.1], - ['2+ apps', 9, 9 / 13.0, 4, 0.4], - ['2 agencies', 7, 7 / 13.0, 3, 0.3], - ['2+ agencies', 7, 7 / 13.0, 3, 0.3], + ['2 apps', 5, 5 / 20.0, 3, 0.3], + ['3 apps', 4, 4 / 20.0, 1, 0.1], + ['9 apps', 0, 0 / 20.0, 1, 0.1], + ['10-12 apps', 6, 6 / 20.0, 5, 0.5], + ['2+ apps', 15, 15 / 20.0, 10, 0.9999999999999999], + ['2 agencies', 13, 13 / 20.0, 9, 0.9], + ['2+ agencies', 13, 13 / 20.0, 9, 0.9], ] aggregate_failures do From eb7aa033a183f3792ff3031e933626197869a7b5 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Thu, 1 Feb 2024 08:52:17 -0500 Subject: [PATCH 04/25] Silently ignore invalid params for FormSteps (#10005) * Silently ignore invalid params for FormSteps changelog: Internal, Document Capture, Refactor handling for invalid URL steps * Remove skipnav bypass for FormSteps interop --- .../packages/form-steps/form-steps.spec.tsx | 6 --- .../packages/form-steps/form-steps.tsx | 3 +- .../form-steps/use-history-param.spec.tsx | 49 +++++++++++++++++-- .../packages/form-steps/use-history-param.ts | 8 ++- app/javascript/packs/application.ts | 5 -- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index 6877d2c6d2e..cf883d4eac1 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -355,12 +355,6 @@ describe('FormSteps', () => { expect(window.location.hash).to.equal('#second'); }); - it('resets hash in URL if there is no matching step', () => { - window.location.hash = '#example'; - render(); - expect(window.location.hash).to.equal(''); - }); - it('syncs step by history events', async () => { const { getByText, findByText, getByLabelText } = render(); diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index d0c96c7e0e8..f454b303388 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -231,10 +231,11 @@ function FormSteps({ promptOnNavigate = true, titleFormat, }: FormStepsProps) { + const stepNames = steps.map((step) => step.name); const [values, setValues] = useState(initialValues); const [activeErrors, setActiveErrors] = useState(initialActiveErrors); const formRef = useRef(null as HTMLFormElement | null); - const [stepName, setStepName] = useHistoryParam(initialStep); + const [stepName, setStepName] = useHistoryParam(initialStep, stepNames); const [stepErrors, setStepErrors] = useState([] as Error[]); const [isSubmitting, setIsSubmitting] = useState(false); const [stepCanComplete, setStepCanComplete] = useState(undefined); diff --git a/app/javascript/packages/form-steps/use-history-param.spec.tsx b/app/javascript/packages/form-steps/use-history-param.spec.tsx index 94c83bd7061..cf9be56e18d 100644 --- a/app/javascript/packages/form-steps/use-history-param.spec.tsx +++ b/app/javascript/packages/form-steps/use-history-param.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, act } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; import useHistoryParam, { getStepParam } from './use-history-param'; @@ -13,8 +13,14 @@ describe('getStepParam', () => { }); describe('useHistoryParam', () => { - function TestComponent({ initialValue }: { initialValue?: string }) { - const [count = 0, setCount] = useHistoryParam(initialValue); + function TestComponent({ + initialValue, + validValues, + }: { + initialValue?: string; + validValues?: string[]; + }) { + const [count = 0, setCount] = useHistoryParam(initialValue, validValues); return ( <> @@ -119,4 +125,41 @@ describe('useHistoryParam', () => { const [path2] = inst2.result.current; expect(path2).to.equal('root'); }); + + context('when specifying valid values', () => { + it('syncs by history events for a valid value', async () => { + const { getByText, getByDisplayValue, findByDisplayValue } = render( + , + ); + expect(getByDisplayValue('0')).to.be.ok(); + + await userEvent.click(getByText('Increment')); + + expect(getByDisplayValue('1')).to.be.ok(); + expect(window.location.hash).to.equal('#1'); + + act(() => { + window.history.back(); + }); + + expect(await findByDisplayValue('0')).to.be.ok(); + expect(window.location.hash).to.equal(''); + }); + + it('maintains value (does not sync) by history events for an invalid value', async () => { + const { getByDisplayValue } = render(); + expect(getByDisplayValue('0')).to.be.ok(); + const popstateHandled = new Promise((resolve) => + window.addEventListener('popstate', resolve, { once: true }), + ); + + act(() => { + window.location.hash = '#wrong'; + }); + + await popstateHandled; + expect(getByDisplayValue('0')).to.be.ok(); + expect(window.location.hash).to.equal('#wrong'); + }); + }); }); diff --git a/app/javascript/packages/form-steps/use-history-param.ts b/app/javascript/packages/form-steps/use-history-param.ts index 1839840924d..7f91b3609ff 100644 --- a/app/javascript/packages/form-steps/use-history-param.ts +++ b/app/javascript/packages/form-steps/use-history-param.ts @@ -29,13 +29,17 @@ const subscribers: Array<() => void> = []; */ function useHistoryParam( initialValue?: string, + validValues?: string[], ): [string | undefined, (nextParamValue: ParamValue) => void] { - function getCurrentValue(): ParamValue { + function getCurrentValue(currentValue?: string): ParamValue { const path = window.location.hash.slice(1); if (path) { - return getStepParam(path); + const value = getStepParam(path); + return !validValues || validValues.includes(value) ? value : currentValue; } + + return initialValue; } const [value, setValue] = useState(initialValue ?? getCurrentValue); diff --git a/app/javascript/packs/application.ts b/app/javascript/packs/application.ts index 6cd8cb725d5..ad2ce264d1c 100644 --- a/app/javascript/packs/application.ts +++ b/app/javascript/packs/application.ts @@ -2,8 +2,3 @@ import { accordion, banner, skipnav } from '@18f/identity-design-system'; const components = [accordion, banner, skipnav]; components.forEach((component) => component.on()); -const mainContent = document.getElementById('main-content'); -document.querySelector('.usa-skipnav')?.addEventListener('click', (event) => { - event.preventDefault(); - mainContent?.scrollIntoView(); -}); From 21d5b412ea73b5006b7cb65738c3c706e40b41b4 Mon Sep 17 00:00:00 2001 From: Amir Reavis-Bey Date: Thu, 1 Feb 2024 13:47:33 -0500 Subject: [PATCH 05/25] LG-12033: selfie_check_performed verifies the vendor performed a selfie check (#9983) * convert DocumentCaputureSessionResult selfie_check_performed from an attribute to a method * remove selfie_check_performed constructur argument for DocAuth Response * ResultResponse get results calls * rename selfie_check_performed method in document session result * update selfie_check_performed mocks * remove selfie_check_performed arg * fix selfie_check_performed method name * document capture spec to use image with liveness * analytics selfie spec to mock selfie_status * hybrid feature spec to use images with liveness data * remove commented lines * refactor selfie check performed into selfie concern * selfie_check_performed must remain for 50/50 * use liveness yaml test file * update state checked * liveness success yml to use same pii as mock default * update spec to use the mock default pii also used in the yml test file * Internal, Document Authentication, Refactor selfie_check_performed to reflect result from vendor * changelog: Internal, Document Authentication, Refactor selfie_check_performed to reflect result from vendor --- .../concerns/idv/document_capture_concern.rb | 4 ++-- app/models/document_capture_session.rb | 1 - .../lexis_nexis/responses/true_id_response.rb | 1 - .../doc_auth/mock/doc_auth_mock_client.rb | 5 ++-- app/services/doc_auth/mock/result_response.rb | 10 ++++---- app/services/doc_auth/response.rb | 4 +--- app/services/doc_auth/selfie_concern.rb | 6 +++++ .../document_capture_session_result.rb | 2 ++ .../idv/document_capture_concern_spec.rb | 2 +- .../idv/image_uploads_controller_spec.rb | 11 +++++---- .../idv/link_sent_controller_spec.rb | 2 +- spec/features/idv/analytics_spec.rb | 4 ++++ .../idv/doc_auth/document_capture_spec.rb | 3 +-- .../idv/hybrid_mobile/hybrid_mobile_spec.rb | 3 +-- .../ial2_test_portrait_match_success.yml | 23 ++++++++++--------- spec/forms/idv/api_image_upload_form_spec.rb | 16 ++++++------- .../mock/doc_auth_mock_client_spec.rb | 4 ---- .../doc_auth/mock/result_response_spec.rb | 5 ++-- spec/services/doc_auth_router_spec.rb | 4 ++-- .../features/document_capture_step_helper.rb | 10 ++++++++ 20 files changed, 66 insertions(+), 54 deletions(-) diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 302bc509d58..7c45dc786e9 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -36,7 +36,7 @@ def extract_pii_from_doc(user, response, store_in_session: false) idv_session.had_barcode_read_failure = response.attention_with_barcode? if store_in_session idv_session.pii_from_doc = response.pii_from_doc - idv_session.selfie_check_performed = response.selfie_check_performed + idv_session.selfie_check_performed = response.selfie_check_performed? end end @@ -49,7 +49,7 @@ def stored_result end def selfie_requirement_met? - !decorated_sp_session.selfie_required? || stored_result.selfie_check_performed + !decorated_sp_session.selfie_required? || stored_result.selfie_check_performed? end private diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 94ca97fe105..c2a42e5c118 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -17,7 +17,6 @@ def store_result_from_response(doc_auth_response) session_result.pii = doc_auth_response.pii_from_doc session_result.captured_at = Time.zone.now session_result.attention_with_barcode = doc_auth_response.attention_with_barcode? - session_result.selfie_check_performed = doc_auth_response.selfie_check_performed? session_result.doc_auth_success = doc_auth_response.doc_auth_success? session_result.selfie_status = doc_auth_response.selfie_status EncryptedRedisStructStorage.store( diff --git a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb index 9c3b96748c0..9e326260ed4 100644 --- a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb +++ b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb @@ -54,7 +54,6 @@ def initialize(http_response, config, liveness_checking_enabled = false) errors: error_messages, extra: extra_attributes, pii_from_doc: pii_from_doc, - selfie_check_performed: liveness_checking_enabled, ) rescue StandardError => e NewRelic::Agent.notice_error(e) diff --git a/app/services/doc_auth/mock/doc_auth_mock_client.rb b/app/services/doc_auth/mock/doc_auth_mock_client.rb index ee285dd47e0..82427f1bd49 100644 --- a/app/services/doc_auth/mock/doc_auth_mock_client.rb +++ b/app/services/doc_auth/mock/doc_auth_mock_client.rb @@ -75,10 +75,10 @@ def post_images( back_image_response = post_back_image(image: back_image, instance_id: instance_id) return back_image_response unless back_image_response.success? - get_results(instance_id: instance_id, selfie_check_performed: liveness_checking_required) + get_results(instance_id: instance_id) end - def get_results(instance_id:, selfie_check_performed:) + def get_results(instance_id:) return mocked_response_for_method(__method__) if method_mocked?(__method__) error_response = http_error_response(self.class.last_uploaded_back_image, 'result') return error_response if error_response @@ -90,7 +90,6 @@ def get_results(instance_id:, selfie_check_performed:) ResultResponse.new( self.class.last_uploaded_back_image, - selfie_check_performed, overriden_config, ) end diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 3e4202ca213..8beb314726c 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -7,16 +7,14 @@ class ResultResponse < DocAuth::Response attr_reader :uploaded_file, :config - def initialize(uploaded_file, selfie_check_performed, config) + def initialize(uploaded_file, config) @uploaded_file = uploaded_file.to_s - @selfie_check_performed = selfie_check_performed @config = config super( success: success?, errors: errors, pii_from_doc: pii_from_doc, doc_type_supported: id_type_supported?, - selfie_check_performed: selfie_check_performed, selfie_live: selfie_live?, selfie_quality_good: selfie_quality_good?, extra: { @@ -63,7 +61,7 @@ def errors mock_args[:image_metrics] = image_metrics.symbolize_keys if image_metrics.present? mock_args[:failed] = failed.map!(&:symbolize_keys) unless failed.nil? mock_args[:passed] = passed.map!(&:symbolize_keys) if passed.present? - mock_args[:liveness_enabled] = @selfie_check_performed + mock_args[:liveness_enabled] = face_match_result ? true : false mock_args[:classification_info] = classification_info if classification_info.present? fake_response_info = create_response_info(**mock_args) ErrorGenerator.new(config).generate_doc_auth_errors(fake_response_info) @@ -183,7 +181,7 @@ def doc_auth_result_from_success def all_doc_capture_values_passing?(doc_auth_result, id_type_supported) doc_auth_result == 'Passed' && id_type_supported && - (@selfie_check_performed ? selfie_passed? : true) + (selfie_check_performed? ? selfie_passed? : true) end def selfie_passed? @@ -238,7 +236,7 @@ def create_response_info( image_metrics: merged_image_metrics, liveness_enabled: liveness_enabled, classification_info: classification_info, - portrait_match_results: @selfie_check_performed ? portrait_match_results : nil, + portrait_match_results: selfie_check_performed? ? portrait_match_results : nil, }.compact end end diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index 9be626d4ce1..5c311934a82 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -18,7 +18,6 @@ def initialize( pii_from_doc: {}, attention_with_barcode: false, doc_type_supported: true, - selfie_check_performed: false, selfie_live: true, selfie_quality_good: true ) @@ -29,7 +28,6 @@ def initialize( @pii_from_doc = pii_from_doc @attention_with_barcode = attention_with_barcode @doc_type_supported = doc_type_supported - @selfie_check_performed = selfie_check_performed @selfie_live = selfie_live @selfie_quality_good = selfie_quality_good end @@ -96,7 +94,7 @@ def network_error? end def selfie_check_performed? - @selfie_check_performed + false end def doc_auth_success? diff --git a/app/services/doc_auth/selfie_concern.rb b/app/services/doc_auth/selfie_concern.rb index ec8eab06ced..89e562168fd 100644 --- a/app/services/doc_auth/selfie_concern.rb +++ b/app/services/doc_auth/selfie_concern.rb @@ -25,8 +25,14 @@ def error_is_poor_quality(error_message) error_message == ERROR_TEXTS[:poor_quality] end + def selfie_check_performed? + SELFIE_PERFORMED_STATUSES.include?(selfie_status) + end + private + SELFIE_PERFORMED_STATUSES = %i[success fail] + ERROR_TEXTS = { success: 'Successful. Liveness: Live', not_live: 'Liveness: NotLive', diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 5d09e275242..c1bfac2a57c 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -18,6 +18,8 @@ :captured_at, :selfie_check_performed, :doc_auth_success, :selfie_status, :selfie_success] ) do + include DocAuth::SelfieConcern + def self.redis_key_prefix 'dcs:result' end diff --git a/spec/controllers/concerns/idv/document_capture_concern_spec.rb b/spec/controllers/concerns/idv/document_capture_concern_spec.rb index 67fbae4b89c..c77665906c4 100644 --- a/spec/controllers/concerns/idv/document_capture_concern_spec.rb +++ b/spec/controllers/concerns/idv/document_capture_concern_spec.rb @@ -23,7 +23,7 @@ def show allow(decorated_sp_session).to receive(:selfie_required?).and_return(selfie_required) allow(controller).to receive(:decorated_sp_session).and_return(decorated_sp_session) stored_result = instance_double(DocumentCaptureSessionResult) - allow(stored_result).to receive(:selfie_check_performed).and_return(selfie_check_performed) + allow(stored_result).to receive(:selfie_check_performed?).and_return(selfie_check_performed) allow(controller).to receive(:stored_result).and_return(stored_result) end diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index f0ab25f4dad..d657d0731a4 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -5,6 +5,7 @@ let(:document_filename_regex) { /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}\.[a-z]+$/ } let(:base64_regex) { /^[a-z0-9+\/]+=*$/i } + let(:back_image) { DocAuthImageFixtures.document_back_image_multipart } let(:selfie_img) { nil } let(:state_id_number) { 'S59397998' } @@ -20,8 +21,8 @@ { front: DocAuthImageFixtures.document_front_image_multipart, front_image_metadata: '{"glare":99.99}', - back: DocAuthImageFixtures.document_back_image_multipart, - selfie: (selfie_img unless selfie_img.nil?), + back: back_image, + selfie: selfie_img, back_image_metadata: '{"glare":99.99}', document_capture_session_uuid: document_capture_session.uuid, flow_path: flow_path, @@ -351,6 +352,7 @@ # fake up a response and verify that selfie_check_performed flows through? context 'selfie included' do + let(:back_image) { DocAuthImageFixtures.portrait_match_success_yaml } let(:selfie_img) { DocAuthImageFixtures.selfie_image_multipart } before do @@ -374,7 +376,7 @@ expect(response.status).to eq(200) expect(json[:success]).to eq(true) expect(document_capture_session.reload.load_result.success?).to eq(true) - expect(document_capture_session.reload.load_result.selfie_check_performed).to eq(true) + expect(document_capture_session.reload.load_result.selfie_check_performed?).to eq(true) end end @@ -1248,6 +1250,7 @@ and_return(double('decorated_session', { selfie_required?: true })) end + let(:back_image) { DocAuthImageFixtures.portrait_match_success_yaml } let(:selfie_img) { DocAuthImageFixtures.selfie_image_multipart } it 'returns a successful response' do @@ -1255,7 +1258,7 @@ expect(response.status).to eq(200) expect(json[:success]).to eq(true) expect(document_capture_session.reload.load_result.success?).to eq(true) - expect(document_capture_session.reload.load_result.selfie_check_performed).to eq(true) + expect(document_capture_session.reload.load_result.selfie_check_performed?).to eq(true) end it 'sends a selfie' do diff --git a/spec/controllers/idv/link_sent_controller_spec.rb b/spec/controllers/idv/link_sent_controller_spec.rb index d84960dd502..475f95cb4c6 100644 --- a/spec/controllers/idv/link_sent_controller_spec.rb +++ b/spec/controllers/idv/link_sent_controller_spec.rb @@ -145,7 +145,7 @@ allow(load_result).to receive(:attention_with_barcode?).and_return(false) allow(load_result).to receive(:success?).and_return(load_result_success) - allow(load_result).to receive(:selfie_check_performed).and_return(false) + allow(load_result).to receive(:selfie_check_performed?).and_return(false) document_capture_session = DocumentCaptureSession.create!( user: user, diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 87209d678c5..d0b647e8e50 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -850,6 +850,10 @@ def wait_for_event(event, wait) to receive(:biometric_comparison_required?). and_return({ biometric_comparison_required: true }) + allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:success) + allow_any_instance_of(DocumentCaptureSessionResult). + to receive(:selfie_status).and_return(:success) + mobile_device = Browser.new(mobile_user_agent) allow(BrowserCache).to receive(:parse).and_return(mobile_device) diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index a84172e862d..deccef71931 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -234,8 +234,7 @@ expect_doc_capture_page_header(t('doc_auth.headings.document_capture_with_selfie')) expect_doc_capture_id_subheader expect_doc_capture_selfie_subheader - attach_images - attach_selfie + attach_liveness_images submit_images expect(page).to have_current_path(idv_ssn_url) diff --git a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb index fb39426ece8..d1ae5b74861 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb @@ -356,8 +356,7 @@ expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) - attach_images - attach_selfie + attach_liveness_images submit_images expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) diff --git a/spec/fixtures/ial2_test_portrait_match_success.yml b/spec/fixtures/ial2_test_portrait_match_success.yml index 70b2ec5af5d..6641f442865 100644 --- a/spec/fixtures/ial2_test_portrait_match_success.yml +++ b/spec/fixtures/ial2_test_portrait_match_success.yml @@ -1,16 +1,17 @@ document: - first_name: 'John' - last_name: 'Doe' - address1: 1800 F Street - address2: Apt 3 - city: Bayside - state: NY - zipcode: '11364' - dob: 10/06/1938 - phone: +1 314-555-1212 - state_id_jurisdiction: 'ND' + address1: '1 FAKE RD' + city: 'GREAT FALLS' + dob: '1938-10-06' + first_name: 'FAKEY' + last_name: 'MCFAKERSON' + state: 'MT' + state_id_expiration: '2099-12-31' + state_id_issued: '2019-12-31' + state_id_jurisdiction: ND state_id_number: '1111111111111' + state_id_type: 'drivers_license' + zipcode: '59010' doc_auth_result: Passed failed_alert: [] portrait_match_results: - FaceMatchResult: Pass \ No newline at end of file + FaceMatchResult: Pass diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 9e936aab656..a092af9fdae 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -202,17 +202,17 @@ it 'logs analytics' do expect(irs_attempts_api_tracker).to receive(:idv_document_upload_submitted).with( { - address: '1800 F Street', + address: '1 FAKE RD', date_of_birth: '1938-10-06', document_back_image_filename: nil, - document_expiration: nil, + document_expiration: '2099-12-31', document_front_image_filename: nil, document_image_encryption_key: nil, - document_issued: nil, + document_issued: '2019-12-31', document_number: '1111111111111', - document_state: 'NY', - first_name: 'John', - last_name: 'Doe', + document_state: 'MT', + first_name: 'FAKEY', + last_name: 'MCFAKERSON', success: true, }, ) @@ -269,8 +269,8 @@ product_status: nil, reference: nil, remaining_attempts: 3, - state: 'NY', - state_id_type: nil, + state: 'MT', + state_id_type: 'drivers_license', success: true, user_id: document_capture_session.user.uuid, vendor_request_time_in_ms: a_kind_of(Float), diff --git a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb index a22023c982a..0357e5d389a 100644 --- a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb +++ b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb @@ -27,7 +27,6 @@ ) get_results_response = client.get_results( instance_id: instance_id, - selfie_check_performed: liveness_checking_required, ) expect(create_document_response.success?).to eq(true) @@ -89,7 +88,6 @@ ) get_results_response = client.get_results( instance_id: create_document_response.instance_id, - selfie_check_performed: liveness_checking_required, ) expect(get_results_response.pii_from_doc).to eq( @@ -130,7 +128,6 @@ ) get_results_response = client.get_results( instance_id: create_document_response.instance_id, - selfie_check_performed: liveness_checking_required, ) expect(get_results_response.attention_with_barcode?).to eq(false) errors = get_results_response.errors @@ -257,7 +254,6 @@ ) response = client.get_results( instance_id: nil, - selfie_check_performed: liveness_checking_required, ) expect(response).to be_a(DocAuth::Response) expect(response.success?).to eq(false) diff --git a/spec/services/doc_auth/mock/result_response_spec.rb b/spec/services/doc_auth/mock/result_response_spec.rb index 06b40ddb8c4..10b2ca04f75 100644 --- a/spec/services/doc_auth/mock/result_response_spec.rb +++ b/spec/services/doc_auth/mock/result_response_spec.rb @@ -2,7 +2,6 @@ RSpec.describe DocAuth::Mock::ResultResponse do let(:warn_notifier) { instance_double('Proc') } - let(:selfie_check_performed) { false } subject(:response) do config = DocAuth::Mock::Config.new( @@ -11,7 +10,7 @@ glare_threshold: 40, warn_notifier: warn_notifier, ) - described_class.new(input, selfie_check_performed, config) + described_class.new(input, config) end context 'with an image file' do @@ -255,7 +254,7 @@ glare_threshold: 40, }, ) - described_class.new(input, selfie_check_performed, config) + described_class.new(input, config) end let(:input) do diff --git a/spec/services/doc_auth_router_spec.rb b/spec/services/doc_auth_router_spec.rb index c0098bf8873..bdf4214871f 100644 --- a/spec/services/doc_auth_router_spec.rb +++ b/spec/services/doc_auth_router_spec.rb @@ -185,7 +185,7 @@ def reload_ab_test_initializer! ) response = I18n.with_locale(:es) do - proxy.get_results(instance_id: 'abcdef', selfie_check_performed: false) + proxy.get_results(instance_id: 'abcdef') end expect(response.errors[:some_other_key]).to eq(['will not be translated']) @@ -208,7 +208,7 @@ def reload_ab_test_initializer! ), ) - response = proxy.get_results(instance_id: 'abcdef', selfie_check_performed: false) + response = proxy.get_results(instance_id: 'abcdef') expect(response.errors[:network]).to eq(I18n.t('doc_auth.errors.general.network_error')) end diff --git a/spec/support/features/document_capture_step_helper.rb b/spec/support/features/document_capture_step_helper.rb index f3e7aa43861..c5ed54a7438 100644 --- a/spec/support/features/document_capture_step_helper.rb +++ b/spec/support/features/document_capture_step_helper.rb @@ -16,6 +16,16 @@ def attach_images(file = Rails.root.join('app', 'assets', 'images', 'logo.png')) attach_file t('doc_auth.headings.document_capture_back'), file, make_visible: true end + def attach_liveness_images( + file = Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_portrait_match_success.yml' + ) + ) + attach_images(file) + attach_selfie + end + def attach_selfie(file = Rails.root.join('app', 'assets', 'images', 'logo.png')) attach_file t('doc_auth.headings.document_capture_selfie'), file, make_visible: true end From 913b82bae820c3dda27ab29efd89d97e1d911c15 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Thu, 1 Feb 2024 16:49:02 -0500 Subject: [PATCH 06/25] Send RISC password reset to confirmed emails (#10022) changelog: Internal, RISC, Send RISC password reset to confirmed emails --- app/services/reset_user_password.rb | 2 +- spec/services/reset_user_password_spec.rb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/services/reset_user_password.rb b/app/services/reset_user_password.rb index d4152b4836c..400df73db3e 100644 --- a/app/services/reset_user_password.rb +++ b/app/services/reset_user_password.rb @@ -32,7 +32,7 @@ def log_event end def notify_user - user.email_addresses.each do |email_address| + user.confirmed_email_addresses.each do |email_address| UserMailer.with(user: user, email_address: email_address).please_reset_password. deliver_now_or_later end diff --git a/spec/services/reset_user_password_spec.rb b/spec/services/reset_user_password_spec.rb index 0b07529d534..f7aafe5f929 100644 --- a/spec/services/reset_user_password_spec.rb +++ b/spec/services/reset_user_password_spec.rb @@ -19,12 +19,13 @@ to(change { user.events.password_invalidated.size }.from(0).to(1)) end - it 'notifies the user via email to each of their email addresses' do + it 'notifies the user via email to each of their confirmed email addresses' do + create(:email_address, user:, email: Faker::Internet.safe_email, confirmed_at: nil) expect { call }. to(change { ActionMailer::Base.deliveries.count }.by(2)) mails = ActionMailer::Base.deliveries.last(2) - expect(mails.map(&:to).flatten).to match_array(user.email_addresses.map(&:email)) + expect(mails.map(&:to).flatten).to match_array(user.confirmed_email_addresses.map(&:email)) end it 'clears all remembered browsers by updating the remember_device_revoked_at timestamp' do From abeed61048d1a2a77fe8f8ff78915190df44e685 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Fri, 2 Feb 2024 10:38:17 -0500 Subject: [PATCH 07/25] Load error tracking script asynchronously (#10013) * Load error tracking script asynchronously changelog: Internal, Performance, Improve performance of JavaScript loading for error tracking * Generalize attributes in favor of named keywords --- app/helpers/script_helper.rb | 43 +++++++++----------- app/views/layouts/base.html.erb | 4 +- app/views/layouts/component_preview.html.erb | 2 +- spec/helpers/script_helper_spec.rb | 29 +++++++++++-- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/app/helpers/script_helper.rb b/app/helpers/script_helper.rb index 5600061a3eb..1c9ce9925d0 100644 --- a/app/helpers/script_helper.rb +++ b/app/helpers/script_helper.rb @@ -6,33 +6,28 @@ def javascript_include_tag_without_preload(...) without_preload_links_header { javascript_include_tag(...) } end - def javascript_packs_tag_once(*names, prepend: false) - @scripts ||= [] - if prepend - @scripts = names | @scripts - else - @scripts |= names - end + def javascript_packs_tag_once(*names, **attributes) + @scripts = @scripts.to_h.merge(names.index_with(attributes)) nil end alias_method :enqueue_component_scripts, :javascript_packs_tag_once - def render_javascript_pack_once_tags(*names) - names = names.presence || @scripts - if names && (sources = AssetSources.get_sources(*names)).present? - safe_join( - [ - javascript_assets_tag(*names), - *sources.map do |source| - javascript_include_tag( - source, - crossorigin: local_crossorigin_sources? ? true : nil, - integrity: AssetSources.get_integrity(source), - ) - end, - ], - ) + def render_javascript_pack_once_tags(...) + capture do + javascript_packs_tag_once(...) + return if @scripts.blank? + concat javascript_assets_tag + @scripts.each do |name, attributes| + AssetSources.get_sources(name).each do |source| + concat javascript_include_tag( + source, + **attributes, + crossorigin: local_crossorigin_sources? ? true : nil, + integrity: AssetSources.get_integrity(source), + ) + end + end end end @@ -46,8 +41,8 @@ def local_crossorigin_sources? Rails.env.development? && ENV['WEBPACK_PORT'].present? end - def javascript_assets_tag(*names) - assets = AssetSources.get_assets(*names) + def javascript_assets_tag + assets = AssetSources.get_assets(*@scripts.keys) if assets.present? asset_map = assets.index_with { |path| asset_path(path, host: asset_host(path)) } content_tag( diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 571d6cc8ef5..60422ebd37b 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -106,8 +106,8 @@ { type: 'application/json', data: { config: '' } }, false, ) %> - <%= javascript_packs_tag_once('application', prepend: true) %> - <%= javascript_packs_tag_once('track-errors') if BrowserSupport.supported?(request.user_agent) %> + <%= javascript_packs_tag_once('application') %> + <%= javascript_packs_tag_once('track-errors', async: true) if BrowserSupport.supported?(request.user_agent) %> <%= render_javascript_pack_once_tags %> <%= render 'shared/dap_analytics' if IdentityConfig.store.participate_in_dap && !session_with_trust? %> diff --git a/app/views/layouts/component_preview.html.erb b/app/views/layouts/component_preview.html.erb index 2e70f8fea54..5a73c75a763 100644 --- a/app/views/layouts/component_preview.html.erb +++ b/app/views/layouts/component_preview.html.erb @@ -16,7 +16,7 @@ <% else %> <%= yield %> <% end %> - <%= javascript_packs_tag_once('application', prepend: true) %> + <%= javascript_packs_tag_once('application') %> <%= render_javascript_pack_once_tags %> diff --git a/spec/helpers/script_helper_spec.rb b/spec/helpers/script_helper_spec.rb index 5e82355dd38..2c23483f3b5 100644 --- a/spec/helpers/script_helper_spec.rb +++ b/spec/helpers/script_helper_spec.rb @@ -27,10 +27,12 @@ context 'scripts enqueued' do before do + javascript_packs_tag_once('application') javascript_packs_tag_once('document-capture', 'document-capture') - javascript_packs_tag_once('application', prepend: true) - allow(AssetSources).to receive(:get_sources).with('application', 'document-capture'). - and_return(['/application.js', '/document-capture.js']) + allow(AssetSources).to receive(:get_sources).with('application'). + and_return(['/application.js']) + allow(AssetSources).to receive(:get_sources).with('document-capture'). + and_return(['/document-capture.js']) allow(AssetSources).to receive(:get_assets).with('application', 'document-capture'). and_return(['clock.svg', 'sprite.svg']) end @@ -100,6 +102,27 @@ end end + context 'with attributes' do + before do + javascript_packs_tag_once('track-errors', async: true) + allow(AssetSources).to receive(:get_sources).with('track-errors'). + and_return(['/track-errors.js']) + allow(AssetSources).to receive(:get_assets). + with('application', 'document-capture', 'track-errors'). + and_return([]) + end + + it 'adds attribute' do + output = render_javascript_pack_once_tags + + expect(output).to have_css( + "script[src^='/track-errors.js'][async]", + count: 1, + visible: :all, + ) + end + end + context 'local development crossorigin sources' do let(:webpack_port) { '3035' } From a7a2684bbecffb4be4505a41d537929cbee1dce6 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Fri, 2 Feb 2024 10:38:47 -0500 Subject: [PATCH 08/25] Remove routes associated with legacy WebAuthn deletion (#10019) changelog: Internal, Code Quality, Remove unused WebAuthn deletion routes --- .../users/webauthn_setup_controller.rb | 55 -------------- .../users/webauthn_setup/delete.html.erb | 33 -------- config/locales/account/en.yml | 2 - config/locales/account/es.yml | 2 - config/locales/account/fr.yml | 3 - config/locales/forms/en.yml | 8 -- config/locales/forms/es.yml | 9 --- config/locales/forms/fr.yml | 10 --- config/locales/notices/en.yml | 2 - config/locales/notices/es.yml | 2 - config/locales/notices/fr.yml | 3 - config/routes.rb | 4 - .../users/webauthn_setup_controller_spec.rb | 76 ------------------- 13 files changed, 209 deletions(-) delete mode 100644 app/views/users/webauthn_setup/delete.html.erb diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 298fc37f259..d8bd370c509 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -90,28 +90,6 @@ def confirm end end - def delete - if MfaPolicy.new(current_user).multiple_factors_enabled? - handle_successful_delete - else - handle_failed_delete - end - redirect_to account_two_factor_authentication_path - end - - def show_delete - @webauthn = WebauthnConfiguration.where( - user_id: current_user.id, id: delete_params[:id], - ).first - - if @webauthn - render 'users/webauthn_setup/delete' - else - flash[:error] = t('errors.general') - redirect_back fallback_location: new_user_session_url, allow_other_host: false - end - end - private def validate_existing_platform_authenticator @@ -142,35 +120,6 @@ def exclude_credentials current_user.webauthn_configurations.map(&:credential_id) end - def handle_successful_delete - webauthn = WebauthnConfiguration.find_by(user_id: current_user.id, id: delete_params[:id]) - return unless webauthn - - create_user_event(:webauthn_key_removed) - webauthn.destroy - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - if webauthn.platform_authenticator - flash[:success] = t('notices.webauthn_platform_deleted') - else - flash[:success] = t('notices.webauthn_deleted') - end - track_delete(success: true, platform_authenticator: webauthn.platform_authenticator?) - end - - def handle_failed_delete - track_delete(success: false, platform_authenticator: nil) - end - - def track_delete(success:, platform_authenticator:) - analytics.webauthn_delete_submitted( - success:, - configuration_id: delete_params[:id], - platform_authenticator:, - ) - end - def save_challenge_in_session credential_creation_options = WebAuthn::Credential.options_for_create(user: current_user) user_session[:webauthn_challenge] = credential_creation_options.challenge.bytes.to_a @@ -224,9 +173,5 @@ def confirm_params :transports, ) end - - def delete_params - params.permit(:id) - end end end diff --git a/app/views/users/webauthn_setup/delete.html.erb b/app/views/users/webauthn_setup/delete.html.erb deleted file mode 100644 index ca15213da93..00000000000 --- a/app/views/users/webauthn_setup/delete.html.erb +++ /dev/null @@ -1,33 +0,0 @@ -<% if @webauthn.platform_authenticator %> - <% self.title = t('forms.webauthn_platform_delete.confirm') %> -<% else %> - <% self.title = t('forms.webauthn_delete.confirm') %> -<% end %> - -<%= render AlertIconComponent.new(icon_name: :warning, class: 'display-block margin-bottom-4') %> - -<%= render PageHeadingComponent.new do %> - <% if @webauthn.platform_authenticator %> - <%= t('forms.webauthn_platform_delete.confirm') %> - <% else %> - <%= t('forms.webauthn_delete.confirm') %> - <% end %> -<% end %> - -<% if @webauthn.platform_authenticator %> -

<%= t('forms.webauthn_platform_delete.caution', app_name: APP_NAME) %>

-<% else %> -

<%= t('forms.webauthn_delete.caution', app_name: APP_NAME) %>

-<% end %> - -<%= button_to(webauthn_setup_path(id: params[:id]), method: :delete, class: 'usa-button usa-button--big usa-button--wide margin-top-5 margin-bottom-2') do %> - <% if @webauthn.platform_authenticator %> - <%= t('account.index.webauthn_platform_confirm_delete') %> - <% else %> - <%= t('account.index.webauthn_confirm_delete') %> - <% end %> -<% end %> - -<%= link_to t('links.cancel'), - account_two_factor_authentication_path, - class: 'usa-button usa-button--big usa-button--wide usa-button--outline' %> diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index 8c51de05e9a..95819a1fc2e 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -54,10 +54,8 @@ en: success: We verified your information webauthn: Security key webauthn_add: Add security key - webauthn_confirm_delete: Yes, remove key webauthn_platform: Face or touch unlock webauthn_platform_add: Add face or touch unlock - webauthn_platform_confirm_delete: Yes, remove face or touch unlock items: delete_your_account: Delete your account personal_key: Personal key diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index daf1a7c82bf..a2ffe35568e 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -55,10 +55,8 @@ es: success: Verificamos tu información webauthn: Clave de seguridad webauthn_add: Añadir clave de seguridad - webauthn_confirm_delete: Si quitar la llave webauthn_platform: El desbloqueo facial o táctil webauthn_platform_add: Añadir el desbloqueo facial o táctil - webauthn_platform_confirm_delete: Si, quitar el desbloqueo facial o táctil items: delete_your_account: Eliminar su cuenta personal_key: Clave personal diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index 6fb436a6fb8..07f8887835a 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -58,11 +58,8 @@ fr: success: Nous avons vérifié vos informations webauthn: Clé de sécurité webauthn_add: Ajouter une clé de sécurité - webauthn_confirm_delete: Oui, supprimer la clé webauthn_platform: Le déverouillage facial ou déverrouillage par empreinte digitale webauthn_platform_add: Ajouter le déverouillage facial ou déverrouillage par empreinte digitale - webauthn_platform_confirm_delete: Oui, supprimer le déverouillage facial ou - déverrouillage par empreinte digitale items: delete_your_account: Supprimer votre compte personal_key: Clé personnelle diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index 290ffd074df..618ccbec39b 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -125,14 +125,6 @@ en: try_again: Use another phone number validation: required_checkbox: Please check this box to continue - webauthn_delete: - caution: If you remove your security key you won’t be able to use it to access - your %{app_name} account. - confirm: Are you sure you want to remove your security key? - webauthn_platform_delete: - caution: If you remove face or touch unlock you won’t be able to use it to - access your %{app_name} account. - confirm: Are you sure you want to remove face or touch unlock? webauthn_platform_setup: continue: Continue info_text: You’ll need to set up an additional authentication method after you diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index a4c1f511f86..123d5438dd8 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -132,15 +132,6 @@ es: try_again: Use otro número de teléfono. validation: required_checkbox: Marque esta casilla para continuar - webauthn_delete: - caution: Si elimina su clave de seguridad, no podrá usarla para acceder a su - cuenta %{app_name}. - confirm: '¿Estás seguro de que quieres eliminar tu clave de seguridad?' - webauthn_platform_delete: - caution: Si elimina su desbloqueo facial o táctil, no podrá usarla para acceder - a su cuenta %{app_name}. - confirm: '¿Estás seguro de que quieres eliminar tu desbloqueo facial o táctil de - seguridad?' webauthn_platform_setup: continue: Continuar info_text: Tendrá que configurar un método de autenticación adicional después de diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index c9e5fcd7fe5..23d0a94cc58 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -134,16 +134,6 @@ fr: try_again: Utilisez un autre numéro de téléphone validation: required_checkbox: Veuillez cocher cette case pour continuer - webauthn_delete: - caution: Si vous supprimez votre clé de sécurité, vous ne pourrez plus - l’utiliser pour accéder à votre compte %{app_name}. - confirm: Êtes-vous sûr de vouloir supprimer votre clé de sécurité? - webauthn_platform_delete: - caution: Si vous supprimez votre déverouillage facial ou déverrouillage par - empreinte digitale, vous ne pourrez plus l’utiliser pour accéder à votre - compte %{app_name}. - confirm: Êtes-vous sûr de vouloir supprimer votre déverouillage facial ou - déverrouillage par empreinte digitale? webauthn_platform_setup: continue: Continuer info_text: Vous aurez besoin de configurer une méthode d’authentification diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index e588579a4ba..c621d34246d 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -59,7 +59,5 @@ en: link: use a different email address text_html: Or, %{link_html} webauthn_configured: A security key was added to your account. - webauthn_deleted: Your security key was deleted from your account. webauthn_platform_configured: You used your device’s screen lock to add face or touch unlock to your account. - webauthn_platform_deleted: Face or touch unlock was deleted from your account. diff --git a/config/locales/notices/es.yml b/config/locales/notices/es.yml index 6113688a5e3..180544da64e 100644 --- a/config/locales/notices/es.yml +++ b/config/locales/notices/es.yml @@ -61,7 +61,5 @@ es: link: use un email diferente text_html: O %{link_html} webauthn_configured: Una llave de seguridad fue agregada a tu cuenta. - webauthn_deleted: Tu llave de seguridad fue eliminada de tu cuenta. webauthn_platform_configured: Usó el bloqueo de pantalla de su dispositivo para agregar el desbloqueo facial o táctil a su cuenta. - webauthn_platform_deleted: Desbloqueo facial o táctil fue eliminada de tu cuenta. diff --git a/config/locales/notices/fr.yml b/config/locales/notices/fr.yml index 06571ca65bb..0e366e5db32 100644 --- a/config/locales/notices/fr.yml +++ b/config/locales/notices/fr.yml @@ -64,9 +64,6 @@ fr: link: utilisez une adresse courriel différente text_html: Ou %{link_html} webauthn_configured: Une clé de sécurité a été ajoutée à votre compte. - webauthn_deleted: Votre clé de sécurité a été supprimée de votre compte. webauthn_platform_configured: Vous avez utilisé le verrouillage de l’écran de votre appareil pour ajouter le déverrouillage facial ou tactile à votre compte. - webauthn_platform_deleted: Déverouillage facial ou déverrouillage par empreinte - digitale a été ajoutée à votre compte. diff --git a/config/routes.rb b/config/routes.rb index ed8509775e4..4f74d0fce9f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -235,10 +235,6 @@ get '/webauthn_setup' => 'users/webauthn_setup#new', as: :webauthn_setup patch '/webauthn_setup' => 'users/webauthn_setup#confirm' - # Deprecated routes: Remove once LG-11454 is fully deployed to production. - delete '/webauthn_setup' => 'users/webauthn_setup#delete' - get '/webauthn_setup_delete' => 'users/webauthn_setup#show_delete' - delete '/authenticator_setup' => 'users/totp_setup#disable', as: :disable_totp get '/authenticator_setup' => 'users/totp_setup#new' patch '/authenticator_setup' => 'users/totp_setup#confirm' diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index 8c934b4dd05..f230f738759 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -122,82 +122,6 @@ patch :confirm, params: params end end - - describe 'delete' do - let(:webauthn_configuration) { create(:webauthn_configuration, user: user) } - - it 'creates a webauthn key removed event' do - delete :delete, params: { id: webauthn_configuration.id } - - expect(response).to redirect_to(account_two_factor_authentication_path) - expect(flash.now[:success]).to eq t('notices.webauthn_deleted') - expect(WebauthnConfiguration.count).to eq(0) - expect( - Event.where( - user_id: controller.current_user.id, - event_type: :webauthn_key_removed, ip: '0.0.0.0' - ).count, - ).to eq 1 - end - - it 'revokes remember device cookies' do - expect(user.remember_device_revoked_at).to eq nil - freeze_time do - delete :delete, params: { id: webauthn_configuration.id } - expect(user.reload.remember_device_revoked_at).to eq Time.zone.now - end - end - - it 'tracks the delete in analytics' do - delete :delete, params: { id: webauthn_configuration.id } - - expect(@analytics).to have_logged_event( - :webauthn_delete_submitted, - success: true, - error_details: nil, - configuration_id: webauthn_configuration.id.to_s, - platform_authenticator: false, - ) - end - - it 'sends a recovery information changed event' do - expect(PushNotification::HttpPush).to receive(:deliver). - with(PushNotification::RecoveryInformationChangedEvent.new(user: user)) - - delete :delete, params: { id: webauthn_configuration.id } - end - - context 'when authenticator is the sole authentication method' do - let(:user) { create(:user) } - - it 'tracks the delete in analytics' do - delete :delete, params: { id: webauthn_configuration.id } - - expect(@analytics).to have_logged_event( - :webauthn_delete_submitted, - success: false, - error_details: nil, - configuration_id: webauthn_configuration.id.to_s, - platform_authenticator: nil, - ) - end - end - end - - describe 'show_delete' do - let(:webauthn_configuration) { create(:webauthn_configuration, user: user) } - - it 'renders page when configuration exists' do - get :show_delete, params: { id: webauthn_configuration.id } - expect(response).to render_template :delete - end - - it 'redirects when the configuration does not exist' do - get :show_delete, params: { id: '_' } - expect(response).to redirect_to(new_user_session_url) - expect(flash[:error]).to eq t('errors.general') - end - end end describe 'when signed in and account creation' do From 772c9124b7eef84348579a2f23128f58b929688f Mon Sep 17 00:00:00 2001 From: Charley Ferguson Date: Fri, 2 Feb 2024 14:14:11 -0500 Subject: [PATCH 09/25] LG-11893: Fix Doubled 'attempts' Warning (#10021) * Reformat testing function so I can add arguments more easily * Add failing test for selfie and add missing check to failed doc type tests * Fix duplicate attempts warning * Update spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx Co-authored-by: Zach Margolis * Remove type: This is a jsx file and converting feels like too much * changelog: Internal, In-Person Proofing, fix duplicate display of attempts remaining header (problem is behind selfie feature flag) --------- Co-authored-by: Zach Margolis --- .../components/document-capture-warning.tsx | 16 ++-- .../document-capture-warning-spec.jsx | 77 ++++++++++++++----- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/app/javascript/packages/document-capture/components/document-capture-warning.tsx b/app/javascript/packages/document-capture/components/document-capture-warning.tsx index 80347039384..c7615976851 100644 --- a/app/javascript/packages/document-capture/components/document-capture-warning.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-warning.tsx @@ -100,13 +100,15 @@ function DocumentCaptureWarning({ /> - {!isFailedDocType && remainingAttempts <= DISPLAY_ATTEMPTS && ( -

- -

- )} + {!isFailedDocType && + !isFailedSelfieLivenessOrQuality && + remainingAttempts <= DISPLAY_ATTEMPTS && ( +

+ +

+ )} {nonIppOrFailedResult && } diff --git a/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx b/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx index 4be309125d4..fdd8791c589 100644 --- a/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx +++ b/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx @@ -47,7 +47,12 @@ describe('DocumentCaptureWarning', () => { } } - function renderCcontent(isFailedDocType, isFailedResult, inPersonUrl) { + function renderContent({ + isFailedDocType, + isFailedResult, + isFailedSelfieLivenessOrQuality = false, + inPersonUrl, + }) { const unknownFieldErrors = [ { field: 'general', @@ -60,6 +65,7 @@ describe('DocumentCaptureWarning', () => { {}} @@ -77,7 +83,7 @@ describe('DocumentCaptureWarning', () => { const isFailedResult = false; const isFailedDocType = false; - renderCcontent(isFailedDocType, isFailedResult, inPersonUrl); + renderContent({ isFailedDocType, isFailedResult, inPersonUrl }); expect(trackEvent).to.have.been.calledWith('IdV: warning shown', { location: 'doc_auth_review_issues', @@ -91,11 +97,16 @@ describe('DocumentCaptureWarning', () => { context('not failed result', () => { const isFailedResult = false; it('renders not failed doc type', () => { - const { getByRole, getByText } = renderCcontent(false, isFailedResult, inPersonUrl); + const { getByRole, getByText, queryByText } = renderContent({ + isFailedDocType: false, + isFailedResult, + inPersonUrl, + }); validateHeader('errors.doc_auth.rate_limited_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, true); expect(getByText('general error')).to.be.ok(); + expect(queryByText('idv.warning.attempts_html')).to.be.null(); expect(getByText('idv.failure.attempts_html')).to.be.ok(); expect(getByRole('button', { name: 'idv.failure.button.try_online' })).to.be.ok(); // ipp section @@ -106,16 +117,17 @@ describe('DocumentCaptureWarning', () => { it('renders with failed doc type', () => { const isFailedDocType = true; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.doc_type_not_supported_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText(/general error/)).to.be.ok(); expect(getByText(/idv.warning.attempts_html/)).to.be.ok(); + expect(queryByText('idv.failure.attempts_html')).to.null(); expect(getByRole('button', { name: 'idv.failure.button.try_online' })).to.be.ok(); // ipp section validateIppSection(true); @@ -128,16 +140,17 @@ describe('DocumentCaptureWarning', () => { const isFailedResult = true; it('renders not failed doc type', () => { const isFailedDocType = false; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.rate_limited_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText('general error')).to.be.ok(); + expect(queryByText('idv.warning.attempts_html')).to.be.null(); expect(getByText('idv.failure.attempts_html')).to.be.ok(); expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); // the ipp section isn't displayed with isFailedResult=true @@ -148,17 +161,41 @@ describe('DocumentCaptureWarning', () => { it('renders with failed doc type', () => { const isFailedDocType = true; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.doc_type_not_supported_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText(/general error/)).to.be.ok(); expect(getByText(/idv.warning.attempts_html/)).to.be.ok(); + expect(queryByText('idv.failure.attempts_html')).to.null(); + expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); + // ipp section not existing + validateIppSection(false); + // troubleshooting section + validateTroubleShootingSection(); + }); + + it('renders with successful selfie', () => { + const isFailedDocType = false; + const isFailedSelfieLivenessOrQuality = true; + const { getByRole, getByText, queryByText } = renderContent({ + isFailedDocType, + isFailedSelfieLivenessOrQuality, + isFailedResult, + inPersonUrl, + }); + + // error message section + validateHeader('errors.doc_auth.selfie_not_live_or_poor_quality_heading', 1, true); + validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); + expect(getByText('general error')).to.be.ok(); + expect(getByText('idv.warning.attempts_html')).to.be.ok(); + expect(queryByText('idv.failure.attempts_html')).to.null(); expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); // ipp section not existing validateIppSection(false); @@ -175,7 +212,7 @@ describe('DocumentCaptureWarning', () => { const isFailedResult = true; const isFailedDocType = true; - renderCcontent(isFailedDocType, isFailedResult, inPersonUrl); + renderContent({ isFailedDocType, isFailedResult, inPersonUrl }); expect(trackEvent).to.have.been.calledWith('IdV: warning shown', { location: 'doc_auth_review_issues', @@ -190,16 +227,17 @@ describe('DocumentCaptureWarning', () => { const isFailedResult = false; it('renders not failed doc type', () => { const isFailedDocType = false; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.rate_limited_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText('general error')).to.be.ok(); + expect(queryByText('idv.warning.attempts_html')).to.null(); expect(getByText('idv.failure.attempts_html')).to.be.ok(); expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); // ipp section not displayed for non ipp @@ -210,17 +248,18 @@ describe('DocumentCaptureWarning', () => { it('renders with failed doc type', () => { const isFailedDocType = true; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.doc_type_not_supported_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText(/general error/)).to.be.ok(); expect(getByText(/idv.warning.attempts_html/)).to.be.ok(); + expect(queryByText('idv.failure.attempts_html')).to.null(); expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); // ipp section not displayed for non ipp validateIppSection(false); @@ -233,16 +272,17 @@ describe('DocumentCaptureWarning', () => { const isFailedResult = true; it('renders not failed doc type', () => { const isFailedDocType = false; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.rate_limited_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText('general error')).to.be.ok(); + expect(queryByText('idv.warning.attempts_html')).to.be.null(); expect(getByText('idv.failure.attempts_html')).to.be.ok(); expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); // the ipp section isn't displayed with isFailedResult=true @@ -253,16 +293,17 @@ describe('DocumentCaptureWarning', () => { it('renders with failed doc type', () => { const isFailedDocType = true; - const { getByRole, getByText } = renderCcontent( + const { getByRole, getByText, queryByText } = renderContent({ isFailedDocType, isFailedResult, inPersonUrl, - ); + }); // error message section validateHeader('errors.doc_auth.doc_type_not_supported_heading', 1, true); validateHeader('errors.doc_auth.rate_limited_subheading', 2, false); expect(getByText(/general error/)).to.be.ok(); expect(getByText(/idv.warning.attempts_html/)).to.be.ok(); + expect(queryByText('idv.failure.attempts_html')).to.null(); expect(getByRole('button', { name: 'idv.failure.button.warning' })).to.be.ok(); // ipp section not existing validateIppSection(false); From 6fc419d13e73619102c69a850a21b9aaab2ce26c Mon Sep 17 00:00:00 2001 From: Jessica Dembe Date: Fri, 2 Feb 2024 14:37:13 -0500 Subject: [PATCH 10/25] LG-12018: Refactor account deletion/message to be variable (#9927) * make 24 hour configuration a config variable * add config in `identity_config` * add `account_deletion_period_hours` to config changelog: Internal, account deletion, make account deletion period a variable * change from 24 hour wait period text to be a variable * use interval for account deletion period * use interval for pending period * refactor for account reset, standardize identity config value * reinstate changes * fix everything * make changes in yml files * refactor in account resete cancel link and account_reset_request * fix devise setting, add test * update test, fix test in yml file * remove hours/horas/hours * lint * add `DateHelper` * restore `Devise.confirm_within` * spelling error * fix interpolation * remove unused config * fix test, remove config from application.yml * fix test, lintfix * address code review comment about interpolation not working * interpolate '24' in '24 hour' * fix interpolation value * add values to interpolate, update tests * lintfix * remove interpolation * Update config/locales/two_factor_authentication/fr.yml Co-authored-by: Andrew Duthie <1779930+aduth@users.noreply.github.com> * normalize yaml * change `interval` -> `hours` * refactor name and variable * change `confirmation_period` what it actually means * fix missing interpolation error * lint yml * clean up test * change test variable * make sure correct variable is in place * fix `confirmation period` interpolation * reset `confirmation_period` * normalize yaml * code review comments: change link expiration period notice and interval for sms text * use `account_reset_token_valid_for_days` for link validity * add test for footer * change to `confirmation_period` * clean up * use `account_reset_deletion_period_interval` * change 24 hours to pending confirmation text --------- Co-authored-by: Andrew Duthie <1779930+aduth@users.noreply.github.com> --- .../account_reset/pending_controller.rb | 16 +++++++- .../account_reset/request_controller.rb | 13 ++++++ app/mailers/user_mailer.rb | 35 +++++++++++++++- .../account_reset/pending_presenter.rb | 4 ++ .../two_factor_login_options_presenter.rb | 16 +++++++- app/services/account_reset/create_request.rb | 14 +++++++ .../account_reset/pending/confirm.html.erb | 2 +- app/views/account_reset/pending/show.html.erb | 1 + app/views/account_reset/request/show.html.erb | 2 +- .../account_reset_granted.html.erb | 4 +- .../account_reset_request.html.erb | 2 +- config/locales/account_reset/en.yml | 10 ++--- config/locales/account_reset/es.yml | 13 +++--- config/locales/account_reset/fr.yml | 12 +++--- config/locales/telephony/en.yml | 4 +- config/locales/telephony/es.yml | 2 +- config/locales/telephony/fr.yml | 4 +- .../locales/two_factor_authentication/en.yml | 2 +- .../locales/two_factor_authentication/es.yml | 2 +- .../locales/two_factor_authentication/fr.yml | 2 +- config/locales/user_mailer/en.yml | 30 +++++++------- config/locales/user_mailer/es.yml | 12 +++--- config/locales/user_mailer/fr.yml | 33 +++++++-------- lib/telephony/alert_sender.rb | 18 ++++++++- spec/lib/telephony/alert_sender_spec.rb | 5 ++- spec/mailers/user_mailer_spec.rb | 40 +++++++++++++++++-- ...two_factor_login_options_presenter_spec.rb | 10 ++--- 27 files changed, 224 insertions(+), 84 deletions(-) diff --git a/app/controllers/account_reset/pending_controller.rb b/app/controllers/account_reset/pending_controller.rb index 77ca7da3f05..855a6e12452 100644 --- a/app/controllers/account_reset/pending_controller.rb +++ b/app/controllers/account_reset/pending_controller.rb @@ -1,6 +1,7 @@ module AccountReset class PendingController < ApplicationController include UserAuthenticator + include ActionView::Helpers::DateHelper before_action :authenticate_user before_action :confirm_account_reset_request_exists @@ -10,7 +11,9 @@ def show @pending_presenter = AccountReset::PendingPresenter.new(pending_account_reset_request) end - def confirm; end + def confirm + @account_reset_deletion_period_interval = account_reset_deletion_period_interval + end def cancel analytics.pending_account_reset_cancelled @@ -29,5 +32,16 @@ def pending_account_reset_request current_user, ).call end + + def account_reset_deletion_period_interval + current_time = Time.zone.now + + distance_of_time_in_words( + current_time, + current_time + IdentityConfig.store.account_reset_wait_period_days.days, + true, + accumulate_on: :hours, + ) + end end end diff --git a/app/controllers/account_reset/request_controller.rb b/app/controllers/account_reset/request_controller.rb index 9c0f9dd3099..afc55e4df98 100644 --- a/app/controllers/account_reset/request_controller.rb +++ b/app/controllers/account_reset/request_controller.rb @@ -1,11 +1,13 @@ module AccountReset class RequestController < ApplicationController include TwoFactorAuthenticatable + include ActionView::Helpers::DateHelper before_action :confirm_two_factor_enabled def show analytics.account_reset_visit + @account_reset_deletion_period_interval = account_reset_deletion_period_interval end def create @@ -39,5 +41,16 @@ def analytics_attributes email_addresses: current_user.email_addresses.count, } end + + def account_reset_deletion_period_interval + current_time = Time.zone.now + + distance_of_time_in_words( + current_time, + current_time + IdentityConfig.store.account_reset_wait_period_days.days, + true, + accumulate_on: :hours, + ) + end end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index cc49ebb6a47..486a77e9d5b 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -15,6 +15,7 @@ class UserMailer < ActionMailer::Base include Mailable include LocaleHelper + include ActionView::Helpers::DateHelper class UserEmailAddressMismatchError < StandardError; end @@ -149,7 +150,11 @@ def personal_key_regenerated def account_reset_request(account_reset) with_user_locale(user) do @token = account_reset&.request_token - @header = t('user_mailer.account_reset_request.header') + @account_reset_deletion_period_hours = account_reset_deletion_period_hours + @header = t( + 'user_mailer.account_reset_request.header', + interval: account_reset_deletion_period_interval, + ) mail( to: email_address.email, subject: t('user_mailer.account_reset_request.subject', app_name: APP_NAME), @@ -161,6 +166,8 @@ def account_reset_granted(account_reset) with_user_locale(user) do @token = account_reset&.request_token @granted_token = account_reset&.granted_token + @account_reset_deletion_period_hours = account_reset_deletion_period_hours + @account_reset_token_valid_period = account_reset_token_valid_period mail( to: email_address.email, subject: t('user_mailer.account_reset_granted.subject', app_name: APP_NAME), @@ -430,4 +437,30 @@ def email_should_receive_nonessential_notifications?(email) modified_email = email.gsub(/\+[^@]+@/, '@') !banlist.include?(modified_email) end + + def account_reset_deletion_period_interval + current_time = Time.zone.now + + distance_of_time_in_words( + current_time, + current_time + IdentityConfig.store.account_reset_wait_period_days.days, + true, + accumulate_on: :hours, + ) + end + + def account_reset_deletion_period_hours + IdentityConfig.store.account_reset_wait_period_days.days.in_hours.to_i + end + + def account_reset_token_valid_period + current_time = Time.zone.now + + distance_of_time_in_words( + current_time, + current_time + IdentityConfig.store.account_reset_token_valid_for_days.days, + true, + accumulate_on: :hours, + ) + end end diff --git a/app/presenters/account_reset/pending_presenter.rb b/app/presenters/account_reset/pending_presenter.rb index c93eca3a809..636b7bcd081 100644 --- a/app/presenters/account_reset/pending_presenter.rb +++ b/app/presenters/account_reset/pending_presenter.rb @@ -18,5 +18,9 @@ def time_remaining_until_granted(now: Time.zone.now) highest_measures: 2, ) end + + def account_reset_deletion_period_hours + IdentityConfig.store.account_reset_wait_period_days.days.in_hours.to_i + end end end diff --git a/app/presenters/two_factor_login_options_presenter.rb b/app/presenters/two_factor_login_options_presenter.rb index da9b171d195..0d9556a4168 100644 --- a/app/presenters/two_factor_login_options_presenter.rb +++ b/app/presenters/two_factor_login_options_presenter.rb @@ -115,7 +115,10 @@ def account_reset_url(locale:) def account_reset_cancel_link safe_join( [ - t('two_factor_authentication.account_reset.pending'), + t( + 'two_factor_authentication.account_reset.pending', + interval: account_reset_deletion_period_interval, + ), @view.link_to( t('two_factor_authentication.account_reset.cancel_link'), account_reset_cancel_url(token: account_reset_token), @@ -140,4 +143,15 @@ def sp_name APP_NAME end end + + def account_reset_deletion_period_interval + current_time = Time.zone.now + + view.distance_of_time_in_words( + current_time, + current_time + IdentityConfig.store.account_reset_wait_period_days.days, + true, + accumulate_on: :hours, + ) + end end diff --git a/app/services/account_reset/create_request.rb b/app/services/account_reset/create_request.rb index 7d2dba04e4e..bbe2d05de2b 100644 --- a/app/services/account_reset/create_request.rb +++ b/app/services/account_reset/create_request.rb @@ -1,5 +1,7 @@ module AccountReset class CreateRequest + include ActionView::Helpers::DateHelper + def initialize(user, requesting_issuer) @user = user @requesting_issuer = requesting_issuer @@ -46,11 +48,23 @@ def notify_user_by_sms_if_applicable @telephony_response = Telephony.send_account_reset_notice( to: phone, country_code: Phonelib.parse(phone).country, + interval: account_reset_wait_period, ) end def extra_analytics_attributes @telephony_response&.extra&.slice(:request_id, :message_id) || {} end + + def account_reset_wait_period + current_time = Time.zone.now + + distance_of_time_in_words( + current_time, + current_time + IdentityConfig.store.account_reset_wait_period_days, + true, + accumulate_on: :hours, + ) + end end end diff --git a/app/views/account_reset/pending/confirm.html.erb b/app/views/account_reset/pending/confirm.html.erb index b94afed79bc..3c94e90c97c 100644 --- a/app/views/account_reset/pending/confirm.html.erb +++ b/app/views/account_reset/pending/confirm.html.erb @@ -1,6 +1,6 @@ <% self.title = t('account_reset.cancel_request.title') %> -

<%= t('account_reset.pending.confirm') %>

+

<%= t('account_reset.pending.confirm', interval: @account_reset_deletion_period_interval) %>

<%= button_to( account_reset_pending_cancel_path, diff --git a/app/views/account_reset/pending/show.html.erb b/app/views/account_reset/pending/show.html.erb index 14e2f17e86d..ceb730fe1fc 100644 --- a/app/views/account_reset/pending/show.html.erb +++ b/app/views/account_reset/pending/show.html.erb @@ -5,6 +5,7 @@

<%= t( 'account_reset.pending.wait_html', + hours: @pending_presenter.account_reset_deletion_period_hours, interval: @pending_presenter.time_remaining_until_granted, ) %>

diff --git a/app/views/account_reset/request/show.html.erb b/app/views/account_reset/request/show.html.erb index 58f60653804..9abfd4eab7f 100644 --- a/app/views/account_reset/request/show.html.erb +++ b/app/views/account_reset/request/show.html.erb @@ -8,7 +8,7 @@

<%= t('account_reset.request.delete_account') %>

-<% t('account_reset.request.delete_account_info').each do |info_p| %> +<% t('account_reset.request.delete_account_info', interval: @account_reset_deletion_period_interval).each do |info_p| %>

<%= info_p %>

<% end %> diff --git a/app/views/user_mailer/account_reset_granted.html.erb b/app/views/user_mailer/account_reset_granted.html.erb index d4406c1cec3..70d608be1d9 100644 --- a/app/views/user_mailer/account_reset_granted.html.erb +++ b/app/views/user_mailer/account_reset_granted.html.erb @@ -1,5 +1,5 @@

- <%= t('user_mailer.account_reset_granted.intro_html', app_name: link_to(APP_NAME, IdentityConfig.store.mailer_domain_name, class: 'gray')) %> + <%= t('user_mailer.account_reset_granted.intro_html', hours: @account_reset_deletion_period_hours, app_name: link_to(APP_NAME, IdentityConfig.store.mailer_domain_name, class: 'gray')) %>

@@ -47,7 +47,7 @@

- <%= t('user_mailer.email_confirmation_instructions.footer', confirmation_period: '24 hours') %> + <%= t('user_mailer.email_confirmation_instructions.footer', confirmation_period: @account_reset_token_valid_period) %>

<%= t( diff --git a/app/views/user_mailer/account_reset_request.html.erb b/app/views/user_mailer/account_reset_request.html.erb index 6a01b4c808e..d486d654efc 100644 --- a/app/views/user_mailer/account_reset_request.html.erb +++ b/app/views/user_mailer/account_reset_request.html.erb @@ -1,5 +1,5 @@

- <%= t('user_mailer.account_reset_request.intro_html', app_name: link_to(APP_NAME, IdentityConfig.store.mailer_domain_name, class: 'gray')) %> + <%= t('user_mailer.account_reset_request.intro_html', app_name: link_to(APP_NAME, IdentityConfig.store.mailer_domain_name, class: 'gray'), hours: @account_reset_deletion_period_hours) %>

diff --git a/config/locales/account_reset/en.yml b/config/locales/account_reset/en.yml index 39163c283d8..f3fe9cf0d60 100644 --- a/config/locales/account_reset/en.yml +++ b/config/locales/account_reset/en.yml @@ -30,10 +30,10 @@ en: pending: cancel_request: Cancel request cancelled: We have cancelled your request to delete your account. - confirm: If you cancel now, you must create a new request and wait another 24 - hours to delete your account. + confirm: If you cancel now, you must create a new request and wait another + %{interval} to delete your account. header: You requested to delete your account - wait_html: There is a 24-hour waiting period to delete your account. In + wait_html: There is a %{hours}-hour waiting period to delete your account. In %{interval}, you will receive an email with instructions to complete the deletion. recovery_options: @@ -61,8 +61,8 @@ en: to your account and you will need to restore each connection. - If you continue, you will first receive an email confirmation. As a security measure, you will receive another email with the link to - continue deleting your account 24 hours after the initial confirmation - email arrives. + continue deleting your account %{interval} after the initial + confirmation email arrives. info: - If you can’t access your account using the authentication methods you set up previously, deleting your account and creating a new one is the diff --git a/config/locales/account_reset/es.yml b/config/locales/account_reset/es.yml index 80b4214f4a3..9f8f1dc4317 100644 --- a/config/locales/account_reset/es.yml +++ b/config/locales/account_reset/es.yml @@ -31,11 +31,11 @@ es: pending: cancel_request: Cancelar petición cancelled: Hemos cancelado su solicitud para eliminar su cuenta. - confirm: Si cancela ahora, debe crear una nueva solicitud y esperar otras 24 - horas para eliminar su cuenta. + confirm: Si cancela ahora, debe crear una nueva solicitud y esperar otras + %{interval} para eliminar su cuenta. header: Solicitaste eliminar tu cuenta - wait_html: Hay un período de espera de 24 horas para eliminar su cuenta. En - %{interval}, recibirá un correo electrónico con + wait_html: Hay un período de espera de %{hours} horas para eliminar su cuenta. + En %{interval}, recibirá un correo electrónico con instrucciones para completar la eliminación. recovery_options: check_saved_credential: Verifica si tienes una credencial almacenada @@ -65,8 +65,9 @@ es: conexión. - Si continúas, tú primero recibirá una confirmación por correo electrónico. Como medida de seguridad, lo hará reciba otro correo - electrónico con el enlace para seguir eliminando su cuenta las 24 - horas después del correo electrónico de confirmación inicial llega. + electrónico con el enlace para seguir eliminando su cuenta las + %{interval} después del correo electrónico de confirmación inicial + llega. info: - Si no puede acceder a su cuenta a través de las opciones de seguridad que configuró anteriormente, eliminar la cuenta y crear una nueva es diff --git a/config/locales/account_reset/fr.yml b/config/locales/account_reset/fr.yml index 90a29cbe5e0..cd52a50fa08 100644 --- a/config/locales/account_reset/fr.yml +++ b/config/locales/account_reset/fr.yml @@ -32,11 +32,11 @@ fr: cancel_request: Demande d’annulation cancelled: Nous avons annulé votre demande de suppression de votre compte. confirm: Si vous annulez maintenant, vous devez créer une nouvelle demande et - attendre encore 24 heures pour supprimer votre compte. + attendre encore %{interval} pour supprimer votre compte. header: Vous avez demandé de supprimer votre compte - wait_html: Il y a un délai d’attente de 24 heures pour supprimer votre compte. - Dans %{interval}, vous recevrez un e-mail avec des - instructions pour terminer la suppression. + wait_html: Il y a un délai d’attente de %{hours} heures pour supprimer votre + compte. Dans %{interval}, vous recevrez un e-mail avec + des instructions pour terminer la suppression. recovery_options: check_saved_credential: Vérifiez si vous avez des informations d’identification sauvegardées check_webauthn_platform_info: Si vous avez configuré le déverrouillage facial ou @@ -65,8 +65,8 @@ fr: devrez restaurer chaque connexion. - Si vous continuez, vous recevra d’abord un email de confirmation. Par mesure de sécurité, vous devrez recevoir un autre e-mail avec le lien - pour continuer la suppression de votre compte 24 heures après l’email - de confirmation initial arrive. + pour continuer la suppression de votre compte %{interval} après + l’email de confirmation initial arrive. info: - Si vous ne pouvez pas accéder à votre compte via les options de sécurité que vous avez définies auparavant, la suppression de votre diff --git a/config/locales/telephony/en.yml b/config/locales/telephony/en.yml index d38ea371145..c8a6b95d05c 100644 --- a/config/locales/telephony/en.yml +++ b/config/locales/telephony/en.yml @@ -3,8 +3,8 @@ en: telephony: account_reset_cancellation_notice: Your request to delete your %{app_name} account has been cancelled. account_reset_notice: As requested, your %{app_name} account will be deleted in - 24 hours. Don't want to delete your account? Sign in to your %{app_name} - account to cancel. + %{interval}. Don't want to delete your account? Sign in to your + %{app_name} account to cancel. authentication_otp: sms: |- %{app_name}: Your one-time code is %{code}. It expires in %{expiration} minutes. Don't share this code with anyone. diff --git a/config/locales/telephony/es.yml b/config/locales/telephony/es.yml index 14b60b64948..37a022203a2 100644 --- a/config/locales/telephony/es.yml +++ b/config/locales/telephony/es.yml @@ -3,7 +3,7 @@ es: telephony: account_reset_cancellation_notice: Su solicitud para eliminar su cuenta de %{app_name} ha sido cancelada. account_reset_notice: Según lo solicitado, su cuenta %{app_name} se eliminará en - 24 horas. ¿No quieres eliminar tu cuenta? Inicie sesión en su cuenta + %{interval}. ¿No quieres eliminar tu cuenta? Inicie sesión en su cuenta %{app_name} para cancelar. authentication_otp: sms: |- diff --git a/config/locales/telephony/fr.yml b/config/locales/telephony/fr.yml index 5e936074fd4..013d8e60358 100644 --- a/config/locales/telephony/fr.yml +++ b/config/locales/telephony/fr.yml @@ -3,8 +3,8 @@ fr: telephony: account_reset_cancellation_notice: Votre demande de suppression de votre compte %{app_name} a été annulée. account_reset_notice: Comme demandé, votre compte %{app_name} sera supprimé dans - les 24 heures. Vous ne voulez pas supprimer votre compte? Connectez-vous à - votre compte %{app_name} pour le annuler. + les %{interval}. Vous ne voulez pas supprimer votre compte? Connectez-vous + à votre compte %{app_name} pour le annuler. authentication_otp: sms: |- %{app_name}: Votre code à usage unique est %{code}. Il est valable pendant %{expiration} minutes. Vous ne devez pas partager ce code avec personne. diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index 58a35387be8..ce76ea169cf 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -11,7 +11,7 @@ en: cancel_link: Cancel your request link: deleting your account pending: You currently have a pending request to delete your account. It takes - 24 hours from the time you made the request to complete the process. + %{interval} from the time you made the request to complete the process. Please check back later. successful_cancel: Thank you. Your request to delete your %{app_name} account has been cancelled. diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 82c91b63c8c..a2c3917f26f 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -11,7 +11,7 @@ es: cancel_link: Cancelar su solicitud link: eliminando su cuenta pending: Actualmente tiene una solicitud pendiente para eliminar su cuenta. Se - necesitan 24 horas desde el momento en que realizó la solicitud para + necesitan %{interval} desde el momento en que realizó la solicitud para completar el proceso. Por favor, vuelva más tarde. successful_cancel: Gracias. Su solicitud para eliminar su cuenta de %{app_name} ha sido cancelada. diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index 7dce721c210..f6506249522 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -13,7 +13,7 @@ fr: cancel_link: Annuler votre demande link: supprimer votre compte pending: Vous avez actuellement une demande en attente pour supprimer votre - compte. Il faut compter 24 heures à partir du moment où vous avez fait + compte. Il faut compter %{interval} à partir du moment où vous avez fait la demande pour terminer le processus. Veuillez vérifier plus tard. successful_cancel: Je vous remercie. Votre demande de suppression de votre compte %{app_name} a été annulée. diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index aaa84453c1f..cf40ca71f44 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -20,26 +20,26 @@ en: button: Yes, continue deleting cancel_link_text: please cancel help_html: If you don’t want to delete your account, %{cancel_account_reset_html}. - intro_html: Your 24 hour waiting period has ended. Please complete step 2 of the - process.

If you’ve been unable to locate your authentication - methods, select “confirm deletion” to delete your %{app_name} - account.

In the future, if you need to access participating - government websites who use %{app_name}, you can create a new - %{app_name} account using the same email address after your account is - deleted.

+ intro_html: Your %{hours} hour waiting period has ended. Please complete step 2 + of the process.

If you’ve been unable to locate your + authentication methods, select “confirm deletion” to delete your + %{app_name} account.

In the future, if you need to access + participating government websites who use %{app_name}, you can create a + new %{app_name} account using the same email address after your account + is deleted.

subject: Delete your %{app_name} account account_reset_request: cancel: Don’t want to delete your account? Sign in to your %{app_name} account to cancel. - header: Your account will be deleted in 24 hours + header: Your account will be deleted in %{interval} intro_html: 'As a security measure, %{app_name} requires a two-step process to - delete your account:

Step One: There is a 24 hour waiting period - if you have lost access to your authentication methods and need to - delete your account. If you locate your authentication methods, you can - sign in to your %{app_name} account to cancel this request.

Step - Two: After your 24 hour waiting period, you will receive an email that - will ask you to confirm the deletion of your %{app_name} account. Your - account will not be deleted until you confirm.' + delete your account:

Step One: There is a %{hours} hour waiting + period if you have lost access to your authentication methods and need + to delete your account. If you locate your authentication methods, you + can sign in to your %{app_name} account to cancel this request.

+ Step Two: After your %{hours} hour waiting period, you will receive an + email that will ask you to confirm the deletion of your %{app_name} + account. Your account will not be deleted until you confirm.' subject: How to delete your %{app_name} account account_verified: change_password_link: change your password diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 7c63447255d..81426f955b4 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -22,8 +22,8 @@ es: button: Sí, continúa eliminando cancel_link_text: por favor cancele help_html: Si no desea eliminar su cuenta, %{cancel_account_reset_html}. - intro_html: Su período de espera de 24 horas ha finalizado. Complete el paso 2 - del proceso.

Si no ha podido localizar sus métodos de + intro_html: Su período de espera de %{hours} horas ha finalizado. Complete el + paso 2 del proceso.

Si no ha podido localizar sus métodos de autenticación, seleccione “confirmar eliminación” para eliminar su cuenta de %{app_name}.

En el futuro, si necesita acceder a los sitios web gubernamentales participantes que utilizan %{app_name}, puede @@ -33,15 +33,15 @@ es: account_reset_request: cancel: '¿No quieres eliminar tu cuenta? Inicie sesión en su cuenta %{app_name} para cancelar.' - header: Su cuenta será eliminada en 24 horas + header: Su cuenta será eliminada en %{interval} intro_html: 'Como medida de seguridad, %{app_name} requiere un proceso de dos pasos para eliminar su cuenta:

Paso uno: hay un período de - espera de 24 horas si ha perdido el acceso a sus métodos de + espera de %{hours} horas si ha perdido el acceso a sus métodos de autenticación y necesita eliminar su cuenta. Si encuentra sus métodos de autenticación, puede iniciar sesión en su cuenta %{app_name} para cancelar esta solicitud.

Paso dos: Después de su período de - espera de 24 horas, recibirá un correo electrónico que le pedirá que - confirme la eliminación de su cuenta %{app_name}. Su cuenta no se + espera de %{hours} horas, recibirá un correo electrónico que le pedirá + que confirme la eliminación de su cuenta %{app_name}. Su cuenta no se eliminará hasta que confirme.' subject: Cómo eliminar su cuenta de %{app_name} account_verified: diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index b0a9337a88a..8b7b7c95872 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -22,28 +22,29 @@ fr: cancel_link_text: veuillez annuler help_html: Si vous ne souhaitez pas supprimer votre compte, %{cancel_account_reset_html}. - intro_html: Votre période d’attente de 24 heures est terminée. Veuillez terminer - l’étape 2 du processus.

Si vous ne parvenez pas à localiser vos - méthodes d’authentification, sélectionnez “confirmer la suppression” - pour supprimer votre compte %{app_name}.

À l’avenir, si vous - devez accéder aux sites Web gouvernementaux participants qui utilisent - %{app_name}, vous pouvez créer un nouveau compte %{app_name} en - utilisant la même adresse e-mail après la suppression de votre - compte.

+ intro_html: Votre période d’attente de %{hours} heures est terminée. Veuillez + terminer l’étape 2 du processus.

Si vous ne parvenez pas à + localiser vos méthodes d’authentification, sélectionnez “confirmer la + suppression” pour supprimer votre compte %{app_name}.

À + l’avenir, si vous devez accéder aux sites Web gouvernementaux + participants qui utilisent %{app_name}, vous pouvez créer un nouveau + compte %{app_name} en utilisant la même adresse e-mail après la + suppression de votre compte.

subject: Supprimer votre compte %{app_name} account_reset_request: cancel: Vous ne voulez pas supprimer votre compte? Connectez-vous à votre compte %{app_name} pour annuler. - header: Votre compte sera supprimé dans 24 heures + header: Votre compte sera supprimé dans %{interval} intro_html: 'Par mesure de sécurité, %{app_name} nécessite un processus en deux étapes pour supprimer votre compte:

Étape 1: Il y a une - période d’attente de 24 heures si vous avez perdu l’accès à vos méthodes - d’authentification et devez supprimer votre compte. Si vous trouvez vos - méthodes d’authentification, vous pouvez vous connecter à votre compte - %{app_name} pour annuler cette demande.

Deuxième étape: après - votre période d’attente de 24 heures, vous recevrez un e-mail qui vous - demandera de confirmer la suppression de votre compte %{app_name}. Votre - compte ne sera pas supprimé tant que vous ne l’aurez pas confirmé.' + période d’attente de %{hours} heures si vous avez perdu l’accès à vos + méthodes d’authentification et devez supprimer votre compte. Si vous + trouvez vos méthodes d’authentification, vous pouvez vous connecter à + votre compte %{app_name} pour annuler cette demande.

Deuxième + étape: après votre période d’attente de %{hours} heures, vous recevrez + un e-mail qui vous demandera de confirmer la suppression de votre compte + %{app_name}. Votre compte ne sera pas supprimé tant que vous ne l’aurez + pas confirmé.' subject: Comment supprimer votre compte %{app_name} account_verified: change_password_link: changer votre mot de passe diff --git a/lib/telephony/alert_sender.rb b/lib/telephony/alert_sender.rb index 9771a7d5975..b202e5bbfc1 100644 --- a/lib/telephony/alert_sender.rb +++ b/lib/telephony/alert_sender.rb @@ -2,8 +2,11 @@ module Telephony class AlertSender SMS_MAX_LENGTH = 160 - def send_account_reset_notice(to:, country_code:) - message = I18n.t('telephony.account_reset_notice', app_name: APP_NAME) + def send_account_reset_notice(to:, country_code:, interval:) + message = I18n.t( + 'telephony.account_reset_notice', app_name: APP_NAME, + interval: interval + ) response = adapter.deliver(message: message, to: to, country_code: country_code) log_response(response, context: __method__.to_s.gsub(/^send_/, '')) response @@ -86,4 +89,15 @@ def log_warning(alert, context:) ) end end + + def confirmation_period + current_time = Time.zone.now + + view.distance_of_time_in_words( + current_time, + current_time + Devise.confirm_within, + true, + accumulate_on: :hours, + ) + end end diff --git a/spec/lib/telephony/alert_sender_spec.rb b/spec/lib/telephony/alert_sender_spec.rb index f13cf648adf..01616c6e797 100644 --- a/spec/lib/telephony/alert_sender_spec.rb +++ b/spec/lib/telephony/alert_sender_spec.rb @@ -3,6 +3,7 @@ RSpec.describe Telephony::AlertSender do let(:configured_adapter) { :test } let(:recipient) { '+1 (202) 555-5000' } + let(:interval) { '24 hours' } before do allow(Telephony.config).to receive(:adapter).and_return(configured_adapter) @@ -11,12 +12,12 @@ describe 'send_account_reset_notice' do it 'sends the correct message' do - subject.send_account_reset_notice(to: recipient, country_code: 'US') + subject.send_account_reset_notice(to: recipient, country_code: 'US', interval: interval) last_message = Telephony::Test::Message.messages.last expect(last_message.to).to eq(recipient) expect(last_message.body).to eq( - I18n.t('telephony.account_reset_notice', app_name: APP_NAME), + I18n.t('telephony.account_reset_notice', app_name: APP_NAME, interval: interval), ) end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index a49ff10a5d1..730aa2eb257 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -339,6 +339,8 @@ def expect_email_body_to_have_help_and_contact_links end let(:account_reset) { user.account_reset_request } + let(:interval) { '24 hours' } + let(:account_reset_deletion_period_hours) { 24 } it_behaves_like 'a system email' it_behaves_like 'an email that respects user email locale preference' @@ -354,11 +356,19 @@ def expect_email_body_to_have_help_and_contact_links it 'renders the body' do expect(mail.html_part.body).to have_content( strip_tags( - t('user_mailer.account_reset_request.intro_html', app_name: APP_NAME), + t( + 'user_mailer.account_reset_request.intro_html', app_name: APP_NAME, + interval: interval, + hours: + account_reset_deletion_period_hours + ), ), ) end + it 'renders the footer' do + end + it 'does not render the subject in the body' do expect(mail.html_part.body).not_to have_content( strip_tags( @@ -370,7 +380,7 @@ def expect_email_body_to_have_help_and_contact_links it 'renders the header within the body' do expect(mail.html_part.body).to have_content( strip_tags( - t('user_mailer.account_reset_request.header'), + t('user_mailer.account_reset_request.header', interval: interval), ), ) end @@ -381,6 +391,8 @@ def expect_email_body_to_have_help_and_contact_links UserMailer.with(user: user, email_address: email_address). account_reset_granted(user.account_reset_request) end + let(:account_reset_deletion_period_hours) { 24 } + let(:token_expiration_interval) { '24 hours' } it_behaves_like 'a system email' it_behaves_like 'an email that respects user email locale preference' @@ -390,13 +402,33 @@ def expect_email_body_to_have_help_and_contact_links end it 'renders the subject' do - expect(mail.subject).to eq t('user_mailer.account_reset_granted.subject', app_name: APP_NAME) + expect(mail.subject).to eq t( + 'user_mailer.account_reset_granted.subject', app_name: APP_NAME + ) end it 'renders the body' do expect(mail.html_part.body).to \ have_content( - strip_tags(t('user_mailer.account_reset_granted.intro_html', app_name: APP_NAME)), + strip_tags( + t( + 'user_mailer.account_reset_granted.intro_html', app_name: APP_NAME, + hours: + account_reset_deletion_period_hours + ), + ), + ) + end + + it 'renders the footer' do + expect(mail.html_part.body).to \ + have_content( + strip_tags( + t( + 'user_mailer.email_confirmation_instructions.footer', + confirmation_period: token_expiration_interval, + ), + ), ) end end diff --git a/spec/presenters/two_factor_login_options_presenter_spec.rb b/spec/presenters/two_factor_login_options_presenter_spec.rb index 79be918a647..14da580896f 100644 --- a/spec/presenters/two_factor_login_options_presenter_spec.rb +++ b/spec/presenters/two_factor_login_options_presenter_spec.rb @@ -55,14 +55,12 @@ end it 'supplies a cancel link when the token is valid' do - allow_any_instance_of(TwoFactorLoginOptionsPresenter).to \ - receive(:account_reset_token_valid?).and_return(true) - - allow_any_instance_of(TwoFactorLoginOptionsPresenter).to \ - receive(:account_reset_token).and_return('foo') + allow(presenter).to receive(:account_reset_token).and_return('foo') + allow(presenter).to receive(:account_reset_token_valid?).and_return(true) + allow(presenter).to receive(:confirmation_period).and_return('24 hours') expect(presenter.account_reset_or_cancel_link).to eq( - t('two_factor_authentication.account_reset.pending') + ' ' + + t('two_factor_authentication.account_reset.pending', interval: '24 hours') + ' ' + view.link_to( t('two_factor_authentication.account_reset.cancel_link'), account_reset_cancel_url(token: 'foo'), From d1e5d30e38c90efccff7e8264f77dab9f2bc5afb Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Fri, 2 Feb 2024 16:32:52 -0500 Subject: [PATCH 11/25] Port and limit entrypoints to TypeScript (#10025) changelog: Internal, TypeScript, Port JavaScript to TypeScript --- app/components/base_component.rb | 2 +- .../{time_component.js => time_component.ts} | 0 ...ponent.js => validated_field_component.ts} | 0 app/javascript/packs/masked-text-toggle.js | 4 -- app/javascript/packs/masked-text-toggle.ts | 4 ++ ...eference.js => otp-delivery-preference.ts} | 63 ++++++------------- ...e-session.js => session-expire-session.ts} | 0 spec/components/base_component_spec.rb | 12 ++-- webpack.config.js | 2 +- 9 files changed, 30 insertions(+), 57 deletions(-) rename app/components/{time_component.js => time_component.ts} (100%) rename app/components/{validated_field_component.js => validated_field_component.ts} (100%) delete mode 100644 app/javascript/packs/masked-text-toggle.js create mode 100644 app/javascript/packs/masked-text-toggle.ts rename app/javascript/packs/{otp-delivery-preference.js => otp-delivery-preference.ts} (57%) rename app/javascript/packs/{session-expire-session.js => session-expire-session.ts} (100%) diff --git a/app/components/base_component.rb b/app/components/base_component.rb index 05beba4c8ba..684e313b4b1 100644 --- a/app/components/base_component.rb +++ b/app/components/base_component.rb @@ -5,7 +5,7 @@ def before_render def self.scripts @scripts ||= begin - scripts = sidecar_files_basenames(['js', 'ts']) + scripts = sidecar_files_basenames(['ts']) scripts.concat superclass.scripts if superclass.respond_to?(:scripts) scripts end diff --git a/app/components/time_component.js b/app/components/time_component.ts similarity index 100% rename from app/components/time_component.js rename to app/components/time_component.ts diff --git a/app/components/validated_field_component.js b/app/components/validated_field_component.ts similarity index 100% rename from app/components/validated_field_component.js rename to app/components/validated_field_component.ts diff --git a/app/javascript/packs/masked-text-toggle.js b/app/javascript/packs/masked-text-toggle.js deleted file mode 100644 index ad14e181850..00000000000 --- a/app/javascript/packs/masked-text-toggle.js +++ /dev/null @@ -1,4 +0,0 @@ -import MaskedTextToggle from '@18f/identity-masked-text-toggle'; - -const wrappers = document.querySelectorAll('.masked-text__toggle'); -wrappers.forEach((toggle) => new MaskedTextToggle(/** @type {HTMLInputElement} */ (toggle)).bind()); diff --git a/app/javascript/packs/masked-text-toggle.ts b/app/javascript/packs/masked-text-toggle.ts new file mode 100644 index 00000000000..ecf9fa6b07f --- /dev/null +++ b/app/javascript/packs/masked-text-toggle.ts @@ -0,0 +1,4 @@ +import MaskedTextToggle from '@18f/identity-masked-text-toggle'; + +const wrappers = document.querySelectorAll('.masked-text__toggle'); +wrappers.forEach((toggle) => new MaskedTextToggle(toggle).bind()); diff --git a/app/javascript/packs/otp-delivery-preference.js b/app/javascript/packs/otp-delivery-preference.ts similarity index 57% rename from app/javascript/packs/otp-delivery-preference.js rename to app/javascript/packs/otp-delivery-preference.ts index 4576e00799c..49a42871b0b 100644 --- a/app/javascript/packs/otp-delivery-preference.js +++ b/app/javascript/packs/otp-delivery-preference.ts @@ -1,46 +1,31 @@ import { t } from '@18f/identity-i18n'; - -/** @typedef {import('@18f/identity-phone-input').PhoneInputElement} PhoneInput */ +import type { PhoneInputElement } from '@18f/identity-phone-input'; /** * Returns the OTP delivery preference element. - * - * @return {HTMLElement} */ -const getOTPDeliveryMethodContainer = () => - /** @type {HTMLElement} */ (document.querySelector('.js-otp-delivery-preferences')); +const getOTPDeliveryMethodContainer = (): HTMLElement => + document.querySelector('.js-otp-delivery-preferences')!; -/** - * @return {HTMLInputElement[]} - */ const getOTPDeliveryMethods = () => - Array.from(document.querySelectorAll('.js-otp-delivery-preference')); + Array.from(document.querySelectorAll('.js-otp-delivery-preference')); /** * Returns true if the delivery option is valid for the selected option, or false otherwise. - * - * @param {string} delivery - * @param {HTMLOptionElement} selectedOption - * @return {boolean} */ -const isDeliveryOptionSupported = (delivery, selectedOption) => +const isDeliveryOptionSupported = (delivery: string, selectedOption: HTMLOptionElement): boolean => selectedOption.getAttribute(`data-supports-${delivery}`) !== 'false'; -/** - * @param {string} delivery - * @param {string} location - * @return {string=} - */ -const getHintTextForDisabledDeliveryOption = (delivery, location) => +const getHintTextForDisabledDeliveryOption = ( + delivery: string, + location: string, +): string | undefined => // i18n-tasks-use t('two_factor_authentication.otp_delivery_preference.voice_unsupported') // i18n-tasks-use t('two_factor_authentication.otp_delivery_preference.sms_unsupported') t(`two_factor_authentication.otp_delivery_preference.${delivery}_unsupported`, { location }); -/** - * @param {string=} hintText - */ function setHintText( - hintText = t('two_factor_authentication.otp_delivery_preference.instruction'), + hintText: string = t('two_factor_authentication.otp_delivery_preference.instruction'), ) { const hintElement = document.querySelector('#otp_delivery_preference_instruction'); if (hintElement) { @@ -50,38 +35,31 @@ function setHintText( /** * Returns true if all inputs are disabled, or false otherwise. - * - * @param {HTMLInputElement[]} inputs - * @return {boolean} */ -const isAllDisabled = (inputs) => inputs.every((input) => input.disabled); +const isAllDisabled = (inputs: HTMLInputElement[]): boolean => + inputs.every((input) => input.disabled); /** * Returns the next non-disabled input in the set of inputs, if one exists. - * - * @param {HTMLInputElement[]} inputs - * @return {HTMLInputElement=} */ -const getFirstEnabledInput = (inputs) => inputs.find((input) => !input.disabled); +const getFirstEnabledInput = (inputs: HTMLInputElement[]): HTMLInputElement | undefined => + inputs.find((input) => !input.disabled); /** * Toggles the delivery preferences selection visible or hidden. * - * @param {boolean} isVisible Whether the selection element should be visible. + * @param isVisible Whether the selection element should be visible. */ -const toggleDeliveryPreferencesVisible = (isVisible) => +const toggleDeliveryPreferencesVisible = (isVisible: boolean) => getOTPDeliveryMethodContainer().classList.toggle('display-none', !isVisible); -/** - * @param {Event} event - */ -function updateOTPDeliveryMethods(event) { +function updateOTPDeliveryMethods(event: Event) { if (!(event.target instanceof HTMLSelectElement)) { return; } const { target: select, currentTarget } = event; - const { textInput } = /** @type {PhoneInput} */ (currentTarget); + const { textInput } = currentTarget as PhoneInputElement; if (!textInput) { return; } @@ -90,7 +68,7 @@ function updateOTPDeliveryMethods(event) { const methods = getOTPDeliveryMethods(); setHintText(); - const location = /** @type {string} */ (selectedOption.dataset.countryName); + const location = selectedOption.dataset.countryName!; methods.forEach((method) => { const delivery = method.value; @@ -113,7 +91,6 @@ function updateOTPDeliveryMethods(event) { toggleDeliveryPreferencesVisible(!isAllMethodsDisabled); } -document.querySelectorAll('lg-phone-input').forEach((node) => { - const phoneInput = /** @type {PhoneInput} */ (node); +document.querySelectorAll('lg-phone-input').forEach((phoneInput) => { phoneInput.addEventListener('change', updateOTPDeliveryMethods); }); diff --git a/app/javascript/packs/session-expire-session.js b/app/javascript/packs/session-expire-session.ts similarity index 100% rename from app/javascript/packs/session-expire-session.js rename to app/javascript/packs/session-expire-session.ts diff --git a/spec/components/base_component_spec.rb b/spec/components/base_component_spec.rb index f8eb34f2dba..286ab6c4dd1 100644 --- a/spec/components/base_component_spec.rb +++ b/spec/components/base_component_spec.rb @@ -30,7 +30,6 @@ def call def self.sidecar_files(extensions) files = [] - files << '/components/example_component_with_script_js.js' if extensions.include?('js') files << '/components/example_component_with_script_ts.ts' if extensions.include?('ts') files.presence || super(extensions) end @@ -44,8 +43,8 @@ def call end def self.sidecar_files(extensions) - if extensions.include?('js') - ['/components/example_component_with_script_rendering_other_component_with_script.js'] + if extensions.include?('ts') + ['/components/example_component_with_script_rendering_other_component_with_script.ts'] else super(extensions) end @@ -56,8 +55,8 @@ def self.sidecar_files(extensions) # rubocop:disable RSpec/LeakyConstantDeclaration class NestedExampleComponentWithScript < ExampleComponentWithScript def self.sidecar_files(extensions) - if extensions.include?('js') - ['/components/nested_example_component_with_script.js'] + if extensions.include?('ts') + ['/components/nested_example_component_with_script.ts'] else super(extensions) end @@ -67,7 +66,6 @@ def self.sidecar_files(extensions) it 'adds script to class variable when rendered' do expect(view_context).to receive(:enqueue_component_scripts).with( - 'example_component_with_script_js', 'example_component_with_script_ts', ) @@ -77,7 +75,6 @@ def self.sidecar_files(extensions) it 'adds own and parent scripts to class variable when rendered' do expect(view_context).to receive(:enqueue_component_scripts).with( 'nested_example_component_with_script', - 'example_component_with_script_js', 'example_component_with_script_ts', ) @@ -95,7 +92,6 @@ def self.sidecar_files(extensions) ] when 2 expect(args).to eq [ - 'example_component_with_script_js', 'example_component_with_script_ts', ] end diff --git a/webpack.config.js b/webpack.config.js index ee6fd03b494..71663a36490 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -16,7 +16,7 @@ const hashSuffix = isProductionEnv ? '-[chunkhash:8].digested' : ''; const devServerPort = process.env.WEBPACK_PORT; const devtool = process.env.WEBPACK_DEVTOOL || (isProductionEnv ? 'source-map' : 'eval-source-map'); -const entries = glob('app/{components,javascript/packs}/*.{ts,tsx,js,jsx}'); +const entries = glob('app/{components,javascript/packs}/*.{ts,tsx}'); module.exports = /** @type {import('webpack').Configuration} */ ({ mode, From 6f8efa1fa65389aa018872793ed997f25b55be43 Mon Sep 17 00:00:00 2001 From: Amir Reavis-Bey Date: Fri, 2 Feb 2024 16:41:51 -0500 Subject: [PATCH 12/25] LG-12183: selfie image metadata (#10006) * add selfie image metadata * check selfie source when liveness checking is req'd * rebase with main * check image source when selfie is submitted * only pass selfie params during liveness when defined * changelog: Upcoming Features, Document Authentication, Process selfie image data for analytics logging and identifying correct doc auth vendor workflow. * fix typo in spec description --- app/forms/idv/api_image_upload_form.rb | 9 ++-- spec/forms/idv/api_image_upload_form_spec.rb | 52 +++++++++++++++++--- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 7227ac29c2e..03f3d7413e3 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -372,12 +372,15 @@ def acuant_sdk_upgrade_ab_test_data def acuant_sdk_capture? image_metadata.dig(:front, :source) == Idp::Constants::Vendors::ACUANT && - image_metadata.dig(:back, :source) == Idp::Constants::Vendors::ACUANT + image_metadata.dig(:back, :source) == Idp::Constants::Vendors::ACUANT && + (liveness_checking_required ? + image_metadata.dig(:selfie, :source) == Idp::Constants::Vendors::ACUANT : + true) end def image_metadata - @image_metadata ||= params.permit(:front_image_metadata, :back_image_metadata). - to_h. + @image_metadata ||= params. + permit(:front_image_metadata, :back_image_metadata, :selfie_image_metadata).to_h. transform_values do |str| JSON.parse(str) rescue JSON::ParserError diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index a092af9fdae..d14be59ef9a 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -6,12 +6,15 @@ subject(:form) do Idv::ApiImageUploadForm.new( ActionController::Parameters.new( - front: front_image, - front_image_metadata: front_image_metadata, - back: back_image, - back_image_metadata: back_image_metadata, - selfie: selfie_image, - document_capture_session_uuid: document_capture_session_uuid, + { + front: front_image, + front_image_metadata: front_image_metadata, + back: back_image, + back_image_metadata: back_image_metadata, + selfie: selfie_image, + selfie_image_metadata: selfie_image_metadata, + document_capture_session_uuid: document_capture_session_uuid, + }.compact, ), service_provider: build(:service_provider, issuer: 'test_issuer'), analytics: fake_analytics, @@ -31,6 +34,7 @@ let(:back_image_metadata) do { width: 20, height: 20, mimeType: 'image/png', source: 'upload' }.to_json end + let(:selfie_image_metadata) { nil } let!(:document_capture_session) { DocumentCaptureSession.create!(user: create(:user)) } let(:document_capture_session_uuid) { document_capture_session.uuid } let(:fake_analytics) { FakeAnalytics.new } @@ -199,6 +203,10 @@ let(:liveness_checking_required) { true } let(:back_image) { DocAuthImageFixtures.portrait_match_success_yaml } let(:selfie_image) { DocAuthImageFixtures.selfie_image_multipart } + let(:selfie_image_metadata) do + { width: 10, height: 10, mimeType: 'image/png', source: 'upload' }.to_json + end + it 'logs analytics' do expect(irs_attempts_api_tracker).to receive(:idv_document_upload_submitted).with( { @@ -254,6 +262,12 @@ source: 'upload', width: 40, }, + selfie: { + height: 10, + mimeType: 'image/png', + source: 'upload', + width: 10, + }, }, conversation_id: nil, decision_product_status: nil, @@ -298,7 +312,6 @@ expect(response.selfie_status).to eq(:success) expect(response.errors).to eq({}) expect(response.attention_with_barcode?).to eq(false) - # expect(response.pii_from_doc).to eq(Idp::Constants::MOCK_IDV_APPLICANT) end end end @@ -685,6 +698,31 @@ it 'sets image source to acuant sdk' do form.submit end + + context 'selfie is submitted' do + let(:liveness_checking_required) { true } + let(:selfie_image) { DocAuthImageFixtures.selfie_image_multipart } + context 'captured with acuant sdk' do + let(:selfie_image_metadata) do + { width: 10, height: 10, mimeType: 'image/png', source: source }.to_json + end + + it 'sets image source to acuant sdk' do + form.submit + end + end + + context 'add using file upload' do + let(:selfie_image_metadata) do + { width: 10, height: 10, mimeType: 'image/png', source: 'upload' }.to_json + end + let(:image_source) { DocAuth::ImageSources::UNKNOWN } + + it 'sets image source to unknown' do + form.submit + end + end + end end context 'malformed image metadata' do From 5722498bbb8298ce712e525f7543d24bd708c851 Mon Sep 17 00:00:00 2001 From: eileen-nava <80347702+eileen-nava@users.noreply.github.com> Date: Fri, 2 Feb 2024 17:07:23 -0500 Subject: [PATCH 13/25] LG-12310: Revert refactor to zipcode pii validation (#10024) * revert zipcode pii validation refactor * changelog: Internal, DocAuth, revert refactor to zipcode pii validation --- app/forms/idv/doc_pii_form.rb | 13 +++++++------ spec/forms/idv/doc_pii_form_spec.rb | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/forms/idv/doc_pii_form.rb b/app/forms/idv/doc_pii_form.rb index 4cca182ca60..96e02d64797 100644 --- a/app/forms/idv/doc_pii_form.rb +++ b/app/forms/idv/doc_pii_form.rb @@ -7,12 +7,7 @@ class DocPiiForm validates_presence_of :address1, { message: proc { I18n.t('doc_auth.errors.alerts.address_check') } } - validates :zipcode, format: { - with: /\A[0-9]{5}(?:-[0-9]{4})?\z/, - message: proc { - I18n.t('doc_auth.errors.general.no_liveness') - }, - } + validate :zipcode_valid? validates :jurisdiction, :state, inclusion: { in: Idp::Constants::STATE_AND_TERRITORY_CODES, message: proc { I18n.t('doc_auth.errors.general.no_liveness') @@ -88,6 +83,12 @@ def dob_valid? end end + def zipcode_valid? + return if zipcode.is_a?(String) && zipcode.present? + + errors.add(:zipcode, generic_error, type: :zipcode) + end + def generic_error I18n.t('doc_auth.errors.general.no_liveness') end diff --git a/spec/forms/idv/doc_pii_form_spec.rb b/spec/forms/idv/doc_pii_form_spec.rb index f1c54883806..1f6ba9f45f0 100644 --- a/spec/forms/idv/doc_pii_form_spec.rb +++ b/spec/forms/idv/doc_pii_form_spec.rb @@ -51,14 +51,14 @@ state_id_number: 'S59397998', } end - let(:invalid_zipcode_pii) do + let(:non_string_zipcode_pii) do { first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, dob: valid_dob, address1: Faker::Address.street_address, state: Faker::Address.state_abbr, - zipcode: 123456, + zipcode: 12345, state_id_jurisdiction: 'AL', state_id_number: 'S59397998', } @@ -199,7 +199,7 @@ end context 'when there is a non-string zipcode' do - let(:pii) { invalid_zipcode_pii } + let(:pii) { non_string_zipcode_pii } it 'returns a single generic pii error' do result = subject.submit From 6f8c6768f2afbbdbeede1dd9b5d30b243d9985eb Mon Sep 17 00:00:00 2001 From: Brittany Greaner Date: Fri, 2 Feb 2024 16:05:02 -0800 Subject: [PATCH 14/25] LG-12075: Rename attempt properties in analytics for clarity (#10011) * changelog: Internal, Doc Auth, Rename attempt properties in analytics for clarity * Rename: - image_added + opened event attrs - image_submitted + validated attrs - These values permeated through a lot of the code, so this ended up having a lot of ripple effects. - IdV: warning shown event attr - idv_doc_auth_warning_visited attrs - idv_doc_auth_exception_visited attrs - idv_session_error_visited attrs - idv_verify_by_mail_enter_code_submitted attrs - A little unsure if this should be "code entry attempts" or something instead...but the definition of this attribute seems to be from the same areas I have renamed submit attempts, so that's why I chose that. - idv_phone_error_visited attrs - The controller did have a method that was `remaining_step`, but as it was defined the exact same way as others named `remaining_attempt`, I changed it to be consistent. - I tried creating a private method in the controller so that the method could be used as the definition, instead of the same code 3x. But I found doing so broke tests in an off-by-one sort of way - I think somehow the private method held onto the value and was not updated the same way as when it's defined each time. * Apply suggestions from code review --------- Co-authored-by: Zach Margolis --- .../concerns/idv/verify_info_concern.rb | 4 +- .../idv/phone_errors_controller.rb | 8 +- .../idv/session_errors_controller.rb | 4 +- app/forms/gpo_verify_form.rb | 4 +- app/forms/idv/api_image_upload_form.rb | 10 +- .../components/acuant-capture.tsx | 12 +-- .../document-capture-review-issues.tsx | 6 +- .../components/document-capture-warning.tsx | 12 +-- .../components/document-capture.tsx | 2 +- .../components/review-issues-step.tsx | 10 +- .../components/unknown-error.tsx | 10 +- .../document-capture/context/upload.tsx | 2 +- .../document-capture/services/upload.ts | 6 +- .../image_upload_response_presenter.rb | 8 +- app/services/analytics_events.rb | 100 +++++++++-------- app/views/idv/phone_errors/warning.html.erb | 2 +- app/views/idv/session_errors/warning.html.erb | 2 +- .../idv/by_mail/enter_code_controller_spec.rb | 16 +-- .../idv/image_uploads_controller_spec.rb | 102 +++++++++--------- .../idv/phone_errors_controller_spec.rb | 14 +-- .../idv/session_errors_controller_spec.rb | 10 +- .../idv/verify_info_controller_spec.rb | 2 +- spec/features/idv/analytics_spec.rb | 40 +++---- .../doc_auth/redo_document_capture_spec.rb | 14 +-- .../idv/doc_auth/verify_info_step_spec.rb | 2 +- spec/forms/idv/api_image_upload_form_spec.rb | 42 ++++---- .../components/acuant-capture-spec.jsx | 18 ++-- .../components/document-capture-spec.jsx | 2 +- .../document-capture-warning-spec.jsx | 6 +- .../components/review-issues-step-spec.jsx | 10 +- .../components/unknown-error-spec.jsx | 12 ++- .../document-capture/services/upload-spec.js | 8 +- .../image_upload_response_presenter_spec.rb | 38 +++---- spec/services/analytics_spec.rb | 4 +- .../idv/phone_errors/warning.html.erb_spec.rb | 6 +- .../session_errors/warning.html.erb_spec.rb | 8 +- 36 files changed, 284 insertions(+), 272 deletions(-) diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index 43dbe2b92c7..0e10a12aa91 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -112,14 +112,14 @@ def idv_failure_log_rate_limited(rate_limit_type) def idv_failure_log_error analytics.idv_doc_auth_exception_visited( step_name: STEP_NAME, - remaining_attempts: resolution_rate_limiter.remaining_count, + remaining_submit_attempts: resolution_rate_limiter.remaining_count, ) end def idv_failure_log_warning analytics.idv_doc_auth_warning_visited( step_name: STEP_NAME, - remaining_attempts: resolution_rate_limiter.remaining_count, + remaining_submit_attempts: resolution_rate_limiter.remaining_count, ) end diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb index 8c6cdfd7fb3..64bcb863185 100644 --- a/app/controllers/idv/phone_errors_controller.rb +++ b/app/controllers/idv/phone_errors_controller.rb @@ -10,7 +10,7 @@ class PhoneErrorsController < ApplicationController before_action :ignore_form_step_wait_requests def warning - @remaining_attempts = rate_limiter.remaining_count + @remaining_submit_attempts = rate_limiter.remaining_count if idv_session.previous_phone_step_params @phone = idv_session.previous_phone_step_params[:phone] @@ -21,12 +21,12 @@ def warning end def timeout - @remaining_step_attempts = rate_limiter.remaining_count + @remaining_submit_attempts = rate_limiter.remaining_count track_event(type: :timeout) end def jobfail - @remaining_attempts = rate_limiter.remaining_count + @remaining_submit_attempts = rate_limiter.remaining_count track_event(type: :jobfail) end @@ -63,7 +63,7 @@ def track_event(type:) if type == :failure attributes[:limiter_expires_at] = @expires_at else - attributes[:remaining_attempts] = @remaining_attempts + attributes[:remaining_submit_attempts] = @remaining_submit_attempts end analytics.idv_phone_error_visited(**attributes) diff --git a/app/controllers/idv/session_errors_controller.rb b/app/controllers/idv/session_errors_controller.rb index 7fbbc145f75..8c0dd5a9f62 100644 --- a/app/controllers/idv/session_errors_controller.rb +++ b/app/controllers/idv/session_errors_controller.rb @@ -20,7 +20,7 @@ def warning ) @step_indicator_steps = step_indicator_steps - @remaining_attempts = rate_limiter.remaining_count + @remaining_submit_attempts = rate_limiter.remaining_count log_event(based_on_limiter: rate_limiter) end @@ -93,7 +93,7 @@ def log_event(based_on_limiter: nil) type: params[:action], } - options[:attempts_remaining] = based_on_limiter.remaining_count if based_on_limiter + options[:submit_attempts_remaining] = based_on_limiter.remaining_count if based_on_limiter analytics.idv_session_error_visited(**options) end diff --git a/app/forms/gpo_verify_form.rb b/app/forms/gpo_verify_form.rb index cb19d512430..5ef87a0f890 100644 --- a/app/forms/gpo_verify_form.rb +++ b/app/forms/gpo_verify_form.rb @@ -41,7 +41,7 @@ def submit enqueued_at: gpo_confirmation_code&.code_sent_at, which_letter: which_letter, letter_count: letter_count, - attempts: attempts, + submit_attempts: submit_attempts, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], pending_in_person_enrollment: !!pending_profile&.in_person_enrollment&.pending?, fraud_check_failed: fraud_check_failed, @@ -76,7 +76,7 @@ def letter_count pending_profile&.gpo_confirmation_codes&.count end - def attempts + def submit_attempts RateLimiter.new(user: user, rate_limit_type: :verify_gpo_key).attempts end diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 03f3d7413e3..37f2bd30269 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -144,10 +144,10 @@ def doc_side_classification(client_response) def extra_attributes return @extra_attributes if defined?(@extra_attributes) && - @extra_attributes&.dig('attempts') == attempts + @extra_attributes&.dig('submit_attempts') == submit_attempts @extra_attributes = { - attempts: attempts, - remaining_attempts: remaining_attempts, + submit_attempts: submit_attempts, + remaining_submit_attempts: remaining_submit_attempts, user_id: user_uuid, pii_like_keypaths: DocPiiForm.pii_like_keypaths, flow_path: params[:flow_path], @@ -186,11 +186,11 @@ def selfie_image_fingerprint end end - def remaining_attempts + def remaining_submit_attempts rate_limiter.remaining_count if document_capture_session end - def attempts + def submit_attempts rate_limiter.attempts if document_capture_session end diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index a9f22662c2b..53e002c57ed 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -53,9 +53,9 @@ interface ImageAnalyticsPayload { */ source: ImageSource; /** - * Total number of attempts at this point + * Total number of attempts to capture / upload an image at this point */ - attempt?: number; + captureAttempts?: number; /** * Size of the image in bytes */ @@ -334,7 +334,7 @@ function AcuantCapture( useMemo(() => setOwnErrorMessage(null), [value]); const { isMobile } = useContext(DeviceContext); const { t, formatHTML } = useI18n(); - const [attempt, incrementAttempt] = useCounter(1); + const [captureAttempts, incrementCaptureAttempts] = useCounter(1); const [acuantFailureCookie, setAcuantFailureCookie, refreshAcuantFailureCookie] = useCookie('AcuantCameraHasFailed'); const [imageCaptureText, setImageCaptureText] = useState(''); @@ -384,10 +384,10 @@ function AcuantCapture( >(payload: P): P { const enhancedPayload = { ...payload, - attempt, + captureAttempts, acuantCaptureMode: payload.source === 'upload' ? null : acuantCaptureMode, }; - incrementAttempt(); + incrementCaptureAttempts(); return enhancedPayload; } @@ -516,7 +516,7 @@ function AcuantCapture( } function onSelfieCaptureSuccess({ image }: { image: string }) { - trackEvent('idv_sdk_selfie_image_added', { attempt }); + trackEvent('idv_sdk_selfie_image_added', { captureAttempts }); onChangeAndResetError(image); onResetFailedCaptureAttempts(); diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx index 4a08856d737..cf67436e688 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx @@ -18,7 +18,7 @@ import type { ReviewIssuesStepValue } from './review-issues-step'; interface DocumentCaptureReviewIssuesProps extends FormStepComponentProps { isFailedDocType: boolean; isFailedSelfieLivenessOrQuality: boolean; - remainingAttempts: number; + remainingSubmitAttempts: number; captureHints: boolean; hasDismissed: boolean; } @@ -26,7 +26,7 @@ interface DocumentCaptureReviewIssuesProps extends FormStepComponentProps undefined, unknownFieldErrors = [], @@ -52,7 +52,7 @@ function DocumentCaptureReviewIssues({ void; unknownFieldErrors: FormStepError<{ front: string; back: string }>[]; hasDismissed: boolean; @@ -40,7 +40,7 @@ function DocumentCaptureWarning({ isFailedDocType, isFailedResult, isFailedSelfieLivenessOrQuality, - remainingAttempts, + remainingSubmitAttempts, actionOnClick, unknownFieldErrors = [], hasDismissed, @@ -66,7 +66,7 @@ function DocumentCaptureWarning({ trackEvent('IdV: warning shown', { location: 'doc_auth_review_issues', - remaining_attempts: remainingAttempts, + remaining_submit_attempts: remainingSubmitAttempts, heading, subheading: subheadingText, error_message_displayed: errorMessageDisplayed, @@ -93,7 +93,7 @@ function DocumentCaptureWarning({

)} diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index cb2f3d813e5..82daac11372 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -112,7 +112,7 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { form: submissionError instanceof UploadFormEntriesError ? withProps({ - remainingAttempts: submissionError.remainingAttempts, + remainingSubmitAttempts: submissionError.remainingSubmitAttempts, isFailedResult: submissionError.isFailedResult, isFailedDocType: submissionError.isFailedDocType, isFailedSelfieLivenessOrQuality: diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx index 5e2c7f82e2e..e44a2c0c163 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -36,7 +36,7 @@ export interface ReviewIssuesStepValue { } interface ReviewIssuesStepProps extends FormStepComponentProps { - remainingAttempts?: number; + remainingSubmitAttempts?: number; isFailedResult?: boolean; isFailedDocType?: boolean; isFailedSelfieLivenessOrQuality?: boolean; @@ -53,7 +53,7 @@ function ReviewIssuesStep({ onError = () => {}, registerField = () => undefined, toPreviousStep = () => undefined, - remainingAttempts = Infinity, + remainingSubmitAttempts = Infinity, isFailedResult = false, isFailedDocType = false, isFailedSelfieLivenessOrQuality = false, @@ -62,7 +62,7 @@ function ReviewIssuesStep({ failedImageFingerprints = { front: [], back: [] }, }: ReviewIssuesStepProps) { const { trackEvent } = useContext(AnalyticsContext); - const [hasDismissed, setHasDismissed] = useState(remainingAttempts === Infinity); + const [hasDismissed, setHasDismissed] = useState(remainingSubmitAttempts === Infinity); const { onPageTransition, changeStepCanComplete } = useContext(FormStepsContext); const [skipWarning, setSkipWarning] = useState(false); useDidUpdateEffect(onPageTransition, [hasDismissed]); @@ -121,7 +121,7 @@ function ReviewIssuesStep({ isFailedDocType={isFailedDocType} isFailedResult={isFailedResult} isFailedSelfieLivenessOrQuality={isFailedSelfieLivenessOrQuality} - remainingAttempts={remainingAttempts} + remainingSubmitAttempts={remainingSubmitAttempts} unknownFieldErrors={unknownFieldErrors} actionOnClick={onWarningPageDismissed} hasDismissed={false} @@ -133,7 +133,7 @@ function ReviewIssuesStep({ { unknownFieldErrors: FormStepError<{ front: string; back: string }>[]; isFailedDocType: boolean; isFailedSelfieLivenessOrQuality: boolean; - remainingAttempts: number; + remainingSubmitAttempts: number; altFailedDocTypeMsg?: string | null; altIsFailedSelfieDontIncludeAttempts?: boolean; hasDismissed: boolean; @@ -44,7 +44,7 @@ function UnknownError({ unknownFieldErrors = [], isFailedDocType = false, isFailedSelfieLivenessOrQuality = false, - remainingAttempts, + remainingSubmitAttempts, altFailedDocTypeMsg = null, altIsFailedSelfieDontIncludeAttempts = false, hasDismissed, @@ -72,10 +72,10 @@ function UnknownError({ } if (isFailedDocType && err) { return ( -

+

{err.message}{' '}

); @@ -87,7 +87,7 @@ function UnknownError({

{!altIsFailedSelfieDontIncludeAttempts && ( )}

diff --git a/app/javascript/packages/document-capture/context/upload.tsx b/app/javascript/packages/document-capture/context/upload.tsx index 5a80aa66c1d..2ad859295ff 100644 --- a/app/javascript/packages/document-capture/context/upload.tsx +++ b/app/javascript/packages/document-capture/context/upload.tsx @@ -78,7 +78,7 @@ export interface UploadErrorResponse { /** * Number of remaining doc capture attempts for user. */ - remaining_attempts?: number; + remaining_submit_attempts?: number; /** * Boolean to decide if capture hints should be shown with error. diff --git a/app/javascript/packages/document-capture/services/upload.ts b/app/javascript/packages/document-capture/services/upload.ts index f124210d35d..27b18db733b 100644 --- a/app/javascript/packages/document-capture/services/upload.ts +++ b/app/javascript/packages/document-capture/services/upload.ts @@ -36,7 +36,7 @@ export class UploadFormEntryError extends FormError { export class UploadFormEntriesError extends FormError { formEntryErrors: UploadFormEntryError[] = []; - remainingAttempts = Infinity; + remainingSubmitAttempts = Infinity; isFailedResult = false; @@ -112,8 +112,8 @@ const upload: UploadImplementation = async function (payload, { method = 'POST', error.formEntryErrors = result.errors.map(toFormEntryError); } - if (result.remaining_attempts) { - error.remainingAttempts = result.remaining_attempts; + if (result.remaining_submit_attempts) { + error.remainingSubmitAttempts = result.remaining_submit_attempts; } if (result.ocr_pii) { diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb index 3078377f680..d2b7ceab314 100644 --- a/app/presenters/image_upload_response_presenter.rb +++ b/app/presenters/image_upload_response_presenter.rb @@ -20,8 +20,8 @@ def errors end end - def remaining_attempts - @form_response.to_h[:remaining_attempts] + def remaining_submit_attempts + @form_response.to_h[:remaining_submit_attempts] end def status @@ -40,9 +40,9 @@ def as_json(*) else json = { success: false, errors: errors, - remaining_attempts: remaining_attempts, + remaining_submit_attempts: remaining_submit_attempts, doc_type_supported: doc_type_supported? } - if remaining_attempts&.zero? + if remaining_submit_attempts&.zero? if @form_response.extra[:flow_path] == 'standard' json[:redirect] = idv_session_errors_rate_limited_url else # hybrid flow on mobile diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d97eb786034..b6d4ab98075 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -678,7 +678,8 @@ def idv_address_visit # @param [Boolean] acuant_sdk_upgrade_a_b_testing_enabled # @param [String] acuant_version # @param [Boolean] assessment - # @param [Integer] attempt number of attempts + # @param [Integer] captureAttempts number of attempts to capture / upload an image + # (previously called "attempt") # @param [String] documentType # @param [Integer] dpi dots per inch of image # @param [Integer] failedImageResubmission @@ -705,7 +706,7 @@ def idv_back_image_added( acuant_sdk_upgrade_a_b_testing_enabled:, acuant_version:, assessment:, - attempt:, + captureAttempts:, documentType:, dpi:, failedImageResubmission:, @@ -733,7 +734,7 @@ def idv_back_image_added( acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, acuant_version: acuant_version, assessment: assessment, - attempt: attempt, + captureAttempts: captureAttempts, documentType: documentType, dpi: dpi, failedImageResubmission: failedImageResubmission, @@ -893,14 +894,14 @@ def idv_doc_auth_document_capture_visited(**extra) end # @param [String] step_name which step the user was on - # @param [Integer] remaining_attempts how many attempts the user has left before - # we rate limit them + # @param [Integer] remaining_submit_attempts how many attempts the user has left before + # we rate limit them (previously called "remaining_attempts") # The user visited an error page due to an encountering an exception talking to a proofing vendor - def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra) + def idv_doc_auth_exception_visited(step_name:, remaining_submit_attempts:, **extra) track_event( 'IdV: doc auth exception visited', step_name: step_name, - remaining_attempts: remaining_attempts, + remaining_submit_attempts: remaining_submit_attempts, **extra, ) end @@ -968,8 +969,8 @@ def idv_doc_auth_ssn_visited(**extra) # @param [Boolean] success # @param [Hash] errors - # @param [Integer] attempts - # @param [Integer] remaining_attempts + # @param [Integer] submit_attempts (previously called "attempts") + # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts") # @param [String] user_id # @param [String] flow_path # @param [String] front_image_fingerprint Fingerprint of front image data @@ -978,9 +979,9 @@ def idv_doc_auth_ssn_visited(**extra) def idv_doc_auth_submitted_image_upload_form( success:, errors:, - remaining_attempts:, + remaining_submit_attempts:, flow_path:, - attempts: nil, + submit_attempts: nil, user_id: nil, front_image_fingerprint: nil, back_image_fingerprint: nil, @@ -990,8 +991,8 @@ def idv_doc_auth_submitted_image_upload_form( 'IdV: doc auth image upload form submitted', success: success, errors: errors, - attempts: attempts, - remaining_attempts: remaining_attempts, + submit_attempts: submit_attempts, + remaining_submit_attempts: remaining_submit_attempts, user_id: user_id, flow_path: flow_path, front_image_fingerprint: front_image_fingerprint, @@ -1008,8 +1009,8 @@ def idv_doc_auth_submitted_image_upload_form( # @param [String] state # @param [String] state_id_type # @param [Boolean] async - # @param [Integer] attempts - # @param [Integer] remaining_attempts + # @param [Integer] submit_attempts (previously called "attempts") + # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts") # @param [Hash] client_image_metrics # @param [String] flow_path # @param [Float] vendor_request_time_in_ms Time it took to upload images & get a response. @@ -1054,8 +1055,8 @@ def idv_doc_auth_submitted_image_upload_vendor( state:, state_id_type:, async:, - attempts:, - remaining_attempts:, + submit_attempts:, + remaining_submit_attempts:, client_image_metrics:, flow_path:, billed: nil, @@ -1092,8 +1093,8 @@ def idv_doc_auth_submitted_image_upload_vendor( state:, state_id_type:, async:, - attempts:, - remaining_attempts:, + submit_attempts: submit_attempts, + remaining_submit_attempts: remaining_submit_attempts, client_image_metrics:, flow_path:, vendor_request_time_in_ms:, @@ -1123,7 +1124,7 @@ def idv_doc_auth_submitted_image_upload_vendor( # @param [Boolean] success # @param [Hash] errors # @param [String] user_id - # @param [Integer] remaining_attempts + # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts") # @param [Hash] pii_like_keypaths # @param [String] flow_path # @param [String] front_image_fingerprint Fingerprint of front image data @@ -1133,7 +1134,7 @@ def idv_doc_auth_submitted_image_upload_vendor( def idv_doc_auth_submitted_pii_validation( success:, errors:, - remaining_attempts:, + remaining_submit_attempts:, pii_like_keypaths:, flow_path:, user_id: nil, @@ -1147,7 +1148,7 @@ def idv_doc_auth_submitted_pii_validation( success: success, errors: errors, user_id: user_id, - remaining_attempts: remaining_attempts, + remaining_submit_attempts: remaining_submit_attempts, pii_like_keypaths: pii_like_keypaths, flow_path: flow_path, front_image_fingerprint: front_image_fingerprint, @@ -1172,13 +1173,13 @@ def idv_doc_auth_verify_visited(**extra) end # @param [String] step_name - # @param [Integer] remaining_attempts + # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts") # The user was sent to a warning page during the IDV flow - def idv_doc_auth_warning_visited(step_name:, remaining_attempts:, **extra) + def idv_doc_auth_warning_visited(step_name:, remaining_submit_attempts:, **extra) track_event( 'IdV: doc auth warning visited', step_name: step_name, - remaining_attempts: remaining_attempts, + remaining_submit_attempts: remaining_submit_attempts, **extra, ) end @@ -1323,7 +1324,8 @@ def idv_forgot_password_confirmed(proofing_components: nil, **extra) # @param [Boolean] acuant_sdk_upgrade_a_b_testing_enabled # @param [String] acuant_version # @param [Boolean] assessment - # @param [Integer] attempt number of attempts + # @param [Integer] captureAttempts number of attempts to capture / upload an image + # (previously called "attempt") # @param [String] documentType # @param [Integer] dpi dots per inch of image # @param [Integer] failedImageResubmission @@ -1350,7 +1352,7 @@ def idv_front_image_added( acuant_sdk_upgrade_a_b_testing_enabled:, acuant_version:, assessment:, - attempt:, + captureAttempts:, documentType:, dpi:, failedImageResubmission:, @@ -1378,7 +1380,7 @@ def idv_front_image_added( acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, acuant_version: acuant_version, assessment: assessment, - attempt: attempt, + captureAttempts: captureAttempts, documentType: documentType, dpi: dpi, failedImageResubmission: failedImageResubmission, @@ -2698,14 +2700,15 @@ def idv_phone_confirmation_vendor_submitted( # @param ['warning','jobfail','failure'] type # @param [Time] limiter_expires_at when the rate limit expires - # @param [Integer] remaining_attempts number of attempts remaining + # @param [Integer] remaining_submit_attempts number of submit attempts remaining + # (previously called "remaining_attempts") # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components # When a user gets an error during the phone finder flow of IDV def idv_phone_error_visited( type:, proofing_components: nil, limiter_expires_at: nil, - remaining_attempts: nil, + remaining_submit_attempts: nil, **extra ) track_event( @@ -2714,7 +2717,7 @@ def idv_phone_error_visited( type: type, proofing_components: proofing_components, limiter_expires_at: limiter_expires_at, - remaining_attempts: remaining_attempts, + remaining_submit_attempts: remaining_submit_attempts, **extra, }.compact, ) @@ -2813,11 +2816,14 @@ def idv_request_letter_visited( ) end - # @param [Integer] attempt number of attempts + # @param [Integer] captureAttempts number of attempts to capture / upload an image + # (previously called "attempt") # User captured and approved of their selfie - def idv_sdk_selfie_image_added(attempt:, **extra) - track_event(:idv_sdk_selfie_image_added, attempt: attempt, **extra) + # rubocop:disable Naming/VariableName,Naming/MethodParameterName + def idv_sdk_selfie_image_added(captureAttempts:, **extra) + track_event(:idv_sdk_selfie_image_added, captureAttempts: captureAttempts, **extra) end + # rubocop:enable Naming/VariableName,Naming/MethodParameterName # User closed the SDK for taking a selfie without submitting a photo def idv_sdk_selfie_image_capture_closed_without_photo(**extra) @@ -2843,7 +2849,8 @@ def idv_sdk_selfie_image_capture_opened(**extra) track_event(:idv_sdk_selfie_image_capture_opened, **extra) end - # @param [Integer] attempt number of attempts + # @param [Integer] captureAttempts number of attempts to capture / upload an image + # (previously called "attempt") # @param [Integer] failedImageResubmission # @param [String] fingerprint fingerprint of the image added # @param [String] flow_path whether the user is in the hybrid or standard flow @@ -2855,7 +2862,7 @@ def idv_sdk_selfie_image_capture_opened(**extra) # User uploaded a selfie using the file picker # rubocop:disable Naming/VariableName,Naming/MethodParameterName def idv_selfie_image_file_uploaded( - attempt:, + captureAttempts:, failedImageResubmission:, fingerprint:, flow_path:, @@ -2868,7 +2875,7 @@ def idv_selfie_image_file_uploaded( ) track_event( :idv_selfie_image_file_uploaded, - attempt: attempt, + captureAttempts: captureAttempts, failedImageResubmission: failedImageResubmission, fingerprint: fingerprint, flow_path: flow_path, @@ -2883,16 +2890,16 @@ def idv_selfie_image_file_uploaded( # Tracks when the user visits one of the the session error pages. # @param [String] type - # @param [Integer,nil] attempts_remaining + # @param [Integer,nil] submit_attempts_remaining (previously called "attempts_remaining") def idv_session_error_visited( type:, - attempts_remaining: nil, + submit_attempts_remaining: nil, **extra ) track_event( 'IdV: session error visited', type: type, - attempts_remaining: attempts_remaining, + submit_attempts_remaining: submit_attempts_remaining, **extra, ) end @@ -2952,7 +2959,8 @@ def idv_usps_auth_token_refresh_job_started(**extra) # @param [DateTime] enqueued_at When was this letter enqueued # @param [Integer] which_letter Sorted by enqueue time, which letter had this code # @param [Integer] letter_count How many letters did the user enqueue for this profile - # @param [Integer] attempts Number of attempts to enter a correct code + # @param [Integer] submit_attempts Number of attempts to enter a correct code + # (previously called "attempts") # @param [Boolean] pending_in_person_enrollment # @param [Boolean] fraud_check_failed # @see Reporting::IdentityVerificationReport#query This event is used by the identity verification @@ -2965,7 +2973,7 @@ def idv_verify_by_mail_enter_code_submitted( enqueued_at:, which_letter:, letter_count:, - attempts:, + submit_attempts:, pending_in_person_enrollment:, fraud_check_failed:, **extra @@ -2978,7 +2986,7 @@ def idv_verify_by_mail_enter_code_submitted( enqueued_at: enqueued_at, which_letter: which_letter, letter_count: letter_count, - attempts: attempts, + submit_attempts: submit_attempts, pending_in_person_enrollment: pending_in_person_enrollment, fraud_check_failed: fraud_check_failed, **extra, @@ -3045,7 +3053,7 @@ def idv_warning_action_triggered( # @param [String] flow_path # @param [String] heading # @param [String] location - # @param [Integer] remaining_attempts + # @param [Integer] remaining_submit_attempts (previously called "remaining_attempts") # @param [String] subheading # @param [Boolean] use_alternate_sdk def idv_warning_shown( @@ -3055,7 +3063,7 @@ def idv_warning_shown( flow_path:, heading:, location:, - remaining_attempts:, + remaining_submit_attempts:, subheading:, use_alternate_sdk:, **_extra @@ -3068,7 +3076,7 @@ def idv_warning_shown( flow_path: flow_path, heading: heading, location: location, - remaining_attempts: remaining_attempts, + remaining_submit_attempts: remaining_submit_attempts, subheading: subheading, use_alternate_sdk: use_alternate_sdk, ) diff --git a/app/views/idv/phone_errors/warning.html.erb b/app/views/idv/phone_errors/warning.html.erb index 58176958201..1d83f972eed 100644 --- a/app/views/idv/phone_errors/warning.html.erb +++ b/app/views/idv/phone_errors/warning.html.erb @@ -28,7 +28,7 @@

- <%= t('idv.failure.phone.warning.attempts_html', count: @remaining_attempts) %> + <%= t('idv.failure.phone.warning.attempts_html', count: @remaining_submit_attempts) %>

diff --git a/app/views/idv/session_errors/warning.html.erb b/app/views/idv/session_errors/warning.html.erb index b7b32eead64..97f32940ff1 100644 --- a/app/views/idv/session_errors/warning.html.erb +++ b/app/views/idv/session_errors/warning.html.erb @@ -13,7 +13,7 @@ <% c.with_header { t('idv.warning.sessions.heading') } %>

<%= t('idv.failure.sessions.warning') %>

-

<%= t('idv.warning.attempts_html', count: @remaining_attempts) %>

+

<%= t('idv.warning.attempts_html', count: @remaining_submit_attempts) %>

<% c.with_action_button( action: ->(**tag_options, &block) { link_to(@try_again_path, **tag_options, &block) }, diff --git a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb index 985d1628185..01a45d74369 100644 --- a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb +++ b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb @@ -195,7 +195,7 @@ enqueued_at: pending_profile.gpo_confirmation_codes.last.code_sent_at, which_letter: 1, letter_count: 1, - attempts: 1, + submit_attempts: 1, ) event_count = user.events.where(event_type: :account_verified, ip: '0.0.0.0'). where(disavowal_token_fingerprint: nil).count @@ -240,7 +240,7 @@ enqueued_at: pending_profile.gpo_confirmation_codes.last.code_sent_at, which_letter: 1, letter_count: 1, - attempts: 1, + submit_attempts: 1, ) expect(response).to redirect_to(idv_personal_key_url) end @@ -271,7 +271,7 @@ enqueued_at: pending_profile.gpo_confirmation_codes.last.code_sent_at, which_letter: 1, letter_count: 1, - attempts: 1, + submit_attempts: 1, ) event_count = user.events.where(event_type: :account_verified, ip: '0.0.0.0'). where(disavowal_token_fingerprint: nil).count @@ -299,7 +299,7 @@ enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, which_letter: 1, letter_count: 1, - attempts: 1, + submit_attempts: 1, ) expect(response).to redirect_to(idv_personal_key_url) @@ -332,7 +332,7 @@ enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, which_letter: 1, letter_count: 1, - attempts: 1, + submit_attempts: 1, ) expect(response).to redirect_to(idv_personal_key_url) @@ -363,7 +363,7 @@ enqueued_at: nil, which_letter: nil, letter_count: 1, - attempts: 1, + submit_attempts: 1, error_details: { otp: { confirmation_code_incorrect: true } }, ) expect(response).to redirect_to(idv_verify_by_mail_enter_code_url) @@ -398,7 +398,7 @@ enqueued_at: nil, which_letter: nil, letter_count: 1, - attempts: 1, + submit_attempts: 1, error_details: { otp: { confirmation_code_incorrect: true } }, } post(:create, params: { gpo_verify_form: { otp: bad_otp } }) @@ -408,7 +408,7 @@ **analytics_args, ) - analytics_args[:attempts] = 2 + analytics_args[:submit_attempts] = 2 expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index d657d0731a4..1c682c6901a 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -66,8 +66,8 @@ front: { blank: true }, }, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', ).exactly(0).times @@ -127,8 +127,8 @@ front: { not_a_file: true }, }, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: nil, @@ -190,7 +190,7 @@ end context 'throttling' do - it 'returns remaining_attempts with error' do + it 'returns remaining_submit_attempts with error' do params.delete(:front) RateLimiter.new(rate_limit_type: :idv_doc_auth, user: user).increment! @@ -201,7 +201,7 @@ { success: false, errors: [{ field: 'front', message: 'Please fill in this field.' }], - remaining_attempts: RateLimiter.max_attempts(:idv_doc_auth) - 2, + remaining_submit_attempts: RateLimiter.max_attempts(:idv_doc_auth) - 2, result_failed: false, ocr_pii: nil, doc_type_supported: true, @@ -217,7 +217,7 @@ success: false, errors: [{ field: 'limit', message: 'We couldn’t verify your ID' }], redirect: redirect_url, - remaining_attempts: 0, + remaining_submit_attempts: 0, result_failed: false, ocr_pii: nil, doc_type_supported: true, @@ -263,8 +263,8 @@ limit: { rate_limited: true }, }, user_id: user.uuid, - attempts: IdentityConfig.store.doc_auth_max_attempts, - remaining_attempts: 0, + submit_attempts: IdentityConfig.store.doc_auth_max_attempts, + remaining_submit_attempts: 0, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -333,7 +333,7 @@ action expect(response.status).to eq(400) expect(json[:success]).to eq(false) - expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(json[:remaining_submit_attempts]).to be_a_kind_of(Numeric) expect(json[:errors]).to eq [ { field: 'general', @@ -408,8 +408,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -430,8 +430,8 @@ state: 'MT', state_id_type: 'drivers_license', user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, client_image_metrics: { front: { glare: 99.99 }, back: { glare: 99.99 }, @@ -469,8 +469,8 @@ errors: {}, attention_with_barcode: false, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -607,8 +607,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -629,8 +629,8 @@ state: 'ND', state_id_type: 'drivers_license', user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, client_image_metrics: { front: { glare: 99.99 }, back: { glare: 99.99 }, @@ -673,8 +673,8 @@ }, attention_with_barcode: false, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -719,8 +719,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -741,8 +741,8 @@ state: 'Maryland', state_id_type: 'drivers_license', user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, client_image_metrics: { front: { glare: 99.99 }, back: { glare: 99.99 }, @@ -785,8 +785,8 @@ }, attention_with_barcode: false, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -831,8 +831,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -853,8 +853,8 @@ state: 'ND', state_id_type: 'drivers_license', user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, client_image_metrics: { front: { glare: 99.99 }, back: { glare: 99.99 }, @@ -897,8 +897,8 @@ }, attention_with_barcode: false, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -940,8 +940,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -962,8 +962,8 @@ state: 'ND', state_id_type: 'drivers_license', user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, client_image_metrics: { front: { glare: 99.99 }, back: { glare: 99.99 }, @@ -1006,8 +1006,8 @@ }, attention_with_barcode: false, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -1055,7 +1055,7 @@ expect(response.status).to eq(400) expect(json[:success]).to eq(false) - expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(json[:remaining_submit_attempts]).to be_a_kind_of(Numeric) expect(json[:errors]).to eq [ { field: 'front', @@ -1072,8 +1072,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -1090,9 +1090,9 @@ }, attention_with_barcode: false, user_id: user.uuid, - attempts: 1, + submit_attempts: 1, billed: nil, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, state: nil, state_id_type: nil, exception: nil, @@ -1141,7 +1141,7 @@ it 'returns error from yaml file' do action - expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(json[:remaining_submit_attempts]).to be_a_kind_of(Numeric) expect(json[:errors]).to eq [ { field: 'general', @@ -1161,8 +1161,8 @@ success: true, errors: {}, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: pii_like_keypaths, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), @@ -1187,8 +1187,8 @@ state_id_type: nil, exception: nil, user_id: user.uuid, - attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + submit_attempts: 1, + remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, client_image_metrics: { front: { glare: 99.99 }, back: { glare: 99.99 }, @@ -1234,7 +1234,7 @@ expect(response.status).to eq(400) expect(json[:success]).to eq(false) - expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(json[:remaining_submit_attempts]).to be_a_kind_of(Numeric) expect(json[:errors]).to eq [ { field: 'dob', diff --git a/spec/controllers/idv/phone_errors_controller_spec.rb b/spec/controllers/idv/phone_errors_controller_spec.rb index 010f7530b20..4f4fe25ea87 100644 --- a/spec/controllers/idv/phone_errors_controller_spec.rb +++ b/spec/controllers/idv/phone_errors_controller_spec.rb @@ -12,7 +12,7 @@ end before do - allow(subject).to receive(:remaining_attempts).and_return(5) + allow(subject).to receive(:remaining_submit_attempts).and_return(5) stub_analytics allow(@analytics).to receive(:track_event) allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) @@ -149,10 +149,10 @@ end end - it 'assigns remaining count' do + it 'assigns the remaining submit count' do get action - expect(assigns(:remaining_attempts)).to be_kind_of(Numeric) + expect(assigns(:remaining_submit_attempts)).to be_kind_of(Numeric) end it 'logs an event' do @@ -161,7 +161,7 @@ expect(@analytics).to have_received(:track_event).with( 'IdV: phone error visited', type: action, - remaining_attempts: 4, + remaining_submit_attempts: 4, **ab_test_args, ) end @@ -184,7 +184,7 @@ it 'assigns remaining count' do get action - expect(assigns(:remaining_step_attempts)).to be_kind_of(Numeric) + expect(assigns(:remaining_submit_attempts)).to be_kind_of(Numeric) end end end @@ -205,7 +205,7 @@ it 'assigns remaining count' do get action - expect(assigns(:remaining_attempts)).to be_kind_of(Numeric) + expect(assigns(:remaining_submit_attempts)).to be_kind_of(Numeric) end it 'logs an event' do @@ -214,7 +214,7 @@ expect(@analytics).to have_received(:track_event).with( 'IdV: phone error visited', type: action, - remaining_attempts: 4, + remaining_submit_attempts: 4, **ab_test_args, ) end diff --git a/spec/controllers/idv/session_errors_controller_spec.rb b/spec/controllers/idv/session_errors_controller_spec.rb index 39ddf77063e..bbb9f25949a 100644 --- a/spec/controllers/idv/session_errors_controller_spec.rb +++ b/spec/controllers/idv/session_errors_controller_spec.rb @@ -170,7 +170,7 @@ it 'assigns remaining count' do response - expect(assigns(:remaining_attempts)).to be_kind_of(Numeric) + expect(assigns(:remaining_submit_attempts)).to be_kind_of(Numeric) end it 'assigns URL to try again' do @@ -184,7 +184,7 @@ 'IdV: session error visited', hash_including( type: action.to_s, - attempts_remaining: IdentityConfig.store.idv_max_attempts - 1, + submit_attempts_remaining: IdentityConfig.store.idv_max_attempts - 1, ), ) response @@ -264,7 +264,7 @@ 'IdV: session error visited', hash_including( type: action.to_s, - attempts_remaining: 0, + submit_attempts_remaining: 0, ), ) get action @@ -305,7 +305,7 @@ 'IdV: session error visited', hash_including( type: 'ssn_failure', - attempts_remaining: 0, + submit_attempts_remaining: 0, ), ) get action @@ -338,7 +338,7 @@ 'IdV: session error visited', hash_including( type: action.to_s, - attempts_remaining: 0, + submit_attempts_remaining: 0, ), ) diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index f0cbb78fc7f..d24645a0051 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -352,7 +352,7 @@ expect(@analytics).to have_logged_event( 'IdV: doc auth warning visited', step_name: 'verify_info', - remaining_attempts: kind_of(Numeric), + remaining_submit_attempts: kind_of(Numeric), ) end diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index d0b647e8e50..28c244165c4 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -63,16 +63,16 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false }, 'Frontend: IdV: front image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'Frontend: IdV: back image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean + success: true, errors: {}, submit_attempts: 1, remaining_submit_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false @@ -171,16 +171,16 @@ flow_path: 'hybrid', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, 'Frontend: IdV: front image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'hybrid', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'hybrid', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'Frontend: IdV: back image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'hybrid', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'hybrid', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'hybrid', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean + success: true, errors: {}, submit_attempts: 1, remaining_submit_attempts: 3, user_id: user.uuid, flow_path: 'hybrid', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'hybrid', step: 'document_capture', acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false @@ -276,16 +276,16 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, skip_hybrid_handoff: nil, analytics_id: 'Doc Auth', irs_reproofing: false }, 'Frontend: IdV: front image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'Frontend: IdV: back image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean + success: true, errors: {}, submit_attempts: 1, remaining_submit_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, skip_hybrid_handoff: nil, analytics_id: 'Doc Auth', irs_reproofing: false @@ -363,13 +363,13 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false }, 'Frontend: IdV: front image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'Frontend: IdV: back image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean + success: true, errors: {}, submit_attempts: 1, remaining_submit_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: nil, liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'standard', attention_with_barcode: true, doc_auth_result: 'Attention'), 'IdV: verify in person troubleshooting option clicked' => { @@ -494,22 +494,22 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, skip_hybrid_handoff: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, 'Frontend: IdV: front image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'Frontend: IdV: back image added' => { - width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, attempt: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil + width: 284, height: 38, mimeType: 'image/png', source: 'upload', size: 3694, captureAttempts: 1, flow_path: 'standard', acuant_sdk_upgrade_a_b_testing_enabled: 'false', use_alternate_sdk: anything, acuant_version: anything, acuantCaptureMode: nil, fingerprint: anything, failedImageResubmission: boolean, documentType: nil, dpi: nil, glare: nil, glareScoreThreshold: nil, isAssessedAsBlurry: nil, isAssessedAsGlare: nil, isAssessedAsUnsupported: nil, moire: nil, sharpness: nil, sharpnessScoreThreshold: nil, assessment: nil }, 'IdV: doc auth image upload form submitted' => { - success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean + success: true, errors: {}, submit_attempts: 1, remaining_submit_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean }, 'IdV: doc auth image upload vendor pii validation' => { - success: true, errors: {}, user_id: user.uuid, attempts: 1, remaining_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} + success: true, errors: {}, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {} }, 'IdV: doc auth document_capture submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, skip_hybrid_handoff: nil, acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, analytics_id: 'Doc Auth', irs_reproofing: false }, :idv_selfie_image_file_uploaded => { - attempt: 1, failedImageResubmission: nil, fingerprint: 'aIzxkX_iMtoxFOURZr55qkshs53emQKUOr7VfTf6G1Q', flow_path: 'standard', height: 38, mimeType: 'image/png', size: 3694, source: 'upload', width: 284 + captureAttempts: 1, failedImageResubmission: nil, fingerprint: 'aIzxkX_iMtoxFOURZr55qkshs53emQKUOr7VfTf6G1Q', flow_path: 'standard', height: 38, mimeType: 'image/png', size: 3694, source: 'upload', width: 284 }, 'IdV: doc auth ssn visited' => { flow_path: 'standard', step: 'ssn', acuant_sdk_upgrade_ab_test_bucket: :default, lexisnexis_instant_verify_workflow_ab_test_bucket: :default, skip_hybrid_handoff: nil, analytics_id: 'Doc Auth', irs_reproofing: false 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 825b8a0c0c0..7c98ec55191 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -120,13 +120,13 @@ ) expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 3), + hash_including(remaining_submit_attempts: 3), ) DocAuth::Mock::DocAuthMockClient.reset! attach_and_submit_images expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 2), + hash_including(remaining_submit_attempts: 2), ) expect(current_path).to eq(idv_ssn_path) check t('forms.ssn.show') @@ -141,7 +141,7 @@ ) expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 3, attempts: 1), + hash_including(remaining_submit_attempts: 3, submit_attempts: 1), ) DocAuth::Mock::DocAuthMockClient.reset! attach_images @@ -161,7 +161,7 @@ ) expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 3, attempts: 1), + hash_including(remaining_submit_attempts: 3, submit_attempts: 1), ) DocAuth::Mock::DocAuthMockClient.reset! expect(page).not_to have_css( @@ -326,7 +326,7 @@ ) expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 3, attempts: 1), + hash_including(remaining_submit_attempts: 3, submit_attempts: 1), ) DocAuth::Mock::DocAuthMockClient.reset! expect(page).not_to have_css( @@ -375,7 +375,7 @@ ) expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 3, attempts: 1), + hash_including(remaining_submit_attempts: 3, submit_attempts: 1), ) DocAuth::Mock::DocAuthMockClient.reset! expect(page).not_to have_css( @@ -447,7 +447,7 @@ ) expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', - hash_including(remaining_attempts: 3, attempts: 1), + hash_including(remaining_submit_attempts: 3, submit_attempts: 1), ) DocAuth::Mock::DocAuthMockClient.reset! expect(page).not_to have_css( diff --git a/spec/features/idv/doc_auth/verify_info_step_spec.rb b/spec/features/idv/doc_auth/verify_info_step_spec.rb index 18b728c952e..e9b5ba2f6c7 100644 --- a/spec/features/idv/doc_auth/verify_info_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_info_step_spec.rb @@ -129,7 +129,7 @@ expect(fake_analytics).to have_logged_event( 'IdV: doc auth exception visited', step_name: 'verify_info', - remaining_attempts: 5, + remaining_submit_attempts: 5, ) expect(page).to have_current_path(idv_session_errors_exception_path) diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index d14be59ef9a..4df0335f9ed 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -122,8 +122,8 @@ 'IdV: doc auth image upload form submitted', success: true, errors: {}, - attempts: 1, - remaining_attempts: 3, + submit_attempts: 1, + remaining_submit_attempts: 3, user_id: document_capture_session.user.uuid, flow_path: anything, front_image_fingerprint: an_instance_of(String), @@ -135,7 +135,7 @@ expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload vendor submitted', async: false, - attempts: 1, + submit_attempts: 1, attention_with_barcode: false, address_line2_present: nil, alert_failure_count: nil, @@ -161,7 +161,7 @@ errors: {}, exception: nil, flow_path: anything, - remaining_attempts: 3, + remaining_submit_attempts: 3, state: 'MT', state_id_type: 'drivers_license', success: true, @@ -231,8 +231,8 @@ 'IdV: doc auth image upload form submitted', success: true, errors: {}, - attempts: 1, - remaining_attempts: 3, + submit_attempts: 1, + remaining_submit_attempts: 3, user_id: document_capture_session.user.uuid, flow_path: anything, front_image_fingerprint: an_instance_of(String), @@ -246,7 +246,7 @@ address_line2_present: nil, alert_failure_count: nil, async: false, - attempts: 1, + submit_attempts: 1, attention_with_barcode: false, billed: true, client_image_metrics: { @@ -282,7 +282,7 @@ processed_alerts: nil, product_status: nil, reference: nil, - remaining_attempts: 3, + remaining_submit_attempts: 3, state: 'MT', state_id_type: 'drivers_license', success: true, @@ -357,8 +357,8 @@ 'IdV: doc auth image upload form submitted', success: true, errors: {}, - attempts: 1, - remaining_attempts: 3, + submit_attempts: 1, + remaining_submit_attempts: 3, user_id: document_capture_session.user.uuid, flow_path: anything, front_image_fingerprint: an_instance_of(String), @@ -392,9 +392,9 @@ expect(response.pii_from_doc).to eq({}) end - it 'includes remaining_attempts' do + it 'includes remaining_submit_attempts' do response = form.submit - expect(response.extra[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(response.extra[:remaining_submit_attempts]).to be_a_kind_of(Numeric) end end @@ -407,7 +407,7 @@ DocAuth::Response.new( success: false, errors: errors, - extra: { remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1 }, + extra: { remaining_submit_attempts: IdentityConfig.store.doc_auth_max_attempts - 1 }, ) end let(:doc_auth_client) { double(DocAuth::LexisNexis::LexisNexisClient) } @@ -428,9 +428,9 @@ expect(response.doc_auth_success?).to eq(false) end - it 'includes remaining_attempts' do + it 'includes remaining_submit_attempts' do response = form.submit - expect(response.extra[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(response.extra[:remaining_submit_attempts]).to be_a_kind_of(Numeric) end it 'includes client response errors' do @@ -525,9 +525,9 @@ expect(response.pii_from_doc).to eq({}) end - it 'includes remaining_attempts' do + it 'includes remaining_submit_attempts' do response = form.submit - expect(response.extra[:remaining_attempts]).to be_a_kind_of(Numeric) + expect(response.extra[:remaining_submit_attempts]).to be_a_kind_of(Numeric) end it 'includes doc_pii errors' do @@ -553,8 +553,8 @@ expect(response.errors).to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) expect(fake_analytics).to have_logged_event( 'IdV: failed doc image resubmitted', - attempts: 1, - remaining_attempts: 3, + submit_attempts: 1, + remaining_submit_attempts: 3, user_id: document_capture_session.user.uuid, flow_path: anything, front_image_fingerprint: an_instance_of(String), @@ -582,8 +582,8 @@ to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) expect(fake_analytics).to have_logged_event( 'IdV: failed doc image resubmitted', - attempts: 1, - remaining_attempts: 3, + submit_attempts: 1, + remaining_submit_attempts: 3, user_id: document_capture_session.user.uuid, flow_path: anything, front_image_fingerprint: an_instance_of(String), diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index 35543c8fa85..c865923ff51 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -636,7 +636,7 @@ describe('document-capture/components/acuant-capture', () => { sharpnessScoreThreshold: sinon.match.number, source: 'acuant', width: sinon.match.number, - attempt: sinon.match.number, + captureAttempts: sinon.match.number, size: sinon.match.number, }), ); @@ -805,7 +805,7 @@ describe('document-capture/components/acuant-capture', () => { assessment: 'glare', sharpness: 100, width: 1748, - attempt: sinon.match.number, + captureAttempts: sinon.match.number, size: sinon.match.number, acuantCaptureMode: 'AUTO', fingerprint: null, @@ -864,7 +864,7 @@ describe('document-capture/components/acuant-capture', () => { assessment: 'blurry', sharpness: 49, width: 1748, - attempt: sinon.match.number, + captureAttempts: sinon.match.number, size: sinon.match.number, acuantCaptureMode: sinon.match.string, fingerprint: null, @@ -976,7 +976,7 @@ describe('document-capture/components/acuant-capture', () => { assessment: 'blurry', sharpness: 49, width: 1748, - attempt: sinon.match.number, + captureAttempts: sinon.match.number, size: sinon.match.number, acuantCaptureMode: sinon.match.string, fingerprint: null, @@ -1208,7 +1208,7 @@ describe('document-capture/components/acuant-capture', () => { expect(trackEvent).to.have.been.calledWith( 'idv_sdk_selfie_image_added', sinon.match({ - attempt: sinon.match.number, + captureAttempts: sinon.match.number, }), ); }); @@ -1313,7 +1313,7 @@ describe('document-capture/components/acuant-capture', () => { source: 'upload', mimeType: 'image/jpeg', size: sinon.match.number, - attempt: sinon.match.number, + captureAttempts: sinon.match.number, acuantCaptureMode: null, }), ); @@ -1349,7 +1349,7 @@ describe('document-capture/components/acuant-capture', () => { source: 'upload', mimeType: 'image/jpeg', size: sinon.match.number, - attempt: sinon.match.number, + captureAttempts: sinon.match.number, acuantCaptureMode: 'AUTO', }), ); @@ -1435,14 +1435,14 @@ describe('document-capture/components/acuant-capture', () => { await expect(trackEvent).to.eventually.be.calledWith( 'IdV: test image added', - sinon.match({ attempt: 1 }), + sinon.match({ captureAttempts: 1 }), ); uploadFile(input, validUpload); await expect(trackEvent).to.eventually.be.calledWith( 'IdV: test image added', - sinon.match({ attempt: 2 }), + sinon.match({ captureAttempts: 2 }), ); }); }); diff --git a/spec/javascript/packages/document-capture/components/document-capture-spec.jsx b/spec/javascript/packages/document-capture/components/document-capture-spec.jsx index 6ab8b14f2d7..4bb342b740d 100644 --- a/spec/javascript/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/document-capture-spec.jsx @@ -333,7 +333,7 @@ describe('document-capture/components/document-capture', () => { ); const response = new Response( - JSON.stringify({ success: false, remaining_attempts: 1, errors: [{}] }), + JSON.stringify({ success: false, remaining_submit_attempts: 1, errors: [{}] }), { status: 400 }, ); sandbox.stub(response, 'url').get(() => endpoint); diff --git a/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx b/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx index fdd8791c589..9a8619ff505 100644 --- a/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx +++ b/spec/javascript/packages/document-capture/components/document-capture-warning-spec.jsx @@ -66,7 +66,7 @@ describe('DocumentCaptureWarning', () => { isFailedDocType={isFailedDocType} isFailedResult={isFailedResult} isFailedSelfieLivenessOrQuality={isFailedSelfieLivenessOrQuality} - remainingAttempts={2} + remainingSubmitAttempts={2} unknownFieldErrors={unknownFieldErrors} actionOnClick={() => {}} /> @@ -90,7 +90,7 @@ describe('DocumentCaptureWarning', () => { heading: 'errors.doc_auth.rate_limited_heading', subheading: 'errors.doc_auth.rate_limited_subheading', error_message_displayed: 'general error', - remaining_attempts: 2, + remaining_submit_attempts: 2, }); }); @@ -219,7 +219,7 @@ describe('DocumentCaptureWarning', () => { heading: 'errors.doc_auth.doc_type_not_supported_heading', subheading: '', error_message_displayed: 'general error idv.warning.attempts_html', - remaining_attempts: 2, + remaining_submit_attempts: 2, }); }); diff --git a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx index 364ed89dafa..4a0b8eaaf29 100644 --- a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx @@ -17,7 +17,7 @@ import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/review-issues-step', () => { const DEFAULT_PROPS = { - remainingAttempts: 3, + remainingSubmitAttempts: 3, unknownFieldErrors: [ { field: 'general', @@ -47,7 +47,7 @@ describe('document-capture/components/review-issues-step', () => { expect(trackEvent).to.have.been.calledWith('IdV: warning shown', { location: 'doc_auth_review_issues', - remaining_attempts: 3, + remaining_submit_attempts: 3, heading: 'We couldn’t verify your ID', subheading: '', error_message_displayed: 'test error', @@ -144,7 +144,7 @@ describe('document-capture/components/review-issues-step', () => { } > { > { > { context('there is no doc type failure', () => { it('render an empty paragraph when no errors', () => { const { container } = render( - , + , ); expect(container.querySelector('p')).to.be.ok(); }); @@ -25,7 +29,7 @@ describe('UnknownError', () => { }, ]} isFailedDocType={false} - remainingAttempts={10} + remainingSubmitAttempts={10} hasDismissed />, ); @@ -47,7 +51,7 @@ describe('UnknownError', () => { }, ]} isFailedDocType={false} - remainingAttempts={10} + remainingSubmitAttempts={10} hasDismissed={false} />, ); @@ -86,7 +90,7 @@ describe('UnknownError', () => { error: toFormEntryError({ field: 'general', message: 'An unknown error occurred' }), }, ]} - remainingAttempts={2} + remainingSubmitAttempts={2} isFailedDocType /> , diff --git a/spec/javascript/packages/document-capture/services/upload-spec.js b/spec/javascript/packages/document-capture/services/upload-spec.js index 9a6733256ae..7876f23650b 100644 --- a/spec/javascript/packages/document-capture/services/upload-spec.js +++ b/spec/javascript/packages/document-capture/services/upload-spec.js @@ -110,7 +110,7 @@ describe('document-capture/services/upload', () => { { field: 'front', message: 'Please fill in this field' }, { field: 'back', message: 'Please fill in this field' }, ], - remaining_attempts: 3, + remaining_submit_attempts: 3, hints: true, result_failed: true, ocr_pii: { first_name: 'Fakey', last_name: 'McFakerson', dob: '1938-10-06' }, @@ -125,7 +125,7 @@ describe('document-capture/services/upload', () => { throw new Error('This is a safeguard and should never be reached, since upload should error'); } catch (error) { expect(error).to.be.instanceOf(UploadFormEntriesError); - expect(error.remainingAttempts).to.equal(3); + expect(error.remainingSubmitAttempts).to.equal(3); expect(error.hints).to.be.true(); expect(error.pii).to.deep.equal({ first_name: 'Fakey', @@ -149,7 +149,7 @@ describe('document-capture/services/upload', () => { JSON.stringify({ success: false, errors: [{ field: 'front', message: 'Using failed image' }], - remaining_attempts: 3, + remaining_submit_attempts: 3, hints: true, result_failed: true, ocr_pii: { first_name: 'Fakey', last_name: 'McFakerson', dob: '1938-10-06' }, @@ -165,7 +165,7 @@ describe('document-capture/services/upload', () => { throw new Error('This is a safeguard and should never be reached, since upload should error'); } catch (error) { expect(error).to.be.instanceOf(UploadFormEntriesError); - expect(error.remainingAttempts).to.equal(3); + expect(error.remainingSubmitAttempts).to.equal(3); expect(error.hints).to.be.true(); expect(error.pii).to.deep.equal({ first_name: 'Fakey', diff --git a/spec/presenters/image_upload_response_presenter_spec.rb b/spec/presenters/image_upload_response_presenter_spec.rb index 58e36fa5134..bac3d9eb7a3 100644 --- a/spec/presenters/image_upload_response_presenter_spec.rb +++ b/spec/presenters/image_upload_response_presenter_spec.rb @@ -4,7 +4,7 @@ include Rails.application.routes.url_helpers let(:extra_attributes) do - { remaining_attempts: 3, flow_path: 'standard' } + { remaining_submit_attempts: 3, flow_path: 'standard' } end let(:form_response) do @@ -52,9 +52,9 @@ end end - describe '#remaining_attempts' do - it 'returns remaining attempts' do - expect(presenter.remaining_attempts).to eq 3 + describe '#remaining_submit_attempts' do + it 'returns remaining submit attempts' do + expect(presenter.remaining_submit_attempts).to eq 3 end end @@ -107,7 +107,7 @@ context 'rate limited' do let(:extra_attributes) do - { remaining_attempts: 0, + { remaining_submit_attempts: 0, flow_path: 'standard', failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] } } end @@ -127,7 +127,7 @@ result_failed: false, errors: [{ field: :limit, message: t('errors.doc_auth.rate_limited_heading') }], redirect: idv_session_errors_rate_limited_url, - remaining_attempts: 0, + remaining_submit_attempts: 0, ocr_pii: nil, doc_type_supported: true, failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] }, @@ -138,7 +138,7 @@ context 'hybrid flow' do let(:extra_attributes) do - { remaining_attempts: 0, + { remaining_submit_attempts: 0, flow_path: 'hybrid', failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] } } end @@ -149,7 +149,7 @@ result_failed: false, errors: [{ field: :limit, message: t('errors.doc_auth.rate_limited_heading') }], redirect: idv_hybrid_mobile_capture_complete_url, - remaining_attempts: 0, + remaining_submit_attempts: 0, ocr_pii: nil, doc_type_supported: true, failed_image_fingerprints: { back: [], front: ['12345'], selfie: [] }, @@ -178,7 +178,7 @@ result_failed: false, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, - remaining_attempts: 3, + remaining_submit_attempts: 3, ocr_pii: nil, doc_type_supported: true, failed_image_fingerprints: { back: [], front: [], selfie: [] }, @@ -195,7 +195,7 @@ front: t('doc_auth.errors.not_a_file'), hints: true, }, - extra: { doc_auth_result: 'Failed', remaining_attempts: 3 }, + extra: { doc_auth_result: 'Failed', remaining_submit_attempts: 3 }, ) end @@ -205,7 +205,7 @@ result_failed: true, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, - remaining_attempts: 3, + remaining_submit_attempts: 3, ocr_pii: nil, doc_type_supported: true, failed_image_fingerprints: { front: [], back: [], selfie: [] }, @@ -217,7 +217,7 @@ context 'no remaining attempts' do let(:extra_attributes) do - { remaining_attempts: 0, flow_path: 'standard' } + { remaining_submit_attempts: 0, flow_path: 'standard' } end let(:form_response) do FormResponse.new( @@ -232,7 +232,7 @@ context 'hybrid flow' do let(:extra_attributes) do - { remaining_attempts: 0, flow_path: 'hybrid' } + { remaining_submit_attempts: 0, flow_path: 'hybrid' } end it 'returns hash of properties' do @@ -242,7 +242,7 @@ errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, redirect: idv_hybrid_mobile_capture_complete_url, - remaining_attempts: 0, + remaining_submit_attempts: 0, ocr_pii: nil, doc_type_supported: true, failed_image_fingerprints: { front: [], back: [], selfie: [] }, @@ -259,7 +259,7 @@ errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, redirect: idv_session_errors_rate_limited_url, - remaining_attempts: 0, + remaining_submit_attempts: 0, ocr_pii: nil, doc_type_supported: true, failed_image_fingerprints: { back: [], front: [], selfie: [] }, @@ -274,7 +274,7 @@ let(:form_response) do response = DocAuth::Response.new( success: true, - extra: { remaining_attempts: 3 }, + extra: { remaining_submit_attempts: 3 }, pii_from_doc: Idp::Constants::MOCK_IDV_APPLICANT, ) allow(response).to receive(:attention_with_barcode?).and_return(true) @@ -287,7 +287,7 @@ result_failed: false, errors: [], hints: true, - remaining_attempts: 3, + remaining_submit_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, failed_image_fingerprints: { back: [], front: [], selfie: [] }, @@ -300,7 +300,7 @@ let(:form_response) do response = DocAuth::Response.new( success: true, - extra: { remaining_attempts: 3 }, + extra: { remaining_submit_attempts: 3 }, pii_from_doc: Idp::Constants::MOCK_IDV_APPLICANT, ) allow(response).to receive(:attention_with_barcode?).and_return(true) @@ -313,7 +313,7 @@ result_failed: false, errors: [], hints: true, - remaining_attempts: 3, + remaining_submit_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, failed_image_fingerprints: { back: [], front: [], selfie: [] }, diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index 76247648bc7..1d4a949e8c0 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -245,8 +245,8 @@ state: nil, state_id_type: nil, async: nil, - attempts: nil, - remaining_attempts: nil, + submit_attempts: nil, + remaining_submit_attempts: nil, client_image_metrics: nil, flow_path: nil, 'DocumentName' => 'some_name', diff --git a/spec/views/idv/phone_errors/warning.html.erb_spec.rb b/spec/views/idv/phone_errors/warning.html.erb_spec.rb index 2f16b78cdb6..a94c30ef57b 100644 --- a/spec/views/idv/phone_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/warning.html.erb_spec.rb @@ -4,7 +4,7 @@ include Devise::Test::ControllerHelpers let(:sp_name) { 'Example SP' } - let(:remaining_attempts) { 5 } + let(:remaining_submit_attempts) { 5 } let(:gpo_letter_available) { false } let(:phone) { '+13602345678' } let(:country_code) { 'US' } @@ -14,7 +14,7 @@ decorated_sp_session = instance_double(ServiceProviderSession, sp_name: sp_name) allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) assign(:gpo_letter_available, gpo_letter_available) - assign(:remaining_attempts, remaining_attempts) + assign(:remaining_submit_attempts, remaining_submit_attempts) assign(:country_code, country_code) assign(:phone, phone) @@ -52,7 +52,7 @@ strip_tags( t( 'idv.failure.phone.warning.attempts_html', - count: remaining_attempts, + count: remaining_submit_attempts, ), ), ) diff --git a/spec/views/idv/session_errors/warning.html.erb_spec.rb b/spec/views/idv/session_errors/warning.html.erb_spec.rb index 2f6d15f38bc..82d9a28c249 100644 --- a/spec/views/idv/session_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/session_errors/warning.html.erb_spec.rb @@ -3,7 +3,7 @@ RSpec.describe 'idv/session_errors/warning.html.erb' do let(:sp_name) { nil } let(:try_again_path) { '/example/path' } - let(:remaining_attempts) { 5 } + let(:remaining_submit_attempts) { 5 } let(:user_session) { {} } before do @@ -11,7 +11,7 @@ allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) allow(view).to receive(:user_session).and_return(user_session) - assign(:remaining_attempts, remaining_attempts) + assign(:remaining_submit_attempts, remaining_submit_attempts) assign(:try_again_path, try_again_path) @step_indicator_steps = Idv::StepIndicatorConcern::STEP_INDICATOR_STEPS @@ -26,7 +26,7 @@ it 'shows remaining attempts' do expect(rendered).to have_text( strip_tags( - t('idv.warning.attempts_html', count: remaining_attempts), + t('idv.warning.attempts_html', count: remaining_submit_attempts), ), ) end @@ -45,7 +45,7 @@ expect(rendered).to have_link(t('idv.failure.button.warning'), href: try_again_path) expect(rendered).to have_text( strip_tags( - t('idv.warning.attempts_html', count: remaining_attempts), + t('idv.warning.attempts_html', count: remaining_submit_attempts), ), ) expect(rendered).to have_link( From 759241da4c295b0c327a0f76201a5b27e4c3fda1 Mon Sep 17 00:00:00 2001 From: Shannon A <20867088+svalexander@users.noreply.github.com> Date: Mon, 5 Feb 2024 10:09:36 -0500 Subject: [PATCH 15/25] LG-12091 Send please call email (#9977) * add email ui and preview * send email in usps job * bold number and code * add strings and remove activate profile * add transations * add compressed icon * delete larger file * rename image and use app_name * fix email not sending * update analytics event * add specs * update analytics event * changelog: User-Facing Improvements, Please call email, send email when user in review passes usps check * lint fix for please call view * updates to analytics event and spec * change method name --- app/assets/images/email/phone_icon.png | Bin 0 -> 8208 bytes app/jobs/get_usps_proofing_results_job.rb | 39 ++++++++++++++++- app/mailers/user_mailer.rb | 14 ++++++ app/services/analytics_events.rb | 10 +++++ .../in_person_please_call.html.erb | 21 +++++++++ config/locales/image_description/en.yml | 1 + config/locales/image_description/es.yml | 1 + config/locales/image_description/fr.yml | 1 + config/locales/user_mailer/en.yml | 8 ++++ config/locales/user_mailer/es.yml | 8 ++++ config/locales/user_mailer/fr.yml | 8 ++++ .../get_usps_proofing_results_job_spec.rb | 40 ++++++++++++++++++ spec/mailers/previews/user_mailer_preview.rb | 6 +++ spec/mailers/user_mailer_spec.rb | 11 +++++ 14 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 app/assets/images/email/phone_icon.png create mode 100644 app/views/user_mailer/in_person_please_call.html.erb diff --git a/app/assets/images/email/phone_icon.png b/app/assets/images/email/phone_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1e44354ba7b21d83a00f3ae0204682f1474a4176 GIT binary patch literal 8208 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 +

<%= t('user_mailer.in_person_please_call.header') %>

+

+ <%= t( + 'user_mailer.in_person_please_call.body.intro_html', + date: I18n.l(14.days.from_now, format: I18n.t('time.formats.event_date')), + ) %> +

+

+ <%= t( + 'user_mailer.in_person_please_call.body.contact_message_html', + contact_number: IdentityConfig.store.idv_contact_phone_number, + support_code: IdentityConfig.store.lexisnexis_threatmetrix_support_code, + ) %> +

+ diff --git a/config/locales/image_description/en.yml b/config/locales/image_description/en.yml index 1218a232aee..ee86dcf7d69 100644 --- a/config/locales/image_description/en.yml +++ b/config/locales/image_description/en.yml @@ -10,6 +10,7 @@ en: laptop: Laptop computer laptop_and_phone: Laptop and phone personal_key: Personal key + phone_icon: Image of a phone post_office: Post office totp_qrcode: QR code for authenticator app us_flag: US flag diff --git a/config/locales/image_description/es.yml b/config/locales/image_description/es.yml index 93a16be6472..bc6fa84ec34 100644 --- a/config/locales/image_description/es.yml +++ b/config/locales/image_description/es.yml @@ -10,6 +10,7 @@ es: laptop: Computadora portátil laptop_and_phone: computadora portátil y celular personal_key: Clave personal + phone_icon: Imagen de un teléfono post_office: Oficina de Correos totp_qrcode: Código QR para la aplicación de autenticación us_flag: Bandera de estados unidos diff --git a/config/locales/image_description/fr.yml b/config/locales/image_description/fr.yml index 80a0cc972f4..cfbb10ebe9c 100644 --- a/config/locales/image_description/fr.yml +++ b/config/locales/image_description/fr.yml @@ -10,6 +10,7 @@ fr: laptop: Ordinateur portable laptop_and_phone: ordinateur et téléphone portable personal_key: Clé personnelle + phone_icon: Image d’un téléphone post_office: Bureau de Poste totp_qrcode: Code QR pour l’application d’authentification us_flag: Drapeau américain diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index cf40ca71f44..430d6552c71 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -159,6 +159,14 @@ en: identity verification at a Post Office between May 20 and May 29, your results may not be emailed to you until Tuesday, May 30. subject: '%{app_name} In-Person Verification Results Delayed' + in_person_please_call: + body: + contact_message_html: Call %{contact_number} and provide them + with the error code %{support_code}. + intro_html: Call our contact center by %{date} to continue + verifying your identity. + header: Please give us a call + subject: Call %{app_name} to continue with your identity verification in_person_ready_to_verify: subject: You’re ready to verify your identity with %{app_name} in person in_person_ready_to_verify_reminder: diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 81426f955b4..e71f33c9287 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -173,6 +173,14 @@ es: martes 30 de mayo. subject: Los resultados de la verificación presencial de %{app_name} se han retrasado + in_person_please_call: + body: + contact_message_html: Llame al %{contact_number} y facilíteles + el código de error %{support_code}. + intro_html: Llame a nuestro centro de atención antes del + %{date} para seguir verificando su identidad. + header: Llámenos + subject: Llame a %{app_name} para proseguir con la verificación de su identidad in_person_ready_to_verify: subject: Está listo para verificar su identidad con %{app_name} en persona in_person_ready_to_verify_reminder: diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 8b7b7c95872..cf220fa3213 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -177,6 +177,14 @@ fr: de poste entre le 20 et le 29 mai, vos résultats ne vous seront peut-être pas envoyés par courriel avant le mardi 30 mai. subject: Les résultats de la vérification en personne de %{app_name} sont reportés + in_person_please_call: + body: + contact_message_html: Appelez le %{contact_number} et indiquez + le code d’erreur %{support_code}. + intro_html: Appelez notre centre de contact avant le %{date} + pour continuer à vérifier votre identité. + header: S’il vous plaît, appelez-nous + subject: Appelez %{app_name} pour poursuivre la vérification de votre identité in_person_ready_to_verify: subject: Vous êtes prêt à vérifier votre identité avec %{app_name} en personne in_person_ready_to_verify_reminder: diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index 59250578295..15012943d94 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -1234,6 +1234,46 @@ expect(profile.fraud_review_pending_at).not_to be_nil expect(profile).not_to be_active end + + context 'when the enrollment has passed' do + before(:each) do + pending_enrollment.profile.update!(fraud_pending_reason: 'threatmetrix_review') + stub_request_passed_proofing_results + end + + it 'sends the please call email' do + user = pending_enrollment.user + + freeze_time do + expect do + job.perform(Time.zone.now) + end.to have_enqueued_mail(UserMailer, :in_person_please_call).with( + params: { user: user, email_address: user.email_addresses.first }, + args: [{ enrollment: pending_enrollment }], + ) + end + end + + it 'logs the expected analytics events' do + freeze_time do + job.perform(Time.zone.now) + end + expect(job_analytics).to have_logged_event( + :idv_in_person_usps_proofing_results_job_please_call_email_initiated, + hash_including( + job_name: 'GetUspsProofingResultsJob', + ), + ) + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment status updated', + hash_including( + passed: true, + reason: 'Passed with fraud pending', + job_name: 'GetUspsProofingResultsJob', + ), + ) + end + end end end end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 367028a3d84..c39f6e1b2b7 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -168,6 +168,12 @@ def in_person_failed_fraud ) end + def in_person_please_call + UserMailer.with(user: user, email_address: email_address_record).in_person_please_call( + enrollment: in_person_enrollment, + ) + end + def in_person_outage_notification UserMailer.with(user: user, email_address: email_address_record).in_person_outage_notification( enrollment: in_person_enrollment, diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 730aa2eb257..69a9484cd80 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -754,6 +754,17 @@ def expect_email_body_to_have_help_and_contact_links it_behaves_like 'an email that respects user email locale preference' end + describe '#in_person_please_call' do + let(:mail) do + UserMailer.with(user: user, email_address: email_address).in_person_please_call( + enrollment: enrollment, + ) + end + + it_behaves_like 'a system email' + it_behaves_like 'an email that respects user email locale preference' + end + describe '#in_person_completion_survey' do let(:mail) do UserMailer.with(user: user, email_address: email_address).in_person_completion_survey From 5e181122f472cdbaed1e5268db86d2a52d6b8135 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 12:55:58 -0500 Subject: [PATCH 16/25] Bump libphonenumber-js from 1.10.54 to 1.10.55 (#10032) Bumps [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js) from 1.10.54 to 1.10.55. - [Changelog](https://gitlab.com/catamphetamine/libphonenumber-js/blob/master/CHANGELOG.md) - [Commits](https://gitlab.com/catamphetamine/libphonenumber-js/compare/v1.10.54...v1.10.55) --- updated-dependencies: - dependency-name: libphonenumber-js dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/javascript/packages/phone-input/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index b360a4f2f6a..56504a875cd 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -4,6 +4,6 @@ "version": "1.0.0", "dependencies": { "intl-tel-input": "^17.0.19", - "libphonenumber-js": "^1.10.54" + "libphonenumber-js": "^1.10.55" } } diff --git a/yarn.lock b/yarn.lock index 8812a371bce..a5547dca4fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4655,10 +4655,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.10.54: - version "1.10.54" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.54.tgz#8dfba112f49d1b9c2a160e55f9697f22e50f0841" - integrity sha512-P+38dUgJsmh0gzoRDoM4F5jLbyfztkU6PY6eSK6S5HwTi/LPvnwXqVCQZlAy1FxZ5c48q25QhxGQ0pq+WQcSlQ== +libphonenumber-js@^1.10.55: + version "1.10.55" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.55.tgz#ec864e369bf7babde02021d06b5f2433d7e9c78e" + integrity sha512-MrTg2JFLscgmTY6/oT9vopYETlgUls/FU6OaeeamGwk4LFxjIgOUML/ZSZICgR0LPYXaonVJo40lzMvaaTJlQA== lightningcss-darwin-arm64@1.23.0: version "1.23.0" From d20b3f694b8cd524d20b725d95fc12ecae4e4ef6 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Mon, 5 Feb 2024 10:03:32 -0800 Subject: [PATCH 17/25] LG-11699/LG-11700: Allow verified user to repeat idv if they need biometric (#10010) * Allow passing biometric_comparison_required to visit_idp_from_sp_with_ial2 * Let users repeat IdV if they need a selfie changelog: User-Facing Improvements, Identity verification, Allow previously verified users to repeat idv to get selfie verification. * Update IdvController spec * Actually run through all of remote unsupervised auth (Need to clean up the document capture step) * Add with_selfie: to various doc auth helper methods Allow specifying that you want it to complete doc auth with a selfie image * Use complete_proofing_steps for feature spec * Clean up confirm_idv_needed a little --- .../concerns/idv_session_concern.rb | 17 +++++--- app/controllers/idv_controller.rb | 13 ++++-- spec/controllers/idv_controller_spec.rb | 25 +++++++++++ spec/features/idv/step_up_spec.rb | 32 ++++++++++++++ spec/support/features/doc_auth_helper.rb | 43 ++++++++++++------- spec/support/features/idv_helper.rb | 4 +- 6 files changed, 108 insertions(+), 26 deletions(-) create mode 100644 spec/features/idv/step_up_spec.rb diff --git a/app/controllers/concerns/idv_session_concern.rb b/app/controllers/concerns/idv_session_concern.rb index 0b024537584..08f16cfb9a3 100644 --- a/app/controllers/concerns/idv_session_concern.rb +++ b/app/controllers/concerns/idv_session_concern.rb @@ -7,17 +7,20 @@ module IdvSessionConcern end def confirm_idv_needed - return if idv_session_user.active_profile.blank? || - decorated_sp_session.requested_more_recent_verification? || - idv_session_user.reproof_for_irs?(service_provider: current_sp) - - redirect_to idv_activated_url + redirect_to idv_activated_url unless idv_needed? end def hybrid_session? session[:doc_capture_user_id].present? end + def idv_needed? + user_needs_selfie? || + idv_session_user.active_profile.blank? || + decorated_sp_session.requested_more_recent_verification? || + idv_session_user.reproof_for_irs?(service_provider: current_sp) + end + def idv_session @idv_session ||= Idv::Session.new( user_session: user_session, @@ -66,4 +69,8 @@ def idv_session_user current_user end + + def user_needs_selfie? + decorated_sp_session.selfie_required? && !current_user.identity_verified_with_selfie? + end end diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index a86a445ebc3..767ad6019e8 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -10,10 +10,7 @@ class IdvController < ApplicationController before_action :confirm_not_rate_limited def index - if decorated_sp_session.requested_more_recent_verification? || - current_user.reproof_for_irs?(service_provider: current_sp) - verify_identity - elsif active_profile? + if already_verified? redirect_to idv_activated_url else verify_identity @@ -32,6 +29,14 @@ def activated private + def already_verified? + if decorated_sp_session.selfie_required? + return current_user.identity_verified_with_selfie? + end + + return current_user.active_profile.present? + end + def verify_identity analytics.idv_intro_visit redirect_to idv_welcome_url diff --git a/spec/controllers/idv_controller_spec.rb b/spec/controllers/idv_controller_spec.rb index 94000a0af5c..03cf1467c77 100644 --- a/spec/controllers/idv_controller_spec.rb +++ b/spec/controllers/idv_controller_spec.rb @@ -47,6 +47,31 @@ expect(response).to redirect_to(idv_not_verified_url) end + context 'user has active profile' do + let(:user) { create(:user, :proofed) } + before do + stub_sign_in(user) + end + it 'redirects to activated' do + get :index + expect(response).to redirect_to idv_activated_url + end + + context 'but user needs to redo idv with biometric' do + let(:current_sp) { create(:service_provider) } + before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + session[:sp] = + { issuer: current_sp.issuer, biometric_comparison_required: true } + end + + it 'redirects to welcome' do + get :index + expect(response).to redirect_to idv_welcome_url + end + end + end + context 'if number of verify_info attempts has been exceeded' do before do user = create(:user) diff --git a/spec/features/idv/step_up_spec.rb b/spec/features/idv/step_up_spec.rb new file mode 100644 index 00000000000..3085068aa52 --- /dev/null +++ b/spec/features/idv/step_up_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe 'IdV step up flow', allowed_extra_analytics: [:*] do + include IdvStepHelper + include InPersonHelper + + let(:sp) { :oidc } + let(:sp_name) { 'Test SP' } + + let(:user) do + create(:user, :proofed, password: RequestHelper::VALID_PASSWORD) + end + + before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + end + + scenario 'User with active profile can redo idv when selfie required', js: true do + visit_idp_from_sp_with_ial2(sp, biometric_comparison_required: true) + sign_in_live_with_2fa(user) + + expect(page).to have_current_path(idv_welcome_path) + + complete_proofing_steps(with_selfie: true) + end + + scenario 'User with active profile cannot redo idv when selfie not required' do + visit_idp_from_sp_with_ial2(sp) + sign_in_live_with_2fa(user) + expect(page).to have_current_path(sign_up_completed_path) + end +end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index 4c763b6ab86..5f0d26d96a6 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -116,8 +116,10 @@ def complete_up_to_how_to_verify_step_for_opt_in_ipp(remote: true) complete_how_to_verify_step(remote: remote) end - def complete_document_capture_step - attach_and_submit_images + def complete_document_capture_step(with_selfie: false) + attach_images + attach_selfie if with_selfie + submit_images end # yml_file example: 'spec/fixtures/puerto_rico_resident.yml' @@ -128,8 +130,11 @@ def complete_document_capture_step_with_yml(proofing_yml, expected_path: idv_ssn expect(page).to have_current_path(expected_path, wait: 10) end - def complete_doc_auth_steps_before_phone_otp_step(expect_accessible: false) - complete_doc_auth_steps_before_verify_step(expect_accessible: expect_accessible) + def complete_doc_auth_steps_before_phone_otp_step(expect_accessible: false, with_selfie: false) + complete_doc_auth_steps_before_verify_step( + expect_accessible: expect_accessible, + with_selfie: with_selfie, + ) click_idv_continue expect_page_to_have_no_accessibility_violations(page) if expect_accessible click_idv_continue @@ -139,9 +144,9 @@ def mobile_device Browser.new(mobile_user_agent) end - def complete_doc_auth_steps_before_ssn_step(expect_accessible: false) + def complete_doc_auth_steps_before_ssn_step(expect_accessible: false, with_selfie: false) complete_doc_auth_steps_before_document_capture_step(expect_accessible: expect_accessible) - complete_document_capture_step + complete_document_capture_step(with_selfie: with_selfie) expect_page_to_have_no_accessibility_violations(page) if expect_accessible end @@ -150,8 +155,11 @@ def complete_ssn_step click_idv_continue end - def complete_doc_auth_steps_before_verify_step(expect_accessible: false) - complete_doc_auth_steps_before_ssn_step(expect_accessible: expect_accessible) + def complete_doc_auth_steps_before_verify_step(expect_accessible: false, with_selfie: false) + complete_doc_auth_steps_before_ssn_step( + expect_accessible: expect_accessible, + with_selfie: with_selfie, + ) complete_ssn_step expect_page_to_have_no_accessibility_violations(page) if expect_accessible end @@ -160,8 +168,8 @@ def complete_verify_step click_idv_submit_default end - def complete_doc_auth_steps_before_address_step(expect_accessible: false) - complete_doc_auth_steps_before_verify_step + def complete_doc_auth_steps_before_address_step(expect_accessible: false, with_selfie: false) + complete_doc_auth_steps_before_verify_step(with_selfie: with_selfie) expect_page_to_have_no_accessibility_violations(page) if expect_accessible click_link t('idv.buttons.change_address_label') end @@ -187,14 +195,17 @@ def complete_letter_enqueued click_on t('idv.cancel.actions.exit', app_name: APP_NAME) end - def complete_all_doc_auth_steps(expect_accessible: false) - complete_doc_auth_steps_before_verify_step(expect_accessible: expect_accessible) + def complete_all_doc_auth_steps(expect_accessible: false, with_selfie: false) + complete_doc_auth_steps_before_verify_step( + expect_accessible: expect_accessible, + with_selfie: with_selfie, + ) complete_verify_step expect_page_to_have_no_accessibility_violations(page) if expect_accessible end - def complete_all_doc_auth_steps_before_password_step(expect_accessible: false) - complete_all_doc_auth_steps(expect_accessible: expect_accessible) + def complete_all_doc_auth_steps_before_password_step(expect_accessible: false, with_selfie: false) + complete_all_doc_auth_steps(expect_accessible: expect_accessible, with_selfie: with_selfie) fill_out_phone_form_ok if find('#idv_phone_form_phone').value.blank? click_continue verify_phone_otp @@ -202,8 +213,8 @@ def complete_all_doc_auth_steps_before_password_step(expect_accessible: false) expect_page_to_have_no_accessibility_violations(page) if expect_accessible end - def complete_proofing_steps - complete_all_doc_auth_steps_before_password_step + def complete_proofing_steps(with_selfie: false) + complete_all_doc_auth_steps_before_password_step(with_selfie: with_selfie) fill_in 'Password', with: RequestHelper::VALID_PASSWORD click_continue acknowledge_and_confirm_personal_key diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 951e805d358..2dd0ba99891 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -141,7 +141,8 @@ def visit_idp_from_oidc_sp_with_ial2( client_id: sp_oidc_issuer, state: SecureRandom.hex, nonce: SecureRandom.hex, - verified_within: nil + verified_within: nil, + biometric_comparison_required: nil ) visit openid_connect_authorize_path( client_id: client_id, @@ -153,6 +154,7 @@ def visit_idp_from_oidc_sp_with_ial2( prompt: 'select_account', nonce: nonce, verified_within: verified_within, + biometric_comparison_required: biometric_comparison_required, ) end From b75b9ca2fda5f8e484d1b1dd47646bf8ea3a3bf9 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Mon, 5 Feb 2024 11:12:05 -0700 Subject: [PATCH 18/25] Read `vtr` property while reading and writing `ServiceProviderRequest` (#9993) In #9991 the `vtr` property is added to the `ServiceProviderRequest`. Since the `vtr` property is introduced there it is unsafe to create a `ServiceProviderRequest` record with `vtr` during a deploy since some instances may have code that is unaware of the `vtr` property and will result in an `ArgumentError` when creating a `ServiceProviderRequest` Once the changes in #9991 are deployed it should be safe to create records with the `vtr` property. This commit does that in the `ServiceProviderRequestProxy`. [skip changelog] --- app/models/service_provider_request.rb | 8 +++++--- app/services/service_provider_request_proxy.rb | 13 +++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/models/service_provider_request.rb b/app/models/service_provider_request.rb index d3e132c9068..61e5365b83f 100644 --- a/app/models/service_provider_request.rb +++ b/app/models/service_provider_request.rb @@ -3,7 +3,7 @@ class ServiceProviderRequest # since these objects are serialized to/from Redis and may be present # upon deployment attr_accessor :uuid, :issuer, :url, :ial, :aal, :requested_attributes, - :biometric_comparison_required + :biometric_comparison_required, :acr_values, :vtr def initialize( uuid: nil, @@ -13,8 +13,8 @@ def initialize( aal: nil, requested_attributes: [], biometric_comparison_required: false, - acr_values: nil, # rubocop:disable Lint/UnusedMethodArgument - vtr: nil # rubocop:disable Lint/UnusedMethodArgument + acr_values: nil, + vtr: nil ) @uuid = uuid @issuer = issuer @@ -23,6 +23,8 @@ def initialize( @aal = aal @requested_attributes = requested_attributes&.map(&:to_s) @biometric_comparison_required = biometric_comparison_required + @acr_values = acr_values + @vtr = vtr end def ==(other) diff --git a/app/services/service_provider_request_proxy.rb b/app/services/service_provider_request_proxy.rb index d39e615c085..f99c21b5947 100644 --- a/app/services/service_provider_request_proxy.rb +++ b/app/services/service_provider_request_proxy.rb @@ -34,7 +34,7 @@ def self.find_or_create_by(uuid:) spr = ServiceProviderRequest.new( uuid: uuid, issuer: nil, url: nil, ial: nil, aal: nil, requested_attributes: nil, - biometric_comparison_required: false + biometric_comparison_required: false, acr_values: nil, vtr: nil ) yield(spr) create( @@ -45,13 +45,22 @@ def self.find_or_create_by(uuid:) aal: spr.aal, requested_attributes: spr.requested_attributes, biometric_comparison_required: spr.biometric_comparison_required, + acr_values: spr.acr_values, + vtr: spr.vtr, ) end def self.create(hash) uuid = hash[:uuid] obj = hash.slice( - :issuer, :url, :ial, :aal, :requested_attributes, :biometric_comparison_required + :issuer, + :url, + :ial, + :aal, + :requested_attributes, + :biometric_comparison_required, + :acr_values, + :vtr, ) write(obj, uuid) hash_to_spr(obj, uuid) From 4957a129a1038ff67128c39334e7fa506857a665 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Mon, 5 Feb 2024 13:19:00 -0500 Subject: [PATCH 19/25] LG-12275 | Fixes opt-in IPP page displaying incorrectly (#10016) changelog: Bug Fixes, In Person Proofing, Opt-in is now only offered to participating SPs Co-authored-by: gina-yamada --- .../idv/how_to_verify_controller.rb | 4 +- .../idv/how_to_verify_controller_spec.rb | 33 +++- spec/factories/service_providers.rb | 4 + spec/features/accessibility/idv_pages_spec.rb | 5 + .../idv/doc_auth/how_to_verify_spec.rb | 178 ++++++++++++++---- .../idv/steps/in_person_opt_in_ipp_spec.rb | 33 +++- spec/support/features/idv_helper.rb | 2 +- spec/support/features/session_helper.rb | 7 +- 8 files changed, 211 insertions(+), 55 deletions(-) diff --git a/app/controllers/idv/how_to_verify_controller.rb b/app/controllers/idv/how_to_verify_controller.rb index 413a2e6cfb3..f9c14d7a9f0 100644 --- a/app/controllers/idv/how_to_verify_controller.rb +++ b/app/controllers/idv/how_to_verify_controller.rb @@ -61,7 +61,9 @@ def self.step_info controller: self, next_steps: [:hybrid_handoff, :document_capture], preconditions: ->(idv_session:, user:) do - self.enabled? && idv_session.idv_consent_given + self.enabled? && + idv_session.idv_consent_given && + idv_session.service_provider&.in_person_proofing_enabled end, undo_step: ->(idv_session:, user:) { idv_session.skip_doc_auth = nil }, ) diff --git a/spec/controllers/idv/how_to_verify_controller_spec.rb b/spec/controllers/idv/how_to_verify_controller_spec.rb index 6d3c2c72923..e07e917d64f 100644 --- a/spec/controllers/idv/how_to_verify_controller_spec.rb +++ b/spec/controllers/idv/how_to_verify_controller_spec.rb @@ -6,6 +6,9 @@ let(:ab_test_args) do { sample_bucket1: :sample_value1, sample_bucket2: :sample_value2 } end + let(:service_provider) do + create(:service_provider, :active, :in_person_proofing_enabled) + end before do allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } @@ -14,6 +17,7 @@ stub_analytics allow(@analytics).to receive(:track_event) allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) + allow(subject.idv_session).to receive(:service_provider).and_return(service_provider) subject.idv_session.welcome_visited = true subject.idv_session.idv_consent_given = true end @@ -73,12 +77,29 @@ end context 'when both ipp and opt-in ipp are enabled' do - it 'renders the show template for how to verify' do - get :show + context 'when the ServiceProvider has IPP enabled' do + it 'renders the show template for how to verify' do + get :show + + expect(Idv::HowToVerifyController.enabled?).to be true + expect(subject.idv_session.service_provider.in_person_proofing_enabled).to be true + expect(subject.idv_session.skip_doc_auth).to be_nil + expect(response).to render_template :show + end + end - expect(Idv::HowToVerifyController.enabled?).to be true - expect(subject.idv_session.skip_doc_auth).to be_nil - expect(response).to render_template :show + context 'when the ServiceProvider has IPP disabled' do + let(:service_provider) do + create(:service_provider, :active) + end + + it 'redirects to hybrid_handoff' do + get :show + + expect(Idv::HowToVerifyController.enabled?).to be true + expect(subject.idv_session.service_provider.in_person_proofing_enabled).to be false + expect(response).to redirect_to(idv_hybrid_handoff_url) + end end end end @@ -94,6 +115,7 @@ irs_reproofing: false, }.merge(ab_test_args) end + it 'renders the show template' do get :show @@ -127,6 +149,7 @@ } end let(:analytics_name) { :idv_doc_auth_how_to_verify_submitted } + context 'no selection made' do let(:analytics_args) do { diff --git a/spec/factories/service_providers.rb b/spec/factories/service_providers.rb index 085f0adedeb..e0843f73190 100644 --- a/spec/factories/service_providers.rb +++ b/spec/factories/service_providers.rb @@ -42,6 +42,10 @@ active { true } end + trait :in_person_proofing_enabled do + in_person_proofing_enabled { true } + end + trait :irs do friendly_name { 'An IRS Service Provider' } ial { 2 } diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 295b25f7db3..0985a390be4 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -5,6 +5,10 @@ describe 'IDV pages' do include IdvStepHelper + let(:service_provider) do + create(:service_provider, :active, :in_person_proofing_enabled) + end + scenario 'home page' do sign_in_and_2fa_user @@ -16,6 +20,7 @@ scenario 'how to verify page' 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(Idv::Session).to receive(:service_provider).and_return(service_provider) sign_in_and_2fa_user visit idv_welcome_url diff --git a/spec/features/idv/doc_auth/how_to_verify_spec.rb b/spec/features/idv/doc_auth/how_to_verify_spec.rb index 10d1c157503..2460e1c50d6 100644 --- a/spec/features/idv/doc_auth/how_to_verify_spec.rb +++ b/spec/features/idv/doc_auth/how_to_verify_spec.rb @@ -4,83 +4,179 @@ include IdvHelper include DocAuthHelper + let(:user) { user_with_2fa } + let(:ipp_service_provider) { create(:service_provider, :active, :in_person_proofing_enabled) } + context 'when ipp is enabled and opt-in ipp is disabled' do - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + context 'and when sp has opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) - sign_in_and_2fa_user - complete_doc_auth_steps_before_agreement_step - complete_agreement_step + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end - it 'skips when disabled and redirects to hybrid handoff' do - expect(page).to have_current_path(idv_hybrid_handoff_url) + context 'and when sp has not opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(false) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) + + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end end context 'when ipp is disabled and opt-in ipp is enabled' do - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + context 'and when sp has opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) - sign_in_and_2fa_user - complete_doc_auth_steps_before_agreement_step - complete_agreement_step + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end - it 'skips when disabled and redirects to hybird handoff' do - expect(page).to have_current_path(idv_hybrid_handoff_url) + context 'and when sp has not opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(false) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) + + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end end context 'when both ipp and opt-in ipp are disabled' do - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + context 'and when sp has opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) - sign_in_and_2fa_user - complete_doc_auth_steps_before_agreement_step - complete_agreement_step + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end - it 'skips when disabled and redirects to hybird handoff' do - expect(page).to have_current_path(idv_hybrid_handoff_url) + context 'and when sp has not opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(false) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) + + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end end context 'when both ipp and opt-in ipp are enabled' do - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + context 'and when sp has opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_agreement_step - complete_agreement_step - end + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'displays expected content and requires a choice' do + expect(page).to have_current_path(idv_how_to_verify_path) + + # Try to continue without an option + click_continue + + expect(page).to have_current_path(idv_how_to_verify_path) + expect(page).to have_content(t('errors.doc_auth.how_to_verify_form')) - it 'displays expected content and requires a choice' do - expect(page).to have_current_path(idv_how_to_verify_path) + complete_how_to_verify_step(remote: true) + expect(page).to have_current_path(idv_hybrid_handoff_url) + + # go back and also test remote: false case + page.go_back + complete_how_to_verify_step(remote: false) + expect(page).to have_current_path(idv_document_capture_path) + end + end - # Try to continue without an option - click_continue + context 'and when sp has not opted into ipp' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(false) - expect(page).to have_current_path(idv_how_to_verify_path) - expect(page).to have_content(t('errors.doc_auth.how_to_verify_form')) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end - complete_how_to_verify_step(remote: true) - expect(page).to have_current_path(idv_hybrid_handoff_url) + it 'skips when disabled and redirects to hybrid handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end end - describe 'navigating to How To Verify from Agreement page in 50/50 state' do + describe 'navigating to How To Verify from Agreement page in 50/50 state + when the sp has opted into ipp' do + let(:user) { user_with_2fa } before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { initial_opt_in_enabled } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) - sign_in_and_2fa_user + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) complete_doc_auth_steps_before_agreement_step complete_agreement_step end diff --git a/spec/features/idv/steps/in_person_opt_in_ipp_spec.rb b/spec/features/idv/steps/in_person_opt_in_ipp_spec.rb index b8f3f6a1dff..8fdba30c11a 100644 --- a/spec/features/idv/steps/in_person_opt_in_ipp_spec.rb +++ b/spec/features/idv/steps/in_person_opt_in_ipp_spec.rb @@ -7,6 +7,8 @@ include InPersonHelper org = 'test_org' + let(:ipp_service_provider) { create(:service_provider, :active, :in_person_proofing_enabled) } + 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) @@ -19,11 +21,13 @@ before do allow(IdentityConfig.store).to receive(:proofing_device_profiling).and_return(:enabled) allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_org_id).and_return(org) + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) end it 'allows the user to continue down the happy path selecting to opt in', allow_browser_log: true do - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) # complete welcome step, agreement step, how to verify step (and opts into Opt-in Ipp) begin_in_person_proofing_with_opt_in_ipp_enabled_and_opting_in @@ -121,7 +125,7 @@ it 'works for a happy path when the user opts into opt-in ipp', allow_browser_log: true do user = user_with_2fa - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) # complete welcome step, agreement step, how to verify step (and opts into Opt-in Ipp) begin_in_person_proofing_with_opt_in_ipp_enabled_and_opting_in @@ -268,10 +272,29 @@ expect(page).to have_current_path(account_path) end + context 'when the service provider does not participate in IPP', + allow_browser_log: true do + before do + allow(IdentityConfig.store).to receive(:proofing_device_profiling).and_return(:enabled) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_org_id).and_return(org) + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(false) + end + + it 'skips how to verify and goes to hybrid_handoff' do + user = user_with_2fa + sign_in_and_2fa_user(user) + visit_idp_from_sp_with_ial2(:oidc) + complete_welcome_step + complete_agreement_step + expect(page).to have_current_path(idv_hybrid_handoff_url) + end + end + it 'works for a happy path when the user opts out of opt-in ipp', allow_browser_log: true do user = user_with_2fa - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) # complete welcome step, agreement step, how to verify step (and opts out of Opt-in Ipp) begin_in_person_proofing_with_opt_in_ipp_enabled_and_opting_out @@ -416,6 +439,8 @@ before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(true) end it 'skips how to verify and continues along the normal path' do @@ -435,7 +460,7 @@ it 'works properly along the normal path when in_person_proofing_enabled is true' do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(user, issuer: ipp_service_provider.issuer) visit_idp_from_sp_with_ial2(:oidc) complete_welcome_step complete_agreement_step diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 2dd0ba99891..4166e930666 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -93,8 +93,8 @@ def visit_idp_from_sp_with_ial2(sp, **extra) visit_idp_from_saml_sp_with_ial2 elsif sp == :oidc @state = SecureRandom.hex - @client_id = sp_oidc_issuer @nonce = SecureRandom.hex + @client_id = sp_oidc_issuer visit_idp_from_oidc_sp_with_ial2(state: @state, client_id: @client_id, nonce: @nonce, **extra) end end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index ed1e55d8d72..b5754b5dc38 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -204,7 +204,7 @@ def sign_in_before_2fa(user = create(:user)) user end - def sign_in_with_warden(user, auth_method: nil) + def sign_in_with_warden(user, auth_method: nil, issuer: nil) login_as(user, scope: :user, run_callbacks: false) Warden.on_next_request do |proxy| @@ -213,12 +213,13 @@ def sign_in_with_warden(user, auth_method: nil) if auth_method session['warden.user.user.session']['auth_events'] = [{ auth_method:, at: Time.zone.now }] end + session['sp'] = { issuer: } if issuer end visit account_path end - def sign_in_and_2fa_user(user = user_with_2fa) - sign_in_with_warden(user, auth_method: 'phone') + def sign_in_and_2fa_user(user = user_with_2fa, issuer: nil) + sign_in_with_warden(user, auth_method: 'phone', issuer:) user end From f5d55499492dc521111a2442ce9b0e5bf1cfeaf1 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Mon, 5 Feb 2024 11:51:11 -0800 Subject: [PATCH 20/25] Fix attaching of selfie image in specs (#10034) [skip changelog] --- spec/support/features/doc_auth_helper.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index 5f0d26d96a6..da996849d65 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -117,8 +117,12 @@ def complete_up_to_how_to_verify_step_for_opt_in_ipp(remote: true) end def complete_document_capture_step(with_selfie: false) - attach_images - attach_selfie if with_selfie + if with_selfie + attach_liveness_images + else + attach_images + end + submit_images end From 6abbb42ba14f0af05dde56f819d3ee17e3714a00 Mon Sep 17 00:00:00 2001 From: Brittany Greaner Date: Mon, 5 Feb 2024 16:57:12 -0800 Subject: [PATCH 21/25] Update nokogiri to 1.16.2 (#10038) * Update Nokogiri Name: nokogiri Version: 1.16.0 GHSA: GHSA-xc9x-jj77-9p9j Criticality: Unknown URL: https://github.com/sparklemotion/nokogiri/security/advisories/GHSA-xc9x-jj77-9p9j Title: Improper Handling of Unexpected Data Type in Nokogiri Solution: upgrade to '>= 1.16.2' * changelog: Internal, Dependencies, Update Nokogiri --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 632fe2405b1..0cebd12839c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -436,7 +436,7 @@ GEM net-ssh (6.1.0) newrelic_rpm (9.7.0) nio4r (2.7.0) - nokogiri (1.16.0) + nokogiri (1.16.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) openssl (3.0.2) From 0145c7eded9ff0be1ed78442b6ce3f11b7752154 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 6 Feb 2024 10:23:27 -0500 Subject: [PATCH 22/25] LG-12190 Store vtr and acr_values in sp_session (#10004) LG-12190 Store vtr and acr_values in sp_session This commit adds code to the `OpenidConnectAuthorizeForm` to consume a `vtr` param. This param validated and then added to the `ServiceProviderRequest` and eventually added to the `sp_session` by the `StoreSpMetadataInSession` service. This `vtr` param will eventually be used along with the new `AuthnContextResolver` tooling to determine what features need to be in place for an authentication and identity proofing transaction. [skip changelog] Co-authored-by: Alex Bradley Co-authored-by: John Maxwell --- app/forms/openid_connect_authorize_form.rb | 55 ++- app/models/federated_protocols/oidc.rb | 8 + app/models/federated_protocols/saml.rb | 8 + app/services/analytics_events.rb | 3 + .../service_provider_request_handler.rb | 2 + app/services/store_sp_metadata_in_session.rb | 2 + config/application.yml.default | 2 + config/locales/openid_connect/en.yml | 1 + config/locales/openid_connect/es.yml | 1 + config/locales/openid_connect/fr.yml | 1 + lib/identity_config.rb | 1 + .../authorization_controller_spec.rb | 26 +- spec/controllers/saml_idp_controller_spec.rb | 16 + .../openid_connect_authorize_form_spec.rb | 411 +++++++++++------- .../store_sp_metadata_in_session_spec.rb | 66 +++ 15 files changed, 435 insertions(+), 168 deletions(-) diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index 9000ec7c518..ff076f99513 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -20,6 +20,7 @@ class OpenidConnectAuthorizeForm ATTRS = [ :unauthorized_scope, :acr_values, + :vtr, :scope, :verified_within, :biometric_comparison_required, @@ -37,7 +38,7 @@ class OpenidConnectAuthorizeForm RANDOM_VALUE_MINIMUM_LENGTH = 22 MINIMUM_REPROOF_VERIFIED_WITHIN_DAYS = 30 - validates :acr_values, presence: true + validates :acr_values, presence: true, if: ->(form) { form.vtr.empty? } validates :client_id, presence: true validates :redirect_uri, presence: true validates :scope, presence: true @@ -49,6 +50,7 @@ class OpenidConnectAuthorizeForm validates :code_challenge_method, inclusion: { in: %w[S256] }, if: :code_challenge validate :validate_acr_values + validate :validate_vtr validate :validate_client_id validate :validate_scope validate :validate_unauthorized_scope @@ -59,6 +61,7 @@ class OpenidConnectAuthorizeForm def initialize(params) @acr_values = parse_to_values(params[:acr_values], Saml::Idp::Constants::VALID_AUTHN_CONTEXTS) + @vtr = parse_vtr(params[:vtr]) SIMPLE_ATTRS.each { |key| instance_variable_set(:"@#{key}", params[key]) } @prompt ||= 'select_account' @scope = parse_to_values(params[:scope], scopes) @@ -119,7 +122,13 @@ def ial_context end def ial - Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_IAL[ial_values.sort.max] + if parsed_vector_of_trust&.identity_proofing? + 2 + elsif parsed_vector_of_trust.present? + 1 + else + Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_IAL[ial_values.sort.max] + end end def aal_values @@ -127,7 +136,13 @@ def aal_values end def aal - Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_AAL[requested_aal_value] + if parsed_vector_of_trust&.aal2? + 2 + elsif parsed_vector_of_trust.present? + 1 + else + Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_AAL[requested_aal_value] + end end def requested_aal_value @@ -163,7 +178,18 @@ def parse_to_values(param_value, possible_values) param_value.split(' ').compact & possible_values end + def parse_vtr(param_value) + return if !IdentityConfig.store.use_vot_in_sp_requests + return [] if param_value.blank? + + JSON.parse(param_value) + rescue JSON::ParserError + nil + end + def validate_acr_values + return if vtr.present? + if acr_values.empty? errors.add( :acr_values, t('openid_connect.authorization.errors.no_valid_acr_values'), @@ -177,6 +203,15 @@ def validate_acr_values end end + def validate_vtr + return if vtr.blank? + return if parsed_vector_of_trust.present? + errors.add( + :vtr, t('openid_connect.authorization.errors.no_valid_vtr'), + type: :no_valid_vtr + ) + end + # This checks that the SP matches something in the database # OpenidConnect::AuthorizationController#check_sp_active checks that it's currently active def validate_client_id @@ -246,6 +281,7 @@ def extra_analytics_attributes redirect_uri: result_uri, scope: scope&.sort&.join(' '), acr_values: acr_values&.sort&.join(' '), + vtr: vtr, unauthorized_scope: @unauthorized_scope, code_digest: code ? Digest::SHA256.hexdigest(code) : nil, code_challenge_present: code_challenge.present?, @@ -275,6 +311,19 @@ def scopes OpenidConnectAttributeScoper::VALID_IAL1_SCOPES end + def parsed_vector_of_trust + return @parsed_vector_of_trust if defined?(@parsed_vector_of_trust) + return @parsed_vector_of_trust = nil if vtr.blank? + + @parsed_vector_of_trust = begin + if vtr.is_a?(Array) && !vtr.empty? + Vot::Parser.new(vector_of_trust: vtr.first).parse + end + rescue Vot::Parser::ParseException + nil + end + end + def validate_privileges if (ial2_requested? && !ial_context.ial2_service_provider?) || (ial_context.ialmax_requested? && diff --git a/app/models/federated_protocols/oidc.rb b/app/models/federated_protocols/oidc.rb index 33b92251cf3..be1605d9b6d 100644 --- a/app/models/federated_protocols/oidc.rb +++ b/app/models/federated_protocols/oidc.rb @@ -16,6 +16,14 @@ def aal request.aal_values.sort.max end + def acr_values + [aal, ial].compact.join(' ') + end + + def vtr + request.vtr + end + def requested_attributes OpenidConnectAttributeScoper.new(request.scope).requested_attributes end diff --git a/app/models/federated_protocols/saml.rb b/app/models/federated_protocols/saml.rb index ecc0dea6569..7659d466abd 100644 --- a/app/models/federated_protocols/saml.rb +++ b/app/models/federated_protocols/saml.rb @@ -16,6 +16,14 @@ def aal request.requested_aal_authn_context end + def acr_values + [aal, ial].compact.join(' ') + end + + def vtr + nil + end + def requested_attributes @requested_attributes ||= SamlRequestPresenter.new( request: request, service_provider: current_service_provider, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 7f392be8655..d7e5e29ef14 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -3636,12 +3636,14 @@ def openid_connect_bearer_token(success:, ial:, client_id:, errors:, **extra) # @param [String] client_id # @param [String] scope # @param [Array] acr_values + # @param [Array] vtr # @param [Boolean] unauthorized_scope # @param [Boolean] user_fully_authenticated def openid_connect_request_authorization( client_id:, scope:, acr_values:, + vtr:, unauthorized_scope:, user_fully_authenticated:, **extra @@ -3651,6 +3653,7 @@ def openid_connect_request_authorization( client_id: client_id, scope: scope, acr_values: acr_values, + vtr: vtr, unauthorized_scope: unauthorized_scope, user_fully_authenticated: user_fully_authenticated, **extra, diff --git a/app/services/service_provider_request_handler.rb b/app/services/service_provider_request_handler.rb index 089293b8f77..bd62d915a9c 100644 --- a/app/services/service_provider_request_handler.rb +++ b/app/services/service_provider_request_handler.rb @@ -63,6 +63,8 @@ def attributes issuer: protocol.issuer, ial: protocol.ial, aal: protocol.aal, + acr_values: protocol.acr_values, + vtr: protocol.vtr, requested_attributes: protocol.requested_attributes, biometric_comparison_required: protocol.biometric_comparison_required?, uuid: request_id, diff --git a/app/services/store_sp_metadata_in_session.rb b/app/services/store_sp_metadata_in_session.rb index 13c052b2646..c3c0577504e 100644 --- a/app/services/store_sp_metadata_in_session.rb +++ b/app/services/store_sp_metadata_in_session.rb @@ -37,6 +37,8 @@ def update_session request_id: sp_request.uuid, requested_attributes: sp_request.requested_attributes, biometric_comparison_required: sp_request.biometric_comparison_required, + acr_values: sp_request.acr_values, + vtr: sp_request.vtr, } end diff --git a/config/application.yml.default b/config/application.yml.default index e848e7bc7fe..e599da246eb 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -321,6 +321,7 @@ team_ursula_email: '' test_ssn_allowed_list: '' totp_code_interval: 30 unauthorized_scope_enabled: false +use_vot_in_sp_requests: true usps_upload_enabled: false usps_upload_sftp_timeout: 5 valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3", "http://idmanagement.gov/ns/assurance/ial/1", "http://idmanagement.gov/ns/assurance/ial/2", "http://idmanagement.gov/ns/assurance/ial/0", "http://idmanagement.gov/ns/assurance/ial/2?strict=true", "urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", "http://idmanagement.gov/ns/assurance/aal/2", "http://idmanagement.gov/ns/assurance/aal/3", "http://idmanagement.gov/ns/assurance/aal/3?hspd12=true","http://idmanagement.gov/ns/assurance/aal/2?phishing_resistant=true","http://idmanagement.gov/ns/assurance/aal/2?hspd12=true"]' @@ -494,6 +495,7 @@ production: state_tracking_enabled: false telephony_adapter: pinpoint use_kms: true + use_vot_in_sp_requests: false usps_auth_token_refresh_job_enabled: true usps_confirmation_max_days: 30 usps_upload_sftp_directory: '' diff --git a/config/locales/openid_connect/en.yml b/config/locales/openid_connect/en.yml index 3c68137adbd..bd5eb70fd1c 100644 --- a/config/locales/openid_connect/en.yml +++ b/config/locales/openid_connect/en.yml @@ -12,6 +12,7 @@ en: no_auth: The acr_values are not authorized no_valid_acr_values: No acceptable acr_values found no_valid_scope: No valid scope values found + no_valid_vtr: No acceptable vots found prompt_invalid: No valid prompt values found redirect_uri_invalid: redirect_uri is invalid redirect_uri_no_match: redirect_uri does not match registered redirect_uri diff --git a/config/locales/openid_connect/es.yml b/config/locales/openid_connect/es.yml index 51ea9ba75b2..7aac8a1747c 100644 --- a/config/locales/openid_connect/es.yml +++ b/config/locales/openid_connect/es.yml @@ -12,6 +12,7 @@ es: no_auth: Los acr_values no están autorizados no_valid_acr_values: ial_valores encontrados no aceptables no_valid_scope: No se han encontrado valores de magnitud válidos + no_valid_vtr: vots encontrados no aceptables prompt_invalid: Prompt no es válido redirect_uri_invalid: Redirect_uri no es válido redirect_uri_no_match: Redirect_uri no coincide con redirect_uri registrado diff --git a/config/locales/openid_connect/fr.yml b/config/locales/openid_connect/fr.yml index 2d8f7eefe24..2ee66ca55c1 100644 --- a/config/locales/openid_connect/fr.yml +++ b/config/locales/openid_connect/fr.yml @@ -12,6 +12,7 @@ fr: no_auth: Les acr_values ne sont pas autorisées no_valid_acr_values: Valeurs acr_values inacceptables trouvées no_valid_scope: Aucune étendue de données valide trouvée + no_valid_vtr: vots encontrados no aceptables prompt_invalid: prompt est non valide redirect_uri_invalid: redirect_uri est non valide redirect_uri_no_match: redirect_uri ne correspond pas au redirect_uri enregistré diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 8d07bde22cf..894ae4ebd83 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -461,6 +461,7 @@ def self.build_store(config_map) config.add(:unauthorized_scope_enabled, type: :boolean) config.add(:use_dashboard_service_providers, type: :boolean) config.add(:use_kms, type: :boolean) + config.add(:use_vot_in_sp_requests, type: :boolean) config.add(:usps_auth_token_refresh_job_enabled, type: :boolean) config.add(:usps_confirmation_max_days, type: :integer) config.add(:usps_ipp_client_id, type: :string) diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index da601abc81e..fb09c85af67 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -110,7 +110,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid') + scope: 'openid', + vtr: []) expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -257,7 +258,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile') + scope: 'openid profile', + vtr: []) expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -495,7 +497,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile') + scope: 'openid profile', + vtr: []) expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -578,7 +581,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile') + scope: 'openid profile', + vtr: []) expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -663,7 +667,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile') + scope: 'openid profile', + vtr: []) expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -865,7 +870,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid') + scope: 'openid', + vtr: []) expect(@analytics).to_not receive(:track_event).with('sp redirect initiated') action @@ -898,7 +904,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid') + scope: 'openid', + vtr: []) expect(@analytics).to_not receive(:track_event).with('SP redirect initiated') action @@ -1013,7 +1020,8 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid') + scope: 'openid', + vtr: []) action sp_request_id = ServiceProviderRequestProxy.last.uuid @@ -1028,6 +1036,7 @@ expect(session[:sp]).to eq( aal_level_requested: nil, + acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, piv_cac_requested: false, phishing_resistant_requested: false, ial: 1, @@ -1038,6 +1047,7 @@ request_url: request.original_url, requested_attributes: %w[], biometric_comparison_required: false, + vtr: [], ) end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 511195be937..d07d0858dfa 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1166,6 +1166,12 @@ def name_id_version(format_urn) end context 'POST to auth correctly stores SP in session' do + let(:acr_values) do + Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF + + ' ' + + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + end + before do @user = create(:user, :fully_registered) @saml_request = saml_request(saml_settings) @@ -1181,6 +1187,7 @@ def name_id_version(format_urn) issuer: saml_settings.issuer, aal_level_requested: aal_level, piv_cac_requested: false, + acr_values: acr_values, phishing_resistant_requested: false, ial: 1, ial2: false, @@ -1189,6 +1196,7 @@ def name_id_version(format_urn) request_id: sp_request_id, requested_attributes: ['email'], biometric_comparison_required: false, + vtr: nil, ) end @@ -1201,6 +1209,12 @@ def name_id_version(format_urn) end context 'service provider is valid' do + let(:acr_values) do + Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF + + ' ' + + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + end + before do @user = create(:user, :fully_registered) @saml_request = saml_get_auth(saml_settings) @@ -1212,6 +1226,7 @@ def name_id_version(format_urn) expect(session[:sp]).to eq( issuer: saml_settings.issuer, aal_level_requested: aal_level, + acr_values: acr_values, piv_cac_requested: false, phishing_resistant_requested: false, ial: 1, @@ -1221,6 +1236,7 @@ def name_id_version(format_urn) request_id: sp_request_id, requested_attributes: ['email'], biometric_comparison_required: false, + vtr: nil, ) end diff --git a/spec/forms/openid_connect_authorize_form_spec.rb b/spec/forms/openid_connect_authorize_form_spec.rb index fafe7188723..a3542a9b9ef 100644 --- a/spec/forms/openid_connect_authorize_form_spec.rb +++ b/spec/forms/openid_connect_authorize_form_spec.rb @@ -4,6 +4,7 @@ subject(:form) do OpenidConnectAuthorizeForm.new( acr_values: acr_values, + vtr: vtr, client_id: client_id, nonce: nonce, prompt: prompt, @@ -18,12 +19,8 @@ ) end - let(:acr_values) do - [ - Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - ].join(' ') - end - + let(:acr_values) { nil } + let(:vtr) { ['C1'].to_json } let(:client_id) { 'urn:gov:gsa:openidconnect:test' } let(:nonce) { SecureRandom.hex } let(:prompt) { 'select_account' } @@ -49,7 +46,8 @@ allow_prompt_login: true, redirect_uri: nil, unauthorized_scope: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + acr_values: '', + vtr: JSON.parse(vtr), scope: 'openid', code_digest: nil, code_challenge_present: false, @@ -73,7 +71,8 @@ redirect_uri: "#{redirect_uri}?error=invalid_request&error_description=" \ "Response+type+is+not+included+in+the+list&state=#{state}", unauthorized_scope: true, - acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + acr_values: '', + vtr: JSON.parse(vtr), scope: 'openid', code_digest: nil, code_challenge_present: false, @@ -93,6 +92,18 @@ expect(result.extra[:redirect_uri]).to be_nil end end + + context 'when use_vot_in_sp_requests flag is false' do + before do + allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(false) + end + + let(:vtr) { ['C1.P1'].to_json } + + it 'does not consume the VTR param' do + expect(form.vtr).to be_nil + end + end end describe '#valid?' do @@ -106,8 +117,18 @@ end end + context 'with an invalid vtr' do + let(:vtr) { ['A1.B2.C3'].to_json } + it 'has errors' do + expect(valid?).to eq(false) + expect(form.errors[:vtr]). + to include(t('openid_connect.authorization.errors.no_valid_vtr')) + end + end + context 'with no valid acr_values' do let(:acr_values) { 'abc def' } + let(:vtr) { nil } it 'has errors' do expect(valid?).to eq(false) expect(form.errors[:acr_values]). @@ -115,8 +136,19 @@ end end + context 'with no authorized vtr components' do + let(:vtr) { ['C1.P1'].to_json } + let(:client_id) { 'urn:gov:gsa:openidconnect:test:loa1' } + it 'has errors' do + expect(valid?).to eq(false) + expect(form.errors[:acr_values]). + to include(t('openid_connect.authorization.errors.no_auth')) + end + end + context 'with no authorized acr_values' do let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } let(:client_id) { 'urn:gov:gsa:openidconnect:test:loa1' } it 'has errors' do expect(valid?).to eq(false) @@ -127,6 +159,7 @@ context 'with ialmax requested' do let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } context 'with a service provider not in the allow list' do it 'has errors' do @@ -150,6 +183,7 @@ context 'with aal but not ial requested via acr_values' do let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { nil } it 'has errors' do expect(valid?).to eq(false) expect(form.errors[:acr_values]). @@ -248,7 +282,7 @@ context 'when scope includes profile:verified_at but the sp is only ial1' do let(:client_id) { 'urn:gov:gsa:openidconnect:test:loa1' } - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + let(:vtr) { ['C1'].to_json } let(:scope) { 'email profile:verified_at' } it 'has errors' do @@ -336,6 +370,7 @@ 'fake_value', ].join(' ') end + let(:vtr) { nil } it 'is parsed into an array of valid ACR values' do expect(form.acr_values).to eq( @@ -348,209 +383,252 @@ end describe '#ial' do - context 'when IAL1 passed' do - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + context 'with vtr param' do + let(:acr_values) { nil } + + context 'when proofing is requested' do + let(:vtr) { ['C1.P1'].to_json } - it 'returns 1' do - expect(form.ial).to eq(1) + it { expect(form.ial).to eq(2) } end - end - context 'when IAL2 passed' do - let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + context 'when proofing is not requested' do + let(:vtr) { ['C1'].to_json } - it 'returns 2' do - expect(form.ial).to eq(2) + it { expect(form.ial).to eq(1) } end end - context 'when IALMAX passed' do - let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } + context 'with acr_values param' do + let(:vtr) { nil } + + context 'when IAL1 passed' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + + it 'returns 1' do + expect(form.ial).to eq(1) + end + end - it 'returns 0' do - expect(form.ial).to eq(0) + context 'when IAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + + it 'returns 2' do + expect(form.ial).to eq(2) + end end - end - context 'when LOA1 passed' do - let(:acr_values) { Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF } + context 'when IALMAX passed' do + let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } - it 'returns 1' do - expect(form.ial).to eq(1) + it 'returns 0' do + expect(form.ial).to eq(0) + end end - end - context 'when LOA3 passed' do - let(:acr_values) { Saml::Idp::Constants::LOA3_AUTHN_CONTEXT_CLASSREF } + context 'when LOA1 passed' do + let(:acr_values) { Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.ial).to eq(2) + it 'returns 1' do + expect(form.ial).to eq(1) + end + end + + context 'when LOA3 passed' do + let(:acr_values) { Saml::Idp::Constants::LOA3_AUTHN_CONTEXT_CLASSREF } + + it 'returns 2' do + expect(form.ial).to eq(2) + end end end end describe '#aal' do - context 'when no AAL passed' do - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + context 'with vtr param' do + let(:acr_values) { nil } + + context 'when AAL2 is requested' do + let(:vtr) { ['C2'].to_json } - it 'returns 0' do - expect(form.aal).to eq(0) + it { expect(form.aal).to eq(2) } end - end - context 'when DEFAULT_AAL passed' do - let(:acr_values) { Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2 is not requested' do + let(:vtr) { ['C1'].to_json } - it 'returns 0' do - expect(form.aal).to eq(0) + it { expect(form.aal).to eq(1) } end end - context 'when AAL2 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } + context 'with acr_values param' do + let(:vtr) { nil } - it 'returns 2' do - expect(form.aal).to eq(2) + context 'when no AAL passed' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + + it 'returns 0' do + expect(form.aal).to eq(0) + end end - end - context 'when AAL2_PHISHING_RESISTANT passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } + context 'when DEFAULT_AAL passed' do + let(:acr_values) { Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.aal).to eq(2) + it 'returns 0' do + expect(form.aal).to eq(0) + end end - end - context 'when AAL2_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.aal).to eq(2) + it 'returns 2' do + expect(form.aal).to eq(2) + end end - end - context 'when AAL3 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_PHISHING_RESISTANT passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } - it 'returns 3' do - expect(form.aal).to eq(3) + it 'returns 2' do + expect(form.aal).to eq(2) + end end - end - context 'when AAL3_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns 3' do - expect(form.aal).to eq(3) + it 'returns 2' do + expect(form.aal).to eq(2) + end end - end - context 'when IAL and AAL passed' do - aal2 = Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF - ial2 = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF + context 'when AAL3 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } - let(:acr_values) do - "#{aal2} #{ial2}" + it 'returns 3' do + expect(form.aal).to eq(3) + end end - it 'returns ial and aal' do - expect(form.aal).to eq(2) - expect(form.ial).to eq(2) + context 'when AAL3_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } + + it 'returns 3' do + expect(form.aal).to eq(3) + end + end + + context 'when IAL and AAL passed' do + aal2 = Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF + ial2 = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF + + let(:acr_values) do + "#{aal2} #{ial2}" + end + + it 'returns ial and aal' do + expect(form.aal).to eq(2) + expect(form.ial).to eq(2) + end end end end describe '#requested_aal_value' do - context 'when AAL2 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } + context 'with ACR values' do + let(:vtr) { nil } + context 'when AAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL2' do - expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF) + it 'returns AAL2' do + expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF) + end end - end - context 'when AAL2_PHISHING_RESISTANT passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_PHISHING_RESISTANT passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL2+Phishing Resistant' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, - ) + it 'returns AAL2+Phishing Resistant' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, + ) + end end - end - context 'when AAL2_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL2+HSPD12' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, - ) + it 'returns AAL2+HSPD12' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, + ) + end end - end - context 'when AAL3 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } + context 'when AAL3 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL3' do - expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF) + it 'returns AAL3' do + expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF) + end end - end - context 'when AAL3_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL3_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL3+HSPD12' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, - ) + it 'returns AAL3+HSPD12' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, + ) + end end - end - context 'when AAL3_HSPD12 and AAL2_HSPD12 passed' do - let(:acr_values) do - [Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF].join(' ') - end + context 'when AAL3_HSPD12 and AAL2_HSPD12 passed' do + let(:acr_values) do + [Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF].join(' ') + end - it 'returns AAL2+HSPD12' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, - ) + it 'returns AAL2+HSPD12' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, + ) + end end - end - context 'when AAL2 and AAL2_PHISHING_RESISTANT passed' do - let(:phishing_resistant) do - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF - end + context 'when AAL2 and AAL2_PHISHING_RESISTANT passed' do + let(:phishing_resistant) do + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF + end - let(:acr_values) do - "#{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF} - #{phishing_resistant}" - end + let(:acr_values) do + "#{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF} + #{phishing_resistant}" + end - it 'returns AAL2+HSPD12' do - expect(form.requested_aal_value).to eq(phishing_resistant) + it 'returns AAL2+HSPD12' do + expect(form.requested_aal_value).to eq(phishing_resistant) + end end - end - context 'when AAL2_PHISHING_RESISTANT and AAL2 passed' do - # this is the same as the previous test, just reverse ordered - # AAL values, to ensure it doesn't just take the 2nd AAL. - let(:phishing_resistant) do - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF - end + context 'when AAL2_PHISHING_RESISTANT and AAL2 passed' do + # this is the same as the previous test, just reverse ordered + # AAL values, to ensure it doesn't just take the 2nd AAL. + let(:phishing_resistant) do + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF + end - let(:acr_values) do - "#{phishing_resistant} - #{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF}" - end + let(:acr_values) do + "#{phishing_resistant} + #{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF}" + end - it 'returns AAL2+HSPD12' do - requested_aal_value = form.requested_aal_value - expect(requested_aal_value).to eq(phishing_resistant) + it 'returns AAL2+HSPD12' do + requested_aal_value = form.requested_aal_value + expect(requested_aal_value).to eq(phishing_resistant) + end end end end @@ -646,29 +724,48 @@ describe '#ial2_requested?' do subject(:ial2_requested?) { form.ial2_requested? } - context 'with ial1' do - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - it { expect(ial2_requested?).to eq(false) } - end - context 'with ial2' do - let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } - it { expect(ial2_requested?).to eq(true) } - end + context 'with vtr params' do + let(:acr_values) { nil } + + context 'when identity proofing is requested' do + let(:vtr) { ['P1'].to_json } + it { expect(ial2_requested?).to eq(true) } + end - context 'with ial1 and ial2' do - let(:acr_values) do - [ - Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - ].join(' ') + context 'when identity proofing is not requested' do + let(:vtr) { ['C1'].to_json } + it { expect(ial2_requested?).to eq(false) } end - it { expect(ial2_requested?).to eq(true) } end - context 'with a malformed ial' do - let(:acr_values) { 'foobarbaz' } - it { expect(ial2_requested?).to eq(false) } + context 'with acr_values param' do + let(:vtr) { nil } + + context 'with ial1' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + it { expect(ial2_requested?).to eq(false) } + end + + context 'with ial2' do + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + it { expect(ial2_requested?).to eq(true) } + end + + context 'with ial1 and ial2' do + let(:acr_values) do + [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + ].join(' ') + end + it { expect(ial2_requested?).to eq(true) } + end + + context 'with a malformed ial' do + let(:acr_values) { 'foobarbaz' } + it { expect(ial2_requested?).to eq(false) } + end end end diff --git a/spec/services/store_sp_metadata_in_session_spec.rb b/spec/services/store_sp_metadata_in_session_spec.rb index 6504e43d929..d1486bb4258 100644 --- a/spec/services/store_sp_metadata_in_session_spec.rb +++ b/spec/services/store_sp_metadata_in_session_spec.rb @@ -18,6 +18,7 @@ ServiceProviderRequestProxy.find_or_create_by(uuid: request_id) do |sp_request| sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + sp_request.acr_values = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = false @@ -27,6 +28,7 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: nil, + acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, piv_cac_requested: false, phishing_resistant_requested: false, ial: 1, @@ -36,6 +38,7 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: false, + vtr: nil, } instance.call @@ -51,6 +54,10 @@ sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF sp_request.aal = Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF + sp_request.acr_values = [ + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, + ].join(' ') sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = false @@ -60,6 +67,10 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: 3, + acr_values: [ + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, + ].join(' '), piv_cac_requested: false, phishing_resistant_requested: true, ial: 2, @@ -69,6 +80,7 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: false, + vtr: nil, } instance.call @@ -84,6 +96,10 @@ sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF sp_request.aal = Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF + sp_request.acr_values = [ + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, + ].join(' ') sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = false @@ -93,6 +109,10 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: 2, + acr_values: [ + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, + ].join(' '), piv_cac_requested: false, phishing_resistant_requested: true, ial: 2, @@ -102,6 +122,7 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: false, + vtr: nil, } instance.call @@ -117,6 +138,10 @@ sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF sp_request.aal = Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF + sp_request.acr_values = [ + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, + ].join(' ') sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = true @@ -126,6 +151,10 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: 3, + acr_values: [ + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, + ].join(' '), piv_cac_requested: false, phishing_resistant_requested: true, ial: 2, @@ -135,6 +164,43 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: true, + vtr: nil, + } + + instance.call + expect(app_session[:sp]).to eq app_session_hash + end + end + + context 'when a vtr is present' do + it 'sets the session[:sp] hash' do + app_session = {} + request_id = SecureRandom.uuid + ServiceProviderRequestProxy.find_or_create_by(uuid: request_id) do |sp_request| + sp_request.issuer = 'issuer' + sp_request.ial = nil + sp_request.aal = nil + sp_request.vtr = ['C2.P1'] + sp_request.url = 'http://issuer.gov' + sp_request.requested_attributes = %w[email] + sp_request.biometric_comparison_required = false + end + instance = StoreSpMetadataInSession.new(session: app_session, request_id: request_id) + + app_session_hash = { + issuer: 'issuer', + aal_level_requested: nil, + acr_values: nil, + piv_cac_requested: false, + phishing_resistant_requested: false, + ial: nil, + ial2: false, + ialmax: nil, + request_url: 'http://issuer.gov', + request_id: request_id, + requested_attributes: %w[email], + biometric_comparison_required: false, + vtr: ['C2.P1'], } instance.call From 3e8fac10a09ad26f794bbe2b0dec23de7a71a190 Mon Sep 17 00:00:00 2001 From: dawei-nava <130466753+dawei-nava@users.noreply.github.com> Date: Tue, 6 Feb 2024 11:02:30 -0500 Subject: [PATCH 23/25] LG-12039: capture session read from new capture session results for document check results (#10041) * LG-12039: consider all document related for doc_auth_success? changelog: Internal, Doc Auth, Doc auth TrueID doc_auth_success? should consider all document business logic. * LG-12039: minor refactor and test. * LG-12039: pull in changes from main * LG-12039: it's questionable to use concern to reduce code complexity. More appropriate to user helper module. changelog: Internal, Doc Auth, New doc_auth_success? should include business decision. --- .../doc_auth/lexis_nexis/doc_pii_reader.rb | 87 +++++++++++++ .../lexis_nexis/image_metrics_reader.rb | 29 +++++ .../lexis_nexis/responses/true_id_response.rb | 123 +++--------------- app/services/doc_auth/mock/result_response.rb | 5 +- .../responses/true_id_response_spec.rb | 8 ++ 5 files changed, 145 insertions(+), 107 deletions(-) create mode 100644 app/services/doc_auth/lexis_nexis/doc_pii_reader.rb create mode 100644 app/services/doc_auth/lexis_nexis/image_metrics_reader.rb diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb new file mode 100644 index 00000000000..6fbe3ab1bb7 --- /dev/null +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -0,0 +1,87 @@ +module DocAuth + module LexisNexis + module DocPiiReader + PII_EXCLUDES = %w[ + Age + DocSize + DOB_Day + DOB_Month + DOB_Year + ExpirationDate_Day + ExpirationDate_Month + ExpirationDate_Year + FullName + Portrait + Sex + ].freeze + + private + + def read_pii(true_id_product) + return {} unless true_id_product&.dig(:IDAUTH_FIELD_DATA).present? + pii = {} + PII_INCLUDES.each do |true_id_key, idp_key| + pii[idp_key] = true_id_product[:IDAUTH_FIELD_DATA][true_id_key] + end + pii[:state_id_type] = DocAuth::Response::ID_TYPE_SLUGS[pii[:state_id_type]] + + dob = parse_date( + year: pii.delete(:dob_year), + month: pii.delete(:dob_month), + day: pii.delete(:dob_day), + ) + pii[:dob] = dob if dob + + exp_date = parse_date( + year: pii.delete(:state_id_expiration_year), + month: pii.delete(:state_id_expiration_month), + day: pii.delete(:state_id_expiration_day), + ) + pii[:state_id_expiration] = exp_date if exp_date + + issued_date = parse_date( + year: pii.delete(:state_id_issued_year), + month: pii.delete(:state_id_issued_month), + day: pii.delete(:state_id_issued_day), + ) + pii[:state_id_issued] = issued_date if issued_date + + pii + end + + PII_INCLUDES = { + 'Fields_FirstName' => :first_name, + 'Fields_MiddleName' => :middle_name, + 'Fields_Surname' => :last_name, + 'Fields_AddressLine1' => :address1, + 'Fields_AddressLine2' => :address2, + 'Fields_City' => :city, + 'Fields_State' => :state, + 'Fields_PostalCode' => :zipcode, + 'Fields_DOB_Year' => :dob_year, + 'Fields_DOB_Month' => :dob_month, + 'Fields_DOB_Day' => :dob_day, + 'Fields_DocumentNumber' => :state_id_number, + 'Fields_IssuingStateCode' => :state_id_jurisdiction, + 'Fields_xpirationDate_Day' => :state_id_expiration_day, # this is NOT a typo + 'Fields_ExpirationDate_Month' => :state_id_expiration_month, + 'Fields_ExpirationDate_Year' => :state_id_expiration_year, + 'Fields_IssueDate_Day' => :state_id_issued_day, + 'Fields_IssueDate_Month' => :state_id_issued_month, + 'Fields_IssueDate_Year' => :state_id_issued_year, + 'Fields_DocumentClassName' => :state_id_type, + 'Fields_CountryCode' => :issuing_country_code, + }.freeze + + def parse_date(year:, month:, day:) + Date.new(year.to_i, month.to_i, day.to_i).to_s if year.to_i.positive? + rescue ArgumentError + message = { + event: 'Failure to parse TrueID date', + }.to_json + Rails.logger.info(message) + nil + end + end + end +end diff --git a/app/services/doc_auth/lexis_nexis/image_metrics_reader.rb b/app/services/doc_auth/lexis_nexis/image_metrics_reader.rb new file mode 100644 index 00000000000..953d31fb8fb --- /dev/null +++ b/app/services/doc_auth/lexis_nexis/image_metrics_reader.rb @@ -0,0 +1,29 @@ +module DocAuth + module LexisNexis + module ImageMetricsReader + private + + def read_image_metrics(true_id_product) + image_metrics = {} + return image_metrics unless true_id_product&.dig(:ParameterDetails).present? + true_id_product[:ParameterDetails].each do |detail| + next unless detail[:Group] == 'IMAGE_METRICS_RESULT' + + inner_val = detail.dig(:Values).collect { |value| value.dig(:Value) } + image_metrics[detail[:Name]] = inner_val + end + + transform_metrics(image_metrics) + end + + def transform_metrics(img_metrics) + new_metrics = {} + img_metrics['Side']&.each_with_index do |side, i| + new_metrics[side.downcase.to_sym] = img_metrics.transform_values { |v| v[i] } + end + + new_metrics + end + end + end +end diff --git a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb index 9e326260ed4..f23cd0b65b9 100644 --- a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb +++ b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb @@ -4,56 +4,23 @@ module DocAuth module LexisNexis module Responses class TrueIdResponse < DocAuth::Response + include ImageMetricsReader + include DocPiiReader include ClassificationConcern include SelfieConcern - PII_EXCLUDES = %w[ - Age - DocSize - DOB_Day - DOB_Month - DOB_Year - ExpirationDate_Day - ExpirationDate_Month - ExpirationDate_Year - FullName - Portrait - Sex - ].freeze - - PII_INCLUDES = { - 'Fields_FirstName' => :first_name, - 'Fields_MiddleName' => :middle_name, - 'Fields_Surname' => :last_name, - 'Fields_AddressLine1' => :address1, - 'Fields_AddressLine2' => :address2, - 'Fields_City' => :city, - 'Fields_State' => :state, - 'Fields_PostalCode' => :zipcode, - 'Fields_DOB_Year' => :dob_year, - 'Fields_DOB_Month' => :dob_month, - 'Fields_DOB_Day' => :dob_day, - 'Fields_DocumentNumber' => :state_id_number, - 'Fields_IssuingStateCode' => :state_id_jurisdiction, - 'Fields_xpirationDate_Day' => :state_id_expiration_day, # this is NOT a typo - 'Fields_ExpirationDate_Month' => :state_id_expiration_month, - 'Fields_ExpirationDate_Year' => :state_id_expiration_year, - 'Fields_IssueDate_Day' => :state_id_issued_day, - 'Fields_IssueDate_Month' => :state_id_issued_month, - 'Fields_IssueDate_Year' => :state_id_issued_year, - 'Fields_DocumentClassName' => :state_id_type, - 'Fields_CountryCode' => :issuing_country_code, - }.freeze + attr_reader :config, :http_response def initialize(http_response, config, liveness_checking_enabled = false) @config = config @http_response = http_response @liveness_checking_enabled = liveness_checking_enabled + @pii_from_doc = read_pii(true_id_product) super( success: successful_result?, errors: error_messages, extra: extra_attributes, - pii_from_doc: pii_from_doc, + pii_from_doc: @pii_from_doc, ) rescue StandardError => e NewRelic::Agent.notice_error(e) @@ -72,7 +39,7 @@ def successful_result? def error_messages return {} if successful_result? - if true_id_product&.dig(:AUTHENTICATION_RESULT).present? + if with_authentication_result? ErrorGenerator.new(config).generate_doc_auth_errors(response_info) elsif true_id_product.present? ErrorGenerator.wrapped_general_error(@liveness_checking_enabled) @@ -82,7 +49,7 @@ def error_messages end def extra_attributes - if true_id_product&.dig(:AUTHENTICATION_RESULT).present? + if with_authentication_result? attrs = response_info.merge(true_id_product[:AUTHENTICATION_RESULT]) attrs.reject! do |k, _v| PII_EXCLUDES.include?(k) || k.start_with?('Alert_') @@ -98,38 +65,6 @@ def extra_attributes basic_logging_info.merge(attrs) end - def pii_from_doc - return {} unless true_id_product&.dig(:IDAUTH_FIELD_DATA).present? - pii = {} - PII_INCLUDES.each do |true_id_key, idp_key| - pii[idp_key] = true_id_product[:IDAUTH_FIELD_DATA][true_id_key] - end - pii[:state_id_type] = DocAuth::Response::ID_TYPE_SLUGS[pii[:state_id_type]] - - dob = parse_date( - year: pii.delete(:dob_year), - month: pii.delete(:dob_month), - day: pii.delete(:dob_day), - ) - pii[:dob] = dob if dob - - exp_date = parse_date( - year: pii.delete(:state_id_expiration_year), - month: pii.delete(:state_id_expiration_month), - day: pii.delete(:state_id_expiration_day), - ) - pii[:state_id_expiration] = exp_date if exp_date - - issued_date = parse_date( - year: pii.delete(:state_id_issued_year), - month: pii.delete(:state_id_issued_month), - day: pii.delete(:state_id_issued_day), - ) - pii[:state_id_issued] = issued_date if issued_date - - pii - end - def attention_with_barcode? return false unless doc_auth_result_attention? @@ -143,10 +78,14 @@ def billed? end def doc_auth_success? - transaction_status_passed? && + # really it's everything else excluding selfie + ((transaction_status_passed? && true_id_product.present? && product_status_passed? && doc_auth_result_passed? + ) || + attention_with_barcode? + ) && id_type_supported? end # @return [:success, :fail, :not_processed] @@ -230,7 +169,7 @@ def create_response_info alert_failure_count: alerts[:failed]&.count.to_i, log_alert_results: log_alert_formatter.log_alerts(alerts), portrait_match_results: portrait_match_results, - image_metrics: parse_image_metrics, + image_metrics: read_image_metrics(true_id_product), address_line2_present: !pii_from_doc[:address2].blank?, classification_info: classification_info, liveness_enabled: @liveness_checking_enabled, @@ -255,7 +194,7 @@ def all_passed? end def selfie_result - response_info&.dig(:portrait_match_results, :FaceMatchResult) + portrait_match_results&.dig(:FaceMatchResult) end def product_status_passed? @@ -324,7 +263,7 @@ def parsed_alerts return @new_alerts if defined?(@new_alerts) @new_alerts = { passed: [], failed: [] } - return @new_alerts unless true_id_product&.dig(:AUTHENTICATION_RESULT).present? + return @new_alerts unless with_authentication_result? all_alerts = true_id_product[:AUTHENTICATION_RESULT].select do |key| key.start_with?('Alert_') end @@ -363,28 +302,6 @@ def combine_alert_data(all_alerts, alert_name, region_details) new_alert_data end - def parse_image_metrics - image_metrics = {} - return image_metrics unless true_id_product&.dig(:ParameterDetails).present? - true_id_product[:ParameterDetails].each do |detail| - next unless detail[:Group] == 'IMAGE_METRICS_RESULT' - - inner_val = detail.dig(:Values).collect { |value| value.dig(:Value) } - image_metrics[detail[:Name]] = inner_val - end - - transform_metrics(image_metrics) - end - - def transform_metrics(img_metrics) - new_metrics = {} - img_metrics['Side']&.each_with_index do |side, i| - new_metrics[side.downcase.to_sym] = img_metrics.transform_values { |v| v[i] } - end - - new_metrics - end - # Generate a hash for image references information that can be linked to Alert # @return A hash with region_id => {:key : 'What region', :side: 'Front|Back'} def parse_document_region @@ -432,14 +349,8 @@ def transform_document_region(region_details, image_sides) end end - def parse_date(year:, month:, day:) - Date.new(year.to_i, month.to_i, day.to_i).to_s if year.to_i.positive? - rescue ArgumentError - message = { - event: 'Failure to parse TrueID date', - }.to_json - Rails.logger.info(message) - nil + def with_authentication_result? + true_id_product&.dig(:AUTHENTICATION_RESULT).present? end end end diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 8beb314726c..4cb188025cc 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -131,7 +131,10 @@ def self.create_network_error_response end def doc_auth_success? - doc_auth_result_from_uploaded_file == 'Passed' || errors.blank? + (doc_auth_result_from_uploaded_file == 'Passed' || + errors.blank? || + attention_with_barcode? + ) && id_type_supported? end def selfie_status diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index 1ce36b10b1b..7eb9b77643a 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -431,6 +431,7 @@ def get_decision_product(resp) end it 'produces reasonable output for a malformed TrueID response' do + allow(NewRelic::Agent).to receive(:notice_error) output = described_class.new(failure_response_malformed, config).to_h expect(output[:success]).to eq(false) @@ -654,6 +655,13 @@ def get_decision_product(resp) expect(response.doc_auth_success?).to eq(false) end end + + context 'when attention barcode read' do + let(:response) { described_class.new(attention_barcode_read, config) } + it 'returns true' do + expect(response.doc_auth_success?).to eq(true) + end + end end describe '#selfie_status' do From b5f16039450ef8192ae74036aaa2fdf0ff3bbb0f Mon Sep 17 00:00:00 2001 From: Brittany Greaner Date: Tue, 6 Feb 2024 09:20:41 -0800 Subject: [PATCH 24/25] LG-11918: Update documentation for SDK monitoring length to 3 days (#10037) * Update monitoring length to 3 days * changelog: Internal, Doc Auth, Update docs for 3 day monitoring length on SDK upgrade --- docs/sdk-upgrade.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sdk-upgrade.md b/docs/sdk-upgrade.md index b6f79973aaa..fc4518f2ca7 100644 --- a/docs/sdk-upgrade.md +++ b/docs/sdk-upgrade.md @@ -106,6 +106,7 @@ Steps: 6. While you monitor the recycle, manually check the document capture page in the environment you are deploying to. Ensure the SDK loads and can capture images. Monitoring the A/B test begins now. Proceed to the next section. + ## Testing Considerations Manual testing should be performed to cover the following with verification *Success* or *Failure*: * SDK UI @@ -131,13 +132,13 @@ Browser: Per the handbook, above, you should monitor the server instances as they come online and manually verify image capture still works. -For 2 weeks, monitor the A/B test with this [AWS CloudWatch Acuant upgrade dashboard](https://us-west-2.console.aws.amazon.com/cloudwatch/home?region=us-west-2#dashboards:name=js-acuant-upgrade). +For 3 days, monitor the A/B test with this [AWS CloudWatch Acuant upgrade dashboard](https://us-west-2.console.aws.amazon.com/cloudwatch/home?region=us-west-2#dashboards:name=js-acuant-upgrade). ![pie-charts-sdk](https://user-images.githubusercontent.com/546123/232889932-432e5cd5-c460-4a0a-8c6b-9f54324f327b.png) In this screenshot from the dashboard, the pie chart on the right shows a newer version of the SDK approaching 50% of document capture requests as A/B testing kicks in. The chart on the left shows that the newer version of the SDK is responsible for a proportionately lesser share of document capture failures, indicating that the new version is likely an improvement on the old. -If the new version of the SDK is performing well for a couple weeks of A/B testing, it is time to cut over 100% of traffic to the new version per the next section. +If the new version of the SDK is performing well for a few days of A/B testing, it is time to cut over 100% of traffic to the new version per the next section. ## Switch versions From 04361ce31d91fa4a8d01f71270220d14c715c702 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 6 Feb 2024 12:35:44 -0500 Subject: [PATCH 25/25] Revert "LG-12190 Store vtr and acr_values in sp_session (#10004)" (#10044) This reverts commit 0145c7eded9ff0be1ed78442b6ce3f11b7752154. --- app/forms/openid_connect_authorize_form.rb | 55 +-- app/models/federated_protocols/oidc.rb | 8 - app/models/federated_protocols/saml.rb | 8 - app/services/analytics_events.rb | 3 - .../service_provider_request_handler.rb | 2 - app/services/store_sp_metadata_in_session.rb | 2 - config/application.yml.default | 2 - config/locales/openid_connect/en.yml | 1 - config/locales/openid_connect/es.yml | 1 - config/locales/openid_connect/fr.yml | 1 - lib/identity_config.rb | 1 - .../authorization_controller_spec.rb | 26 +- spec/controllers/saml_idp_controller_spec.rb | 16 - .../openid_connect_authorize_form_spec.rb | 411 +++++++----------- .../store_sp_metadata_in_session_spec.rb | 66 --- 15 files changed, 168 insertions(+), 435 deletions(-) diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index ff076f99513..9000ec7c518 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -20,7 +20,6 @@ class OpenidConnectAuthorizeForm ATTRS = [ :unauthorized_scope, :acr_values, - :vtr, :scope, :verified_within, :biometric_comparison_required, @@ -38,7 +37,7 @@ class OpenidConnectAuthorizeForm RANDOM_VALUE_MINIMUM_LENGTH = 22 MINIMUM_REPROOF_VERIFIED_WITHIN_DAYS = 30 - validates :acr_values, presence: true, if: ->(form) { form.vtr.empty? } + validates :acr_values, presence: true validates :client_id, presence: true validates :redirect_uri, presence: true validates :scope, presence: true @@ -50,7 +49,6 @@ class OpenidConnectAuthorizeForm validates :code_challenge_method, inclusion: { in: %w[S256] }, if: :code_challenge validate :validate_acr_values - validate :validate_vtr validate :validate_client_id validate :validate_scope validate :validate_unauthorized_scope @@ -61,7 +59,6 @@ class OpenidConnectAuthorizeForm def initialize(params) @acr_values = parse_to_values(params[:acr_values], Saml::Idp::Constants::VALID_AUTHN_CONTEXTS) - @vtr = parse_vtr(params[:vtr]) SIMPLE_ATTRS.each { |key| instance_variable_set(:"@#{key}", params[key]) } @prompt ||= 'select_account' @scope = parse_to_values(params[:scope], scopes) @@ -122,13 +119,7 @@ def ial_context end def ial - if parsed_vector_of_trust&.identity_proofing? - 2 - elsif parsed_vector_of_trust.present? - 1 - else - Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_IAL[ial_values.sort.max] - end + Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_IAL[ial_values.sort.max] end def aal_values @@ -136,13 +127,7 @@ def aal_values end def aal - if parsed_vector_of_trust&.aal2? - 2 - elsif parsed_vector_of_trust.present? - 1 - else - Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_AAL[requested_aal_value] - end + Saml::Idp::Constants::AUTHN_CONTEXT_CLASSREF_TO_AAL[requested_aal_value] end def requested_aal_value @@ -178,18 +163,7 @@ def parse_to_values(param_value, possible_values) param_value.split(' ').compact & possible_values end - def parse_vtr(param_value) - return if !IdentityConfig.store.use_vot_in_sp_requests - return [] if param_value.blank? - - JSON.parse(param_value) - rescue JSON::ParserError - nil - end - def validate_acr_values - return if vtr.present? - if acr_values.empty? errors.add( :acr_values, t('openid_connect.authorization.errors.no_valid_acr_values'), @@ -203,15 +177,6 @@ def validate_acr_values end end - def validate_vtr - return if vtr.blank? - return if parsed_vector_of_trust.present? - errors.add( - :vtr, t('openid_connect.authorization.errors.no_valid_vtr'), - type: :no_valid_vtr - ) - end - # This checks that the SP matches something in the database # OpenidConnect::AuthorizationController#check_sp_active checks that it's currently active def validate_client_id @@ -281,7 +246,6 @@ def extra_analytics_attributes redirect_uri: result_uri, scope: scope&.sort&.join(' '), acr_values: acr_values&.sort&.join(' '), - vtr: vtr, unauthorized_scope: @unauthorized_scope, code_digest: code ? Digest::SHA256.hexdigest(code) : nil, code_challenge_present: code_challenge.present?, @@ -311,19 +275,6 @@ def scopes OpenidConnectAttributeScoper::VALID_IAL1_SCOPES end - def parsed_vector_of_trust - return @parsed_vector_of_trust if defined?(@parsed_vector_of_trust) - return @parsed_vector_of_trust = nil if vtr.blank? - - @parsed_vector_of_trust = begin - if vtr.is_a?(Array) && !vtr.empty? - Vot::Parser.new(vector_of_trust: vtr.first).parse - end - rescue Vot::Parser::ParseException - nil - end - end - def validate_privileges if (ial2_requested? && !ial_context.ial2_service_provider?) || (ial_context.ialmax_requested? && diff --git a/app/models/federated_protocols/oidc.rb b/app/models/federated_protocols/oidc.rb index be1605d9b6d..33b92251cf3 100644 --- a/app/models/federated_protocols/oidc.rb +++ b/app/models/federated_protocols/oidc.rb @@ -16,14 +16,6 @@ def aal request.aal_values.sort.max end - def acr_values - [aal, ial].compact.join(' ') - end - - def vtr - request.vtr - end - def requested_attributes OpenidConnectAttributeScoper.new(request.scope).requested_attributes end diff --git a/app/models/federated_protocols/saml.rb b/app/models/federated_protocols/saml.rb index 7659d466abd..ecc0dea6569 100644 --- a/app/models/federated_protocols/saml.rb +++ b/app/models/federated_protocols/saml.rb @@ -16,14 +16,6 @@ def aal request.requested_aal_authn_context end - def acr_values - [aal, ial].compact.join(' ') - end - - def vtr - nil - end - def requested_attributes @requested_attributes ||= SamlRequestPresenter.new( request: request, service_provider: current_service_provider, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d7e5e29ef14..7f392be8655 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -3636,14 +3636,12 @@ def openid_connect_bearer_token(success:, ial:, client_id:, errors:, **extra) # @param [String] client_id # @param [String] scope # @param [Array] acr_values - # @param [Array] vtr # @param [Boolean] unauthorized_scope # @param [Boolean] user_fully_authenticated def openid_connect_request_authorization( client_id:, scope:, acr_values:, - vtr:, unauthorized_scope:, user_fully_authenticated:, **extra @@ -3653,7 +3651,6 @@ def openid_connect_request_authorization( client_id: client_id, scope: scope, acr_values: acr_values, - vtr: vtr, unauthorized_scope: unauthorized_scope, user_fully_authenticated: user_fully_authenticated, **extra, diff --git a/app/services/service_provider_request_handler.rb b/app/services/service_provider_request_handler.rb index bd62d915a9c..089293b8f77 100644 --- a/app/services/service_provider_request_handler.rb +++ b/app/services/service_provider_request_handler.rb @@ -63,8 +63,6 @@ def attributes issuer: protocol.issuer, ial: protocol.ial, aal: protocol.aal, - acr_values: protocol.acr_values, - vtr: protocol.vtr, requested_attributes: protocol.requested_attributes, biometric_comparison_required: protocol.biometric_comparison_required?, uuid: request_id, diff --git a/app/services/store_sp_metadata_in_session.rb b/app/services/store_sp_metadata_in_session.rb index c3c0577504e..13c052b2646 100644 --- a/app/services/store_sp_metadata_in_session.rb +++ b/app/services/store_sp_metadata_in_session.rb @@ -37,8 +37,6 @@ def update_session request_id: sp_request.uuid, requested_attributes: sp_request.requested_attributes, biometric_comparison_required: sp_request.biometric_comparison_required, - acr_values: sp_request.acr_values, - vtr: sp_request.vtr, } end diff --git a/config/application.yml.default b/config/application.yml.default index e599da246eb..e848e7bc7fe 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -321,7 +321,6 @@ team_ursula_email: '' test_ssn_allowed_list: '' totp_code_interval: 30 unauthorized_scope_enabled: false -use_vot_in_sp_requests: true usps_upload_enabled: false usps_upload_sftp_timeout: 5 valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3", "http://idmanagement.gov/ns/assurance/ial/1", "http://idmanagement.gov/ns/assurance/ial/2", "http://idmanagement.gov/ns/assurance/ial/0", "http://idmanagement.gov/ns/assurance/ial/2?strict=true", "urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", "http://idmanagement.gov/ns/assurance/aal/2", "http://idmanagement.gov/ns/assurance/aal/3", "http://idmanagement.gov/ns/assurance/aal/3?hspd12=true","http://idmanagement.gov/ns/assurance/aal/2?phishing_resistant=true","http://idmanagement.gov/ns/assurance/aal/2?hspd12=true"]' @@ -495,7 +494,6 @@ production: state_tracking_enabled: false telephony_adapter: pinpoint use_kms: true - use_vot_in_sp_requests: false usps_auth_token_refresh_job_enabled: true usps_confirmation_max_days: 30 usps_upload_sftp_directory: '' diff --git a/config/locales/openid_connect/en.yml b/config/locales/openid_connect/en.yml index bd5eb70fd1c..3c68137adbd 100644 --- a/config/locales/openid_connect/en.yml +++ b/config/locales/openid_connect/en.yml @@ -12,7 +12,6 @@ en: no_auth: The acr_values are not authorized no_valid_acr_values: No acceptable acr_values found no_valid_scope: No valid scope values found - no_valid_vtr: No acceptable vots found prompt_invalid: No valid prompt values found redirect_uri_invalid: redirect_uri is invalid redirect_uri_no_match: redirect_uri does not match registered redirect_uri diff --git a/config/locales/openid_connect/es.yml b/config/locales/openid_connect/es.yml index 7aac8a1747c..51ea9ba75b2 100644 --- a/config/locales/openid_connect/es.yml +++ b/config/locales/openid_connect/es.yml @@ -12,7 +12,6 @@ es: no_auth: Los acr_values no están autorizados no_valid_acr_values: ial_valores encontrados no aceptables no_valid_scope: No se han encontrado valores de magnitud válidos - no_valid_vtr: vots encontrados no aceptables prompt_invalid: Prompt no es válido redirect_uri_invalid: Redirect_uri no es válido redirect_uri_no_match: Redirect_uri no coincide con redirect_uri registrado diff --git a/config/locales/openid_connect/fr.yml b/config/locales/openid_connect/fr.yml index 2ee66ca55c1..2d8f7eefe24 100644 --- a/config/locales/openid_connect/fr.yml +++ b/config/locales/openid_connect/fr.yml @@ -12,7 +12,6 @@ fr: no_auth: Les acr_values ne sont pas autorisées no_valid_acr_values: Valeurs acr_values inacceptables trouvées no_valid_scope: Aucune étendue de données valide trouvée - no_valid_vtr: vots encontrados no aceptables prompt_invalid: prompt est non valide redirect_uri_invalid: redirect_uri est non valide redirect_uri_no_match: redirect_uri ne correspond pas au redirect_uri enregistré diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 894ae4ebd83..8d07bde22cf 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -461,7 +461,6 @@ def self.build_store(config_map) config.add(:unauthorized_scope_enabled, type: :boolean) config.add(:use_dashboard_service_providers, type: :boolean) config.add(:use_kms, type: :boolean) - config.add(:use_vot_in_sp_requests, type: :boolean) config.add(:usps_auth_token_refresh_job_enabled, type: :boolean) config.add(:usps_confirmation_max_days, type: :integer) config.add(:usps_ipp_client_id, type: :string) diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index fb09c85af67..da601abc81e 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -110,8 +110,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid', - vtr: []) + scope: 'openid') expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -258,8 +257,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/2', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile', - vtr: []) + scope: 'openid profile') expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -497,8 +495,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile', - vtr: []) + scope: 'openid profile') expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -581,8 +578,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile', - vtr: []) + scope: 'openid profile') expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -667,8 +663,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/0', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid profile', - vtr: []) + scope: 'openid profile') expect(@analytics).to receive(:track_event). with('OpenID Connect: authorization request handoff', success: true, @@ -870,8 +865,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid', - vtr: []) + scope: 'openid') expect(@analytics).to_not receive(:track_event).with('sp redirect initiated') action @@ -904,8 +898,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid', - vtr: []) + scope: 'openid') expect(@analytics).to_not receive(:track_event).with('SP redirect initiated') action @@ -1020,8 +1013,7 @@ acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', code_challenge_present: false, service_provider_pkce: nil, - scope: 'openid', - vtr: []) + scope: 'openid') action sp_request_id = ServiceProviderRequestProxy.last.uuid @@ -1036,7 +1028,6 @@ expect(session[:sp]).to eq( aal_level_requested: nil, - acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, piv_cac_requested: false, phishing_resistant_requested: false, ial: 1, @@ -1047,7 +1038,6 @@ request_url: request.original_url, requested_attributes: %w[], biometric_comparison_required: false, - vtr: [], ) end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index d07d0858dfa..511195be937 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1166,12 +1166,6 @@ def name_id_version(format_urn) end context 'POST to auth correctly stores SP in session' do - let(:acr_values) do - Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF + - ' ' + - Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF - end - before do @user = create(:user, :fully_registered) @saml_request = saml_request(saml_settings) @@ -1187,7 +1181,6 @@ def name_id_version(format_urn) issuer: saml_settings.issuer, aal_level_requested: aal_level, piv_cac_requested: false, - acr_values: acr_values, phishing_resistant_requested: false, ial: 1, ial2: false, @@ -1196,7 +1189,6 @@ def name_id_version(format_urn) request_id: sp_request_id, requested_attributes: ['email'], biometric_comparison_required: false, - vtr: nil, ) end @@ -1209,12 +1201,6 @@ def name_id_version(format_urn) end context 'service provider is valid' do - let(:acr_values) do - Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF + - ' ' + - Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF - end - before do @user = create(:user, :fully_registered) @saml_request = saml_get_auth(saml_settings) @@ -1226,7 +1212,6 @@ def name_id_version(format_urn) expect(session[:sp]).to eq( issuer: saml_settings.issuer, aal_level_requested: aal_level, - acr_values: acr_values, piv_cac_requested: false, phishing_resistant_requested: false, ial: 1, @@ -1236,7 +1221,6 @@ def name_id_version(format_urn) request_id: sp_request_id, requested_attributes: ['email'], biometric_comparison_required: false, - vtr: nil, ) end diff --git a/spec/forms/openid_connect_authorize_form_spec.rb b/spec/forms/openid_connect_authorize_form_spec.rb index a3542a9b9ef..fafe7188723 100644 --- a/spec/forms/openid_connect_authorize_form_spec.rb +++ b/spec/forms/openid_connect_authorize_form_spec.rb @@ -4,7 +4,6 @@ subject(:form) do OpenidConnectAuthorizeForm.new( acr_values: acr_values, - vtr: vtr, client_id: client_id, nonce: nonce, prompt: prompt, @@ -19,8 +18,12 @@ ) end - let(:acr_values) { nil } - let(:vtr) { ['C1'].to_json } + let(:acr_values) do + [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ].join(' ') + end + let(:client_id) { 'urn:gov:gsa:openidconnect:test' } let(:nonce) { SecureRandom.hex } let(:prompt) { 'select_account' } @@ -46,8 +49,7 @@ allow_prompt_login: true, redirect_uri: nil, unauthorized_scope: true, - acr_values: '', - vtr: JSON.parse(vtr), + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', scope: 'openid', code_digest: nil, code_challenge_present: false, @@ -71,8 +73,7 @@ redirect_uri: "#{redirect_uri}?error=invalid_request&error_description=" \ "Response+type+is+not+included+in+the+list&state=#{state}", unauthorized_scope: true, - acr_values: '', - vtr: JSON.parse(vtr), + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', scope: 'openid', code_digest: nil, code_challenge_present: false, @@ -92,18 +93,6 @@ expect(result.extra[:redirect_uri]).to be_nil end end - - context 'when use_vot_in_sp_requests flag is false' do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(false) - end - - let(:vtr) { ['C1.P1'].to_json } - - it 'does not consume the VTR param' do - expect(form.vtr).to be_nil - end - end end describe '#valid?' do @@ -117,18 +106,8 @@ end end - context 'with an invalid vtr' do - let(:vtr) { ['A1.B2.C3'].to_json } - it 'has errors' do - expect(valid?).to eq(false) - expect(form.errors[:vtr]). - to include(t('openid_connect.authorization.errors.no_valid_vtr')) - end - end - context 'with no valid acr_values' do let(:acr_values) { 'abc def' } - let(:vtr) { nil } it 'has errors' do expect(valid?).to eq(false) expect(form.errors[:acr_values]). @@ -136,19 +115,8 @@ end end - context 'with no authorized vtr components' do - let(:vtr) { ['C1.P1'].to_json } - let(:client_id) { 'urn:gov:gsa:openidconnect:test:loa1' } - it 'has errors' do - expect(valid?).to eq(false) - expect(form.errors[:acr_values]). - to include(t('openid_connect.authorization.errors.no_auth')) - end - end - context 'with no authorized acr_values' do let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } - let(:vtr) { nil } let(:client_id) { 'urn:gov:gsa:openidconnect:test:loa1' } it 'has errors' do expect(valid?).to eq(false) @@ -159,7 +127,6 @@ context 'with ialmax requested' do let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } - let(:vtr) { nil } context 'with a service provider not in the allow list' do it 'has errors' do @@ -183,7 +150,6 @@ context 'with aal but not ial requested via acr_values' do let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } - let(:vtr) { nil } it 'has errors' do expect(valid?).to eq(false) expect(form.errors[:acr_values]). @@ -282,7 +248,7 @@ context 'when scope includes profile:verified_at but the sp is only ial1' do let(:client_id) { 'urn:gov:gsa:openidconnect:test:loa1' } - let(:vtr) { ['C1'].to_json } + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } let(:scope) { 'email profile:verified_at' } it 'has errors' do @@ -370,7 +336,6 @@ 'fake_value', ].join(' ') end - let(:vtr) { nil } it 'is parsed into an array of valid ACR values' do expect(form.acr_values).to eq( @@ -383,252 +348,209 @@ end describe '#ial' do - context 'with vtr param' do - let(:acr_values) { nil } - - context 'when proofing is requested' do - let(:vtr) { ['C1.P1'].to_json } + context 'when IAL1 passed' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - it { expect(form.ial).to eq(2) } - end - - context 'when proofing is not requested' do - let(:vtr) { ['C1'].to_json } - - it { expect(form.ial).to eq(1) } + it 'returns 1' do + expect(form.ial).to eq(1) end end - context 'with acr_values param' do - let(:vtr) { nil } - - context 'when IAL1 passed' do - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - - it 'returns 1' do - expect(form.ial).to eq(1) - end - end - - context 'when IAL2 passed' do - let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + context 'when IAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.ial).to eq(2) - end + it 'returns 2' do + expect(form.ial).to eq(2) end + end - context 'when IALMAX passed' do - let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } + context 'when IALMAX passed' do + let(:acr_values) { Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF } - it 'returns 0' do - expect(form.ial).to eq(0) - end + it 'returns 0' do + expect(form.ial).to eq(0) end + end - context 'when LOA1 passed' do - let(:acr_values) { Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF } + context 'when LOA1 passed' do + let(:acr_values) { Saml::Idp::Constants::LOA1_AUTHN_CONTEXT_CLASSREF } - it 'returns 1' do - expect(form.ial).to eq(1) - end + it 'returns 1' do + expect(form.ial).to eq(1) end + end - context 'when LOA3 passed' do - let(:acr_values) { Saml::Idp::Constants::LOA3_AUTHN_CONTEXT_CLASSREF } + context 'when LOA3 passed' do + let(:acr_values) { Saml::Idp::Constants::LOA3_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.ial).to eq(2) - end + it 'returns 2' do + expect(form.ial).to eq(2) end end end describe '#aal' do - context 'with vtr param' do - let(:acr_values) { nil } - - context 'when AAL2 is requested' do - let(:vtr) { ['C2'].to_json } + context 'when no AAL passed' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - it { expect(form.aal).to eq(2) } + it 'returns 0' do + expect(form.aal).to eq(0) end + end - context 'when AAL2 is not requested' do - let(:vtr) { ['C1'].to_json } + context 'when DEFAULT_AAL passed' do + let(:acr_values) { Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF } - it { expect(form.aal).to eq(1) } + it 'returns 0' do + expect(form.aal).to eq(0) end end - context 'with acr_values param' do - let(:vtr) { nil } + context 'when AAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - context 'when no AAL passed' do - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - - it 'returns 0' do - expect(form.aal).to eq(0) - end + it 'returns 2' do + expect(form.aal).to eq(2) end + end - context 'when DEFAULT_AAL passed' do - let(:acr_values) { Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_PHISHING_RESISTANT passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } - it 'returns 0' do - expect(form.aal).to eq(0) - end + it 'returns 2' do + expect(form.aal).to eq(2) end + end - context 'when AAL2 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.aal).to eq(2) - end + it 'returns 2' do + expect(form.aal).to eq(2) end + end - context 'when AAL2_PHISHING_RESISTANT passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } + context 'when AAL3 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.aal).to eq(2) - end + it 'returns 3' do + expect(form.aal).to eq(3) end + end - context 'when AAL2_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL3_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns 2' do - expect(form.aal).to eq(2) - end - end - - context 'when AAL3 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } - - it 'returns 3' do - expect(form.aal).to eq(3) - end + it 'returns 3' do + expect(form.aal).to eq(3) end + end - context 'when AAL3_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when IAL and AAL passed' do + aal2 = Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF + ial2 = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF - it 'returns 3' do - expect(form.aal).to eq(3) - end + let(:acr_values) do + "#{aal2} #{ial2}" end - context 'when IAL and AAL passed' do - aal2 = Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF - ial2 = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF - - let(:acr_values) do - "#{aal2} #{ial2}" - end - - it 'returns ial and aal' do - expect(form.aal).to eq(2) - expect(form.ial).to eq(2) - end + it 'returns ial and aal' do + expect(form.aal).to eq(2) + expect(form.ial).to eq(2) end end end describe '#requested_aal_value' do - context 'with ACR values' do - let(:vtr) { nil } - context 'when AAL2 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL2' do - expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF) - end + it 'returns AAL2' do + expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF) end + end - context 'when AAL2_PHISHING_RESISTANT passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_PHISHING_RESISTANT passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL2+Phishing Resistant' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, - ) - end + it 'returns AAL2+Phishing Resistant' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, + ) end + end - context 'when AAL2_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL2_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL2+HSPD12' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, - ) - end + it 'returns AAL2+HSPD12' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, + ) end + end - context 'when AAL3 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } + context 'when AAL3 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL3' do - expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF) - end + it 'returns AAL3' do + expect(form.requested_aal_value).to eq(Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF) end + end - context 'when AAL3_HSPD12 passed' do - let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } + context 'when AAL3_HSPD12 passed' do + let(:acr_values) { Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF } - it 'returns AAL3+HSPD12' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, - ) - end + it 'returns AAL3+HSPD12' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, + ) end + end - context 'when AAL3_HSPD12 and AAL2_HSPD12 passed' do - let(:acr_values) do - [Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF].join(' ') - end + context 'when AAL3_HSPD12 and AAL2_HSPD12 passed' do + let(:acr_values) do + [Saml::Idp::Constants::AAL3_HSPD12_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF].join(' ') + end - it 'returns AAL2+HSPD12' do - expect(form.requested_aal_value).to eq( - Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, - ) - end + it 'returns AAL2+HSPD12' do + expect(form.requested_aal_value).to eq( + Saml::Idp::Constants::AAL2_HSPD12_AUTHN_CONTEXT_CLASSREF, + ) end + end - context 'when AAL2 and AAL2_PHISHING_RESISTANT passed' do - let(:phishing_resistant) do - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF - end + context 'when AAL2 and AAL2_PHISHING_RESISTANT passed' do + let(:phishing_resistant) do + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF + end - let(:acr_values) do - "#{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF} - #{phishing_resistant}" - end + let(:acr_values) do + "#{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF} + #{phishing_resistant}" + end - it 'returns AAL2+HSPD12' do - expect(form.requested_aal_value).to eq(phishing_resistant) - end + it 'returns AAL2+HSPD12' do + expect(form.requested_aal_value).to eq(phishing_resistant) end + end - context 'when AAL2_PHISHING_RESISTANT and AAL2 passed' do - # this is the same as the previous test, just reverse ordered - # AAL values, to ensure it doesn't just take the 2nd AAL. - let(:phishing_resistant) do - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF - end + context 'when AAL2_PHISHING_RESISTANT and AAL2 passed' do + # this is the same as the previous test, just reverse ordered + # AAL values, to ensure it doesn't just take the 2nd AAL. + let(:phishing_resistant) do + Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF + end - let(:acr_values) do - "#{phishing_resistant} - #{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF}" - end + let(:acr_values) do + "#{phishing_resistant} + #{Saml::Idp::Constants::AAL2_AUTHN_CONTEXT_CLASSREF}" + end - it 'returns AAL2+HSPD12' do - requested_aal_value = form.requested_aal_value - expect(requested_aal_value).to eq(phishing_resistant) - end + it 'returns AAL2+HSPD12' do + requested_aal_value = form.requested_aal_value + expect(requested_aal_value).to eq(phishing_resistant) end end end @@ -724,48 +646,29 @@ describe '#ial2_requested?' do subject(:ial2_requested?) { form.ial2_requested? } - - context 'with vtr params' do - let(:acr_values) { nil } - - context 'when identity proofing is requested' do - let(:vtr) { ['P1'].to_json } - it { expect(ial2_requested?).to eq(true) } - end - - context 'when identity proofing is not requested' do - let(:vtr) { ['C1'].to_json } - it { expect(ial2_requested?).to eq(false) } - end + context 'with ial1' do + let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } + it { expect(ial2_requested?).to eq(false) } end - context 'with acr_values param' do - let(:vtr) { nil } - - context 'with ial1' do - let(:acr_values) { Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } - it { expect(ial2_requested?).to eq(false) } - end - - context 'with ial2' do - let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } - it { expect(ial2_requested?).to eq(true) } - end + context 'with ial2' do + let(:acr_values) { Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF } + it { expect(ial2_requested?).to eq(true) } + end - context 'with ial1 and ial2' do - let(:acr_values) do - [ - Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - ].join(' ') - end - it { expect(ial2_requested?).to eq(true) } + context 'with ial1 and ial2' do + let(:acr_values) do + [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + ].join(' ') end + it { expect(ial2_requested?).to eq(true) } + end - context 'with a malformed ial' do - let(:acr_values) { 'foobarbaz' } - it { expect(ial2_requested?).to eq(false) } - end + context 'with a malformed ial' do + let(:acr_values) { 'foobarbaz' } + it { expect(ial2_requested?).to eq(false) } end end diff --git a/spec/services/store_sp_metadata_in_session_spec.rb b/spec/services/store_sp_metadata_in_session_spec.rb index d1486bb4258..6504e43d929 100644 --- a/spec/services/store_sp_metadata_in_session_spec.rb +++ b/spec/services/store_sp_metadata_in_session_spec.rb @@ -18,7 +18,6 @@ ServiceProviderRequestProxy.find_or_create_by(uuid: request_id) do |sp_request| sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF - sp_request.acr_values = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = false @@ -28,7 +27,6 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: nil, - acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, piv_cac_requested: false, phishing_resistant_requested: false, ial: 1, @@ -38,7 +36,6 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: false, - vtr: nil, } instance.call @@ -54,10 +51,6 @@ sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF sp_request.aal = Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF - sp_request.acr_values = [ - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, - ].join(' ') sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = false @@ -67,10 +60,6 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: 3, - acr_values: [ - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, - ].join(' '), piv_cac_requested: false, phishing_resistant_requested: true, ial: 2, @@ -80,7 +69,6 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: false, - vtr: nil, } instance.call @@ -96,10 +84,6 @@ sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF sp_request.aal = Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF - sp_request.acr_values = [ - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, - ].join(' ') sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = false @@ -109,10 +93,6 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: 2, - acr_values: [ - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, - ].join(' '), piv_cac_requested: false, phishing_resistant_requested: true, ial: 2, @@ -122,7 +102,6 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: false, - vtr: nil, } instance.call @@ -138,10 +117,6 @@ sp_request.issuer = 'issuer' sp_request.ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF sp_request.aal = Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF - sp_request.acr_values = [ - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, - ].join(' ') sp_request.url = 'http://issuer.gov' sp_request.requested_attributes = %w[email] sp_request.biometric_comparison_required = true @@ -151,10 +126,6 @@ app_session_hash = { issuer: 'issuer', aal_level_requested: 3, - acr_values: [ - Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF, - ].join(' '), piv_cac_requested: false, phishing_resistant_requested: true, ial: 2, @@ -164,43 +135,6 @@ request_id: request_id, requested_attributes: %w[email], biometric_comparison_required: true, - vtr: nil, - } - - instance.call - expect(app_session[:sp]).to eq app_session_hash - end - end - - context 'when a vtr is present' do - it 'sets the session[:sp] hash' do - app_session = {} - request_id = SecureRandom.uuid - ServiceProviderRequestProxy.find_or_create_by(uuid: request_id) do |sp_request| - sp_request.issuer = 'issuer' - sp_request.ial = nil - sp_request.aal = nil - sp_request.vtr = ['C2.P1'] - sp_request.url = 'http://issuer.gov' - sp_request.requested_attributes = %w[email] - sp_request.biometric_comparison_required = false - end - instance = StoreSpMetadataInSession.new(session: app_session, request_id: request_id) - - app_session_hash = { - issuer: 'issuer', - aal_level_requested: nil, - acr_values: nil, - piv_cac_requested: false, - phishing_resistant_requested: false, - ial: nil, - ial2: false, - ialmax: nil, - request_url: 'http://issuer.gov', - request_id: request_id, - requested_attributes: %w[email], - biometric_comparison_required: false, - vtr: ['C2.P1'], } instance.call