From 0d8cbc5d74cad5c5132dccb677c12d1a575e9931 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 19 Oct 2022 12:33:11 -0400 Subject: [PATCH 1/6] LG-7145 Use validated attributes from AAMVA to cover failed attributes from resolution Previous commits made the State ID Proofer (AAMVA) and the Resolution Proofer (LexisNexis InstantVerify) aware of specific attributes that passed and failed. The purpose of this work was to allow attributes that failed during resolution (namely dob and address) to be covered by attributes verified by the State ID Proofer. This commit makes a number of changes to that effect: 1. A request is sent to the state ID proofer even if the resolution proofer fails 2. If the attributes that failed the resolution proofer check pass the state ID proofer checks that is considered a pass A new adjudicator class was created to house this logic. [skip changelog] --- app/jobs/resolution_proofing_job.rb | 20 ++-- .../proofing/resolution_result_adjudicator.rb | 46 +++++++++ spec/jobs/resolution_proofing_job_spec.rb | 18 ++-- .../resolution_result_adjudicator_spec.rb | 97 +++++++++++++++++++ 4 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 app/services/proofing/resolution_result_adjudicator.rb create mode 100644 spec/services/proofing/resolution_result_adjudicator_spec.rb diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index 55c85d9ce3b..8429f04d586 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -143,25 +143,17 @@ def proof_lexisnexis_then_aamva(timer:, applicant_pii:, should_proof_state_id:) state_id_result = Proofing::StateIdResult.new( success: true, errors: {}, exception: nil, vendor_name: 'UnsupportedJurisdiction', ) - if should_proof_state_id && resolution_result.success? + if should_proof_state_id timer.time('state_id') do state_id_result = state_id_proofer.proof(applicant_pii) end end - result = { - success: resolution_result.success? && state_id_result.success?, - errors: resolution_result.errors.merge(state_id_result.errors), - exception: resolution_result.exception || state_id_result.exception, - timed_out: resolution_result.timed_out? || state_id_result.timed_out?, - context: { - should_proof_state_id: should_proof_state_id, - stages: { - resolution: resolution_result.to_h, - state_id: state_id_result.to_h, - }, - }, - } + result = Proofing::ResolutionResultAdjudicator.new( + resolution_result: resolution_result, + state_id_result: state_id_result, + should_proof_state_id: should_proof_state_id, + ).adjudicated_result.to_h CallbackLogData.new( result: result, diff --git a/app/services/proofing/resolution_result_adjudicator.rb b/app/services/proofing/resolution_result_adjudicator.rb new file mode 100644 index 00000000000..a88227232c0 --- /dev/null +++ b/app/services/proofing/resolution_result_adjudicator.rb @@ -0,0 +1,46 @@ +module Proofing + class ResolutionResultAdjudicator + attr_reader :resolution_result, :state_id_result, :should_proof_state_id + + def initialize(resolution_result:, state_id_result:, should_proof_state_id:) + @resolution_result = resolution_result + @state_id_result = state_id_result + @should_proof_state_id = should_proof_state_id + end + + def adjudicated_result + FormResponse.new( + success: success?, + errors: resolution_result.errors.merge(state_id_result.errors), + extra: { + exception: resolution_result.exception || state_id_result.exception, + timed_out: resolution_result.timed_out? || state_id_result.timed_out?, + context: { + should_proof_state_id: should_proof_state_id, + stages: { + resolution: resolution_result.to_h, + state_id: state_id_result.to_h, + }, + }, + }, + ) + end + + private + + def success? + return true if resolution_result.success? && state_id_result.success? + return false unless should_proof_state_id + return true if state_id_attributes_cover_resolution_failures? + false + end + + def state_id_attributes_cover_resolution_failures? + return false unless resolution_result.failed_result_can_pass_with_additional_verification? + failed_resolution_attributes = resolution_result.attributes_requiring_additional_verification + passed_state_id_attributes = state_id_result.verified_attributes + + (failed_resolution_attributes - passed_state_id_attributes).empty? + end + end +end diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index 637d881bf08..5ef1192eb84 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -270,13 +270,15 @@ to eq([]) # result[:context][:stages][:state_id] - expect(result_context_stages_state_id[:vendor_name]).to eq('UnsupportedJurisdiction') + expect(result_context_stages_state_id[:vendor_name]).to eq('aamva:state_id') expect(result_context_stages_state_id[:errors]).to eq({}) expect(result_context_stages_state_id[:exception]).to eq(nil) expect(result_context_stages_state_id[:success]).to eq(true) expect(result_context_stages_state_id[:timed_out]).to eq(false) - expect(result_context_stages_state_id[:transaction_id]).to eq('') - expect(result_context_stages_state_id[:verified_attributes]).to eq([]) + expect(result_context_stages_state_id[:transaction_id]).to eq('1234-abcd-efgh') + expect(result_context_stages_state_id[:verified_attributes]).to eq( + ["address", "state_id_number", "state_id_type", "dob", "last_name", "first_name"], + ) # result[:context][:stages][:threatmetrix] expect(result_context_stages_threatmetrix[:client]).to eq('DdpMock') @@ -559,11 +561,12 @@ end end - context 'does not call state id with an unsuccessful response from the proofer' do + context 'does call state id with an unsuccessful response from the proofer' do it 'posts back to the callback url' do expect(resolution_proofer).to receive(:proof). and_return(Proofing::Result.new(exception: 'error')) - expect(state_id_proofer).not_to receive(:proof) + expect(state_id_proofer).to receive(:proof). + and_return(Proofing::Result.new) perform end @@ -639,11 +642,12 @@ end end - context 'does not call state id with an unsuccessful response from the proofer' do + context 'does call state id with an unsuccessful response from the proofer' do it 'posts back to the callback url' do expect(resolution_proofer).to receive(:proof). and_return(Proofing::Result.new(exception: 'error')) - expect(state_id_proofer).not_to receive(:proof) + expect(state_id_proofer).to receive(:proof). + and_return(Proofing::Result.new) perform end diff --git a/spec/services/proofing/resolution_result_adjudicator_spec.rb b/spec/services/proofing/resolution_result_adjudicator_spec.rb new file mode 100644 index 00000000000..e2234d8f7e1 --- /dev/null +++ b/spec/services/proofing/resolution_result_adjudicator_spec.rb @@ -0,0 +1,97 @@ +require 'rails_helper' + +RSpec.describe Proofing::ResolutionResultAdjudicator do + let(:resolution_success) { true } + let(:can_pass_with_additional_verification) { false } + let(:attributes_requiring_additional_verification) { [] } + let(:resolution_result) do + Proofing::ResolutionResult.new( + success: resolution_success, + errors: {}, + exception: nil, + vendor_name: 'test-resolution-vendor', + failed_result_can_pass_with_additional_verification: can_pass_with_additional_verification, + attributes_requiring_additional_verification: attributes_requiring_additional_verification, + ) + end + + let(:state_id_success) { true } + let(:state_id_verified_attributes) { [] } + let(:state_id_result) do + Proofing::StateIdResult.new( + success: state_id_success, + errors: {}, + exception: nil, + vendor_name: 'test-state-id-vendor', + verified_attributes: state_id_verified_attributes, + ) + end + + let(:should_proof_state_id) { true } + + subject do + described_class.new( + resolution_result: resolution_result, + state_id_result: state_id_result, + should_proof_state_id: should_proof_state_id, + ) + end + + describe '#adjudicated_result' do + context 'AAMVA and LexisNexis both pass' do + it 'returns a successful response' do + result = subject.adjudicated_result + + expect(result.success?).to eq(true) + end + end + + context 'LexisNexis fails with attributes covered by AAMVA response' do + let(:resolution_success) { false } + let(:can_pass_with_additional_verification) { true } + let(:attributes_requiring_additional_verification) { [:dob] } + let(:state_id_verified_attributes) { [:dob, :address] } + + it 'returns a successful response' do + result = subject.adjudicated_result + + expect(result.success?).to eq(true) + end + end + + context 'LexisNexis fails with attributes not covered by AAMVA response' do + let(:resolution_success) { false } + let(:can_pass_with_additional_verification) { true } + let(:attributes_requiring_additional_verification) { [:address] } + let(:state_id_verified_attributes) { [:dob] } + + it 'returns a failed response' do + result = subject.adjudicated_result + + expect(result.success?).to eq(false) + end + end + + context 'LexisNexis fails and AAMVA state is unsupported' do + let(:should_proof_state_id) { false } + let(:resolution_success) { false } + + it 'returns a failed response' do + result = subject.adjudicated_result + + expect(result.success?).to eq(false) + end + end + + context 'LexisNexis passes and AAMVA fails' do + let(:resolution_success) { true } + let(:state_id_success) { false } + + it 'returns a failed response' do + result = subject.adjudicated_result + + expect(result.success?).to eq(false) + end + end + end +end From 37319c91591b7fc408e6eca97ff484130088cda0 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 19 Oct 2022 15:12:59 -0400 Subject: [PATCH 2/6] delint --- spec/jobs/resolution_proofing_job_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index 5ef1192eb84..cefc4cd661d 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -277,7 +277,7 @@ expect(result_context_stages_state_id[:timed_out]).to eq(false) expect(result_context_stages_state_id[:transaction_id]).to eq('1234-abcd-efgh') expect(result_context_stages_state_id[:verified_attributes]).to eq( - ["address", "state_id_number", "state_id_type", "dob", "last_name", "first_name"], + ['address', 'state_id_number', 'state_id_type', 'dob', 'last_name', 'first_name'], ) # result[:context][:stages][:threatmetrix] From e2965132406f6e8202409a52811db2cf071d8444 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 19 Oct 2022 15:44:03 -0400 Subject: [PATCH 3/6] pr feedback --- .../proofing/resolution_result_adjudicator.rb | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/app/services/proofing/resolution_result_adjudicator.rb b/app/services/proofing/resolution_result_adjudicator.rb index a88227232c0..d04e203667a 100644 --- a/app/services/proofing/resolution_result_adjudicator.rb +++ b/app/services/proofing/resolution_result_adjudicator.rb @@ -1,6 +1,6 @@ module Proofing class ResolutionResultAdjudicator - attr_reader :resolution_result, :state_id_result, :should_proof_state_id + attr_reader :resolution_result, :state_id_result def initialize(resolution_result:, state_id_result:, should_proof_state_id:) @resolution_result = resolution_result @@ -9,14 +9,16 @@ def initialize(resolution_result:, state_id_result:, should_proof_state_id:) end def adjudicated_result + success, adjudication_reason = result_and_adjudication_reason FormResponse.new( - success: success?, + success: success, errors: resolution_result.errors.merge(state_id_result.errors), extra: { exception: resolution_result.exception || state_id_result.exception, timed_out: resolution_result.timed_out? || state_id_result.timed_out?, context: { - should_proof_state_id: should_proof_state_id, + adjudication_reason: adjudication_reason, + should_proof_state_id: should_proof_state_id?, stages: { resolution: resolution_result.to_h, state_id: state_id_result.to_h, @@ -26,13 +28,22 @@ def adjudicated_result ) end + def should_proof_state_id? + @should_proof_state_id + end + private - def success? - return true if resolution_result.success? && state_id_result.success? - return false unless should_proof_state_id - return true if state_id_attributes_cover_resolution_failures? - false + def result_and_adjudication_reason + if resolution_result.success? && state_id_result.success? + [true, :pass_resolution_and_state_id] + elsif !should_proof_state_id? + [false, :fail_resolution_skip_state_id] + elsif state_id_attributes_cover_resolution_failures? + [true, :state_id_covers_failed_resolution] + else + [false, :fail_resolution_without_state_id_coverage] + end end def state_id_attributes_cover_resolution_failures? From d541d1cedc94ba5739fe39579c43cbd755688a11 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 19 Oct 2022 15:46:47 -0400 Subject: [PATCH 4/6] cleanup agent spec --- spec/services/idv/agent_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 5fc50c28631..37f08f9cb18 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -33,7 +33,7 @@ let(:document_capture_session) { DocumentCaptureSession.new(result_id: SecureRandom.hex) } context 'proofing state_id enabled' do - it 'does not proof state_id if resolution fails' do + it 'still proofs state_id if resolution fails' do agent = Idv::Agent.new( Idp::Constants::MOCK_IDV_APPLICANT.merge(uuid: user.uuid, ssn: '444-55-6666'), ) @@ -49,7 +49,7 @@ result = document_capture_session.load_proofing_result.result expect(result[:errors][:ssn]).to eq ['Unverified SSN.'] - expect(result[:context][:stages][:state_id][:vendor_name]).to eq 'UnsupportedJurisdiction' + expect(result[:context][:stages][:state_id][:vendor_name]).to eq 'StateIdMock' end it 'does proof state_id if resolution succeeds' do @@ -82,7 +82,7 @@ ) agent.proof_resolution( document_capture_session, - should_proof_state_id: true, + should_proof_state_id: false, trace_id: trace_id, user_id: user.id, threatmetrix_session_id: nil, From 5f4dab039b92be44de1f80e1362e237f1aa8d3d3 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 19 Oct 2022 16:09:50 -0400 Subject: [PATCH 5/6] quick touchup --- app/services/proofing/resolution_result_adjudicator.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/services/proofing/resolution_result_adjudicator.rb b/app/services/proofing/resolution_result_adjudicator.rb index d04e203667a..1fe419e7434 100644 --- a/app/services/proofing/resolution_result_adjudicator.rb +++ b/app/services/proofing/resolution_result_adjudicator.rb @@ -37,6 +37,8 @@ def should_proof_state_id? def result_and_adjudication_reason if resolution_result.success? && state_id_result.success? [true, :pass_resolution_and_state_id] + elsif !state_id_result.success? + [false, :fail_state_id] elsif !should_proof_state_id? [false, :fail_resolution_skip_state_id] elsif state_id_attributes_cover_resolution_failures? From 4a3c199b0069b160ce44441cbad98705687b956a Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 19 Oct 2022 16:15:07 -0400 Subject: [PATCH 6/6] fix the analytics spec --- spec/features/idv/analytics_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 9b7d927a108..8536c0351fd 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -30,7 +30,7 @@ 'IdV: doc auth verify visited' => { flow_path: 'standard', step: 'verify', step_count: 1 }, 'IdV: doc auth verify submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'verify', step_count: 1 }, 'IdV: doc auth verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 }, - 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' }, + 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, adjudication_reason: 'pass_resolution_and_state_id', stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' }, 'IdV: phone of record visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } }, 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } }, 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, @@ -70,7 +70,7 @@ 'IdV: doc auth verify visited' => { flow_path: 'standard', step: 'verify', step_count: 1 }, 'IdV: doc auth verify submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'verify', step_count: 1 }, 'IdV: doc auth verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 }, - 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' }, + 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, adjudication_reason: 'pass_resolution_and_state_id', stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' }, 'IdV: phone of record visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } }, 'IdV: USPS address letter requested' => { resend: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } }, 'IdV: review info visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },