diff --git a/Gemfile b/Gemfile index 9eb5b52f8d6..4b37b6aafd6 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,7 @@ gem 'jsbundling-rails', '~> 1.1.2' gem 'jwe' gem 'jwt' gem 'lograge', '>= 0.11.2' -gem 'lookbook', '~> 2.0.0', require: false +gem 'lookbook', '~> 2.2', require: false gem 'lru_redux' gem 'mail' gem 'msgpack', '~> 1.6' diff --git a/Gemfile.lock b/Gemfile.lock index f3799b684e9..7fca116d58a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -383,7 +383,7 @@ GEM loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lookbook (2.0.5) + lookbook (2.2.0) activemodel css_parser htmlbeautifier (~> 1.3) @@ -781,7 +781,7 @@ DEPENDENCIES letter_opener (~> 1.8) listen lograge (>= 0.11.2) - lookbook (~> 2.0.0) + lookbook (~> 2.2) lru_redux mail maxminddb diff --git a/app/controllers/idv/document_capture_controller.rb b/app/controllers/idv/document_capture_controller.rb index bc89c68b478..baa18944435 100644 --- a/app/controllers/idv/document_capture_controller.rb +++ b/app/controllers/idv/document_capture_controller.rb @@ -48,6 +48,7 @@ def extra_view_variables failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), skip_doc_auth: idv_session.skip_doc_auth, opted_in_to_in_person_proofing: idv_session.opted_in_to_in_person_proofing, + doc_auth_selfie_capture: decorated_sp_session.selfie_required?, }.merge( acuant_sdk_upgrade_a_b_testing_variables, ) diff --git a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb index a9f90166220..4a35965aeea 100644 --- a/app/controllers/idv/hybrid_mobile/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/document_capture_controller.rb @@ -41,6 +41,7 @@ def extra_view_variables flow_path: 'hybrid', document_capture_session_uuid: document_capture_session_uuid, failure_to_proof_url: return_to_sp_failure_to_proof_url(step: 'document_capture'), + doc_auth_selfie_capture: decorated_sp_session.selfie_required?, }.merge( acuant_sdk_upgrade_a_b_testing_variables, ) diff --git a/app/controllers/idv/image_uploads_controller.rb b/app/controllers/idv/image_uploads_controller.rb index c94dacbbb51..4ac24e83deb 100644 --- a/app/controllers/idv/image_uploads_controller.rb +++ b/app/controllers/idv/image_uploads_controller.rb @@ -1,7 +1,5 @@ module Idv class ImageUploadsController < ApplicationController - include ApplicationHelper # for liveness_checking_enabled? - respond_to :json def create @@ -20,12 +18,12 @@ def create def image_upload_form @image_upload_form ||= Idv::ApiImageUploadForm.new( params, - liveness_checking_enabled: liveness_checking_enabled?, service_provider: current_sp, analytics: analytics, uuid_prefix: current_sp&.app_id, irs_attempts_api_tracker: irs_attempts_api_tracker, store_encrypted_images: store_encrypted_images?, + liveness_checking_required: decorated_sp_session.selfie_required?, ) end @@ -36,13 +34,5 @@ def store_encrypted_images? def liveness_checking_enabled? IdentityConfig.store.doc_auth_selfie_capture_enabled end - - def ial_context - @ial_context ||= IalContext.new( - ial: sp_session_ial, - service_provider: current_sp, - user: current_user, - ) - end end end diff --git a/app/decorators/null_service_provider_session.rb b/app/decorators/null_service_provider_session.rb index 4608ca4d9d5..7c41dd783ee 100644 --- a/app/decorators/null_service_provider_session.rb +++ b/app/decorators/null_service_provider_session.rb @@ -45,6 +45,10 @@ def request_url_params {} end + def selfie_required? + false + end + private attr_reader :view_context diff --git a/app/decorators/service_provider_session.rb b/app/decorators/service_provider_session.rb index 4081137ea6c..0d941c95cc5 100644 --- a/app/decorators/service_provider_session.rb +++ b/app/decorators/service_provider_session.rb @@ -71,6 +71,8 @@ def sp_issuer end def selfie_required? + return false if Identity::Hostdata.env == 'prod' + !!(IdentityConfig.store.doc_auth_selfie_capture_enabled && sp_session[:biometric_comparison_required]) end diff --git a/app/forms/event_disavowal/password_reset_from_disavowal_form.rb b/app/forms/event_disavowal/password_reset_from_disavowal_form.rb index c45b066b869..ba222e008ff 100644 --- a/app/forms/event_disavowal/password_reset_from_disavowal_form.rb +++ b/app/forms/event_disavowal/password_reset_from_disavowal_form.rb @@ -31,6 +31,8 @@ def update_user end def mark_profile_inactive + return if user.active_profile.blank? + user.active_profile&.deactivate(:password_reset) Funnel::DocAuth::ResetSteps.call(@user.id) user.proofing_component&.destroy diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index b6f70e745f8..438794746d4 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -5,7 +5,7 @@ class ApiImageUploadForm validates_presence_of :front validates_presence_of :back - validates_presence_of :selfie, if: :liveness_checking_enabled + validates_presence_of :selfie, if: :liveness_checking_required validates_presence_of :document_capture_session validate :validate_images @@ -13,8 +13,8 @@ class ApiImageUploadForm validate :limit_if_rate_limited def initialize(params, service_provider:, analytics: nil, - uuid_prefix: nil, irs_attempts_api_tracker: nil, store_encrypted_images: false, - liveness_checking_enabled: false) + uuid_prefix: nil, irs_attempts_api_tracker: nil, + store_encrypted_images: false, liveness_checking_required: false) @params = params @service_provider = service_provider @analytics = analytics @@ -22,7 +22,7 @@ def initialize(params, service_provider:, analytics: nil, @uuid_prefix = uuid_prefix @irs_attempts_api_tracker = irs_attempts_api_tracker @store_encrypted_images = store_encrypted_images - @liveness_checking_enabled = liveness_checking_enabled + @liveness_checking_required = liveness_checking_required end def submit @@ -54,7 +54,7 @@ def submit private attr_reader :params, :analytics, :service_provider, :form_response, :uuid_prefix, - :irs_attempts_api_tracker, :liveness_checking_enabled + :irs_attempts_api_tracker, :liveness_checking_required def increment_rate_limiter! return unless document_capture_session @@ -83,11 +83,11 @@ def post_images_to_client doc_auth_client.post_images( front_image: front_image_bytes, back_image: back_image_bytes, - selfie_image: liveness_checking_enabled ? selfie_image_bytes : nil, + selfie_image: liveness_checking_required ? selfie_image_bytes : nil, image_source: image_source, user_uuid: user_uuid, uuid_prefix: uuid_prefix, - liveness_checking_enabled: liveness_checking_enabled, + liveness_checking_required: liveness_checking_required, ) end @@ -370,7 +370,7 @@ def add_costs(response) Db::AddDocumentVerificationAndSelfieCosts. new(user_id: user_id, service_provider: service_provider, - liveness_checking_enabled: liveness_checking_enabled). + liveness_checking_enabled: liveness_checking_required). call(response) end diff --git a/app/forms/idv/doc_pii_form.rb b/app/forms/idv/doc_pii_form.rb index 35e1a81d2a7..4cca182ca60 100644 --- a/app/forms/idv/doc_pii_form.rb +++ b/app/forms/idv/doc_pii_form.rb @@ -7,24 +7,23 @@ class DocPiiForm validates_presence_of :address1, { message: proc { I18n.t('doc_auth.errors.alerts.address_check') } } - validates_length_of :state, { is: 2, - message: proc { - I18n.t('doc_auth.errors.general.no_liveness') - } } validates :zipcode, format: { with: /\A[0-9]{5}(?:-[0-9]{4})?\z/, message: proc { I18n.t('doc_auth.errors.general.no_liveness') }, } + validates :jurisdiction, :state, inclusion: { in: Idp::Constants::STATE_AND_TERRITORY_CODES, + message: proc { + I18n.t('doc_auth.errors.general.no_liveness') + } } - validates :jurisdiction, inclusion: { in: Idp::Constants::STATE_AND_TERRITORY_CODES, - message: proc { - I18n.t('doc_auth.errors.general.no_liveness') - } } + validates_presence_of :state_id_number, { message: proc { + I18n.t('doc_auth.errors.general.no_liveness') + } } attr_reader :first_name, :last_name, :dob, :address1, :state, :zipcode, :attention_with_barcode, - :jurisdiction + :jurisdiction, :state_id_number alias_method :attention_with_barcode?, :attention_with_barcode def initialize(pii:, attention_with_barcode: false) @@ -36,6 +35,7 @@ def initialize(pii:, attention_with_barcode: false) @state = pii[:state] @zipcode = pii[:zipcode] @jurisdiction = pii[:state_id_jurisdiction] + @state_id_number = pii[:state_id_number] @attention_with_barcode = attention_with_barcode end @@ -54,7 +54,7 @@ def submit def self.pii_like_keypaths keypaths = [[:pii]] - attrs = %i[name dob dob_min_age address1 state zipcode jurisdiction] + attrs = %i[name dob dob_min_age address1 state zipcode jurisdiction state_id_number] attrs.each do |k| keypaths << [:errors, k] keypaths << [:error_details, k] diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx index e0edf5410b2..dcfd2355724 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx @@ -1,35 +1,29 @@ import { useContext } from 'react'; import { PageHeading } from '@18f/identity-components'; -import { - FormStepError, - FormStepsButton, - OnErrorCallback, - RegisterFieldCallback, -} from '@18f/identity-form-steps'; +import { FormStepsButton } from '@18f/identity-form-steps'; import { Cancel } from '@18f/identity-verify-flow'; import { useI18n } from '@18f/identity-react-i18n'; +import type { FormStepComponentProps } from '@18f/identity-form-steps'; import UnknownError from './unknown-error'; import TipList from './tip-list'; -import DocumentSideAcuantCapture from './document-side-acuant-capture'; import DocumentCaptureNotReady from './document-capture-not-ready'; import { FeatureFlagContext } from '../context'; import DocumentCaptureAbandon from './document-capture-abandon'; +import { + DocumentCaptureSubheaderOne, + SelfieCaptureWithHeader, + DocumentFrontAndBackCapture, +} from './documents-step'; +import type { ReviewIssuesStepValue } from './review-issues-step'; -interface DocumentCaptureReviewIssuesProps { +interface DocumentCaptureReviewIssuesProps + extends Omit, 'toPreviousStep'> { isFailedDocType: boolean; remainingAttempts: number; captureHints: boolean; - registerField: RegisterFieldCallback; - value: { string: Blob | string | null | undefined } | {}; - unknownFieldErrors: FormStepError[]; - errors: FormStepError[]; - onChange: (...args: any) => void; - onError: OnErrorCallback; hasDismissed: boolean; } -type DocumentSide = 'front' | 'back' | 'selfie'; - function DocumentCaptureReviewIssues({ isFailedDocType, remainingAttempts = Infinity, @@ -39,21 +33,24 @@ function DocumentCaptureReviewIssues({ errors = [], onChange = () => undefined, onError = () => undefined, - value = {}, + value, hasDismissed, }: DocumentCaptureReviewIssuesProps) { const { t } = useI18n(); const { notReadySectionEnabled, exitQuestionSectionEnabled, selfieCaptureEnabled } = useContext(FeatureFlagContext); - // Sides of document to present as file input. - const documentSides: DocumentSide[] = selfieCaptureEnabled - ? ['front', 'back', 'selfie'] - : ['front', 'back']; + const defaultSideProps = { + registerField, + onChange, + errors, + onError, + }; return ( <> {t('doc_auth.headings.review_issues')} + )} - {documentSides.map((side) => ( - - ))} + + {selfieCaptureEnabled && ( + + )} {notReadySectionEnabled && } {exitQuestionSectionEnabled && } diff --git a/app/javascript/packages/document-capture/components/documents-step.jsx b/app/javascript/packages/document-capture/components/documents-step.tsx similarity index 52% rename from app/javascript/packages/document-capture/components/documents-step.jsx rename to app/javascript/packages/document-capture/components/documents-step.tsx index 239cbfb2c23..8bb2d7fd0e5 100644 --- a/app/javascript/packages/document-capture/components/documents-step.jsx +++ b/app/javascript/packages/document-capture/components/documents-step.tsx @@ -1,6 +1,10 @@ import { useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import { FormStepsButton, FormStepsContext } from '@18f/identity-form-steps'; +import { + FormStepComponentProps, + FormStepsButton, + FormStepsContext, +} from '@18f/identity-form-steps'; import { PageHeading } from '@18f/identity-components'; import { Cancel } from '@18f/identity-verify-flow'; import HybridDocCaptureWarning from './hybrid-doc-capture-warning'; @@ -12,37 +16,96 @@ import DocumentCaptureNotReady from './document-capture-not-ready'; import { FeatureFlagContext } from '../context'; import DocumentCaptureAbandon from './document-capture-abandon'; -/** - * @typedef {'front'|'back'|'selfie'} DocumentSide - */ +export function DocumentCaptureSubheaderOne({ + selfieCaptureEnabled, +}: { + selfieCaptureEnabled: boolean; +}) { + const { t } = useI18n(); + return ( +

