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 59636f30253..acb1acbdb02 100644 --- a/app/javascript/packages/document-capture/components/document-capture-warning.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-warning.tsx @@ -11,6 +11,7 @@ import AnalyticsContext from '../context/analytics'; import SelfieCaptureContext from '../context/selfie-capture'; interface DocumentCaptureWarningProps { + isResultCodeInvalid: boolean; isFailedDocType: boolean; isFailedResult: boolean; isFailedSelfie: boolean; @@ -24,12 +25,14 @@ interface DocumentCaptureWarningProps { const DISPLAY_ATTEMPTS = 3; type GetHeadingArguments = { + isResultCodeInvalid: boolean; isFailedDocType: boolean; isFailedSelfie: boolean; isFailedSelfieLivenessOrQuality: boolean; t: typeof I18n.prototype.t; }; function getHeading({ + isResultCodeInvalid, isFailedDocType, isFailedSelfie, isFailedSelfieLivenessOrQuality, @@ -38,6 +41,9 @@ function getHeading({ if (isFailedDocType) { return t('errors.doc_auth.doc_type_not_supported_heading'); } + if (isResultCodeInvalid) { + return t('errors.doc_auth.rate_limited_heading'); + } if (isFailedSelfieLivenessOrQuality) { return t('errors.doc_auth.selfie_not_live_or_poor_quality_heading'); } @@ -67,6 +73,7 @@ function getSubheading({ } function DocumentCaptureWarning({ + isResultCodeInvalid, isFailedDocType, isFailedResult, isFailedSelfie, @@ -83,6 +90,7 @@ function DocumentCaptureWarning({ const nonIppOrFailedResult = !inPersonURL || isFailedResult; const heading = getHeading({ + isResultCodeInvalid, isFailedDocType, isFailedSelfie, isFailedSelfieLivenessOrQuality, diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index 0b8e36b8b8a..4d61c6efbd7 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -114,9 +114,10 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { submissionError instanceof UploadFormEntriesError ? withProps({ remainingSubmitAttempts: submissionError.remainingSubmitAttempts, + isResultCodeInvalid: submissionError.isResultCodeInvalid, isFailedResult: submissionError.isFailedResult, - isFailedSelfie: submissionError.isFailedSelfie, isFailedDocType: submissionError.isFailedDocType, + isFailedSelfie: submissionError.isFailedSelfie, isFailedSelfieLivenessOrQuality: submissionError.selfieNotLive || submissionError.selfieNotGoodQuality, captureHints: submissionError.hints, 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 cf47bda15fd..9875d59c146 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -38,6 +38,7 @@ export interface ReviewIssuesStepValue { interface ReviewIssuesStepProps extends FormStepComponentProps { remainingSubmitAttempts?: number; + isResultCodeInvalid?: boolean; isFailedResult?: boolean; isFailedSelfie?: boolean; isFailedDocType?: boolean; @@ -56,6 +57,7 @@ function ReviewIssuesStep({ registerField = () => undefined, toPreviousStep = () => undefined, remainingSubmitAttempts = Infinity, + isResultCodeInvalid = false, isFailedResult = false, isFailedDocType = false, isFailedSelfie = false, @@ -124,6 +126,7 @@ function ReviewIssuesStep({ // Warning(try again screen) return ( 1 + error = ErrorGenerator.general_error(liveness_enabled) + side = ErrorGenerator::ID + end + ErrorResult.new(error, side) + end + end + class SelfieErrorHandler < ErrorHandler include SelfieConcern def handle(response_info) liveness_enabled = response_info[:liveness_enabled] - get_selfie_error(liveness_enabled, response_info) + selfie_error = get_selfie_error(liveness_enabled, response_info) + + if is_generic_selfie_error?(selfie_error) + selfie_general_failure_error + else + error = selfie_error + side = ErrorGenerator::SELFIE + ErrorResult.new(error, side) + end end def is_generic_selfie_error?(error) @@ -156,90 +228,31 @@ def get_selfie_error(liveness_enabled, response_info) end end - class AlertErrorHandler < ErrorHandler - def initialize(config:, liveness_enabled:) + class UnknownErrorHandler < ErrorHandler + def initialize(config:) @config = config - @liveness_enabled = liveness_enabled end - def handle(known_alert_error_count, response_info, selfie_error) - alert_errors = get_error_messages(response_info) - # take non general selfie error into account - if @liveness_enabled && !!selfie_error - alert_errors[ErrorGenerator::SELFIE] << selfie_error - end - known_alert_error_count += 1 if alert_errors.include?(ErrorGenerator::SELFIE) - - # consolidate error based on count - if known_alert_error_count < 1 - process_unknown_alert_error(response_info) - elsif known_alert_error_count == 1 - process_single_alert_error(alert_errors) - elsif known_alert_error_count > 1 - # Simplify multiple errors into a single error for the user - consolidate_multiple_alert_errors(alert_errors) - else - # default fall back - ErrorResult.new(error, side) - end + def handle(response_info) + process_unknown_error(response_info) end private - def get_error_messages(response_info) - errors = Hash.new { |hash, key| hash[key] = Set.new } - - if response_info[:doc_auth_result] != 'Passed' - response_info[:processed_alerts][:failed]&.each do |alert| - alert_msg_hash = ErrorGenerator::ALERT_MESSAGES[alert[:name].to_sym] - - if alert_msg_hash.present? - field_type = alert[:side] || alert_msg_hash[:type] - errors[field_type.to_sym] << alert_msg_hash[:msg_key] - end - end - end - errors - end - ## # Return ErrorResult as hash, there is error but known_error_count = 0 ## - def process_unknown_alert_error(response_info) + def process_unknown_error(response_info) @config.warn_notifier&.call( message: 'DocAuth failure escaped without useful errors', response_info: response_info, ) - error = Errors::GENERAL_ERROR + liveness_enabled = response_info[:liveness_enabled] + error = ErrorGenerator.general_error(liveness_enabled) side = ErrorGenerator::ID ErrorResult.new(error, side) end - - def process_single_alert_error(alert_errors) - error = alert_errors.values[0].to_a.pop - side = alert_errors.keys[0] - ErrorResult.new(error, side) - end - - def consolidate_multiple_alert_errors(alert_errors) - error_fields = alert_errors.keys - if error_fields.length == 1 - side = error_fields.first - case side - when ErrorGenerator::ID - error = Errors::GENERAL_ERROR - when ErrorGenerator::FRONT - error = Errors::MULTIPLE_FRONT_ID_FAILURES - when ErrorGenerator::BACK - error = Errors::MULTIPLE_BACK_ID_FAILURES - end - elsif error_fields.length > 1 - error = Errors::GENERAL_ERROR - side = ErrorGenerator::ID - end - ErrorResult.new(error, side) - end end class ErrorGenerator @@ -312,29 +325,39 @@ def generate_doc_auth_errors(response_info) metrics_error = metrics_error_handler.handle(response_info) return metrics_error.to_h if metrics_error.present? && !metrics_error.empty? - # check selfie error - selfie_error_handler = SelfieErrorHandler.new - selfie_error = selfie_error_handler.handle(response_info) + doc_auth_error_count = doc_auth_error_count(response_info) + known_error_count = doc_auth_error_count - unknown_fail_count + doc_auth_error_handler = DocAuthErrorHandler.new + doc_auth_error = doc_auth_error_handler.handle(response_info, known_error_count) - # if selfie itself is ok, but we have selfie related error - if selfie_error_handler.is_generic_selfie_error?(selfie_error) - return selfie_error_handler.selfie_general_failure_error + if doc_auth_error.present? && !doc_auth_error.empty? + return doc_auth_error.to_h end - # other vendor response detail error - liveness_enabled = response_info[:liveness_enabled] - alert_error_count = response_info[:doc_auth_result] == 'Passed' ? - 0 : response_info[:alert_failure_count] + # check selfie error + if doc_auth_error_count < 1 + selfie_error_handler = SelfieErrorHandler.new + selfie_error = selfie_error_handler.handle(response_info) + if selfie_error.present? && !selfie_error.empty? + return selfie_error.to_h + end + end - known_alert_error_count = alert_error_count - unknown_fail_count - alert_error_handler = AlertErrorHandler.new( - config: config, - liveness_enabled: liveness_enabled, - ) - alert_error = alert_error_handler.handle(known_alert_error_count, response_info, selfie_error) - alert_error.to_h + # catch all route, technically should not happen + unknown_error_handler = UnknownErrorHandler.new(config: config) + unknown_error_handler.handle(response_info).to_h end + def self.general_error(liveness_enabled) + liveness_enabled ? Errors::GENERAL_ERROR_LIVENESS : Errors::GENERAL_ERROR + end + + def self.wrapped_general_error(liveness_enabled) + { general: [ErrorGenerator.general_error(liveness_enabled)], hints: true } + end + + private + def scan_for_unknown_alerts(response_info) all_alerts = [ *response_info[:processed_alerts][:failed], @@ -347,7 +370,7 @@ def scan_for_unknown_alerts(response_info) if ErrorGenerator::ALERT_MESSAGES[alert[:name].to_sym].blank? unknown_alerts.push(alert[:name]) - unknown_fail_count += 1 if alert[:result] != 'Passed' + unknown_fail_count += 1 if alert[:result] != LexisNexis::ResultCodes::PASSED.name end end @@ -362,12 +385,28 @@ def scan_for_unknown_alerts(response_info) unknown_fail_count end - def self.general_error(_liveness_enabled) - Errors::GENERAL_ERROR + # This method replicates TrueIdResponse::attention_with_barcode? and + # should be removed/updated when that is. + def attention_with_barcode_result(doc_auth_result, processed_alerts) + attention_result_name = LexisNexis::ResultCodes::ATTENTION.name + barcode_alerts = processed_alerts[:failed]&.count.to_i == 1 && + processed_alerts.dig(:failed, 0, :name) == '2D Barcode Read' && + processed_alerts.dig(:failed, 0, :result) == 'Attention' + + doc_auth_result == attention_result_name && barcode_alerts end - def self.wrapped_general_error(liveness_enabled) - { general: [ErrorGenerator.general_error(liveness_enabled)], hints: true } + def doc_auth_passed_or_attn_with_barcode(response_info) + doc_auth_result = response_info[:doc_auth_result] + processed_alerts = response_info[:processed_alerts] + + doc_auth_result_passed = doc_auth_result == LexisNexis::ResultCodes::PASSED.name + doc_auth_result_passed || attention_with_barcode_result(doc_auth_result, processed_alerts) + end + + def doc_auth_error_count(response_info) + doc_auth_passed_or_attn_with_barcode(response_info) ? + 0 : response_info[:alert_failure_count] end end end diff --git a/app/services/doc_auth/errors.rb b/app/services/doc_auth/errors.rb index 262b0ed3590..197df623e65 100644 --- a/app/services/doc_auth/errors.rb +++ b/app/services/doc_auth/errors.rb @@ -22,6 +22,7 @@ module Errors EXPIRATION_CHECKS = 'expiration_checks' # expiration date valid, expiration crosscheck FULL_NAME_CHECK = 'full_name_check' GENERAL_ERROR = 'general_error' + GENERAL_ERROR_LIVENESS = 'general_error_liveness' ID_NOT_RECOGNIZED = 'id_not_recognized' ID_NOT_VERIFIED = 'id_not_verified' ISSUE_DATE_CHECKS = 'issue_date_checks' @@ -65,6 +66,7 @@ module Errors EXPIRATION_CHECKS, FULL_NAME_CHECK, GENERAL_ERROR, + GENERAL_ERROR_LIVENESS, ID_NOT_RECOGNIZED, ID_NOT_VERIFIED, ISSUE_DATE_CHECKS, @@ -120,6 +122,7 @@ module Errors MULTIPLE_BACK_ID_FAILURES => { long_msg: MULTIPLE_BACK_ID_FAILURES, field_msg: FALLBACK_FIELD_LEVEL, hints: true }, GENERAL_ERROR => { long_msg: GENERAL_ERROR, field_msg: FALLBACK_FIELD_LEVEL, hints: true }, # Selfie errors + GENERAL_ERROR_LIVENESS => { long_msg: GENERAL_ERROR_LIVENESS, field_msg: FALLBACK_FIELD_LEVEL, hints: false }, SELFIE_FAILURE => { long_msg: SELFIE_FAILURE, field_msg: SELFIE_FAILURE, hints: false }, SELFIE_NOT_LIVE => { long_msg: SELFIE_NOT_LIVE, field_msg: SELFIE_FAILURE, hints: false }, SELFIE_POOR_QUALITY => { long_msg: SELFIE_POOR_QUALITY, field_msg: SELFIE_FAILURE, hints: false }, diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 020118dfcd9..10b10d5cfe0 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -41,7 +41,7 @@ def errors else doc_auth_result = file_data.dig('doc_auth_result') image_metrics = file_data.dig('image_metrics') - failed = failed_file_data(file_data.dig('failed_alerts')&.dup) + failed = file_data.dig('failed_alerts')&.dup passed = file_data.dig('passed_alerts') face_match_result = file_data.dig('portrait_match_results', 'FaceMatchResult') classification_info = file_data.dig('classification_info') @@ -235,13 +235,6 @@ def create_response_info( extra: { liveness_checking_required: liveness_enabled }, }.compact end - - def failed_file_data(failed_alerts_data) - if attention_with_barcode? - failed_alerts_data&.delete(ATTENTION_WITH_BARCODE_ALERT) - end - failed_alerts_data - end end end end diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 38331817ffd..ccb201a59ec 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -38,6 +38,9 @@ module DocAuthRouter # i18n-tasks-use t('doc_auth.errors.general.no_liveness') DocAuth::Errors::GENERAL_ERROR => 'doc_auth.errors.general.no_liveness', + # i18n-tasks-use t('doc_auth.errors.dpi.top_msg_plural') + DocAuth::Errors::GENERAL_ERROR_LIVENESS => + 'doc_auth.errors.dpi.top_msg_plural', # i18n-tasks-use t('doc_auth.errors.alerts.id_not_recognized') DocAuth::Errors::ID_NOT_RECOGNIZED => 'doc_auth.errors.alerts.id_not_recognized', diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index 829d687033e..4de05bf5f26 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -19,7 +19,7 @@ en: address_check: We couldn’t read the address on your ID. Try taking new pictures. barcode_content_check: We couldn’t read the barcode on the back of your ID. It could be because of a problem with the barcode, or the barcode is a - new type that we don’t recognize yet. Use another state‑issued ID if + new type that we don’t recognize yet. Use another state‑issued ID if you have one. barcode_read_check: We couldn’t read the barcode on the back of your ID. Try taking a new picture. diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 7e48898ad57..b4e4fbb5189 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -184,6 +184,7 @@ success: false, errors: [{ field: 'front', message: 'Please fill in this field.' }], remaining_submit_attempts: RateLimiter.max_attempts(:idv_doc_auth) - 2, + result_code_invalid: true, result_failed: false, ocr_pii: nil, doc_type_supported: true, @@ -200,6 +201,7 @@ errors: [{ field: 'limit', message: 'We couldn’t verify your ID' }], redirect: redirect_url, remaining_submit_attempts: 0, + result_code_invalid: true, result_failed: false, ocr_pii: nil, doc_type_supported: true, diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 7247f56981e..fb374ad06ff 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -7,15 +7,24 @@ include ActionView::Helpers::DateHelper let(:max_attempts) { IdentityConfig.store.doc_auth_max_attempts } - let(:user) { user_with_2fa } - let(:fake_analytics) { FakeAnalytics.new } - let(:sp_name) { 'Test SP' } - before do - allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) - allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(sp_name) + before(:each) do + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(@fake_analytics) + allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(@sp_name) + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) + end - sign_in_and_2fa_user(user) + before(:all) do + @user = user_with_2fa + @fake_analytics = FakeAnalytics.new + @sp_name = 'Test SP' + end + + after(:all) do + @user.destroy + @fake_analytics = '' + @sp_name = '' end context 'standard desktop flow' do @@ -112,7 +121,7 @@ it 'logs the rate limited analytics event for doc_auth' do attach_and_submit_images - expect(fake_analytics).to have_logged_event( + expect(@fake_analytics).to have_logged_event( 'Rate Limit Reached', limiter_type: :idv_doc_auth, ) @@ -160,7 +169,7 @@ allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(false) attach_and_submit_images - expect(DocAuthLog.find_by(user_id: user.id).state).to be_nil + expect(DocAuthLog.find_by(user_id: @user.id).state).to be_nil end end @@ -168,7 +177,7 @@ it 'proceeds to the next page with valid info' do perform_in_browser(:mobile) do visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step expect(page).to have_current_path(idv_document_capture_url) @@ -186,7 +195,7 @@ expect(page).to have_current_path(idv_ssn_url) expect_costing_for_document - expect(DocAuthLog.find_by(user_id: user.id).state).to eq('NY') + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') expect(page).to have_current_path(idv_ssn_url) fill_out_ssn_form_ok @@ -202,13 +211,14 @@ before do expect(FeatureManagement).to receive(:idv_allow_selfie_check?).at_least(:once). and_return(selfie_check_enabled) + complete_doc_auth_steps_before_document_capture_step end context 'when a selfie is not requested by SP' do it 'proceeds to the next page with valid info, excluding a selfie image' do perform_in_browser(:mobile) do visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step expect(page).to have_current_path(idv_document_capture_url) @@ -221,7 +231,7 @@ expect(page).to have_current_path(idv_ssn_url) expect_costing_for_document - expect(DocAuthLog.find_by(user_id: user.id).state).to eq('MT') + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') expect(page).to have_current_path(idv_ssn_url) fill_out_ssn_form_ok @@ -252,7 +262,7 @@ it 'proceeds to the next page with valid info, including a selfie image' do perform_in_browser(:mobile) do visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step expect(page).to have_current_path(idv_document_capture_url) @@ -269,7 +279,7 @@ expect(page).to have_current_path(idv_ssn_url) expect_costing_for_document - expect(DocAuthLog.find_by(user_id: user.id).state).to eq('MT') + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') expect(page).to have_current_path(idv_ssn_url) fill_out_ssn_form_ok @@ -281,9 +291,353 @@ end context 'selfie with error is uploaded' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(99) + + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?). + and_return(true) + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + end + end + + it 'shows the correct error message for the given error' do + # when the only error is a doc auth error + + perform_in_browser(:mobile) do + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_fail_selfie_pass.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_fail_selfie_pass.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.dpi.top_msg')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags(t('doc_auth.errors.dpi.failed_short')) + expect(page).to have_content(inline_error_message) + + expect(page).to have_current_path(idv_document_capture_url) + + # when doc auth result passes but liveness fails + + 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 + + h1_error_message = strip_tags( + t('errors.doc_auth.selfie_not_live_or_poor_quality_heading'), + ) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.alerts.selfie_not_live')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + # inline error to be fixed in lg-12999 + + # when there are both doc auth errors and liveness errors + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_fail_and_no_liveness.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_fail_and_no_liveness.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.dpi.top_msg')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags(t('doc_auth.errors.dpi.failed_short')) + expect(page).to have_content(inline_error_message) + + # when there are both doc auth errors and face match errors + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_fail_face_match_fail.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_fail_face_match_fail.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.dpi.top_msg')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags(t('doc_auth.errors.dpi.failed_short')) + expect(page).to have_content(inline_error_message) + + # when doc auth result and liveness pass but face match fails + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_portrait_match_failure.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_portrait_match_failure.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.selfie_fail_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.general.selfie_failure')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags( + t('doc_auth.errors.general.multiple_front_id_failures'), + ) + expect(page).to have_content(inline_error_message) + + # when there is a doc auth error on one side of the ID and face match errors + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_back_fail_doc_auth_face_match_errors.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_back_fail_doc_auth_face_match_errors.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.alerts.barcode_content_check')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags(t('doc_auth.errors.general.fallback_field_level')) + expect(page).to have_content(inline_error_message) + + # when there is a doc auth error on one side of the ID and a liveness error + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_back_fail_doc_auth_liveness_errors.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_back_fail_doc_auth_liveness_errors.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.alerts.barcode_content_check')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags(t('doc_auth.errors.general.fallback_field_level')) + expect(page).to have_content(inline_error_message) + + # when doc auth result is "attention" and face match errors + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_attention_face_match_fail.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_attention_face_match_fail.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.dpi.top_msg_plural')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags(t('doc_auth.errors.general.fallback_field_level')) + expect(page).to have_content(inline_error_message) + + # when doc auth passes but there are both liveness errors and face match errors + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_liveness_fail_face_match_fail.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_liveness_fail_face_match_fail.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags( + t('errors.doc_auth.selfie_not_live_or_poor_quality_heading'), + ) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.alerts.selfie_not_live')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + # when doc auth, liveness, and face match pass but PII validation fails + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_selfie_pass_pii_fail.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_doc_auth_selfie_pass_pii_fail.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.rate_limited_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.alerts.address_check')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags( + t('doc_auth.errors.general.multiple_front_id_failures'), + ) + expect(page).to have_content(inline_error_message) + + # when there are both face match errors and pii errors + + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_face_match_fail_and_pii_fail.yml' + ), + ) + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_face_match_fail_and_pii_fail.yml' + ), + ) + + submit_images + + h1_error_message = strip_tags(t('errors.doc_auth.selfie_fail_heading')) + expect(page).to have_content(h1_error_message) + + body_error_message = strip_tags(t('doc_auth.errors.general.selfie_failure')) + expect(page).to have_content(body_error_message) + + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + + inline_error_message = strip_tags( + t('doc_auth.errors.general.multiple_front_id_failures'), + ) + expect(page).to have_content(inline_error_message) + end + end + it 'try again and page show no liveness inline error message' do visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step attach_images( Rails.root.join( @@ -318,9 +672,10 @@ inline_error = strip_tags(t('doc_auth.errors.general.selfie_failure')) 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(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step attach_images( Rails.root.join( @@ -358,7 +713,7 @@ it 'try again and page show selfie fail inline error message' do visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step attach_images( Rails.root.join( @@ -397,7 +752,7 @@ context 'with Attention with Barcode' do it 'try again and page show selfie fail inline error message' do visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step attach_images( Rails.root.join( @@ -440,7 +795,7 @@ it 'proceeds to the next page with valid info, excluding a selfie image' do perform_in_browser(:mobile) do visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step expect(page).to have_current_path(idv_document_capture_url) @@ -457,7 +812,7 @@ expect(page).to have_current_path(idv_ssn_url) expect_costing_for_document - expect(DocAuthLog.find_by(user_id: user.id).state).to eq('MT') + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') expect(page).to have_current_path(idv_ssn_url) fill_out_ssn_form_ok @@ -478,7 +833,7 @@ it 'can only proceed to link sent page' do perform_in_browser(:desktop) do visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_hybrid_handoff_step # we still have option to continue expect(page).to have_current_path(idv_hybrid_handoff_path) @@ -495,7 +850,7 @@ it 'proceed to the next page with valid info, including a selfie image' do perform_in_browser(:desktop) do visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_hybrid_handoff_step # we still have option to continue on handoff, since it's desktop no skip_hand_off expect(page).to have_current_path(idv_hybrid_handoff_path) @@ -513,7 +868,7 @@ expect(page).to have_current_path(idv_ssn_url) expect_costing_for_document - expect(DocAuthLog.find_by(user_id: user.id).state).to eq('MT') + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') expect(page).to have_current_path(idv_ssn_url) fill_out_ssn_form_ok @@ -536,7 +891,7 @@ it 'proceed to the next page and start ipp' do perform_in_browser(:desktop) do visit_idp_from_oidc_sp_with_ial2(biometric_comparison_required: true) - sign_in_and_2fa_user(user) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_hybrid_handoff_step # we still have option to continue on handoff, since it's desktop no skip_hand_off expect(page).to have_current_path(idv_hybrid_handoff_path) diff --git a/spec/fixtures/ial2_test_credential_back_fail_doc_auth_face_match_errors.yml b/spec/fixtures/ial2_test_credential_back_fail_doc_auth_face_match_errors.yml new file mode 100644 index 00000000000..8d56a635d42 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_back_fail_doc_auth_face_match_errors.yml @@ -0,0 +1,20 @@ +portrait_match_results: + FaceMatchResult: Fail + FaceErrorMessage: 'Successful. Liveness: Live' +doc_auth_result: Unknown +failed_alerts: + - name: 2D Barcode Content + result: Attention +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' diff --git a/spec/fixtures/ial2_test_credential_back_fail_doc_auth_liveness_errors.yml b/spec/fixtures/ial2_test_credential_back_fail_doc_auth_liveness_errors.yml new file mode 100644 index 00000000000..e570cc2eb61 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_back_fail_doc_auth_liveness_errors.yml @@ -0,0 +1,20 @@ +portrait_match_results: + FaceMatchResult: Fail + FaceErrorMessage: 'Liveness: NotLive' +doc_auth_result: Unknown +failed_alerts: + - name: 2D Barcode Content + result: Attention +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' diff --git a/spec/fixtures/ial2_test_credential_doc_auth_attention_face_match_fail.yml b/spec/fixtures/ial2_test_credential_doc_auth_attention_face_match_fail.yml new file mode 100644 index 00000000000..55effd3d317 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_doc_auth_attention_face_match_fail.yml @@ -0,0 +1,21 @@ +failed_alerts: + - name: unexpected attention alert + result: Attention +doc_auth_result: Attention +portrait_match_results: + FaceMatchResult: Fail + FaceErrorMessage: 'Successful. Liveness: Live' +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_doc_auth_fail_and_no_liveness.yml b/spec/fixtures/ial2_test_credential_doc_auth_fail_and_no_liveness.yml new file mode 100644 index 00000000000..204eed7ad6a --- /dev/null +++ b/spec/fixtures/ial2_test_credential_doc_auth_fail_and_no_liveness.yml @@ -0,0 +1,26 @@ +portrait_match_results: + # returns the portrait match result + FaceMatchResult: Fail + # returns the liveness result + FaceErrorMessage: 'Liveness: NotLive' +doc_auth_result: Failed +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 +image_metrics: + back: + HorizontalResolution: 0 + VerticalResolution: 0 + GlareMetric: 0 + SharpnessMetric: 0 diff --git a/spec/fixtures/ial2_test_credential_doc_auth_fail_face_match_fail.yml b/spec/fixtures/ial2_test_credential_doc_auth_fail_face_match_fail.yml new file mode 100644 index 00000000000..b2e85695378 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_doc_auth_fail_face_match_fail.yml @@ -0,0 +1,25 @@ +failed_alerts: [] +portrait_match_results: + FaceMatchResult: Fail + FaceErrorMessage: 'Successful. Liveness: Live' +doc_auth_result: Failed +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 +image_metrics: + back: + HorizontalResolution: 0 + VerticalResolution: 0 + GlareMetric: 0 + SharpnessMetric: 0 diff --git a/spec/fixtures/ial2_test_credential_doc_auth_fail_selfie_pass.yml b/spec/fixtures/ial2_test_credential_doc_auth_fail_selfie_pass.yml new file mode 100644 index 00000000000..0986801e54a --- /dev/null +++ b/spec/fixtures/ial2_test_credential_doc_auth_fail_selfie_pass.yml @@ -0,0 +1,24 @@ +failed_alerts: [] +portrait_match_results: + FaceMatchResult: Pass +doc_auth_result: Failed +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 +image_metrics: + back: + HorizontalResolution: 0 + VerticalResolution: 0 + GlareMetric: 0 + SharpnessMetric: 0 diff --git a/spec/fixtures/ial2_test_credential_doc_auth_selfie_pass_pii_fail.yml b/spec/fixtures/ial2_test_credential_doc_auth_selfie_pass_pii_fail.yml new file mode 100644 index 00000000000..e05b4273941 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_doc_auth_selfie_pass_pii_fail.yml @@ -0,0 +1,16 @@ +document: + first_name: Jane + last_name: Doe + middle_name: Q + address1: null + address2: null + city: Bayside + state: NY + zipcode: '11364' + dob: 10/06/1938 + phone: +1 314-555-1212 + state_id_jurisdiction: 'ND' +doc_auth_result: Passed +failed_alerts: [] +portrait_match_results: + FaceMatchResult: Pass diff --git a/spec/fixtures/ial2_test_credential_face_match_fail_and_pii_fail.yml b/spec/fixtures/ial2_test_credential_face_match_fail_and_pii_fail.yml new file mode 100644 index 00000000000..a108ec84c2a --- /dev/null +++ b/spec/fixtures/ial2_test_credential_face_match_fail_and_pii_fail.yml @@ -0,0 +1,16 @@ +document: + first_name: Jane + last_name: Doe + middle_name: Q + address1: null + address2: null + city: Bayside + state: NY + zipcode: '11364' + dob: 10/06/1938 + phone: +1 314-555-1212 + state_id_jurisdiction: 'ND' +doc_auth_result: Passed +failed_alerts: [] +portrait_match_results: + FaceMatchResult: Fail diff --git a/spec/fixtures/ial2_test_credential_liveness_fail_face_match_fail.yml b/spec/fixtures/ial2_test_credential_liveness_fail_face_match_fail.yml new file mode 100644 index 00000000000..7f32eaca941 --- /dev/null +++ b/spec/fixtures/ial2_test_credential_liveness_fail_face_match_fail.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_alerts: [] +portrait_match_results: + FaceMatchResult: Fail + FaceErrorMessage: 'Liveness: NotLive' diff --git a/spec/fixtures/ial2_test_portrait_match_failure.yml b/spec/fixtures/ial2_test_portrait_match_failure.yml index 647d641b468..26e8b340c91 100644 --- a/spec/fixtures/ial2_test_portrait_match_failure.yml +++ b/spec/fixtures/ial2_test_portrait_match_failure.yml @@ -11,7 +11,7 @@ document: state_id_jurisdiction: 'ND' state_id_number: '1111111111111' doc_auth_result: Passed -failed_alert: [] +failed_alerts: [] portrait_match_results: FaceMatchResult: Fail - FaceErrorMessage: 'Successful. Liveness: Live' \ No newline at end of file + FaceErrorMessage: 'Successful. Liveness: Live' 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 68090e6cb57..4ff7761b482 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 @@ -97,7 +97,7 @@ describe('DocumentCaptureWarning', () => { }); }); - context('not failed result', () => { + context('not failed result from vendor', () => { const isFailedResult = false; it('renders not failed doc type', () => { const { getByRole, getByText, queryByText } = renderContent({ @@ -139,7 +139,7 @@ describe('DocumentCaptureWarning', () => { }); }); - context('failed result', () => { + context('failed result from vendor', () => { const isFailedResult = true; it('renders not failed doc type', () => { const isFailedDocType = false; @@ -182,52 +182,54 @@ describe('DocumentCaptureWarning', () => { // troubleshooting section validateTroubleShootingSection(); }); + }); - it('renders with failed facematch for selfie', () => { - const isFailedDocType = false; - const isFailedSelfieFaceMatch = true; - const { getByRole, getByText, queryByText } = renderContent({ - isFailedDocType, - isFailedSelfieFaceMatch, - isFailedResult, - inPersonUrl, - }); - - // error message section - validateHeader('errors.doc_auth.selfie_fail_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 failed facematch for selfie', () => { + const isFailedDocType = false; + const isFailedResult = false; + const isFailedSelfieFaceMatch = true; + const { getByRole, getByText, queryByText } = renderContent({ + isFailedDocType, + isFailedSelfieFaceMatch, + isFailedResult, + inPersonUrl, }); - it('renders with failed quality/liveness selfie', () => { - const isFailedDocType = false; - const isFailedSelfieLivenessOrQuality = true; - const { getByRole, getByText, queryByText } = renderContent({ - isFailedDocType, - isFailedSelfieLivenessOrQuality, - isFailedResult, - inPersonUrl, - }); + // error message section + validateHeader('errors.doc_auth.selfie_fail_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 should exist + validateIppSection(true); + // troubleshooting section + validateTroubleShootingSection(); + }); - // 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); - // troubleshooting section - validateTroubleShootingSection(); + it('renders with failed quality/liveness selfie', () => { + const isFailedDocType = false; + const isFailedResult = 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.try_online' })).to.be.ok(); + // ipp section exists + validateIppSection(true); + // troubleshooting section + validateTroubleShootingSection(); }); }); diff --git a/spec/presenters/image_upload_response_presenter_spec.rb b/spec/presenters/image_upload_response_presenter_spec.rb index 69118b4defc..19cb7f0decb 100644 --- a/spec/presenters/image_upload_response_presenter_spec.rb +++ b/spec/presenters/image_upload_response_presenter_spec.rb @@ -124,6 +124,7 @@ it 'returns hash of properties' do expected = { success: false, + result_code_invalid: true, result_failed: false, errors: [{ field: :limit, message: t('errors.doc_auth.rate_limited_heading') }], redirect: idv_session_errors_rate_limited_url, @@ -146,6 +147,7 @@ it 'returns hash of properties redirecting to capture_complete' do expected = { success: false, + result_code_invalid: true, result_failed: false, errors: [{ field: :limit, message: t('errors.doc_auth.rate_limited_heading') }], redirect: idv_hybrid_mobile_capture_complete_url, @@ -175,6 +177,7 @@ it 'returns hash of properties' do expected = { success: false, + result_code_invalid: true, result_failed: false, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, @@ -202,6 +205,7 @@ it 'returns hash of properties' do expected = { success: false, + result_code_invalid: true, result_failed: true, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, @@ -238,6 +242,7 @@ it 'returns hash of properties' do expected = { success: false, + result_code_invalid: true, result_failed: false, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, @@ -255,6 +260,7 @@ it 'returns hash of properties' do expected = { success: false, + result_code_invalid: true, result_failed: false, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, @@ -334,6 +340,7 @@ hints: true, remaining_submit_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), + result_code_invalid: false, doc_type_supported: true, failed_image_fingerprints: { back: [], front: [], selfie: [] }, } @@ -356,6 +363,7 @@ expected = { success: false, result_failed: false, + result_code_invalid: false, errors: [], hints: true, remaining_submit_attempts: 3, diff --git a/spec/services/doc_auth/error_generator_spec.rb b/spec/services/doc_auth/error_generator_spec.rb index 870af6f800a..db61b4f7fdd 100644 --- a/spec/services/doc_auth/error_generator_spec.rb +++ b/spec/services/doc_auth/error_generator_spec.rb @@ -44,6 +44,8 @@ } end + let(:result_code_invalid) { false } + def build_error_info( doc_result: nil, passed: [], @@ -66,20 +68,72 @@ def build_error_info( portrait_match_results: portrait_match_results, image_metrics: image_metrics, classification_info: classification_info, + result_code_invalid: result_code_invalid, } end context 'The correct errors are delivered when' do - it 'DocAuthResult is Attention' do + let(:result_code_invalid) { true } + context 'when is attention' do + let(:result_code_invalid) { false } + it 'DocAuthResult is Attention with barcode' do + # noop - because we check for success or attn with barcode + # before entering the error generator, this case should never happen. + end + + it 'DocAuthResult is Attention of Barcode Read and general selfie error' do + error_info = build_error_info( + doc_result: 'Attention', + failed: [{ name: '2D Barcode Read', result: 'Attention' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Successful. Liveness: Live', + } + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :back, :front, :selfie, :hints) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:front]).to contain_exactly(DocAuth::Errors::MULTIPLE_FRONT_ID_FAILURES) + expect(output[:back]).to contain_exactly(DocAuth::Errors::MULTIPLE_BACK_ID_FAILURES) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is Attention with unknown alert' do + error_info = build_error_info( + doc_result: 'Attention', + failed: [{ name: 'Unknown Alert', result: 'Attention' }], + ) + + expect(warn_notifier).to receive(:call). + with(hash_including(:response_info, :message)).twice + + output = described_class.new(config).generate_doc_auth_errors(error_info) + + expect(output.keys).to contain_exactly(:general, :front, :back, :hints) + expect(output[:general]).to contain_exactly(DocAuth::Errors::GENERAL_ERROR) + expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:hints]).to eq(true) + end + end + it 'DocAuthResult is Unknown and general selfie error' do error_info = build_error_info( - doc_result: 'Attention', - failed: [{ name: '2D Barcode Read', result: 'Attention' }], + doc_result: 'Unknown', + failed: [{ name: 'Visible Pattern', result: 'Failed' }], ) - + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Successful. Liveness: Live', + } output = described_class.new(config).generate_doc_auth_errors(error_info) - - expect(output.keys).to contain_exactly(:general, :back, :hints) - expect(output[:general]).to contain_exactly(DocAuth::Errors::BARCODE_READ_CHECK) + expect(output.keys).to contain_exactly(:general, :front, :back, :hints) + expect(output[:general]).to contain_exactly(DocAuth::Errors::ID_NOT_VERIFIED) + expect(output[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) expect(output[:hints]).to eq(true) end @@ -261,6 +315,46 @@ def build_error_info( expect(output[:hints]).to eq(true) end + it 'DocAuthResult is Failed with unknown alert and general selfie error' do + error_info = build_error_info( + doc_result: 'Failed', + failed: [{ name: 'Unknown alert', result: 'Failed' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Successful. Liveness: Live', + } + expect(warn_notifier).to receive(:call). + with(hash_including(:response_info, :message)).twice + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :front, :back, :hints) + expect(output[:general]).to contain_exactly(DocAuth::Errors::GENERAL_ERROR_LIVENESS) + expect(output[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is Failed with known alert and specific selfie no liveness error' do + error_info = build_error_info( + doc_result: 'Failed', + failed: [{ name: 'Visible Pattern', result: 'Failed' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Liveness: NotLive', + } + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :front, :back, :hints) + expect(output[:general]).to contain_exactly(DocAuth::Errors::ID_NOT_VERIFIED) + expect(output[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:hints]).to eq(true) + end + it 'DocAuthResult is success with unsupported doc type' do error_info = build_error_info( doc_result: 'Passed', @@ -322,6 +416,165 @@ def build_error_info( expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) expect(output[:hints]).to eq(true) end + + it 'DocAuthResult is success with an unknown alert' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [{ name: 'Not a known alert', result: 'Failed' }], + ) + expect(warn_notifier).to receive(:call). + with(hash_including(:response_info, :message)).twice + + # this is a fall back result, we cannot generate error but the generator is called + # which should not happen + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :front, :back, :hints) + expect(output[:general]).to contain_exactly(DocAuth::Errors::GENERAL_ERROR) + expect(output[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(output[:hints]).to eq(true) + end + it 'DocAuthResult is success with general selfie error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Successful. Liveness: Live', + } + + output = described_class.new(config).generate_doc_auth_errors(error_info) + + expect(output.keys).to contain_exactly(:general, :front, :back, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:front]).to contain_exactly(DocAuth::Errors::MULTIPLE_FRONT_ID_FAILURES) + expect(output[:back]).to contain_exactly(DocAuth::Errors::MULTIPLE_BACK_ID_FAILURES) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is success with specific selfie no liveness error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Liveness: NotLive', + } + + output = described_class.new(config).generate_doc_auth_errors(error_info) + + expect(output.keys).to contain_exactly(:general, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_NOT_LIVE) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is success with specific selfie liveness quality error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Liveness: PoorQuality', + } + + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_POOR_QUALITY) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is success with alert and general selfie error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [{ name: 'Visible Pattern', result: 'Failed' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Successful. Liveness: Live', + } + + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :front, :back, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:front]).to contain_exactly(DocAuth::Errors::MULTIPLE_FRONT_ID_FAILURES) + expect(output[:back]).to contain_exactly(DocAuth::Errors::MULTIPLE_BACK_ID_FAILURES) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is success with unknown alert and general selfie error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [{ name: 'Unknown alert', result: 'Failed' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Successful. Liveness: Live', + } + expect(warn_notifier).to receive(:call). + with(hash_including(:response_info, :message)).once + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :front, :back, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:front]).to contain_exactly(DocAuth::Errors::MULTIPLE_FRONT_ID_FAILURES) + expect(output[:back]).to contain_exactly(DocAuth::Errors::MULTIPLE_BACK_ID_FAILURES) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is success with alert and specific no liveness error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [{ name: 'Visible Pattern', result: 'Failed' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Liveness: NotLive', + } + + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_NOT_LIVE) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end + + it 'DocAuthResult is success with alert and specific liveness quality error' do + error_info = build_error_info( + doc_result: 'Passed', + failed: [{ name: 'Visible Pattern', result: 'Failed' }], + ) + error_info[:liveness_enabled] = true + # Selfie not match ID + error_info[:portrait_match_results] = { + FaceMatchResult: 'Fail', + FaceErrorMessage: 'Liveness: PoorQuality', + } + + output = described_class.new(config).generate_doc_auth_errors(error_info) + expect(output.keys).to contain_exactly(:general, :hints, :selfie) + expect(output[:general]).to contain_exactly(DocAuth::Errors::SELFIE_POOR_QUALITY) + expect(output[:selfie]).to contain_exactly(DocAuth::Errors::SELFIE_FAILURE) + expect(output[:hints]).to eq(false) + end end context 'The correct errors are delivered for image metrics when' do @@ -514,38 +767,36 @@ def build_error_info( front: { 'HorizontalResolution' => 300, 'VerticalResolution' => 300, - 'SharpnessMetric' => 50, - 'GlareMetric' => 50, + 'SharpnessMetric' => 25, + 'GlareMetric' => 25, }, back: { 'HorizontalResolution' => 300, 'VerticalResolution' => 300, - 'SharpnessMetric' => 50, - 'GlareMetric' => 50, + 'SharpnessMetric' => 25, + 'GlareMetric' => 25, }, } end context 'when liveness is enabled' do let(:liveness_enabled) { true } + context 'when liveness check passed' do let(:face_match_result) { 'Pass' } - it 'DocAuthResult is Passed with no other error' do + it 'returns a metric error with no other error' do error_info = build_error_info(doc_result: 'Passed', image_metrics: metrics) - # this is an edge case, the generate_doc_auth_errors function should no be - # called when everything is successful - expect(warn_notifier).to receive(:call). - with(hash_including(:response_info, :message)).once - described_class.new(config).generate_doc_auth_errors(error_info) + errors = described_class.new(config).generate_doc_auth_errors(error_info) + expect(errors.keys).to contain_exactly(:front, :back, :general, :hints) end end context 'when liveness check failed' do let(:face_match_result) { 'Fail' } - it 'DocAuthResult is failed with selfie error' do + it 'returns a metric error without a selfie error' do error_info = build_error_info(doc_result: 'Passed', image_metrics: metrics) errors = described_class.new(config).generate_doc_auth_errors(error_info) - expect(errors.keys).to contain_exactly(:front, :back, :general, :selfie, :hints) + expect(errors.keys).to contain_exactly(:front, :back, :general, :hints) end end end diff --git a/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb b/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb index 17f641bcab4..101cc14c778 100644 --- a/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb @@ -82,15 +82,25 @@ expect(response.success?).to eq(false) expect(response.errors.keys).to contain_exactly(:general, :front, :back, :hints) - expect(response.errors[:general]).to contain_exactly(DocAuth::Errors::GENERAL_ERROR) - expect(response.errors[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) - expect(response.errors[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) - expect(response.errors[:hints]).to eq(true) - expect(response.exception).to be_nil + if include_liveness_expected + expect(response.errors[:general]).to contain_exactly( + DocAuth::Errors::GENERAL_ERROR_LIVENESS, + ) + expect(response.errors[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(response.errors[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(response.errors[:hints]).to eq(false) + + expect(response.exception).to be_nil expect(request_stub_liveness).to have_been_requested expect(response.selfie_check_performed?).to be(true) else + expect(response.errors[:general]).to contain_exactly(DocAuth::Errors::GENERAL_ERROR) + expect(response.errors[:front]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(response.errors[:back]).to contain_exactly(DocAuth::Errors::FALLBACK_FIELD_LEVEL) + expect(response.errors[:hints]).to eq(true) + + expect(response.exception).to be_nil expect(request_stub).to have_been_requested expect(response.selfie_check_performed?).to be(false) end 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 d783ed877ad..277f46e84ca 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 @@ -400,10 +400,10 @@ def get_decision_product(resp) success: false, exception: nil, errors: { - general: [DocAuth::Errors::GENERAL_ERROR], + general: [DocAuth::Errors::GENERAL_ERROR_LIVENESS], front: [DocAuth::Errors::FALLBACK_FIELD_LEVEL], back: [DocAuth::Errors::FALLBACK_FIELD_LEVEL], - hints: true, + hints: false, }, attention_with_barcode: false, doc_type_supported: true, diff --git a/spec/services/doc_auth/mock/result_response_spec.rb b/spec/services/doc_auth/mock/result_response_spec.rb index dfbe33f9cf3..d1a93bec518 100644 --- a/spec/services/doc_auth/mock/result_response_spec.rb +++ b/spec/services/doc_auth/mock/result_response_spec.rb @@ -140,7 +140,10 @@ it 'returns a successful result' do expect(response.success?).to eq(true) - expect(response.errors).to eq({}) + expect(response.errors).to eq( + back: ['fallback_field_level'], + general: ['barcode_read_check'], hints: true + ) expect(response.exception).to eq(nil) expect(response.pii_from_doc).to include(first_name: 'Susan', last_name: nil) expect(response.attention_with_barcode?).to eq(true)