diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 7925d98f223..f9ba4f53c81 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -8,6 +8,7 @@ class ApiImageUploadForm validates_presence_of :document_capture_session validate :validate_images + validate :validate_duplicate_images, if: :image_resubmission_check? validate :limit_if_rate_limited def initialize(params, service_provider:, analytics: nil, @@ -41,8 +42,9 @@ def submit doc_pii_response: doc_pii_response, ) + failed_fingerprints = store_failed_images(client_response, doc_pii_response) + response.extra[:failed_image_fingerprints] = failed_fingerprints track_event(response) - response end @@ -92,7 +94,6 @@ def post_images_to_client client_response: response, vendor_request_time_in_ms: timer.results['vendor_request'], ) - response end @@ -122,7 +123,8 @@ def validate_pii_from_doc(client_response) end def extra_attributes - return @extra_attributes if defined?(@extra_attributes) + return @extra_attributes if defined?(@extra_attributes) && + @extra_attributes&.dig('attempts') == attempts @extra_attributes = { attempts: attempts, remaining_attempts: remaining_attempts, @@ -131,17 +133,26 @@ def extra_attributes flow_path: params[:flow_path], } + @extra_attributes[:front_image_fingerprint] = front_image_fingerprint + @extra_attributes[:back_image_fingerprint] = back_image_fingerprint + @extra_attributes.merge!(getting_started_ab_test_analytics_bucket) + @extra_attributes + end + + def front_image_fingerprint + return @front_image_fingerprint if @front_image_fingerprint if readable?(:front) - @extra_attributes[:front_image_fingerprint] = + @front_image_fingerprint = Digest::SHA256.urlsafe_base64digest(front_image_bytes) end + end + def back_image_fingerprint + return @back_image_fingerprint if @back_image_fingerprint if readable?(:back) - @extra_attributes[:back_image_fingerprint] = + @back_image_fingerprint = Digest::SHA256.urlsafe_base64digest(back_image_bytes) end - - @extra_attributes.merge!(getting_started_ab_test_analytics_bucket) end def remaining_attempts @@ -199,8 +210,31 @@ def validate_images end end + def validate_duplicate_images + capture_result = document_capture_session&.load_result + return unless capture_result + error_sides = [] + if capture_result&.failed_front_image?(front_image_fingerprint) + errors.add( + :front, t('doc_auth.errors.doc.resubmit_failed_image'), type: :duplicate_image + ) + error_sides << 'front' + end + + if capture_result&.failed_back_image?(back_image_fingerprint) + errors.add( + :back, t('doc_auth.errors.doc.resubmit_failed_image'), type: :duplicate_image + ) + error_sides << 'back' + end + unless error_sides.empty? + analytics.idv_doc_auth_failed_image_resubmitted( + side: error_sides.length == 2 ? 'both' : error_sides[0], **extra_attributes, + ) + end + end + def limit_if_rate_limited - return unless document_capture_session return unless rate_limited? errors.add(:limit, t('errors.doc_auth.rate_limited_heading'), type: :rate_limited) @@ -367,5 +401,53 @@ def track_event(response) failure_reason: response.errors&.except(:hints)&.presence, ) end + + ## + # Store failed image fingerprints in document_capture_session_result + # when client_response is not successful and not a network error + # ( http status except handled status 438, 439, 440 ) or doc_pii_response is not successful. + # @param [Object] client_response + # @param [Object] doc_pii_response + # @return [Object] latest failed fingerprints + def store_failed_images(client_response, doc_pii_response) + unless image_resubmission_check? + return { + front: [], + back: [], + } + end + # doc auth failed due to non network error or doc_pii is not valid + if client_response && !client_response.success? && !client_response.network_error? + errors_hash = client_response.errors&.to_h || {} + ## assume both sides' error presents or both sides' error missing + failed_front_fingerprint = extra_attributes[:front_image_fingerprint] + failed_back_fingerprint = extra_attributes[:back_image_fingerprint] + ## not both sides' error present nor both sides' error missing + ## equivalent to: only one side error presents + only_one_side_error = errors_hash[:front]&.present? ^ errors_hash[:back]&.present? + if only_one_side_error + ## find which side is missing + failed_front_fingerprint = nil unless errors_hash[:front]&.present? + failed_back_fingerprint = nil unless errors_hash[:back]&.present? + end + document_capture_session. + store_failed_auth_image_fingerprint(failed_front_fingerprint, failed_back_fingerprint) + elsif doc_pii_response && !doc_pii_response.success? + document_capture_session.store_failed_auth_image_fingerprint( + extra_attributes[:front_image_fingerprint], + extra_attributes[:back_image_fingerprint], + ) + end + # retrieve updated data from session + captured_result = document_capture_session&.load_result + { + front: captured_result&.failed_front_image_fingerprints || [], + back: captured_result&.failed_back_image_fingerprints || [], + } + end + + def image_resubmission_check? + IdentityConfig.store.doc_auth_check_failed_image_resubmission_enabled + end end end diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 5d4d17228c3..45eadb56963 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -62,6 +62,16 @@ interface ImageAnalyticsPayload { * capture functionality */ acuantCaptureMode?: AcuantCaptureMode; + + /** + * Fingerprint of the image, base64 encoded SHA-256 digest + */ + fingerprint: string | null; + + /** + * + */ + failedImageResubmission: boolean; } interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload { @@ -75,6 +85,7 @@ interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload { sharpnessScoreThreshold: number; isAssessedAsBlurry: boolean; assessment: AcuantImageAssessment; + isAssessedAsUnsupported: boolean; } interface AcuantCaptureProps { @@ -178,6 +189,31 @@ export function getNormalizedAcuantCaptureFailureMessage( } } +function getFingerPrint(file: File): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const dataBuffer = reader.result; + window.crypto.subtle + .digest('SHA-256', dataBuffer as ArrayBuffer) + .then((arrayBuffer) => { + const digestArray = new Uint8Array(arrayBuffer); + const strDigest = digestArray.reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ); + const base64String = window.btoa(strDigest); + const urlSafeBase64String = base64String + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + resolve(urlSafeBase64String); + }) + .catch(() => null); + }; + reader.readAsArrayBuffer(file); + }); +} function getImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> { let objectURL: string; return file.type.indexOf('image/') === 0 @@ -196,6 +232,22 @@ function getImageDimensions(file: File): Promise<{ width: number | null; height: : Promise.resolve({ width: null, height: null }); } +function getImageMetadata( + file: File, +): Promise<{ width: number | null; height: number | null; fingerprint: string | null }> { + const dimension = getImageDimensions(file); + const fingerprint = getFingerPrint(file); + return new Promise<{ width: number | null; height: number | null; fingerprint: string | null }>( + function (resolve) { + Promise.all([dimension, fingerprint]) + .then((results) => { + resolve({ width: results[0].width, height: results[0].height, fingerprint: results[1] }); + }) + .catch(() => ({ width: null, height: null, fingerprint: null })); + }, + ); +} + /** * Pauses default focus trap behaviors for a single tick. If a focus transition occurs during this * tick, the focus trap's deactivation will be overridden to prevent any default focus return, in @@ -289,6 +341,7 @@ function AcuantCapture( onResetFailedCaptureAttempts, failedSubmissionAttempts, forceNativeCamera, + failedSubmissionImageFingerprints, } = useContext(FailedCaptureAttemptsContext); const hasCapture = !isError && (isReady ? isCameraSupported : isMobile); @@ -330,17 +383,19 @@ function AcuantCapture( */ async function onUpload(nextValue: File | null) { let analyticsPayload: ImageAnalyticsPayload | undefined; + let hasFailed = false; if (nextValue) { - const { width, height } = await getImageDimensions(nextValue); - + const { width, height, fingerprint } = await getImageMetadata(nextValue); + hasFailed = failedSubmissionImageFingerprints[name]?.includes(fingerprint); analyticsPayload = getAddAttemptAnalyticsPayload({ width, height, + fingerprint, mimeType: nextValue.type, source: 'upload', size: nextValue.size, + failedImageResubmission: hasFailed, }); - trackEvent(`IdV: ${name} image added`, analyticsPayload); } @@ -472,6 +527,8 @@ function AcuantCapture( isAssessedAsBlurry, assessment, size: getDecodedBase64ByteSize(nextCapture.image.data), + fingerprint: null, + failedImageResubmission: false, }); trackEvent(`IdV: ${name} image added`, analyticsPayload); diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index d53a06cc9ac..9ec0fa2209d 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -117,6 +117,7 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { isFailedDocType: submissionError.isFailedDocType, captureHints: submissionError.hints, pii: submissionError.pii, + failedImageFingerprints: submissionError.failed_image_fingerprints, })(ReviewIssuesStep) : ReviewIssuesStep, title: t('errors.doc_auth.rate_limited_heading'), diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx index 5d8e200d622..984b91bb249 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx @@ -1,5 +1,6 @@ import { t } from '@18f/identity-i18n'; -import { FormError } from '@18f/identity-form-steps'; +import { FormError, FormStepsContext } from '@18f/identity-form-steps'; +import { useContext } from 'react'; import AcuantCapture from './acuant-capture'; /** @typedef {import('@18f/identity-form-steps').FormStepError<*>} FormStepError */ @@ -43,7 +44,7 @@ function DocumentSideAcuantCapture({ className, }) { const error = errors.find(({ field }) => field === side)?.error; - + const { changeStepCanComplete } = useContext(FormStepsContext); return ( + onChange={(nextValue, metadata) => { onChange({ [side]: nextValue, [`${side}_image_metadata`]: JSON.stringify(metadata), - }) - } + }); + if (metadata?.failedImageResubmission) { + onError(new Error(t('doc_auth.errors.doc.resubmit_failed_image')), { field: side }); + changeStepCanComplete(false); + } else { + changeStepCanComplete(true); + } + }} onCameraAccessDeclined={() => { onError(new CameraAccessDeclinedError(), { field: side }); onError(new CameraAccessDeclinedError(undefined, { isDetail: true })); diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx index b3cc4ab2b96..5bcaa4fe1d9 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from 'react'; +import { useContext, useEffect, useLayoutEffect, useState } from 'react'; import { useDidUpdateEffect } from '@18f/identity-react-hooks'; import { FormStepsContext } from '@18f/identity-form-steps'; import type { FormStepComponentProps } from '@18f/identity-form-steps'; @@ -24,7 +24,6 @@ interface ReviewIssuesStepValue { * Front image metadata. */ front_image_metadata?: string; - /** * Back image metadata. */ @@ -41,6 +40,8 @@ interface ReviewIssuesStepProps extends FormStepComponentProps onFailedSubmissionAttempt(), []); + const { onFailedSubmissionAttempt, failedSubmissionImageFingerprints } = useContext( + FailedCaptureAttemptsContext, + ); + useEffect(() => onFailedSubmissionAttempt(failedImageFingerprints), []); + + useLayoutEffect(() => { + let frontMetaData: { fingerprint: string | null } = { fingerprint: null }; + try { + frontMetaData = JSON.parse( + typeof value.front_image_metadata === 'undefined' ? '{}' : value.front_image_metadata, + ); + } catch (e) {} + const frontHasFailed = !!failedSubmissionImageFingerprints?.front?.includes( + frontMetaData?.fingerprint ?? '', + ); + + let backMetaData: { fingerprint: string | null } = { fingerprint: null }; + try { + backMetaData = JSON.parse( + typeof value.back_image_metadata === 'undefined' ? '{}' : value.back_image_metadata, + ); + } catch (e) {} + const backHasFailed = !!failedSubmissionImageFingerprints?.back?.includes( + backMetaData?.fingerprint ?? '', + ); + if (frontHasFailed || backHasFailed) { + setSkipWarning(true); + } + }, []); + function onWarningPageDismissed() { trackEvent('IdV: Capture troubleshooting dismissed'); @@ -72,14 +103,15 @@ function ReviewIssuesStep({ // let FormSteps know, via FormStepsContext, whether this page // is ready to submit form values useEffect(() => { - changeStepCanComplete(!!hasDismissed); + changeStepCanComplete(!!hasDismissed && !skipWarning); }, [hasDismissed]); if (!hasDismissed && pii) { return ; } + // Show warning screen - if (!hasDismissed) { + if (!hasDismissed && !skipWarning) { // Warning(try again screen) return ( void; + onFailedSubmissionAttempt: (failedImageFingerprints: UploadedImageFingerprints) => void; /** * The maximum number of failed Acuant capture attempts @@ -58,6 +66,8 @@ interface FailedCaptureAttemptsContextInterface { * after maxCaptureAttemptsBeforeNativeCamera number of failed attempts */ forceNativeCamera: boolean; + + failedSubmissionImageFingerprints: UploadedImageFingerprints; } const DEFAULT_LAST_ATTEMPT_METADATA: CaptureAttemptMetadata = { @@ -76,6 +86,7 @@ const FailedCaptureAttemptsContext = createContext( DEFAULT_LAST_ATTEMPT_METADATA, @@ -98,13 +111,17 @@ function FailedCaptureAttemptsContextProvider({ useCounter(); const [failedSubmissionAttempts, incrementFailedSubmissionAttempts] = useCounter(); + const [failedSubmissionImageFingerprints, setFailedSubmissionImageFingerprints] = + useState(failedFingerprints); + function onFailedCaptureAttempt(metadata: CaptureAttemptMetadata) { incrementFailedCaptureAttempts(); setLastAttemptMetadata(metadata); } - function onFailedSubmissionAttempt() { + function onFailedSubmissionAttempt(failedOnes: UploadedImageFingerprints) { incrementFailedSubmissionAttempts(); + setFailedSubmissionImageFingerprints(failedOnes); } const forceNativeCamera = @@ -123,6 +140,7 @@ function FailedCaptureAttemptsContextProvider({ maxSubmissionAttemptsBeforeNativeCamera, lastAttemptMetadata, forceNativeCamera, + failedSubmissionImageFingerprints, }} > {children} @@ -132,3 +150,4 @@ function FailedCaptureAttemptsContextProvider({ export default FailedCaptureAttemptsContext; export { FailedCaptureAttemptsContextProvider as Provider }; +export { UploadedImageFingerprints }; diff --git a/app/javascript/packages/document-capture/context/upload.tsx b/app/javascript/packages/document-capture/context/upload.tsx index 1fc8192ead8..6f6879cfee7 100644 --- a/app/javascript/packages/document-capture/context/upload.tsx +++ b/app/javascript/packages/document-capture/context/upload.tsx @@ -55,7 +55,10 @@ export interface UploadSuccessResponse { */ isPending: boolean; } - +export interface ImageFingerprints { + front: string[] | null; + back: string[] | null; +} export interface UploadErrorResponse { /** * Whether request was successful. @@ -96,6 +99,11 @@ export interface UploadErrorResponse { * Whether the doc type is clearly not supported type. */ doc_type_supported: boolean; + + /** + * Record of failed image fingerprints + */ + failed_image_fingerprints: ImageFingerprints | null; } export type UploadImplementation = ( diff --git a/app/javascript/packages/document-capture/services/upload.ts b/app/javascript/packages/document-capture/services/upload.ts index f2ce10624ce..ed0c77e16ed 100644 --- a/app/javascript/packages/document-capture/services/upload.ts +++ b/app/javascript/packages/document-capture/services/upload.ts @@ -6,6 +6,7 @@ import type { UploadErrorResponse, UploadFieldError, UploadImplementation, + ImageFingerprints, } from '../context/upload'; /** @@ -44,6 +45,8 @@ export class UploadFormEntriesError extends FormError { pii?: PII; hints = false; + + failed_image_fingerprints: ImageFingerprints = { front: [], back: [] }; } /** @@ -121,6 +124,8 @@ const upload: UploadImplementation = async function (payload, { method = 'POST', error.isFailedDocType = !result.doc_type_supported; + error.failed_image_fingerprints = result.failed_image_fingerprints ?? { front: [], back: [] }; + throw error; } diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index 497b3e9ce56..94a6b96f50d 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -213,7 +213,6 @@ function getFieldActiveErrorFieldElement( fields: Record, ) { const error = errors.find(({ field }) => field && fields[field]?.element); - if (error) { return fields[error.field!].element || undefined; } diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 1889f7e0517..95e0b2bf218 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -4,23 +4,39 @@ class DocumentCaptureSession < ApplicationRecord belongs_to :user def load_result + return nil unless result_id.present? EncryptedRedisStructStorage.load(result_id, type: DocumentCaptureSessionResult) end def store_result_from_response(doc_auth_response) + session_result = load_result || DocumentCaptureSessionResult.new( + id: generate_result_id, + ) + session_result.success = doc_auth_response.success? + session_result.pii = doc_auth_response.pii_from_doc + session_result.attention_with_barcode = doc_auth_response.attention_with_barcode? EncryptedRedisStructStorage.store( - DocumentCaptureSessionResult.new( - id: generate_result_id, - success: doc_auth_response.success?, - pii: doc_auth_response.pii_from_doc, - attention_with_barcode: doc_auth_response.attention_with_barcode?, - ), + session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, ) self.ocr_confirmation_pending = doc_auth_response.attention_with_barcode? save! end + def store_failed_auth_image_fingerprint(front_image_fingerprint, back_image_fingerprint) + session_result = load_result || DocumentCaptureSessionResult.new( + id: generate_result_id, + ) + session_result.success = false + session_result.add_failed_front_image!(front_image_fingerprint) if front_image_fingerprint + session_result.add_failed_back_image!(back_image_fingerprint) if back_image_fingerprint + EncryptedRedisStructStorage.store( + session_result, + expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, + ) + save! + end + def load_doc_auth_async_result EncryptedRedisStructStorage.load(result_id, type: DocumentCaptureSessionAsyncResult) end diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb index edeee46a277..74ad43535fa 100644 --- a/app/presenters/image_upload_response_presenter.rb +++ b/app/presenters/image_upload_response_presenter.rb @@ -49,6 +49,7 @@ def as_json(*) json[:ocr_pii] = ocr_pii json[:result_failed] = doc_auth_result_failed? json[:doc_type_supported] = doc_type_supported? + json[:failed_image_fingerprints] = failed_fingerprints json end end @@ -80,4 +81,8 @@ def doc_type_supported? # default to true by assuming using supported doc type unless we clearly detect unsupported type @form_response.respond_to?(:id_type_supported?) ? @form_response.id_type_supported? : true end + + def failed_fingerprints + @form_response.extra[:failed_image_fingerprints] || { front: [], back: [] } + end end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d2d817cf6d0..412d7fb4c53 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -701,6 +701,15 @@ def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra) ) end + # @param [String] side the side of the image submission + def idv_doc_auth_failed_image_resubmitted(side:, **extra) + track_event( + 'IdV: failed doc image resubmitted', + side: side, + **extra, + ) + end + def idv_doc_auth_getting_started_submitted(**extra) track_event('IdV: doc auth getting_started submitted', **extra) end diff --git a/app/services/doc_auth/mock/doc_auth_mock_client.rb b/app/services/doc_auth/mock/doc_auth_mock_client.rb index de3061a925f..63e32360828 100644 --- a/app/services/doc_auth/mock/doc_auth_mock_client.rb +++ b/app/services/doc_auth/mock/doc_auth_mock_client.rb @@ -34,7 +34,6 @@ def create_document # rubocop:disable Lint/UnusedMethodArgument def post_front_image(image:, instance_id:) return mocked_response_for_method(__method__) if method_mocked?(__method__) - self.class.last_uploaded_front_image = image DocAuth::Response.new(success: true) end diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 56c76d99bce..84e517b21fc 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -71,6 +71,39 @@ def attention_with_barcode? parsed_alerts == [ATTENTION_WITH_BARCODE_ALERT] end + def self.create_image_error_response(status) + error = case status + when 438 + Errors::IMAGE_LOAD_FAILURE + when 439 + Errors::PIXEL_DEPTH_FAILURE + when 440 + Errors::IMAGE_SIZE_FAILURE + end + errors = { general: [error] } + message = [ + 'Unexpected HTTP response', + status, + ].join(' ') + exception = DocAuth::RequestError.new(message, status) + DocAuth::Response.new( + success: false, + errors: errors, + exception: exception, + extra: { vendor: 'Mock' }, + ) + end + + def self.create_network_error_response + errors = { network: true } + DocAuth::Response.new( + success: false, + errors: errors, + exception: Faraday::TimeoutError.new, + extra: { vendor: 'Mock' }, + ) + end + private def parsed_alerts diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index a652200b6a1..d7c3652e9e7 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -68,5 +68,10 @@ def first_error_message def attention_with_barcode? @attention_with_barcode end + + def network_error? + return false unless @errors + return !!@errors&.with_indifferent_access&.dig(:network) + end end end diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 320468a7968..6400dbcf592 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -6,8 +6,11 @@ :success, :pii, :attention_with_barcode, + :failed_front_image_fingerprints, + :failed_back_image_fingerprints, keyword_init: true, - allowed_members: [:id, :success, :attention_with_barcode], + allowed_members: [:id, :success, :attention_with_barcode, :failed_front_image_fingerprints, + :failed_back_image_fingerprints], ) do def self.redis_key_prefix 'dcs:result' @@ -16,4 +19,18 @@ def self.redis_key_prefix alias_method :success?, :success alias_method :attention_with_barcode?, :attention_with_barcode alias_method :pii_from_doc, :pii + + %w[front back].each do |side| + define_method("add_failed_#{side}_image!") do |fingerprint| + member_name = "failed_#{side}_image_fingerprints" + self[member_name] ||= [] + self[member_name] << fingerprint + end + + define_method("failed_#{side}_image?") do |fingerprint| + member_name = "failed_#{side}_image_fingerprints" + return false unless self[member_name]&.is_a?(Array) + return self[member_name]&.include?(fingerprint) + end + end end diff --git a/config/application.yml.default b/config/application.yml.default index e48f2cf8c7c..85c6af20ee7 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -75,6 +75,7 @@ disallow_all_web_crawlers: true disposable_email_services: '[]' doc_auth_attempt_window_in_minutes: 360 doc_capture_polling_enabled: true +doc_auth_check_failed_image_resubmission_enabled: true doc_auth_client_glare_threshold: 50 doc_auth_client_sharpness_threshold: 50 doc_auth_s3_request_timeout: 5 diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index 896682efd6b..c0a779abd04 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -56,6 +56,8 @@ en: card_type: Try again with your driver’s license or state ID card. doc: doc_type_check: Other forms of ID are not accepted. + resubmit_failed_image: You already tried this image, and it failed. Please try + adding a different image. wrong_id_type: We only accept a driver’s license or a state ID card at this time. Other forms of ID are not accepted. dpi: diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index c72483ac0ea..1e379a2862b 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -68,6 +68,8 @@ es: estatales. doc: doc_type_check: No se aceptan otras formas de identificación. + resubmit_failed_image: Ya intentó con esta imagen pero falló. Intente añadir una + imagen diferente. wrong_id_type: Solo aceptamos una licencia de conducir o un documento de identidad estatal. No se aceptan otras formas de identificación. dpi: diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index bbc982b19ea..97533c51ad8 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -73,6 +73,8 @@ fr: par l’État. doc: doc_type_check: Les autres pièces d’identité ne sont pas acceptées. + resubmit_failed_image: Vous avez déjà essayé cette image et elle a échoué. + Veuillez essayer d’ajouter une image différente. wrong_id_type: Nous n’acceptons que les permis de conduire ou les cartes d’identité délivrées par l’État. Les autres pièces d’identité ne sont pas acceptées. diff --git a/lib/identity_config.rb b/lib/identity_config.rb index c3bb51fa3c3..c34c4581af2 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -174,6 +174,7 @@ def self.build_store(config_map) config.add(:disallow_all_web_crawlers, type: :boolean) config.add(:disposable_email_services, type: :json) config.add(:doc_auth_attempt_window_in_minutes, type: :integer) + config.add(:doc_auth_check_failed_image_resubmission_enabled, type: :boolean) config.add(:doc_auth_client_glare_threshold, type: :integer) config.add(:doc_auth_client_sharpness_threshold, type: :integer) config.add(:doc_auth_error_dpi_threshold, type: :integer) diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 7b8472f0bf2..0de4acc241b 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -202,6 +202,7 @@ result_failed: false, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, }, ) end @@ -217,6 +218,7 @@ result_failed: false, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, } end diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 3851f80b57e..77634db5a82 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -63,10 +63,10 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, getting_started_ab_test_bucket: :welcome_default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false }, 'Frontend: IdV: front image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'Frontend: IdV: back image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), getting_started_ab_test_bucket: :welcome_default @@ -168,10 +168,10 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, getting_started_ab_test_bucket: :welcome_default, skip_hybrid_handoff: nil, analytics_id: 'Doc Auth', irs_reproofing: false }, 'Frontend: IdV: front image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'Frontend: IdV: back image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), getting_started_ab_test_bucket: :welcome_default @@ -255,10 +255,10 @@ flow_path: 'standard', step: 'document_capture', redo_document_capture: nil, acuant_sdk_upgrade_ab_test_bucket: :default, getting_started_ab_test_bucket: :welcome_default, analytics_id: 'Doc Auth', skip_hybrid_handoff: nil, irs_reproofing: false }, 'Frontend: IdV: front image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'Frontend: IdV: back image added' => { - 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO' + 'width' => 284, 'height' => 38, 'mimeType' => 'image/png', 'source' => 'upload', 'size' => 3694, 'attempt' => 1, 'flow_path' => 'standard', 'acuant_sdk_upgrade_a_b_testing_enabled' => 'false', 'use_alternate_sdk' => anything, 'acuant_version' => anything, 'acuantCaptureMode' => 'AUTO', 'fingerprint' => anything, 'failedImageResubmission' => boolean }, 'IdV: doc auth image upload form submitted' => { success: true, errors: {}, attempts: 1, remaining_attempts: 3, user_id: user.uuid, flow_path: 'standard', front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), getting_started_ab_test_bucket: :welcome_default diff --git a/spec/features/idv/doc_auth/redo_document_capture_spec.rb b/spec/features/idv/doc_auth/redo_document_capture_spec.rb index 0137b789e92..7e3eea50122 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -57,7 +57,7 @@ expect(current_path).to eq(idv_verify_info_path) check t('forms.ssn.show') expect(page).to have_content(DocAuthHelper::GOOD_SSN) - expect(page).to have_css('[role="status"]') # We verified your ID + expect(page).to have_css('[role="status"]') # We verified your ID end it 'document capture cannot be reached after submitting verify info step' do @@ -148,4 +148,115 @@ end end end + + shared_examples_for 'image re-upload allowed' do + it 'allows user to submit the same image again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3), + ) + DocAuth::Mock::DocAuthMockClient.reset! + attach_and_submit_images + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 2), + ) + expect(current_path).to eq(idv_ssn_path) + check t('forms.ssn.show') + end + end + + shared_examples_for 'image re-upload not allowed' do + it 'stops user submitting the same image again' do + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth document_capture visited', + hash_including(redo_document_capture: nil), + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload form submitted', + hash_including(remaining_attempts: 3, attempts: 1), + ) + DocAuth::Mock::DocAuthMockClient.reset! + attach_images + # Error message without submit + expect(page).to have_css( + '.usa-error-message[role="alert"]', + text: t('doc_auth.errors.doc.resubmit_failed_image'), + ) + end + end + + context 'error due to data issue with 2xx status code', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_error_unknown + attach_and_submit_images + click_try_again + end + it_behaves_like 'image re-upload not allowed' + end + + context 'error due to data issue with 4xx status code with trueid', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_trueid_http_non2xx_status(438) + attach_and_submit_images + click_try_again + end + + it_behaves_like 'image re-upload allowed' + end + + context 'error due to http status error but non 4xx status code with trueid', + allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_trueid_http_non2xx_status(500) + attach_and_submit_images + click_try_again + end + it_behaves_like 'image re-upload allowed' + end + + context 'error due to data issue with 4xx status code with assureid', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_http_4xx_status(440) + attach_and_submit_images + click_try_again + end + it_behaves_like 'image re-upload not allowed' + end + + context 'error due to data issue with 5xx status code with assureid', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_http_5xx_status + attach_and_submit_images + click_try_again + end + + it_behaves_like 'image re-upload allowed' + end + + context 'unknown error for acuant', allow_browser_log: true do + before do + sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_acuant_error_unknown + attach_and_submit_images + click_try_again + end + + it_behaves_like 'image re-upload not allowed' + end end diff --git a/spec/features/idv/doc_auth/test_credentials_spec.rb b/spec/features/idv/doc_auth/test_credentials_spec.rb index 34976f8c287..849236666e8 100644 --- a/spec/features/idv/doc_auth/test_credentials_spec.rb +++ b/spec/features/idv/doc_auth/test_credentials_spec.rb @@ -61,6 +61,8 @@ def triggers_error_test_credentials_missing(credential_file, alert_message) it 'rate limits the user if invalid credentials submitted for max allowed attempts', allow_browser_log: true do + allow(IdentityConfig.store).to receive(:doc_auth_check_failed_image_resubmission_enabled). + and_return(false) max_attempts = IdentityConfig.store.doc_auth_max_attempts (max_attempts - 1).times do complete_document_capture_step_with_yml( diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 20388b584e9..d1a95f84c5d 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -194,7 +194,6 @@ it 'logs analytics excluding invalid metadata' do form.submit - expect(fake_analytics).to have_logged_event( 'IdV: doc auth image upload form submitted', success: true, @@ -269,6 +268,16 @@ response = form.submit expect(response.errors[:front]).to eq('glare') end + + it 'keeps fingerprints of failed image and triggers error when submit same image' do + form.submit + session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) + capture_result = session.load_result + expect(capture_result.failed_front_image_fingerprints).not_to match_array([]) + response = form.submit + expect(response.errors).to have_key(:front) + expect(response.errors).to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) + end end context 'PII validation from client response fails' do @@ -311,6 +320,27 @@ response = form.submit expect(response.errors[:doc_pii]).to eq('bad') end + + it 'keeps fingerprints of failed image and triggers error when submit same image' do + form.submit + session = DocumentCaptureSession.find_by(uuid: document_capture_session_uuid) + capture_result = session.load_result + expect(capture_result.failed_front_image_fingerprints).not_to match_array([]) + response = form.submit + expect(response.errors).to have_key(:front) + expect(response.errors).to have_value([I18n.t('doc_auth.errors.doc.resubmit_failed_image')]) + expect(fake_analytics).to have_logged_event( + 'IdV: failed doc image resubmitted', + attempts: 1, + remaining_attempts: 3, + user_id: document_capture_session.user.uuid, + flow_path: anything, + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + getting_started_ab_test_bucket: :welcome_default, + side: 'both', + ) + end end describe 'encrypted document storage' do @@ -428,4 +458,74 @@ end end end + describe '#store_failed_images' do + let(:doc_pii_response) { instance_double(Idv::DocAuthFormResponse) } + let(:client_response) { instance_double(DocAuth::Response) } + context 'when client_response is not success and not network error' do + context 'when both sides error message missing' do + let(:errors) { {} } + it 'stores both sides as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(false) + allow(client_response).to receive(:errors).and_return(errors) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).not_to be_empty + end + end + context 'when both sides error message exist' do + let(:errors) { { front: 'blurry', back: 'dpi' } } + it 'stores both sides as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(false) + allow(client_response).to receive(:errors).and_return(errors) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).not_to be_empty + end + end + context 'when one sides error message exists' do + let(:errors) { { front: 'blurry', back: nil } } + it 'stores only the error side as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(false) + allow(client_response).to receive(:errors).and_return(errors) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).to be_empty + end + end + end + + context 'when client_response is not success and is network error' do + let(:errors) { {} } + context 'when doc_pii_response is success' do + it 'stores neither of the side as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(true) + allow(client_response).to receive(:errors).and_return(errors) + allow(doc_pii_response).to receive(:success?).and_return(true) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).to be_empty + expect(capture_result[:back]).to be_empty + end + end + context 'when doc_pii_response is failure' do + it 'stores both sides as failed' do + allow(client_response).to receive(:success?).and_return(false) + allow(client_response).to receive(:network_error?).and_return(true) + allow(client_response).to receive(:errors).and_return(errors) + allow(doc_pii_response).to receive(:success?).and_return(false) + form.send(:validate_form) + capture_result = form.send(:store_failed_images, client_response, doc_pii_response) + expect(capture_result[:front]).not_to be_empty + expect(capture_result[:back]).not_to be_empty + end + end + end + end end diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index 78d32e2e13b..d3a46c94e1b 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -4,7 +4,11 @@ import AcuantCapture, { getNormalizedAcuantCaptureFailureMessage, isAcuantCameraAccessFailure, } from '@18f/identity-document-capture/components/acuant-capture'; -import { AcuantContextProvider, AnalyticsContext } from '@18f/identity-document-capture'; +import { + AcuantContextProvider, + AnalyticsContext, + FailedCaptureAttemptsContextProvider, +} from '@18f/identity-document-capture'; import { createEvent, waitFor } from '@testing-library/dom'; import DeviceContext from '@18f/identity-document-capture/context/device'; @@ -794,6 +798,8 @@ describe('document-capture/components/acuant-capture', () => { attempt: sinon.match.number, size: sinon.match.number, acuantCaptureMode: 'AUTO', + fingerprint: null, + failedImageResubmission: false, }); expect(error).to.be.ok(); @@ -850,6 +856,8 @@ describe('document-capture/components/acuant-capture', () => { attempt: sinon.match.number, size: sinon.match.number, acuantCaptureMode: sinon.match.string, + fingerprint: null, + failedImageResubmission: false, }); expect(error).to.be.ok(); @@ -959,6 +967,8 @@ describe('document-capture/components/acuant-capture', () => { attempt: sinon.match.number, size: sinon.match.number, acuantCaptureMode: sinon.match.string, + fingerprint: null, + failedImageResubmission: false, }); }); @@ -1149,26 +1159,74 @@ describe('document-capture/components/acuant-capture', () => { it('logs metrics for manual upload', async () => { const trackEvent = sinon.stub(); + const onChange = sinon.stub(); + const { getByLabelText } = render( - - - + + + + + , ); - const input = getByLabelText('Image'); uploadFile(input, validUpload); + onChange.calls; + await new Promise((resolve) => onChange.callsFake(resolve)); + expect(trackEvent).to.be.calledOnce(); + expect(trackEvent).to.have.been.calledWith( + 'IdV: front image added', + sinon.match({ + width: sinon.match.number, + height: sinon.match.number, + fingerprint: sinon.match.string, + source: 'upload', + mimeType: 'image/jpeg', + size: sinon.match.number, + attempt: sinon.match.number, + acuantCaptureMode: 'AUTO', + }), + ); + }); - await expect(trackEvent).to.eventually.be.calledWith('IdV: test image added', { - height: sinon.match.number, - mimeType: 'image/jpeg', - source: 'upload', - width: sinon.match.number, - attempt: sinon.match.number, - size: sinon.match.number, - acuantCaptureMode: sinon.match.string, - }); + it('logs metrics for failed reupload', async () => { + const trackEvent = sinon.stub(); + const onChange = sinon.stub(); + const { getByLabelText } = render( + + + + + + + , + ); + const input = getByLabelText('Image'); + uploadFile(input, validUpload); + onChange.calls; + await new Promise((resolve) => onChange.callsFake(resolve)); + expect(trackEvent).to.be.calledOnce(); + expect(trackEvent).to.be.eventually.calledWith( + 'IdV: failed front image resubmitted', + sinon.match({ + width: sinon.match.number, + height: sinon.match.number, + fingerprint: sinon.match.string, + source: 'upload', + mimeType: 'image/jpeg', + size: sinon.match.number, + attempt: sinon.match.number, + acuantCaptureMode: 'AUTO', + }), + ); }); it('logs clicks', async () => { diff --git a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx index a5c5cb4d2a7..d160d95c5b4 100644 --- a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx @@ -1,7 +1,11 @@ import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; import { t } from '@18f/identity-i18n'; -import { DeviceContext, UploadContextProvider } from '@18f/identity-document-capture'; +import { + DeviceContext, + UploadContextProvider, + FailedCaptureAttemptsContextProvider, +} from '@18f/identity-document-capture'; import DocumentsStep from '@18f/identity-document-capture/components/documents-step'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; @@ -19,7 +23,14 @@ describe('document-capture/components/documents-step', () => { it('calls onChange callback with uploaded image', async () => { const onChange = sinon.stub(); - const { getByLabelText } = render(); + const { getByLabelText } = render( + + , + , + ); const file = await getFixtureFile('doc_auth_images/id-back.jpg'); await Promise.all([ diff --git a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx index 5984f8f72d4..0db08592fe9 100644 --- a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx @@ -4,6 +4,7 @@ import { ServiceProviderContextProvider, AnalyticsContext, InPersonContext, + FailedCaptureAttemptsContextProvider, } from '@18f/identity-document-capture'; import { I18n } from '@18f/identity-i18n'; import { I18nContext } from '@18f/identity-react-i18n'; @@ -154,7 +155,12 @@ describe('document-capture/components/review-issues-step', () => { it('calls onChange callback with uploaded image', async () => { const onChange = sinon.stub(); const { getByLabelText, getByRole } = render( - , + + , + , ); const file = await getFixtureFile('doc_auth_images/id-back.jpg'); await userEvent.click(getByRole('button', { name: 'idv.failure.button.warning' })); @@ -336,6 +342,49 @@ describe('document-capture/components/review-issues-step', () => { }); }); + it('skip renders initially with warning page when failed image is submitted again', () => { + const { findByRole, getByRole, getByText } = render( + One attempt remaining', + other: '%{count} attempts remaining', + }, + }, + }) + } + > + + + + , + ); + + expect(findByRole('button', { name: 'idv.failure.button.warning' })).not.to.exist; + expect(getByRole('heading', { name: 'doc_auth.headings.review_issues' })).to.be.ok; + expect(getByText('duplicate image')).to.be.ok; + }); + context('ial2 strict', () => { it('renders with front and back inputs', async () => { const { getByLabelText, getByRole } = render( diff --git a/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx b/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx index c40ae2483df..801d217bf14 100644 --- a/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx +++ b/spec/javascript/packages/document-capture/context/failed-capture-attempts-spec.jsx @@ -24,6 +24,7 @@ describe('document-capture/context/failed-capture-attempts', () => { 'maxCaptureAttemptsBeforeNativeCamera', 'maxSubmissionAttemptsBeforeNativeCamera', 'lastAttemptMetadata', + 'failedSubmissionImageFingerprints', ]); expect(result.current.failedCaptureAttempts).to.equal(0); expect(result.current.failedSubmissionAttempts).to.equal(0); @@ -32,6 +33,7 @@ describe('document-capture/context/failed-capture-attempts', () => { expect(result.current.onResetFailedCaptureAttempts).to.be.a('function'); expect(result.current.maxCaptureAttemptsBeforeNativeCamera).to.be.a('number'); expect(result.current.lastAttemptMetadata).to.be.an('object'); + expect(result.current.failedSubmissionImageFingerprints).to.be.an('object'); }); describe('Provider', () => { @@ -83,10 +85,14 @@ describe('FailedCaptureAttemptsContext testing of forceNativeCamera logic', () = {children} ), }); - result.current.onFailedSubmissionAttempt(); + result.current.onFailedSubmissionAttempt({ front: ['abcdefg'], back: [] }); rerender(true); expect(result.current.failedSubmissionAttempts).to.equal(1); expect(result.current.forceNativeCamera).to.equal(false); + expect(result.current.failedSubmissionImageFingerprints).to.eql({ + front: ['abcdefg'], + back: [], + }); }); it('Updating failed captures to a number gte the maxCaptureAttemptsBeforeNativeCamera will set forceNativeCamera to true', () => { diff --git a/spec/javascript/packages/document-capture/services/upload-spec.js b/spec/javascript/packages/document-capture/services/upload-spec.js index 6bd72957a79..9a6733256ae 100644 --- a/spec/javascript/packages/document-capture/services/upload-spec.js +++ b/spec/javascript/packages/document-capture/services/upload-spec.js @@ -142,6 +142,44 @@ describe('document-capture/services/upload', () => { } }); + it('handles validation error due to resubmit failed message', async () => { + const endpoint = 'https://example.com'; + + const response = new Response( + JSON.stringify({ + success: false, + errors: [{ field: 'front', message: 'Using failed image' }], + remaining_attempts: 3, + hints: true, + result_failed: true, + ocr_pii: { first_name: 'Fakey', last_name: 'McFakerson', dob: '1938-10-06' }, + failed_image_fingerprints: { front: ['12345'], back: [] }, + }), + { status: 400 }, + ); + sandbox.stub(response, 'url').get(() => endpoint); + sandbox.stub(global, 'fetch').callsFake(() => Promise.resolve(response)); + + try { + await upload({}, { endpoint }); + throw new Error('This is a safeguard and should never be reached, since upload should error'); + } catch (error) { + expect(error).to.be.instanceOf(UploadFormEntriesError); + expect(error.remainingAttempts).to.equal(3); + expect(error.hints).to.be.true(); + expect(error.pii).to.deep.equal({ + first_name: 'Fakey', + last_name: 'McFakerson', + dob: '1938-10-06', + }); + expect(error.isFailedResult).to.be.true(); + expect(error.formEntryErrors[0]).to.be.instanceOf(UploadFormEntryError); + expect(error.formEntryErrors[0].field).to.equal('front'); + expect(error.formEntryErrors[0].message).to.equal('Using failed image'); + expect(error.failed_image_fingerprints).to.eql({ front: ['12345'], back: [] }); + } + }); + it('redirects error', async () => { const endpoint = 'https://example.com'; diff --git a/spec/models/document_capture_session_spec.rb b/spec/models/document_capture_session_spec.rb index fdb50150e60..6d61bd49392 100644 --- a/spec/models/document_capture_session_spec.rb +++ b/spec/models/document_capture_session_spec.rb @@ -11,6 +11,12 @@ ) end + let(:failed_doc_auth_response) do + DocAuth::Response.new( + success: false, + ) + end + describe '#store_result_from_response' do it 'generates a result ID stores the result encrypted in redis' do record = DocumentCaptureSession.new @@ -114,4 +120,23 @@ end end end + + describe('#store_failed_auth_image_fingerprint') do + it 'stores image finger print' do + record = DocumentCaptureSession.new(result_id: SecureRandom.uuid) + + record.store_failed_auth_image_fingerprint( + 'fingerprint1', nil + ) + + result_id = record.result_id + key = EncryptedRedisStructStorage.key(result_id, type: DocumentCaptureSessionResult) + data = REDIS_POOL.with { |client| client.get(key) } + expect(data).to be_a(String) + result = record.load_result + expect(result.failed_front_image?('fingerprint1')).to eq(true) + expect(result.failed_front_image?(nil)).to eq(false) + expect(result.failed_back_image?(nil)).to eq(false) + end + end end diff --git a/spec/presenters/image_upload_response_presenter_spec.rb b/spec/presenters/image_upload_response_presenter_spec.rb index 89de4ef42ee..03e81f90681 100644 --- a/spec/presenters/image_upload_response_presenter_spec.rb +++ b/spec/presenters/image_upload_response_presenter_spec.rb @@ -107,7 +107,9 @@ context 'rate limited' do let(:extra_attributes) do - { remaining_attempts: 0, flow_path: 'standard' } + { remaining_attempts: 0, + flow_path: 'standard', + failed_image_fingerprints: { back: [], front: ['12345'] } } end let(:form_response) do FormResponse.new( @@ -128,6 +130,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: ['12345'] }, } expect(presenter.as_json).to eq expected @@ -135,7 +138,9 @@ context 'hybrid flow' do let(:extra_attributes) do - { remaining_attempts: 0, flow_path: 'hybrid' } + { remaining_attempts: 0, + flow_path: 'hybrid', + failed_image_fingerprints: { back: [], front: ['12345'] } } end it 'returns hash of properties redirecting to capture_complete' do @@ -147,6 +152,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: ['12345'] }, } expect(presenter.as_json).to eq expected @@ -175,6 +181,7 @@ remaining_attempts: 3, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected @@ -201,6 +208,7 @@ remaining_attempts: 3, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, } expect(presenter.as_json).to eq expected @@ -237,6 +245,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { front: [], back: [] }, } expect(presenter.as_json).to eq expected @@ -253,6 +262,7 @@ remaining_attempts: 0, ocr_pii: nil, doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected @@ -280,6 +290,7 @@ remaining_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected @@ -305,6 +316,7 @@ remaining_attempts: 3, ocr_pii: Idp::Constants::MOCK_IDV_APPLICANT.slice(:first_name, :last_name, :dob), doc_type_supported: true, + failed_image_fingerprints: { back: [], front: [] }, } expect(presenter.as_json).to eq expected diff --git a/spec/services/doc_auth/acuant/request_spec.rb b/spec/services/doc_auth/acuant/request_spec.rb index 913cbfb635e..d0ee51c2b95 100644 --- a/spec/services/doc_auth/acuant/request_spec.rb +++ b/spec/services/doc_auth/acuant/request_spec.rb @@ -86,6 +86,32 @@ def handle_http_response(http_response) expect(response.errors).to eq(network: true) expect(response.exception.message).to include('Unexpected HTTP response 404') end + + it 'returns a response of status 440 with an exception' do + stub_request(:get, full_url). + with(headers: request_headers). + to_return(body: 'test response body', status: 440) + allow(NewRelic::Agent).to receive(:notice_error) + + response = subject.fetch + + expect(response.success?).to eq(false) + expect(response.errors).to have_key(:general) + expect(response.exception.message).to include('Unexpected HTTP response 440') + end + + it 'returns a response of status 500 with an exception' do + stub_request(:get, full_url). + with(headers: request_headers). + to_return(body: 'test response body', status: 500) + allow(NewRelic::Agent).to receive(:notice_error) + + response = subject.fetch + + expect(response.success?).to eq(false) + expect(response.errors).to have_key(:network) + expect(response.exception.message).to include('Unexpected HTTP response 500') + end end context 'when the request resolves with retriable error then succeeds it only retries once' do diff --git a/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb b/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb index 3c0182409f9..4dd103ff2a2 100644 --- a/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb +++ b/spec/services/doc_auth/acuant/requests/get_results_request_spec.rb @@ -22,5 +22,19 @@ expect(response.pii_from_doc).to_not be_empty expect(request_stub).to have_been_requested end + + it 'get general error for 4xx' do + stub_request(:get, url).to_return(status: 440) + response = described_class.new(config: config, instance_id: instance_id).fetch + expect(response.errors).to have_key(:general) + expect(response.network_error?).to eq(false) + end + + it 'get network error for 500' do + stub_request(:get, url).to_return(status: 500) + response = described_class.new(config: config, instance_id: instance_id).fetch + expect(response.errors).to have_key(:network) + expect(response.network_error?).to eq(true) + end end end diff --git a/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb b/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb index 1e9f1424b53..7ad5597fe51 100644 --- a/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb @@ -53,6 +53,21 @@ it_behaves_like 'a successful request' end + + context 'with non 200 http status code' do + let(:workflow) { 'test_workflow' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + it 'is a network error with 5xx status' do + stub_request(:post, full_url).to_return(body: '{}', status: 500) + response = subject.fetch + expect(response.network_error?).to eq(true) + end + it 'is not a network error with 440, 438, 439' do + stub_request(:post, full_url).to_return(body: '{}', status: 443) + response = subject.fetch + expect(response.network_error?).to eq(true) + end + end end def response_body diff --git a/spec/services/document_capture_session_result_spec.rb b/spec/services/document_capture_session_result_spec.rb index 11f724a4206..308c729e019 100644 --- a/spec/services/document_capture_session_result_spec.rb +++ b/spec/services/document_capture_session_result_spec.rb @@ -21,5 +21,19 @@ expect(loaded_result.pii).to eq(pii.deep_symbolize_keys) expect(loaded_result.attention_with_barcode?).to eq(false) end + it 'add fingerprint with EncryptedRedisStructStorage' do + result = DocumentCaptureSessionResult.new( + id: id, + success: success, + pii: pii, + attention_with_barcode: false, + ) + result.add_failed_front_image!('abcdefg') + expect(result.failed_front_image_fingerprints.is_a?(Array)).to eq(true) + expect(result.failed_front_image_fingerprints.length).to eq(1) + expect(result.failed_front_image?('abcdefg')).to eq(true) + expect(result.failed_front_image?(nil)).to eq(false) + expect(result.failed_back_image?(nil)).to eq(false) + end end end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index e7f77073226..b89572cf6c5 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -229,6 +229,36 @@ def mock_doc_auth_attention_with_barcode ) end + def mock_doc_auth_trueid_http_non2xx_status(status) + network_error_response = instance_double( + Faraday::Response, + status: status, + body: '{}', + ) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :get_results, + response: DocAuth::LexisNexis::Responses::TrueIdResponse.new( + network_error_response, + DocAuth::LexisNexis::Config.new, + ), + ) + end + + # @param [Object] status one of 440, 438, 439 + def mock_doc_auth_acuant_http_4xx_status(status, method = :post_front_image) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: method, + response: DocAuth::Mock::ResultResponse.create_image_error_response(status), + ) + end + + def mock_doc_auth_acuant_http_5xx_status(method = :post_front_image) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: method, + response: DocAuth::Mock::ResultResponse.create_network_error_response, + ) + end + def mock_doc_auth_acuant_error_unknown failed_http_response = instance_double( Faraday::Response,