+ {selfieCaptureEnabled && '1. '} + {t('doc_auth.headings.document_capture_subheader_id')} +

+ ); +} + +export function SelfieCaptureWithHeader({ + defaultSideProps, + selfieValue, +}: { + defaultSideProps: DefaultSideProps; + selfieValue: ImageValue; +}) { + const { t } = useI18n(); + return ( + <> +
+

2. {t('doc_auth.headings.document_capture_subheader_selfie')}

+ + + + ); +} + +export function DocumentFrontAndBackCapture({ + defaultSideProps, + value, +}: { + defaultSideProps: DefaultSideProps; + value: Record; +}) { + type DocumentSide = 'front' | 'back'; + const documentsSides: DocumentSide[] = ['front', 'back']; + return ( + <> + {documentsSides.map((side) => ( + + ))} + + ); +} + +type ImageValue = Blob | string | null | undefined; -/** - * @typedef DocumentsStepValue - * - * @prop {Blob|string|null|undefined} front Front image value. - * @prop {Blob|string|null|undefined} back Back image value. - * @prop {Blob|string|null|undefined} selfie Selfie image value. - * @prop {string=} front_image_metadata Front image metadata. - * @prop {string=} back_image_metadata Back image metadata. - */ +interface DocumentsStepValue { + front: ImageValue; + back: ImageValue; + selfie: ImageValue; + front_image_metadata?: string; + back_image_metadata?: string; +} + +type DefaultSideProps = Pick< + FormStepComponentProps, + 'registerField' | 'onChange' | 'errors' | 'onError' +>; -/** - * @param {import('@18f/identity-form-steps').FormStepComponentProps} props Props object. - */ function DocumentsStep({ value = {}, onChange = () => {}, errors = [], onError = () => {}, registerField = () => undefined, -}) { - /** - * Sides of the ID document to present as file input. - * - * @type {DocumentSide[]} - */ - const documentsSides = ['front', 'back']; - +}: FormStepComponentProps) { const { t } = useI18n(); const { isMobile } = useContext(DeviceContext); const { isLastStep } = useContext(FormStepsContext); @@ -54,7 +117,7 @@ function DocumentsStep({ ? t('doc_auth.headings.document_capture_with_selfie') : t('doc_auth.headings.document_capture'); - const defaultSideProps = { + const defaultSideProps: DefaultSideProps = { registerField, onChange, errors, @@ -64,10 +127,7 @@ function DocumentsStep({ <> {flowPath === 'hybrid' && } {pageHeaderText} -

- {selfieCaptureEnabled && '1. '} - {t('doc_auth.headings.document_capture_subheader_id')} -

+ - {documentsSides.map((side) => ( - - ))} + {selfieCaptureEnabled && ( - <> -
-

2. {t('doc_auth.headings.document_capture_subheader_selfie')}

- - - + )} {isLastStep ? : } {notReadySectionEnabled && } 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 5bcaa4fe1d9..f07ed0597a7 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -9,16 +9,21 @@ import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; import DocumentCaptureWarning from './document-capture-warning'; import DocumentCaptureReviewIssues from './document-capture-review-issues'; -interface ReviewIssuesStepValue { +export interface ReviewIssuesStepValue { /** * Front image value. */ - front: Blob | string | null | undefined; + front?: Blob | string | null | undefined; /** * Back image value. */ - back: Blob | string | null | undefined; + back?: Blob | string | null | undefined; + + /** + * Selfie image value. + */ + selfie?: Blob | string | null | undefined; /** * Front image metadata. @@ -32,15 +37,10 @@ interface ReviewIssuesStepValue { interface ReviewIssuesStepProps extends FormStepComponentProps { remainingAttempts?: number; - isFailedResult?: boolean; - isFailedDocType?: boolean; - captureHints?: boolean; - pii?: PII; - failedImageFingerprints?: { front: string[] | null; back: string[] | null }; } diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index fb15e1fa7d1..b1fc9c8cc45 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -4,6 +4,6 @@ "version": "1.0.0", "dependencies": { "intl-tel-input": "^17.0.19", - "libphonenumber-js": "^1.10.52" + "libphonenumber-js": "^1.10.53" } } diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 1a27313ef3e..93b4230e66b 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -54,8 +54,7 @@ function getServiceProvider() { function getSelfieCaptureEnabled() { const { docAuthSelfieCapture } = appRoot.dataset; - const docAuthSelfieCaptureObject = docAuthSelfieCapture ? JSON.parse(docAuthSelfieCapture) : {}; - return !!docAuthSelfieCaptureObject?.enabled; + return docAuthSelfieCapture === 'true'; } function getMetaContent(name): string | null { diff --git a/app/jobs/reports/duplicate_ssn_report.rb b/app/jobs/reports/duplicate_ssn_report.rb index 4d665aca9af..59f31b56fb4 100644 --- a/app/jobs/reports/duplicate_ssn_report.rb +++ b/app/jobs/reports/duplicate_ssn_report.rb @@ -24,10 +24,13 @@ def finish # @return [String] def report_body - # note, this will table scan until we add an index, for a once-a-day job it may be ok - todays_profiles = Profile. - select(:id, :ssn_signature). - where(active: true, activated_at: start..finish) + todays_profiles = transaction_with_timeout do + # note, this will table scan until we add an index, for a once-a-day job it may be ok + Profile. + select(:id, :ssn_signature). + where(active: true, activated_at: start..finish). + to_a + end todays_profile_ids = todays_profiles.map(&:id).to_set diff --git a/app/jobs/reports/identity_verification_report.rb b/app/jobs/reports/identity_verification_report.rb index 2903770f8ca..5694fd8384e 100644 --- a/app/jobs/reports/identity_verification_report.rb +++ b/app/jobs/reports/identity_verification_report.rb @@ -2,7 +2,7 @@ module Reports class IdentityVerificationReport < BaseReport - REPORT_NAME = 'identity-verification-report' + REPORT_NAME = 'identity-verification-report'.freeze attr_accessor :report_date @@ -13,6 +13,40 @@ def perform(report_date) csv = report_maker.to_csv save_report(REPORT_NAME, csv, extension: 'csv') + + if emails.empty? + Rails.logger.warn 'No email addresses received - Identity Verification Report NOT SENT' + return false + end + + emails.each do |email| + ReportMailer.tables_report( + email: email, + subject: "Daily Identity Verification Report - #{report_date.to_date}", + reports: reports, + message: preamble, + attachment_format: :csv, + ).deliver_now + end + end + + def preamble + <<~HTML.html_safe # rubocop:disable Rails/OutputSafety +

+ Identity Verification Report +

+

+ Disclaimer: This Report is In Progress: Not Production Ready +

+ HTML + end + + def emails + [IdentityConfig.store.team_ada_email] + end + + def reports + [report_maker.identity_verification_emailable_report] end def report_maker diff --git a/app/jobs/reports/monthly_key_metrics_report.rb b/app/jobs/reports/monthly_key_metrics_report.rb index 683820b46ea..e472f3f7f0c 100644 --- a/app/jobs/reports/monthly_key_metrics_report.rb +++ b/app/jobs/reports/monthly_key_metrics_report.rb @@ -75,10 +75,9 @@ def reports end def emails - emails = [IdentityConfig.store.team_agnes_email] + emails = [*IdentityConfig.store.team_daily_reports_emails] if report_date.next_day.day == 1 - emails << IdentityConfig.store.team_all_feds_email - emails << IdentityConfig.store.team_all_contractors_email + emails += IdentityConfig.store.team_all_login_emails end emails end diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 3536998935f..867ec34fe68 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -16,6 +16,7 @@ def store_result_from_response(doc_auth_response) session_result.pii = doc_auth_response.pii_from_doc session_result.captured_at = Time.zone.now session_result.attention_with_barcode = doc_auth_response.attention_with_barcode? + session_result.selfie_check_performed = doc_auth_response.selfie_check_performed? EncryptedRedisStructStorage.store( session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.seconds.to_i, diff --git a/app/services/doc_auth/acuant/acuant_client.rb b/app/services/doc_auth/acuant/acuant_client.rb index eccef4d89c0..3eec6b365f4 100644 --- a/app/services/doc_auth/acuant/acuant_client.rb +++ b/app/services/doc_auth/acuant/acuant_client.rb @@ -44,7 +44,7 @@ def post_images( user_uuid: nil, uuid_prefix: nil, selfie_image: nil, - liveness_checking_enabled: false + liveness_checking_required: false ) document_response = create_document(image_source: image_source) return document_response unless document_response.success? diff --git a/app/services/doc_auth/lexis_nexis/lexis_nexis_client.rb b/app/services/doc_auth/lexis_nexis/lexis_nexis_client.rb index 8c6a8438af8..8cc25fc34c7 100644 --- a/app/services/doc_auth/lexis_nexis/lexis_nexis_client.rb +++ b/app/services/doc_auth/lexis_nexis/lexis_nexis_client.rb @@ -19,7 +19,7 @@ def post_images( image_source: nil, user_uuid: nil, uuid_prefix: nil, - liveness_checking_enabled: false + liveness_checking_required: false ) Requests::TrueIdRequest.new( config: config, @@ -29,7 +29,7 @@ def post_images( back_image: back_image, selfie_image: selfie_image, image_source: image_source, - liveness_checking_required: liveness_checking_enabled, + liveness_checking_required: liveness_checking_required, ).fetch end end diff --git a/app/services/doc_auth/lexis_nexis/requests/true_id_request.rb b/app/services/doc_auth/lexis_nexis/requests/true_id_request.rb index 31a37282838..5193ee1ab77 100644 --- a/app/services/doc_auth/lexis_nexis/requests/true_id_request.rb +++ b/app/services/doc_auth/lexis_nexis/requests/true_id_request.rb @@ -42,7 +42,7 @@ def handle_http_response(http_response) LexisNexis::Responses::TrueIdResponse.new( http_response, config, - liveness_checking_required, + include_liveness?, ) end @@ -91,6 +91,9 @@ def timeout end def include_liveness? + return false if Identity::Hostdata.env == 'prod' + return false unless IdentityConfig.store.doc_auth_selfie_capture_enabled + liveness_checking_required end end diff --git a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb index a48f155fd22..c9c3c2244c6 100644 --- a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb +++ b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb @@ -53,6 +53,7 @@ def initialize(http_response, config, liveness_checking_enabled = false) errors: error_messages, extra: extra_attributes, pii_from_doc: pii_from_doc, + selfie_check_performed: liveness_checking_enabled, ) rescue StandardError => e NewRelic::Agent.notice_error(e) diff --git a/app/services/doc_auth/mock/doc_auth_mock_client.rb b/app/services/doc_auth/mock/doc_auth_mock_client.rb index a72aa774b2d..ee285dd47e0 100644 --- a/app/services/doc_auth/mock/doc_auth_mock_client.rb +++ b/app/services/doc_auth/mock/doc_auth_mock_client.rb @@ -29,7 +29,11 @@ def create_document return mocked_response_for_method(__method__) if method_mocked?(__method__) instance_id = SecureRandom.uuid - Responses::CreateDocumentResponse.new(success: true, errors: {}, instance_id: instance_id) + Responses::CreateDocumentResponse.new( + success: true, + errors: {}, + instance_id: instance_id, + ) end # rubocop:disable Lint/UnusedMethodArgument @@ -56,7 +60,7 @@ def post_images( image_source: nil, user_uuid: nil, uuid_prefix: nil, - liveness_checking_enabled: false + liveness_checking_required: false ) return mocked_response_for_method(__method__) if method_mocked?(__method__) @@ -71,10 +75,10 @@ def post_images( back_image_response = post_back_image(image: back_image, instance_id: instance_id) return back_image_response unless back_image_response.success? - get_results(instance_id: instance_id) + get_results(instance_id: instance_id, selfie_check_performed: liveness_checking_required) end - def get_results(instance_id:) + def get_results(instance_id:, selfie_check_performed:) return mocked_response_for_method(__method__) if method_mocked?(__method__) error_response = http_error_response(self.class.last_uploaded_back_image, 'result') return error_response if error_response @@ -86,6 +90,7 @@ def get_results(instance_id:) ResultResponse.new( self.class.last_uploaded_back_image, + selfie_check_performed, overriden_config, ) end diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index a39f11ddb83..e18566f5181 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -6,7 +6,7 @@ class ResultResponse < DocAuth::Response attr_reader :uploaded_file, :config - def initialize(uploaded_file, config) + def initialize(uploaded_file, selfie_check_performed, config) @uploaded_file = uploaded_file.to_s @config = config super( @@ -14,6 +14,7 @@ def initialize(uploaded_file, config) errors: errors, pii_from_doc: pii_from_doc, doc_type_supported: id_type_supported?, + selfie_check_performed: selfie_check_performed, extra: { doc_auth_result: doc_auth_result, billed: true, diff --git a/app/services/doc_auth/response.rb b/app/services/doc_auth/response.rb index fc49164abb3..3b6ea9d0532 100644 --- a/app/services/doc_auth/response.rb +++ b/app/services/doc_auth/response.rb @@ -14,7 +14,8 @@ def initialize( extra: {}, pii_from_doc: {}, attention_with_barcode: false, - doc_type_supported: true + doc_type_supported: true, + selfie_check_performed: false ) @success = success @errors = errors.to_h @@ -23,6 +24,7 @@ def initialize( @pii_from_doc = pii_from_doc @attention_with_barcode = attention_with_barcode @doc_type_supported = doc_type_supported + @selfie_check_performed = selfie_check_performed end def merge(other) @@ -71,5 +73,9 @@ def network_error? return false unless @errors return !!@errors&.with_indifferent_access&.dig(:network) end + + def selfie_check_performed? + @selfie_check_performed + end end end diff --git a/app/services/document_capture_session_result.rb b/app/services/document_capture_session_result.rb index 1a5bdb794bc..1acb7f10458 100644 --- a/app/services/document_capture_session_result.rb +++ b/app/services/document_capture_session_result.rb @@ -9,9 +9,10 @@ :failed_front_image_fingerprints, :failed_back_image_fingerprints, :captured_at, + :selfie_check_performed, keyword_init: true, allowed_members: [:id, :success, :attention_with_barcode, :failed_front_image_fingerprints, - :failed_back_image_fingerprints, :captured_at], + :failed_back_image_fingerprints, :captured_at, :selfie_check_performed], ) do def self.redis_key_prefix 'dcs:result' diff --git a/app/views/idv/document_capture/show.html.erb b/app/views/idv/document_capture/show.html.erb index d1f87744bad..4ef8ddd986b 100644 --- a/app/views/idv/document_capture/show.html.erb +++ b/app/views/idv/document_capture/show.html.erb @@ -9,4 +9,5 @@ acuant_version: acuant_version, opted_in_to_in_person_proofing: opted_in_to_in_person_proofing, skip_doc_auth: skip_doc_auth, + doc_auth_selfie_capture: doc_auth_selfie_capture, ) %> diff --git a/app/views/idv/hybrid_mobile/document_capture/show.html.erb b/app/views/idv/hybrid_mobile/document_capture/show.html.erb index f969093f4d1..1432d0e6782 100644 --- a/app/views/idv/hybrid_mobile/document_capture/show.html.erb +++ b/app/views/idv/hybrid_mobile/document_capture/show.html.erb @@ -9,4 +9,5 @@ acuant_version: acuant_version, opted_in_to_in_person_proofing: false, skip_doc_auth: false, + doc_auth_selfie_capture: doc_auth_selfie_capture, ) %> diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index d8203565068..bb8e2cbe273 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -36,9 +36,7 @@ in_person_outage_message_enabled: IdentityConfig.store.in_person_outage_message_enabled, in_person_outage_expected_update_date: IdentityConfig.store.in_person_outage_expected_update_date, us_states_territories: us_states_territories, - doc_auth_selfie_capture: { - enabled: IdentityConfig.store.doc_auth_selfie_capture_enabled, - }, + doc_auth_selfie_capture: (Identity::Hostdata.env == 'prod' || !IdentityConfig.store.doc_auth_selfie_capture_enabled) ? false : doc_auth_selfie_capture, skip_doc_auth: skip_doc_auth, how_to_verify_url: idv_how_to_verify_url, ui_not_ready_section_enabled: IdentityConfig.store.doc_auth_not_ready_section_enabled, diff --git a/config/application.rb b/config/application.rb index a0c15a0c479..ab9a8086545 100644 --- a/config/application.rb +++ b/config/application.rb @@ -151,6 +151,13 @@ class Application < Rails::Application config.lookbook.auto_refresh = false config.lookbook.project_name = "#{APP_NAME} Component Previews" config.lookbook.ui_theme = 'blue' + if IdentityConfig.store.component_previews_embed_frame_ancestors.present? + # so we can embed a lookbook component into the dev docs + config.lookbook.preview_embeds.policy = 'ALLOWALL' + # lookbook strips out CSP, this brings it back so we aren't so permissive + require 'component_preview_csp' + config.middleware.insert_after ActionDispatch::Static, ComponentPreviewCsp + end end end end diff --git a/config/application.yml.default b/config/application.yml.default index 7971699c8fb..2b9d59b06f6 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -59,6 +59,7 @@ backup_code_cost: '2000$8$1$' broken_personal_key_window_start: '2021-07-29T00:00:00Z' broken_personal_key_window_finish: '2021-09-22T00:00:00Z' component_previews_enabled: false +component_previews_embed_frame_ancestors: '[]' country_phone_number_overrides: '{}' database_pool_idp: 5 database_socket: '' @@ -78,6 +79,7 @@ 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_custom_ui_enabled: false doc_auth_s3_request_timeout: 5 doc_auth_error_dpi_threshold: 290 doc_auth_error_glare_threshold: 40 @@ -314,9 +316,9 @@ show_user_attribute_deprecation_warnings: false otp_min_attempts_remaining_warning_count: 3 system_demand_report_email: 'foo@bar.com' sp_issuer_user_counts_report_configs: '[]' -team_agnes_email: '' -team_all_contractors_email: '' -team_all_feds_email: '' +team_ada_email: '' +team_all_login_emails: '[]' +team_daily_reports_emails: '[]' team_ursula_email: '' test_ssn_allowed_list: '' totp_code_interval: 30 @@ -382,8 +384,8 @@ development: database_worker_jobs_username: '' database_worker_jobs_host: '' database_worker_jobs_password: '' - doc_auth_exit_question_section_enabled: true - doc_auth_not_ready_section_enabled: true + doc_auth_exit_question_section_enabled: false + doc_auth_not_ready_section_enabled: false doc_auth_selfie_capture_enabled: false doc_auth_vendor: 'mock' doc_auth_vendor_randomize: false @@ -575,9 +577,9 @@ test: session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 skip_encryption_allowed_list: '[]' state_tracking_enabled: true - team_agnes_email: 'a@example.com' - team_all_contractors_email: 'c@example.com' - team_all_feds_email: 'f@example.com' + team_ada_email: 'ada@example.com' + team_all_login_emails: '["b@example.com", "c@example.com"]' + team_daily_reports_emails: '["a@example.com", "d@example.com"]' telephony_adapter: test test_ssn_allowed_list: '999999999' totp_code_interval: 3 diff --git a/dockerfiles/idp_review_app.Dockerfile b/dockerfiles/idp_review_app.Dockerfile index 195fb7c1fe5..df938109947 100644 --- a/dockerfiles/idp_review_app.Dockerfile +++ b/dockerfiles/idp_review_app.Dockerfile @@ -34,7 +34,7 @@ ENV REDIS_URL redis://redis:6379 ENV ASSET_HOST http://localhost:3000 ENV DOMAIN_NAME localhost:3000 ENV PIV_CAC_SERVICE_URL https://localhost:8443/ -ENV PIV_CAC_VERIFY_TOKEN_URL https://localhost:8443/ +ENV PIV_CAC_VERIFY_TOKEN_URL https://localhost:8443/ RUN echo Env Value : $CI_ENVIRONMENT_SLUG @@ -121,7 +121,6 @@ COPY --chown=app:app ./bin ./bin COPY --chown=app:app ./public ./public COPY --chown=app:app ./scripts ./scripts COPY --chown=app:app ./spec ./spec -COPY --chown=app:app ./vendor ./vendor COPY --chown=app:app ./Rakefile ./Rakefile COPY --chown=app:app ./Makefile ./Makefile COPY --chown=app:app ./babel.config.js ./babel.config.js diff --git a/lib/component_preview_csp.rb b/lib/component_preview_csp.rb new file mode 100644 index 00000000000..02ca266ff02 --- /dev/null +++ b/lib/component_preview_csp.rb @@ -0,0 +1,22 @@ +require_relative './identity_config' + +class ComponentPreviewCsp + COMPONENT_REQUEST_PATH = /^\/components(\/|$)/ + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + request = Rack::Request.new(env) + + if request.path.match?(COMPONENT_REQUEST_PATH) + frame_ancestors = IdentityConfig.store.component_previews_embed_frame_ancestors + headers['Content-Security-Policy'] = + "frame-ancestors 'self' #{frame_ancestors.join(' ')}" + end + + [status, headers, body] + end +end diff --git a/lib/data_pull.rb b/lib/data_pull.rb index 3f70c278484..31d3c3c7a3e 100644 --- a/lib/data_pull.rb +++ b/lib/data_pull.rb @@ -34,13 +34,13 @@ def banner * #{basename} events-summary uuid1 uuid2 - * #{basename} ig-request uuid1 uuid2 --requesting-issuer ABC:DEF:GHI + * #{basename} ig-request uuid1 uuid2 --requesting-issuer=ABC:DEF:GHI * #{basename} profile-summary uuid1 uuid2 * #{basename} uuid-convert partner-uuid1 partner-uuid2 - * #{basename} uuid-export uuid1 uuid2 --requesting-issuer ABC:DEF:GHI + * #{basename} uuid-export uuid1 uuid2 --requesting-issuer=ABC:DEF:GHI * #{basename} uuid-lookup email1@example.com email2@example.com Options: diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 323bbe5fc70..62b79b8fd54 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -145,6 +145,7 @@ def self.build_store(config_map) config.add(:backup_code_cost, type: :string) config.add(:broken_personal_key_window_finish, type: :timestamp) config.add(:broken_personal_key_window_start, type: :timestamp) + config.add(:component_previews_embed_frame_ancestors, type: :json) config.add(:component_previews_enabled, type: :boolean) config.add(:country_phone_number_overrides, type: :json) config.add(:dashboard_api_token, type: :string) @@ -177,6 +178,7 @@ def self.build_store(config_map) 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_custom_ui_enabled, type: :boolean) config.add(:doc_auth_error_dpi_threshold, type: :integer) config.add(:doc_auth_error_glare_threshold, type: :integer) config.add(:doc_auth_error_sharpness_threshold, type: :integer) @@ -451,9 +453,9 @@ def self.build_store(config_map) config.add(:sp_issuer_user_counts_report_configs, type: :json) config.add(:state_tracking_enabled, type: :boolean) config.add(:system_demand_report_email, type: :string) - config.add(:team_agnes_email, type: :string) - config.add(:team_all_contractors_email, type: :string) - config.add(:team_all_feds_email, type: :string) + config.add(:team_ada_email, type: :string) + config.add(:team_all_login_emails, type: :json) + config.add(:team_daily_reports_emails, type: :json) config.add(:team_ursula_email, type: :string) config.add(:telephony_adapter, type: :string) config.add(:test_ssn_allowed_list, type: :comma_separated_string_list) diff --git a/lib/reporting/identity_verification_report.rb b/lib/reporting/identity_verification_report.rb index ecc569b2d78..61e6d5fd1f7 100644 --- a/lib/reporting/identity_verification_report.rb +++ b/lib/reporting/identity_verification_report.rb @@ -77,31 +77,47 @@ def progress? @progress end + def identity_verification_emailable_report + EmailableReport.new( + title: 'Identiy Verification Metrics', + table: as_csv, + filename: 'identity_verification_metrics', + ) + end + + def as_csv + csv = [] + + csv << ['Report Timeframe', "#{time_range.begin} to #{time_range.end}"] + # This needs to be Date.today so it works when run on the command line + csv << ['Report Generated', Date.today.to_s] # rubocop:disable Rails/Date + csv << ['Issuer', issuers.join(', ')] if issuers.present? + csv << [] + csv << ['Metric', '# of Users'] + csv << [] + csv << ['Started IdV Verification', idv_started] + csv << ['Submitted welcome page', idv_doc_auth_welcome_submitted] + csv << ['Images uploaded', idv_doc_auth_image_vendor_submitted] + csv << [] + csv << ['Workflow completed', idv_final_resolution] + csv << ['Workflow completed - Verified', idv_final_resolution_verified] + csv << ['Workflow completed - Total Pending', idv_final_resolution_total_pending] + csv << ['Workflow completed - GPO Pending', idv_final_resolution_gpo] + csv << ['Workflow completed - In-Person Pending', idv_final_resolution_in_person] + csv << ['Workflow completed - Fraud Review Pending', idv_final_resolution_fraud_review] + csv << [] + csv << ['Successfully verified', successfully_verified_users] + csv << ['Successfully verified - Inline', idv_final_resolution_verified] + csv << ['Successfully verified - GPO Code Entry', gpo_verification_submitted] + csv << ['Successfully verified - In Person', usps_enrollment_status_updated] + csv << ['Successfully verified - Passed Fraud Review', fraud_review_passed] + end + def to_csv CSV.generate do |csv| - csv << ['Report Timeframe', "#{time_range.begin} to #{time_range.end}"] - # This needs to be Date.today so it works when run on the command line - csv << ['Report Generated', Date.today.to_s] # rubocop:disable Rails/Date - csv << ['Issuer', issuers.join(', ')] if issuers.present? - csv << [] - csv << ['Metric', '# of Users'] - csv << [] - csv << ['Started IdV Verification', idv_started] - csv << ['Submitted welcome page', idv_doc_auth_welcome_submitted] - csv << ['Images uploaded', idv_doc_auth_image_vendor_submitted] - csv << [] - csv << ['Workflow completed', idv_final_resolution] - csv << ['Workflow completed - Verified', idv_final_resolution_verified] - csv << ['Workflow completed - Total Pending', idv_final_resolution_total_pending] - csv << ['Workflow completed - GPO Pending', idv_final_resolution_gpo] - csv << ['Workflow completed - In-Person Pending', idv_final_resolution_in_person] - csv << ['Workflow completed - Fraud Review Pending', idv_final_resolution_fraud_review] - csv << [] - csv << ['Successfully verified', successfully_verified_users] - csv << ['Successfully verified - Inline', idv_final_resolution_verified] - csv << ['Successfully verified - GPO Code Entry', gpo_verification_submitted] - csv << ['Successfully verified - In Person', usps_enrollment_status_updated] - csv << ['Successfully verified - Passed Fraud Review', fraud_review_passed] + as_csv.each do |row| + csv << row + end end end diff --git a/spec/controllers/idv/document_capture_controller_spec.rb b/spec/controllers/idv/document_capture_controller_spec.rb index 8dd84a6324b..a4948cc0cde 100644 --- a/spec/controllers/idv/document_capture_controller_spec.rb +++ b/spec/controllers/idv/document_capture_controller_spec.rb @@ -69,6 +69,7 @@ :show, locals: hash_including( document_capture_session_uuid: document_capture_session_uuid, + doc_auth_selfie_capture: false, ), ).and_call_original @@ -77,6 +78,27 @@ expect(response).to render_template :show end + context 'when a selfie is requested' do + before do + allow(subject).to receive(:decorated_sp_session). + and_return(double('decorated_session', { selfie_required?: true, sp_name: 'sp' })) + end + + it 'renders the show template with selfie' do + expect(subject).to receive(:render).with( + :show, + locals: hash_including( + document_capture_session_uuid: document_capture_session_uuid, + doc_auth_selfie_capture: true, + ), + ).and_call_original + + get :show + + expect(response).to render_template :show + end + end + it 'sends analytics_visited event' do get :show diff --git a/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb index 4e60db66461..1642ddb67cc 100644 --- a/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/document_capture_controller_spec.rb @@ -74,6 +74,28 @@ expect(response).to render_template :show end + context 'when a selfie is requested' do + before do + allow(subject).to receive(:decorated_sp_session). + and_return(double('decorated_session', { selfie_required?: true, sp_name: 'sp' })) + end + context 'when selfie is required by sp session' do + it 'requests FE to display selfie' do + expect(subject).to receive(:render).with( + :show, + locals: hash_including( + document_capture_session_uuid: document_capture_session_uuid, + doc_auth_selfie_capture: true, + ), + ).and_call_original + + get :show + + expect(response).to render_template :show + end + end + end + it 'sends analytics_visited event' do get :show diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 3da49f2e3df..ded564c6dfb 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -6,6 +6,8 @@ let(:document_filename_regex) { /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}\.[a-z]+$/ } let(:base64_regex) { /^[a-z0-9+\/]+=*$/i } let(:selfie_img) { nil } + let(:state_id_number) { 'S59397998' } + describe '#create' do subject(:action) do post :create, params: params @@ -31,9 +33,6 @@ before do allow(controller).to receive(:store_encrypted_images?).and_return(store_encrypted_images) - end - - before do Funnel::DocAuth::RegisterStep.new(user.id, '').call('welcome', :view, true) allow(IdentityConfig.store).to receive(:idv_acuant_sdk_upgrade_a_b_testing_enabled). and_return(false) @@ -344,7 +343,52 @@ end context 'when image upload succeeds' do + # 50/50 state for selfie_check_performed in redis + # fake up a response and verify that selfie_check_performed flows through? + + context 'selfie included' do + let(:selfie_img) { DocAuthImageFixtures.selfie_image_multipart } + + before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled). + and_return(true) + + allow(controller.decorated_sp_session).to receive(:selfie_required?).and_return(true) + end + + it 'returns a successful response and modifies the session' do + expect_any_instance_of(DocAuth::Mock::DocAuthMockClient). + to receive(:post_images).with( + front_image: an_instance_of(String), + back_image: an_instance_of(String), + selfie_image: an_instance_of(String), + image_source: :unknown, + user_uuid: an_instance_of(String), + uuid_prefix: nil, + liveness_checking_required: true, + ).and_call_original + + action + + expect(response.status).to eq(200) + expect(json[:success]).to eq(true) + expect(document_capture_session.reload.load_result.success?).to eq(true) + expect(document_capture_session.reload.load_result.selfie_check_performed).to eq(true) + end + end + it 'returns a successful response and modifies the session' do + expect_any_instance_of(DocAuth::Mock::DocAuthMockClient). + to receive(:post_images).with( + front_image: an_instance_of(String), + back_image: an_instance_of(String), + selfie_image: nil, + image_source: :unknown, + user_uuid: an_instance_of(String), + uuid_prefix: nil, + liveness_checking_required: false, + ).and_call_original + action expect(response.status).to eq(200) @@ -491,6 +535,7 @@ state_id_type: state_id_type, dob: dob, state_id_jurisdiction: jurisdiction, + state_id_number: state_id_number, zipcode: zipcode, }, ), @@ -508,7 +553,7 @@ :idv_document_upload_submitted, success: false, document_state: 'ND', - document_number: nil, + document_number: state_id_number, document_issued: nil, document_expiration: nil, first_name: nil, @@ -597,7 +642,7 @@ :idv_document_upload_submitted, success: false, document_state: 'ND', - document_number: nil, + document_number: state_id_number, document_issued: nil, document_expiration: nil, first_name: nil, @@ -666,7 +711,7 @@ state: [I18n.t('doc_auth.errors.general.no_liveness')], }, error_details: { - state: { wrong_length: true }, + state: { inclusion: true }, }, attention_with_barcode: false, user_id: user.uuid, @@ -686,7 +731,7 @@ :idv_document_upload_submitted, success: false, document_state: 'Maryland', - document_number: nil, + document_number: state_id_number, document_issued: nil, document_expiration: nil, first_name: 'FAKEY', @@ -702,6 +747,92 @@ end end + context 'but doc_pii validation fails due to missing state_id_number' do + let(:state_id_number) { nil } + + it 'tracks state_id_number validation errors in analytics' do + stub_analytics + stub_attempts_tracker + + expect(@analytics).to receive(:track_event).with( + 'IdV: doc auth image upload form submitted', + success: true, + errors: {}, + user_id: user.uuid, + attempts: 1, + remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + pii_like_keypaths: pii_like_keypaths, + flow_path: 'standard', + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + ) + + expect(@analytics).to receive(:track_event).with( + 'IdV: doc auth image upload vendor submitted', + success: true, + errors: {}, + attention_with_barcode: false, + async: false, + billed: true, + exception: nil, + doc_auth_result: 'Passed', + state: 'ND', + state_id_type: 'drivers_license', + user_id: user.uuid, + attempts: 1, + remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + client_image_metrics: { + front: { glare: 99.99 }, + back: { glare: 99.99 }, + }, + pii_like_keypaths: pii_like_keypaths, + flow_path: 'standard', + vendor_request_time_in_ms: a_kind_of(Float), + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + doc_type_supported: boolean, + ) + + expect(@analytics).to receive(:track_event).with( + 'IdV: doc auth image upload vendor pii validation', + success: false, + errors: { + state_id_number: [I18n.t('doc_auth.errors.general.no_liveness')], + }, + error_details: { + state_id_number: { blank: true }, + }, + attention_with_barcode: false, + user_id: user.uuid, + attempts: 1, + remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + pii_like_keypaths: pii_like_keypaths, + flow_path: 'standard', + front_image_fingerprint: an_instance_of(String), + back_image_fingerprint: an_instance_of(String), + classification_info: hash_including(:Front, :Back), + ) + + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :idv_document_upload_submitted, + success: false, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, + document_state: 'ND', + document_number: state_id_number, + document_issued: nil, + document_expiration: nil, + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + date_of_birth: '10/06/1938', + address: address1, + ) + + action + end + end + context 'but doc_pii validation fails due to invalid DOB' do let(:dob) { nil } @@ -775,7 +906,7 @@ document_front_image_filename: nil, document_image_encryption_key: nil, document_state: 'ND', - document_number: nil, + document_number: state_id_number, document_issued: nil, document_expiration: nil, first_name: 'FAKEY', @@ -953,16 +1084,38 @@ end end - context 'when liveness checking enabled' do + context 'the frontend requests a selfie' do before do - allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + allow(controller).to receive(:decorated_sp_session). + and_return(double('decorated_session', { selfie_required?: true })) end + let(:selfie_img) { DocAuthImageFixtures.selfie_image_multipart } + it 'returns a successful response' do action expect(response.status).to eq(200) expect(json[:success]).to eq(true) expect(document_capture_session.reload.load_result.success?).to eq(true) + expect(document_capture_session.reload.load_result.selfie_check_performed).to eq(true) + end + + it 'sends a selfie' do + expect_any_instance_of(DocAuth::Mock::DocAuthMockClient). + to receive(:post_images).with( + front_image: an_instance_of(String), + back_image: an_instance_of(String), + selfie_image: an_instance_of(String), + image_source: :unknown, + user_uuid: an_instance_of(String), + uuid_prefix: nil, + liveness_checking_required: true, + ).and_call_original + + action + expect(response.status).to eq(200) + expect(json[:success]).to eq(true) + expect(document_capture_session.reload.load_result.success?).to eq(true) end end end diff --git a/spec/decorators/service_provider_session_spec.rb b/spec/decorators/service_provider_session_spec.rb index 3e02af038a1..dec10398950 100644 --- a/spec/decorators/service_provider_session_spec.rb +++ b/spec/decorators/service_provider_session_spec.rb @@ -207,6 +207,21 @@ sp_session[:biometric_comparison_required] = nil expect(subject.selfie_required?).to eq(false) end + + context 'when environment is prod' do + before do + allow(Identity::Hostdata).to receive(:env).and_return('prod') + end + it 'returns false when sp biometric_comparison_required is true' do + sp_session[:biometric_comparison_required] = true + expect(subject.selfie_required?).to eq(false) + end + + it 'returns false when sp biometric_comparison_required is truthy' do + sp_session[:biometric_comparison_required] = 1 + expect(subject.selfie_required?).to eq(false) + end + end end context 'doc_auth_selfie_capture_enabled is false' do diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 302d6814ad3..e709090cb1a 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -845,6 +845,9 @@ def wait_for_event(event, wait) context 'Happy selfie path' do before do allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?). + and_return({ biometric_comparison_required: true }) mobile_device = Browser.new(mobile_user_agent) allow(BrowserCache).to receive(:parse).and_return(mobile_device) diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 7c43f38dde4..5b4ac60eff5 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -109,6 +109,8 @@ end it 'proceeds to the next page with valid info' do + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) attach_and_submit_images expect(page).to have_current_path(idv_ssn_url) @@ -176,6 +178,7 @@ expect(page).to have_current_path(idv_document_capture_url) expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) attach_and_submit_images @@ -197,30 +200,98 @@ allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) end - it 'proceeds to the next page with valid info, including a selfie image' do - perform_in_browser(:mobile) do - visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(user) - complete_doc_auth_steps_before_document_capture_step + 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) + complete_doc_auth_steps_before_document_capture_step - expect(page).to have_current_path(idv_document_capture_url) - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect_doc_capture_page_header(t('doc_auth.headings.document_capture_with_selfie')) - expect_doc_capture_id_subheader - expect_doc_capture_selfie_subheader - attach_images - attach_selfie - submit_images + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - 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_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(page).to have_current_path(idv_ssn_url) - fill_out_ssn_form_ok - click_idv_continue - complete_verify_step - expect(page).to have_current_path(idv_phone_url) + attach_images + submit_images + + 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(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + end + + context 'when a selfie is required by the SP' do + before do + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?). + and_return({ biometric_comparison_required: true }) + end + + it 'proceeds to the next page with valid info, including a selfie image' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_document_capture_step + + expect(page).to have_current_path(idv_document_capture_url) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect_doc_capture_page_header(t('doc_auth.headings.document_capture_with_selfie')) + expect_doc_capture_id_subheader + expect_doc_capture_selfie_subheader + attach_images + attach_selfie + submit_images + + 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(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + + context 'when hosted env is prod' do + before do + allow(Identity::Hostdata).to receive(:env).and_return('prod') + end + 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) + complete_doc_auth_steps_before_document_capture_step + + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_images + submit_images + + 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(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end end end end 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 82df41f6551..40543b52ce5 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -244,6 +244,9 @@ context 'error due to data issue with 2xx status code', allow_browser_log: true do before do allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) + start_idv_from_sp sign_in_and_2fa_user complete_doc_auth_steps_before_document_capture_step mock_doc_auth_acuant_error_unknown diff --git a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb index 4e0a16f59db..e0b8eca4005 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb @@ -58,6 +58,8 @@ expect(page).to have_current_path(root_url) visit idv_hybrid_mobile_document_capture_url + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) attach_and_submit_images expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) @@ -119,6 +121,8 @@ perform_in_browser(:mobile) do visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) click_on t('links.cancel') click_on t('forms.buttons.cancel') # Yes, cancel end @@ -259,6 +263,12 @@ end context 'barcode read error on desktop, redo document capture on mobile' do + before do + allow(Identity::Hostdata).to receive(:env).and_return('prod') + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) + end it 'continues to ssn on desktop when user selects Continue', js: true do user = nil @@ -292,6 +302,14 @@ visit @sms_link DocAuth::Mock::DocAuthMockClient.reset! + + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + + visit(idv_hybrid_mobile_document_capture_url(selfie: true)) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url(selfie: true)) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_and_submit_images expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) @@ -317,6 +335,10 @@ end it 'prefils the phone number used on the phone step if the user has no MFA phone', :js do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled). + and_return(true) + allow_any_instance_of(FederatedProtocols::Oidc). + to receive(:biometric_comparison_required?).and_return(true) user = create(:user, :with_authentication_app) perform_in_browser(:desktop) do @@ -332,7 +354,12 @@ perform_in_browser(:mobile) do visit @sms_link - attach_and_submit_images + + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + + attach_images + attach_selfie + submit_images expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) expect(page).to have_text(t('doc_auth.instructions.switch_back')) diff --git a/spec/fixtures/ial2_test_credential.yml b/spec/fixtures/ial2_test_credential.yml index efddc3077f6..ee692253fae 100644 --- a/spec/fixtures/ial2_test_credential.yml +++ b/spec/fixtures/ial2_test_credential.yml @@ -10,3 +10,4 @@ document: 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_no_dob.yml b/spec/fixtures/ial2_test_credential_no_dob.yml index 73cd837ad83..3e71de2bec9 100644 --- a/spec/fixtures/ial2_test_credential_no_dob.yml +++ b/spec/fixtures/ial2_test_credential_no_dob.yml @@ -9,3 +9,4 @@ document: zipcode: '11364' phone: +1 314-555-1212 state_id_jurisdiction: 'NY' + state_id_number: 'S59397998' \ No newline at end of file diff --git a/spec/fixtures/puerto_rico_resident.yml b/spec/fixtures/puerto_rico_resident.yml index b52658f3be8..c31ac1869fa 100644 --- a/spec/fixtures/puerto_rico_resident.yml +++ b/spec/fixtures/puerto_rico_resident.yml @@ -10,3 +10,4 @@ document: dob: 01/06/1998 phone: +1 787-555-1212 state_id_jurisdiction: 'PR' + state_id_number: 'S59397998' diff --git a/spec/forms/event_disavowal/password_reset_from_disavowal_form_spec.rb b/spec/forms/event_disavowal/password_reset_from_disavowal_form_spec.rb index 7d442e98706..a68373ee313 100644 --- a/spec/forms/event_disavowal/password_reset_from_disavowal_form_spec.rb +++ b/spec/forms/event_disavowal/password_reset_from_disavowal_form_spec.rb @@ -26,4 +26,26 @@ expect(user.reload.valid_password?(new_password)).to eq(false) end end + + context 'user has an active profile' do + let(:user) { create(:user, :proofed) } + + it 'destroys the proofing component' do + ProofingComponent.create(user_id: user.id, document_check: 'acuant') + + subject.submit(password: new_password) + + expect(user.reload.proofing_component).to be_nil + end + end + + context 'user does not have an active profile' do + it 'does not destroy the proofing component' do + ProofingComponent.create(user_id: user.id, document_check: 'acuant') + + subject.submit(password: new_password) + + expect(user.reload.proofing_component).to_not be_nil + end + end end diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index e54ecad42b9..98f4099164e 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -17,14 +17,14 @@ analytics: fake_analytics, irs_attempts_api_tracker: irs_attempts_api_tracker, store_encrypted_images: store_encrypted_images, - liveness_checking_enabled: liveness_checking_enabled, + liveness_checking_required: liveness_checking_required, ) end let(:front_image) { DocAuthImageFixtures.document_front_image_multipart } let(:back_image) { DocAuthImageFixtures.document_back_image_multipart } let(:selfie_image) { nil } - let(:liveness_checking_enabled) { false } + let(:liveness_checking_required) { false } let(:front_image_metadata) do { width: 40, height: 40, mimeType: 'image/png', source: 'upload' }.to_json end @@ -78,8 +78,8 @@ end end - context 'when liveness check is enabled' do - let(:liveness_checking_enabled) { true } + context 'when liveness check is required' do + let(:liveness_checking_required) { true } it 'is not valid without selfie' do expect(form.valid?).to eq(false) end diff --git a/spec/forms/idv/doc_pii_form_spec.rb b/spec/forms/idv/doc_pii_form_spec.rb index f339e7aea35..f1c54883806 100644 --- a/spec/forms/idv/doc_pii_form_spec.rb +++ b/spec/forms/idv/doc_pii_form_spec.rb @@ -18,21 +18,28 @@ zipcode: Faker::Address.zip_code, state: Faker::Address.state_abbr, state_id_jurisdiction: 'AL', + state_id_number: 'S59397998', } end let(:name_errors_pii) do - { first_name: nil, + { + first_name: nil, last_name: nil, dob: valid_dob, address1: Faker::Address.street_address, - state: Faker::Address.state_abbr } + state: Faker::Address.state_abbr, + state_id_number: 'S59397998', + } end let(:name_and_dob_errors_pii) do - { first_name: nil, + { + first_name: nil, last_name: nil, dob: nil, address1: Faker::Address.street_address, - state: Faker::Address.state_abbr } + state: Faker::Address.state_abbr, + state_id_number: 'S59397998', + } end let(:dob_min_age_error_pii) do { @@ -41,6 +48,7 @@ dob: too_young_dob, address1: Faker::Address.street_address, state: Faker::Address.state_abbr, + state_id_number: 'S59397998', } end let(:invalid_zipcode_pii) do @@ -52,6 +60,7 @@ state: Faker::Address.state_abbr, zipcode: 123456, state_id_jurisdiction: 'AL', + state_id_number: 'S59397998', } end let(:nil_zipcode_pii) do @@ -63,6 +72,7 @@ state: Faker::Address.state_abbr, zipcode: nil, state_id_jurisdiction: 'AL', + state_id_number: 'S59397998', } end let(:state_error_pii) do @@ -74,6 +84,7 @@ zipcode: Faker::Address.zip_code, state: 'YORK', state_id_jurisdiction: 'AL', + state_id_number: 'S59397998', } end let(:jurisdiction_error_pii) do @@ -85,6 +96,7 @@ zipcode: Faker::Address.zip_code, state: Faker::Address.state_abbr, state_id_jurisdiction: 'XX', + state_id_number: 'S59397998', } end let(:address1_error_pii) do @@ -96,6 +108,19 @@ zipcode: Faker::Address.zip_code, state: Faker::Address.state_abbr, state_id_jurisdiction: 'AL', + state_id_number: 'S59397998', + } + end + let(:nil_state_id_number_pii) do + { + first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + dob: valid_dob, + address1: nil, + zipcode: Faker::Address.zip_code, + state: Faker::Address.state_abbr, + state_id_jurisdiction: 'AL', + state_id_number: nil, } end let(:pii) { nil } @@ -257,4 +282,17 @@ expect(result.errors[:state]).to eq([I18n.t('doc_auth.errors.general.no_liveness')]) end end + + context 'when the state_id_number is missing' do + let(:subject) { Idv::DocPiiForm.new(pii: nil_state_id_number_pii) } + + it 'responds with an unsuccessful result' do + result = subject.submit + + expect(result.success?).to eq(false) + expect(result.errors[:state_id_number]).to eq( + [I18n.t('doc_auth.errors.general.no_liveness')], + ) + end + end end diff --git a/spec/jobs/reports/identity_verification_report_spec.rb b/spec/jobs/reports/identity_verification_report_spec.rb index 56d35b79572..ab6f507250a 100644 --- a/spec/jobs/reports/identity_verification_report_spec.rb +++ b/spec/jobs/reports/identity_verification_report_spec.rb @@ -1,13 +1,49 @@ require 'rails_helper' RSpec.describe Reports::IdentityVerificationReport do + let(:report_date) { Date.new(2023, 12, 12).in_time_zone('UTC') } + before do allow(IdentityConfig.store).to receive(:s3_reports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:team_ada_email).and_return('ada@example.com') end describe '#perform' do - it 'gets a CSV from the report maker and saves it to S3' do - report_maker = double(Reporting::IdentityVerificationReport, to_csv: 'I am a CSV, see') + it 'gets a CSV from the report maker, saves it to S3, and sends email to team' do + reports = + Reporting::EmailableReport.new( + title: 'Identity Verification Metrics', + table: [ + ['Report Timeframe', '2023-12-10 00:00:00 UTC to 2023-12-10 23:59:59 UTC'], + ['Report Generated', '2023-12-11'], + ['Issuer', 'some:issuer'], + ['Metric', '# of Users'], + [], + ['Started IdV Verification', '78'], + ['Submitted welcome page', '75'], + ['Images uploaded', '73'], + [], + ['Workflow completed', '72'], + ['Workflow completed - Verified', '71'], + ['Workflow completed - Total Pending', '33'], + ['Workflow completed - GPO Pending', '23'], + ['Workflow completed - In-Person Pending', '55'], + ['Workflow completed - Fraud Review Pending', '34'], + [], + ['Successfully verified', '56'], + ['Successfully verified - Inline', '55'], + ['Successfully verified - GPO Code Entry', '25'], + ['Successfully verified - In Person', '25'], + ['Successfully verified - Passed Fraud Review', '15'], + ], + filename: 'identity_verification_metrics', + ) + + report_maker = double( + Reporting::IdentityVerificationReport, + to_csv: 'I am a CSV, see', + identity_verification_emailable_report: reports, + ) allow(subject).to receive(:report_maker).and_return(report_maker) expect(subject).to receive(:save_report).with( 'identity-verification-report', @@ -15,7 +51,15 @@ extension: 'csv', ) - subject.perform(Date.new(2023, 12, 25)) + expect(ReportMailer).to receive(:tables_report).once.with( + email: IdentityConfig.store.team_ada_email, + subject: 'Daily Identity Verification Report - 2023-12-12', + reports: anything, + message: anything, + attachment_format: :csv, + ).and_call_original + + subject.perform(report_date) end end diff --git a/spec/jobs/reports/monthly_key_metrics_report_spec.rb b/spec/jobs/reports/monthly_key_metrics_report_spec.rb index c3ab446953e..cad5b0ea09b 100644 --- a/spec/jobs/reports/monthly_key_metrics_report_spec.rb +++ b/spec/jobs/reports/monthly_key_metrics_report_spec.rb @@ -30,17 +30,19 @@ } end - let(:mock_proofing_report_data) do - [ - ['metric', 'num_users', 'percent'], - ] - end + let(:mock_all_login_emails) { ['mock_feds@example.com', 'mock_contractors@example.com'] } + let(:mock_daily_reports_emails) { ['mock_agnes@example.com', 'mock_daily@example.com'] } let(:mock_proofing_rate_data) do [ ['Metric', 'Trailing 30d', 'Trailing 60d', 'Trailing 90d'], ] end + let(:mock_proofing_report_data) do + [ + ['metric', 'num_users', 'percent'], + ] + end before do allow(Identity::Hostdata).to receive(:env).and_return('int') @@ -57,11 +59,16 @@ allow(report.proofing_rate_report).to receive(:as_csv). and_return(mock_proofing_rate_data) + + allow(IdentityConfig.store).to receive(:team_daily_reports_emails). + and_return(mock_daily_reports_emails) + allow(IdentityConfig.store).to receive(:team_all_login_emails). + and_return(mock_all_login_emails) end it 'sends out a report to just to team agnes' do expect(ReportMailer).to receive(:tables_report).once.with( - email: [IdentityConfig.store.team_agnes_email], + email: anything, subject: 'Monthly Key Metrics Report - 2021-03-02', reports: anything, message: report.preamble, @@ -76,11 +83,7 @@ it 'sends out a report to everybody' do expect(ReportMailer).to receive(:tables_report).once.with( - email: [ - IdentityConfig.store.team_agnes_email, - IdentityConfig.store.team_all_feds_email, - IdentityConfig.store.team_all_contractors_email, - ], + email: anything, subject: 'Monthly Key Metrics Report - 2021-02-28', reports: anything, message: report.preamble, @@ -92,7 +95,7 @@ end it 'does not send out a report with no emails' do - allow(IdentityConfig.store).to receive(:team_agnes_email).and_return('') + allow(IdentityConfig.store).to receive(:team_daily_reports_emails).and_return('') expect(report).to_not receive(:reports) @@ -112,6 +115,28 @@ report.perform(report_date) end + describe '#emails' do + context 'on the first of the month' do + let(:report_date) { Date.new(2021, 3, 1).prev_day } + + it 'emails the whole login team' do + expected_array = mock_daily_reports_emails + expected_array += mock_all_login_emails + + expect(report.emails).to match_array(expected_array) + end + end + + context 'during the rest of the month' do + let(:report_date) { Date.new(2021, 3, 2).prev_day } + it 'only emails team_daily_reports' do + expect(report.emails).to match_array( + mock_daily_reports_emails, + ) + end + end + end + describe '#preamble' do let(:env) { 'prod' } subject(:preamble) { report.preamble(env:) } diff --git a/spec/lib/reporting/identity_verification_report_spec.rb b/spec/lib/reporting/identity_verification_report_spec.rb index 39d97239396..2d7fede3de9 100644 --- a/spec/lib/reporting/identity_verification_report_spec.rb +++ b/spec/lib/reporting/identity_verification_report_spec.rb @@ -58,6 +58,40 @@ allow(report).to receive(:cloudwatch_client).and_return(cloudwatch_client) end # rubocop:enable Layout/LineLength + describe '#as_csv' do + it 'renders a csv report' do + expected_csv = [ + ['Report Timeframe', "#{time_range.begin} to #{time_range.end}"], + ['Report Generated', Date.today.to_s], # rubocop:disable Rails/Date + ['Issuer', issuer], + [], + ['Metric', '# of Users'], + [], + ['Started IdV Verification', 5], + ['Submitted welcome page', 5], + ['Images uploaded', 5], + [], + ['Workflow completed', 4], + ['Workflow completed - Verified', 1], + ['Workflow completed - Total Pending', 3], + ['Workflow completed - GPO Pending', 1], + ['Workflow completed - In-Person Pending', 1], + ['Workflow completed - Fraud Review Pending', 1], + [], + ['Successfully verified', 4], + ['Successfully verified - Inline', 1], + ['Successfully verified - GPO Code Entry', 1], + ['Successfully verified - In Person', 1], + ['Successfully verified - Passed Fraud Review', 1], + ] + + aggregate_failures do + report.as_csv.zip(expected_csv).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end describe '#to_csv' do it 'generates a csv' do 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 33abcc154b1..51340d3420d 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 @@ -35,35 +35,41 @@ shared_examples 'a successful request' do it 'uploads the image and returns a successful result' do - include_liveness = liveness_checking_required && !selfie_image.nil? request_stub_liveness = stub_request(:post, full_url).with do |request| JSON.parse(request.body, symbolize_names: true)[:Document][:Selfie].present? - end.to_return(body: response_body(include_liveness), status: 201) + end.to_return(body: response_body(include_liveness_expected), status: 201) request_stub = stub_request(:post, full_url).with do |request| !JSON.parse(request.body, symbolize_names: true)[:Document][:Selfie].present? - end.to_return(body: response_body(include_liveness), status: 201) + end.to_return(body: response_body(include_liveness_expected), status: 201) response = subject.fetch expect(response.success?).to eq(true) expect(response.errors).to eq({}) expect(response.exception).to be_nil - if include_liveness + if include_liveness_expected expect(request_stub_liveness).to have_been_requested + expect(response.selfie_check_performed?).to be(true) else expect(request_stub).to have_been_requested + expect(response.selfie_check_performed?).to be(false) end end context 'fails document authentication' do it 'fails response with errors' do - include_liveness = liveness_checking_required && !selfie_image.nil? request_stub_liveness = stub_request(:post, full_url).with do |request| JSON.parse(request.body, symbolize_names: true)[:Document][:Selfie].present? - end.to_return(body: response_body_with_doc_auth_errors(include_liveness), status: 201) + end.to_return( + body: response_body_with_doc_auth_errors(include_liveness_expected), + status: 201, + ) request_stub = stub_request(:post, full_url).with do |request| !JSON.parse(request.body, symbolize_names: true)[:Document][:Selfie].present? - end.to_return(body: response_body_with_doc_auth_errors(include_liveness), status: 201) + end.to_return( + body: response_body_with_doc_auth_errors(include_liveness_expected), + status: 201, + ) response = subject.fetch @@ -74,49 +80,113 @@ 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 + if include_liveness_expected expect(request_stub_liveness).to have_been_requested + expect(response.selfie_check_performed?).to be(true) else expect(request_stub).to have_been_requested + expect(response.selfie_check_performed?).to be(false) end end end + + def include_liveness_expected + return false if Identity::Hostdata.env == 'prod' + return false unless IdentityConfig.store.doc_auth_selfie_capture_enabled + + liveness_checking_required + end end context 'with liveness_checking_enabled as false' do - let(:liveness_checking_required) { false } - context 'with acuant image source' do - let(:workflow) { 'test_workflow' } - let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } - it_behaves_like 'a successful request' - - it 'does not include a nil selfie in the request body sent to TrueID' do - body_as_json = subject.send(:body) - body_as_hash = JSON.parse(body_as_json) - expect(body_as_hash['Document']).not_to have_key('Selfie') + context 'when liveness checking is NOT required' do + let(:liveness_checking_required) { false } + context 'with acuant image source' do + let(:workflow) { 'test_workflow' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + it_behaves_like 'a successful request' + + it 'does not include a nil selfie in the request body sent to TrueID' do + body_as_json = subject.send(:body) + body_as_hash = JSON.parse(body_as_json) + expect(body_as_hash['Document']).not_to have_key('Selfie') + end + end + context 'with unknown image source' do + let(:workflow) { 'test_workflow_cropping' } + let(:image_source) { DocAuth::ImageSources::UNKNOWN } + + it_behaves_like 'a successful request' end end - context 'with unknown image source' do - let(:workflow) { 'test_workflow_cropping' } - let(:image_source) { DocAuth::ImageSources::UNKNOWN } - it_behaves_like 'a successful request' + context 'when liveness checking is required' do + let(:liveness_checking_required) { true } + context 'with acuant image source' do + let(:workflow) { 'test_workflow' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + it_behaves_like 'a successful request' + end + context 'with unknown image source' do + let(:workflow) { 'test_workflow_cropping' } + let(:image_source) { DocAuth::ImageSources::UNKNOWN } + + it_behaves_like 'a successful request' + end end end context 'with liveness_checking_enabled as true' do - let(:liveness_checking_required) { true } - context 'with acuant image source' do - let(:workflow) { 'test_workflow_liveness' } - let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + end + + context 'when liveness checking is NOT required' do + let(:liveness_checking_required) { false } + context 'with acuant image source' do + let(:workflow) { 'test_workflow' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + it_behaves_like 'a successful request' + end + context 'with unknown image source' do + let(:workflow) { 'test_workflow_cropping' } + let(:image_source) { DocAuth::ImageSources::UNKNOWN } - it_behaves_like 'a successful request' + it_behaves_like 'a successful request' + end end - context 'with unknown image source' do - let(:workflow) { 'test_workflow_liveness_cropping' } - let(:image_source) { DocAuth::ImageSources::UNKNOWN } - it_behaves_like 'a successful request' + context 'when liveness checking is required' do + let(:liveness_checking_required) { true } + context 'with acuant image source' do + let(:workflow) { 'test_workflow_liveness' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + + it_behaves_like 'a successful request' + end + context 'with unknown image source' do + let(:workflow) { 'test_workflow_liveness_cropping' } + let(:image_source) { DocAuth::ImageSources::UNKNOWN } + + it_behaves_like 'a successful request' + end + + context 'when hosted env is prod' do + before do + allow(Identity::Hostdata).to receive(:env).and_return('prod') + end + context 'with acuant image source' do + let(:workflow) { 'test_workflow' } + let(:image_source) { DocAuth::ImageSources::ACUANT_SDK } + it_behaves_like 'a successful request' + end + context 'with unknown image source' do + let(:workflow) { 'test_workflow_cropping' } + let(:image_source) { DocAuth::ImageSources::UNKNOWN } + + it_behaves_like 'a successful request' + end + end end end diff --git a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb index d051a630073..19c23a1ecc3 100644 --- a/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb +++ b/spec/services/doc_auth/mock/doc_auth_mock_client_spec.rb @@ -3,6 +3,8 @@ RSpec.describe DocAuth::Mock::DocAuthMockClient do subject(:client) { described_class.new } + let(:liveness_checking_required) { false } + it 'implements all the public methods of the real Acuant client' do expect( described_class.instance_methods.sort, @@ -23,7 +25,10 @@ instance_id: instance_id, image: DocAuthImageFixtures.document_back_image, ) - get_results_response = client.get_results(instance_id: instance_id) + get_results_response = client.get_results( + instance_id: instance_id, + selfie_check_performed: liveness_checking_required, + ) expect(create_document_response.success?).to eq(true) expect(create_document_response.instance_id).to_not be_blank @@ -84,6 +89,7 @@ ) get_results_response = client.get_results( instance_id: create_document_response.instance_id, + selfie_check_performed: liveness_checking_required, ) expect(get_results_response.pii_from_doc).to eq( @@ -124,6 +130,7 @@ ) get_results_response = client.get_results( instance_id: create_document_response.instance_id, + selfie_check_performed: liveness_checking_required, ) expect(get_results_response.attention_with_barcode?).to eq(false) errors = get_results_response.errors @@ -177,6 +184,32 @@ expect(post_images_response).to be_a(DocAuth::Response) end + describe 'selfie check performed flag' do + let(:response) do + client.post_images( + front_image: DocAuthImageFixtures.document_front_image, + back_image: DocAuthImageFixtures.document_back_image, + liveness_checking_required: liveness_checking_required, + ) + end + + context 'when a liveness check is required' do + let(:liveness_checking_required) { true } + + it 'sets selfie_check_performed to true' do + expect(response.selfie_check_performed?).to be(true) + end + end + + context 'when a liveness check is not required' do + let(:liveness_checking_required) { false } + + it 'sets selfie_check_performed to false' do + expect(response.selfie_check_performed?).to be(false) + end + end + end + describe 'generate response for failure indicating http status' do it 'generate network error response for status 500 when post image' do image = <<-YAML @@ -202,7 +235,10 @@ image: image, instance_id: nil, ) - response = client.get_results(instance_id: nil) + response = client.get_results( + instance_id: nil, + selfie_check_performed: liveness_checking_required, + ) expect(response).to be_a(DocAuth::Response) expect(response.success?).to eq(false) expect(response.errors).to eq(general: ['network']) diff --git a/spec/services/doc_auth/mock/result_response_spec.rb b/spec/services/doc_auth/mock/result_response_spec.rb index 09a81706549..5b893d82a30 100644 --- a/spec/services/doc_auth/mock/result_response_spec.rb +++ b/spec/services/doc_auth/mock/result_response_spec.rb @@ -2,6 +2,7 @@ RSpec.describe DocAuth::Mock::ResultResponse do let(:warn_notifier) { instance_double('Proc') } + let(:selfie_check_performed) { false } subject(:response) do config = DocAuth::Mock::Config.new( @@ -10,7 +11,7 @@ glare_threshold: 40, warn_notifier: warn_notifier, ) - described_class.new(input, config) + described_class.new(input, selfie_check_performed, config) end context 'with an image file' do @@ -254,7 +255,7 @@ glare_threshold: 40, }, ) - described_class.new(input, config) + described_class.new(input, selfie_check_performed, config) end let(:input) do @@ -645,4 +646,18 @@ ) end end + + context 'when a selfie check is performed' do + let(:input) { DocAuthImageFixtures.document_front_image } + let(:selfie_check_performed) { true } + + it { expect(response.selfie_check_performed?).to eq(true) } + end + + context 'when a selfie check is not performed' do + let(:input) { DocAuthImageFixtures.document_front_image } + let(:selfie_check_performed) { false } + + it { expect(response.selfie_check_performed?).to eq(false) } + end end diff --git a/spec/services/doc_auth/response_spec.rb b/spec/services/doc_auth/response_spec.rb index f26601807a0..fac9902f03b 100644 --- a/spec/services/doc_auth/response_spec.rb +++ b/spec/services/doc_auth/response_spec.rb @@ -6,6 +6,7 @@ let(:exception) { nil } let(:pii_from_doc) { {} } let(:attention_with_barcode) { false } + subject(:response) do described_class.new( success: success, @@ -22,6 +23,7 @@ let(:other_exception) { nil } let(:other_pii_from_doc) { {} } let(:other_attention_with_barcode) { false } + let(:other) do described_class.new( success: other_success, diff --git a/spec/services/doc_auth_router_spec.rb b/spec/services/doc_auth_router_spec.rb index c507fc7562d..4f7f4971fb4 100644 --- a/spec/services/doc_auth_router_spec.rb +++ b/spec/services/doc_auth_router_spec.rb @@ -183,7 +183,7 @@ def reload_ab_test_initializer! ) response = I18n.with_locale(:es) do - proxy.get_results(instance_id: 'abcdef') + proxy.get_results(instance_id: 'abcdef', selfie_check_performed: false) end expect(response.errors[:some_other_key]).to eq(['will not be translated']) @@ -206,7 +206,7 @@ def reload_ab_test_initializer! ), ) - response = proxy.get_results(instance_id: 'abcdef') + response = proxy.get_results(instance_id: 'abcdef', selfie_check_performed: false) expect(response.errors[:network]).to eq(I18n.t('doc_auth.errors.general.network_error')) end diff --git a/spec/support/doc_pii_helper.rb b/spec/support/doc_pii_helper.rb index 2f9be8fb391..af3532d96ca 100644 --- a/spec/support/doc_pii_helper.rb +++ b/spec/support/doc_pii_helper.rb @@ -23,6 +23,9 @@ def pii_like_keypaths [:errors, :jurisdiction], [:error_details, :jurisdiction], [:error_details, :jurisdiction, :jurisdiction], + [:errors, :state_id_number], + [:error_details, :state_id_number], + [:error_details, :state_id_number, :state_id_number], ] end end diff --git a/spec/views/idv/shared/_document_capture.html.erb_spec.rb b/spec/views/idv/shared/_document_capture.html.erb_spec.rb index 47501f5da20..17100994ebe 100644 --- a/spec/views/idv/shared/_document_capture.html.erb_spec.rb +++ b/spec/views/idv/shared/_document_capture.html.erb_spec.rb @@ -12,7 +12,8 @@ let(:in_person_proofing_enabled_issuer) { nil } let(:acuant_sdk_upgrade_a_b_testing_enabled) { false } let(:use_alternate_sdk) { false } - let(:doc_auth_selfie_capture_enabled) { false } + let(:doc_auth_selfie_capture_enabled) { true } + let(:acuant_version) { '1.3.3.7' } let(:skip_doc_auth) { false } let(:opted_in_to_in_person_proofing) { false } @@ -44,7 +45,7 @@ acuant_sdk_upgrade_a_b_testing_enabled: acuant_sdk_upgrade_a_b_testing_enabled, use_alternate_sdk: use_alternate_sdk, acuant_version: acuant_version, - doc_auth_selfie_capture: { enabled: doc_auth_selfie_capture_enabled }, + doc_auth_selfie_capture: doc_auth_selfie_capture_enabled, skip_doc_auth: skip_doc_auth, opted_in_to_in_person_proofing: opted_in_to_in_person_proofing, } @@ -100,8 +101,30 @@ it 'sends doc_auth_selfie_capture_enabled to the FE' do render_partial expect(rendered).to have_css( - "#document-capture-form[data-doc-auth-selfie-capture='{\"enabled\":false}']", + "#document-capture-form[data-doc-auth-selfie-capture='false']", ) end + + context 'when selfie FF enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) + end + it 'does send doc_auth_selfie_capture to the FE' do + render_partial + expect(rendered).to have_css( + "#document-capture-form[data-doc-auth-selfie-capture='true']", + ) + end + context 'when hosted in prod env' do + it 'does not send doc_auth_selfie_capture to the FE' do + allow(Identity::Hostdata).to receive(:env).and_return('prod') + + render_partial + expect(rendered).to have_css( + "#document-capture-form[data-doc-auth-selfie-capture='false']", + ) + end + end + end end end diff --git a/vendor/.gitkeep b/vendor/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/vendor/assets/images/intl-tel-number/flags.png b/vendor/assets/images/intl-tel-number/flags.png deleted file mode 100755 index 0ffa81a6f83..00000000000 Binary files a/vendor/assets/images/intl-tel-number/flags.png and /dev/null differ diff --git a/vendor/assets/images/intl-tel-number/flags@2x.png b/vendor/assets/images/intl-tel-number/flags@2x.png deleted file mode 100755 index 3624e635645..00000000000 Binary files a/vendor/assets/images/intl-tel-number/flags@2x.png and /dev/null differ diff --git a/yarn.lock b/yarn.lock index d8646614eed..a468f2ad812 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4572,10 +4572,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.10.52: - version "1.10.52" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.52.tgz#786d7743e75aba1996824057b60003bb539f8daa" - integrity sha512-6vCuCHgem+OW1/VCAKgkasfegItCea8zIT7s9/CG/QxdCMIM7GfzbEBG5d7lGO3rzipjt5woOQL3DiHa8Fy78Q== +libphonenumber-js@^1.10.53: + version "1.10.53" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.53.tgz#8dbfe1355ef1a3d8e13b8d92849f7db7ebddc98f" + integrity sha512-sDTnnqlWK4vH4AlDQuswz3n4Hx7bIQWTpIcScJX+Sp7St3LXHmfiax/ZFfyYxHmkdCvydOLSuvtAO/XpXiSySw== lightningcss-darwin-arm64@1.22.0: version "1.22.0"