From 64e371af2d08341672cccc786b46dcd92e17be33 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 26 Sep 2024 10:38:23 -0700 Subject: [PATCH 1/7] Add individual tests for different attributes in AAMVA proofer spec (#11282) * Add tests for all supported AAMVA attributes Update AAMVA proofer spec to include test cases for when each attribute is unverified or missing from the response. [skip changelog] * Update comment * Update other specs that use AAMVA fixtures * Rework attribute tests - Call out each attribute in a describe block - Use helper methods to assert different characteristics * Re-rework attribute tests - Define when_unverified and when_missing helpers - Add methods to do assertions for success and presence in requested_attributes and verified_attributes * Fix ResolutionProofingJob spec * Update spec/services/proofing/aamva/proofer_spec.rb Co-authored-by: Zach Margolis --------- Co-authored-by: Zach Margolis --- app/services/proofing/aamva/proofer.rb | 1 + .../aamva/responses/verification_response.xml | 5 +- ...rification_response_namespaced_success.xml | 5 +- spec/jobs/resolution_proofing_job_spec.rb | 30 +- spec/services/proofing/aamva/proofer_spec.rb | 310 +++++++++++++++++- .../response/verification_response_spec.rb | 6 +- .../aamva/verification_client_spec.rb | 12 +- 7 files changed, 354 insertions(+), 15 deletions(-) diff --git a/app/services/proofing/aamva/proofer.rb b/app/services/proofing/aamva/proofer.rb index b9b3bf1aee1..1981f344941 100644 --- a/app/services/proofing/aamva/proofer.rb +++ b/app/services/proofing/aamva/proofer.rb @@ -56,6 +56,7 @@ def proof(applicant) ).send_verification_request( applicant: aamva_applicant, ) + build_result_from_response(response, applicant[:state]) rescue => exception failed_result = Proofing::StateIdResult.new( diff --git a/spec/fixtures/proofing/aamva/responses/verification_response.xml b/spec/fixtures/proofing/aamva/responses/verification_response.xml index 9cbf1ecc2ff..a55dc4e890c 100644 --- a/spec/fixtures/proofing/aamva/responses/verification_response.xml +++ b/spec/fixtures/proofing/aamva/responses/verification_response.xml @@ -8,6 +8,8 @@ true + true + true true true true @@ -17,7 +19,8 @@ true true true + true true true true - + \ No newline at end of file diff --git a/spec/fixtures/proofing/aamva/responses/verification_response_namespaced_success.xml b/spec/fixtures/proofing/aamva/responses/verification_response_namespaced_success.xml index d875f2d12a4..6fdc71ded19 100644 --- a/spec/fixtures/proofing/aamva/responses/verification_response_namespaced_success.xml +++ b/spec/fixtures/proofing/aamva/responses/verification_response_namespaced_success.xml @@ -16,15 +16,18 @@ true + true + true true true true true true + true true true true - + \ No newline at end of file diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index bb5a31a8779..1855adf69cb 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -114,7 +114,15 @@ 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 match_array( - %w[address state_id_number state_id_type dob last_name first_name], + %w[ + address + state_id_expiration + state_id_issued + state_id_number + state_id_type dob + last_name + first_name + ], ) # result[:context][:stages][:threatmetrix] @@ -198,7 +206,15 @@ expect(result_context_stages_state_id[:vendor_name]).to eq('aamva:state_id') expect(result_context_stages_state_id[:success]).to eq(true) expect(result_context_stages_state_id[:verified_attributes]).to match_array( - %w[address state_id_number state_id_type dob last_name first_name], + %w[ + address + state_id_expiration + state_id_issued + state_id_number + state_id_type dob + last_name + first_name + ], ) end end @@ -439,7 +455,15 @@ 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 match_array( - %w[address state_id_number state_id_type dob last_name first_name], + %w[ + address + state_id_expiration + state_id_issued + state_id_number + state_id_type dob + last_name + first_name + ], ) # result[:context][:stages][:threatmetrix] diff --git a/spec/services/proofing/aamva/proofer_spec.rb b/spec/services/proofing/aamva/proofer_spec.rb index 1de70664a09..e6b947f8d4e 100644 --- a/spec/services/proofing/aamva/proofer_spec.rb +++ b/spec/services/proofing/aamva/proofer_spec.rb @@ -14,7 +14,7 @@ } end - let(:verification_results) do + let(:verification_result) do { state_id_number: true, dob: true, @@ -44,6 +44,302 @@ end describe '#proof' do + describe 'individual attributes' do + subject(:result) do + described_class.new(AamvaFixtures.example_config.to_h).proof(state_id_data) + end + + def self.when_missing(&block) + context 'when missing' do + let(:verification_response) do + XmlHelper.delete_xml_at_xpath( + AamvaFixtures.verification_response, + "//#{match_indicator_name}", + ) + end + + instance_eval(&block) + end + end + + def self.when_unverified(&block) + context 'when unverified' do + let(:verification_response) do + XmlHelper.modify_xml_at_xpath( + AamvaFixtures.verification_response, + "//#{match_indicator_name}", + 'false', + ) + end + + instance_eval(&block) + end + end + + def self.test_in_requested_attributes(logged_attribute = nil) + if logged_attribute + it "does not stop #{logged_attribute} from appearing in requested_attributes" do + expect(result.requested_attributes).to include(logged_attribute => 1) + end + it 'does not itself appear in requested_attributes' do + expect(result.requested_attributes).not_to include(attribute => 1) + end + else + it 'appears in requested_attributes' do + expect(result.requested_attributes).to include(attribute => 1) + end + end + end + + def self.test_not_in_requested_attributes(logged_attribute = nil) + if logged_attribute + it "stops #{logged_attribute} from appearing in requested_attributes" do + expect(result.requested_attributes).not_to include(logged_attribute => 1) + end + end + it 'does not appear in requested_attributes' do + expect(result.requested_attributes).not_to include(attribute => 1) + end + end + + def self.test_in_verified_attributes(logged_attribute) + it "does not stop #{logged_attribute} from appearing in verified_attributes" do + expect(result.verified_attributes).to include(logged_attribute) + end + + it 'does not itself appear in verified_attributes' do + expect(result.verified_attributes).not_to include(attribute) + end + end + + def self.test_not_in_verified_attributes(logged_attribute = nil) + if logged_attribute + it "stops #{logged_attribute} from appearing in verified_attributes" do + expect(result.verified_attributes).not_to include(logged_attribute) + end + end + it 'does not appear in verified_attributes' do + expect(result.verified_attributes).not_to include(attribute) + end + end + + def self.test_still_successful + it 'the result is still successful' do + expect(result.success?).to be true + end + end + + def self.test_not_successful + it 'the result is not successful' do + expect(result.success?).to be false + end + end + + describe '#address1' do + let(:attribute) { :address1 } + let(:match_indicator_name) { 'AddressLine1MatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + + when_missing do + test_still_successful + test_not_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + end + + describe '#address2' do + let(:attribute) { :address2 } + let(:match_indicator_name) { 'AddressLine2MatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes(:address) + test_in_verified_attributes(:address) + end + + when_missing do + test_still_successful + test_in_requested_attributes(:address) + test_in_verified_attributes(:address) + end + end + + describe '#city' do + let(:attribute) { :city } + let(:match_indicator_name) { 'AddressCityMatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + + when_missing do + test_still_successful + test_not_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + end + + describe '#state' do + let(:attribute) { :city } + let(:match_indicator_name) { 'AddressStateCodeMatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + + when_missing do + test_still_successful + test_not_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + end + + describe '#zipcode' do + let(:attribute) { :zipcode } + let(:match_indicator_name) { 'AddressZIP5MatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + + when_missing do + test_still_successful + test_not_in_requested_attributes(:address) + test_not_in_verified_attributes(:address) + end + end + + describe '#dob' do + let(:attribute) { :dob } + let(:match_indicator_name) { 'PersonBirthDateMatchIndicator' } + + when_unverified do + test_not_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_not_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + + describe '#state_id_issued' do + let(:attribute) { :state_id_issued } + let(:match_indicator_name) { 'DriverLicenseIssueDateMatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_still_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + + describe '#state_id_number' do + let(:attribute) { :state_id_number } + let(:match_indicator_name) { 'DriverLicenseNumberMatchIndicator' } + + when_unverified do + test_not_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_not_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + + describe '#state_id_expiration' do + let(:attribute) { :state_id_expiration } + let(:match_indicator_name) { 'DriverLicenseExpirationDateMatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_still_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + + describe '#state_id_type' do + let(:attribute) { :state_id_type } + let(:match_indicator_name) { 'DocumentCategoryMatchIndicator' } + + when_unverified do + test_still_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_still_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + + describe '#first_name' do + let(:attribute) { :first_name } + let(:match_indicator_name) { 'PersonFirstNameExactMatchIndicator' } + + when_unverified do + test_not_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_not_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + + describe '#last_name' do + let(:attribute) { :last_name } + let(:match_indicator_name) { 'PersonLastNameExactMatchIndicator' } + + when_unverified do + test_not_successful + test_in_requested_attributes + test_not_in_verified_attributes + end + + when_missing do + test_not_successful + test_not_in_requested_attributes + test_not_in_verified_attributes + end + end + end + context 'when verification is successful' do it 'the result is successful' do result = subject.proof(state_id_data) @@ -59,6 +355,8 @@ expect(result.verified_attributes).to eq( %i[ dob + state_id_issued + state_id_expiration state_id_number state_id_type last_name @@ -73,6 +371,8 @@ expect(result.requested_attributes).to eq( { dob: 1, + state_id_issued: 1, + state_id_expiration: 1, state_id_number: 1, state_id_type: 1, last_name: 1, @@ -104,6 +404,8 @@ expect(result.verified_attributes).to eq( %i[ + state_id_expiration + state_id_issued state_id_number state_id_type last_name @@ -118,6 +420,8 @@ expect(result.requested_attributes).to eq( { dob: 1, + state_id_expiration: 1, + state_id_issued: 1, state_id_number: 1, state_id_type: 1, last_name: 1, @@ -148,6 +452,8 @@ expect(result.verified_attributes).to eq( %i[ + state_id_expiration + state_id_issued state_id_number state_id_type last_name @@ -161,6 +467,8 @@ result = subject.proof(state_id_data) expect(result.requested_attributes).to eq( { + state_id_expiration: 1, + state_id_issued: 1, state_id_number: 1, state_id_type: 1, last_name: 1, diff --git a/spec/services/proofing/aamva/response/verification_response_spec.rb b/spec/services/proofing/aamva/response/verification_response_spec.rb index d52f1165bb0..3c91ffb880a 100644 --- a/spec/services/proofing/aamva/response/verification_response_spec.rb +++ b/spec/services/proofing/aamva/response/verification_response_spec.rb @@ -13,15 +13,15 @@ end let(:verification_results) do { - state_id_expiration: nil, - state_id_issued: nil, + state_id_expiration: true, + state_id_issued: true, state_id_number: true, state_id_type: true, dob: true, last_name: true, first_name: true, address1: true, - address2: nil, + address2: true, city: true, state: true, zipcode: true, diff --git a/spec/services/proofing/aamva/verification_client_spec.rb b/spec/services/proofing/aamva/verification_client_spec.rb index 8f298374090..25fefd7702e 100644 --- a/spec/services/proofing/aamva/verification_client_spec.rb +++ b/spec/services/proofing/aamva/verification_client_spec.rb @@ -62,14 +62,14 @@ expect(response.verification_results).to eq( { address1: true, - address2: nil, + address2: true, city: true, dob: true, first_name: true, last_name: true, state: true, - state_id_expiration: nil, - state_id_issued: nil, + state_id_expiration: true, + state_id_issued: true, state_id_number: true, state_id_type: true, zipcode: true, @@ -93,14 +93,14 @@ expect(response.verification_results).to eq( { address1: true, - address2: nil, + address2: true, city: true, dob: false, first_name: true, last_name: true, state: true, - state_id_expiration: nil, - state_id_issued: nil, + state_id_expiration: true, + state_id_issued: true, state_id_number: true, state_id_type: true, zipcode: true, From 9e0a5dbb3de41574c227a0acc67ac036d10daaec Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Thu, 26 Sep 2024 14:19:54 -0400 Subject: [PATCH 2/7] LG-14282 | Pass phone number to Socure proofer (#11272) changelog: Upcoming Features, Identity verification, send phone number to Socure --- .../concerns/idv/verify_info_concern.rb | 13 +- app/jobs/socure_shadow_mode_proofing_job.rb | 6 + app/services/analytics_events.rb | 3 + .../resolution/progressive_proofer.rb | 2 +- .../proofing/socure/id_plus/proofer.rb | 2 +- .../in_person/verify_info_controller_spec.rb | 4 + .../idv/verify_info_controller_spec.rb | 34 ++++- .../socure_shadow_mode_proofing_job_spec.rb | 140 ++++++++++++++---- 8 files changed, 170 insertions(+), 34 deletions(-) diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index 783d8f67c62..ddf84940e57 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -31,7 +31,10 @@ def shared_update idv_session.verify_info_step_document_capture_session_uuid = document_capture_session.uuid - Idv::Agent.new(pii).proof_resolution( + user_pii = pii + user_pii[:best_effort_phone_number_for_socure] = best_effort_phone + + Idv::Agent.new(user_pii).proof_resolution( document_capture_session, trace_id: amzn_trace_id, user_id: current_user.id, @@ -48,6 +51,14 @@ def log_event_for_missing_threatmetrix_session_id analytics.idv_verify_info_missing_threatmetrix_session_id if idv_session.ssn_step_complete? end + def best_effort_phone + if idv_session.phone_for_mobile_flow + { source: :hybrid_handoff, phone: idv_session.phone_for_mobile_flow } + elsif current_user.default_phone_configuration + { source: :mfa, phone: current_user.default_phone_configuration.formatted_phone } + end + end + private def ipp_enrollment_in_progress? diff --git a/app/jobs/socure_shadow_mode_proofing_job.rb b/app/jobs/socure_shadow_mode_proofing_job.rb index 83b8aa15bf1..d9bbe33faae 100644 --- a/app/jobs/socure_shadow_mode_proofing_job.rb +++ b/app/jobs/socure_shadow_mode_proofing_job.rb @@ -42,6 +42,7 @@ def perform( analytics.idv_socure_shadow_mode_proofing_result( resolution_result: format_proofing_result_for_logs(proofing_result), socure_result: socure_result.to_h, + phone_source: applicant[:phone_source], user_id: user.uuid, pii_like_keypaths: [ [:errors, :ssn], @@ -91,6 +92,10 @@ def build_applicant( ) applicant_pii = decrypted_arguments[:applicant_pii] + if applicant_pii[:phone].nil? && applicant_pii[:best_effort_phone_number_for_socure] + applicant_pii[:phone] = applicant_pii[:best_effort_phone_number_for_socure][:phone] + applicant_pii[:phone_source] = applicant_pii[:best_effort_phone_number_for_socure][:source] + end { **applicant_pii.slice( @@ -102,6 +107,7 @@ def build_applicant( :state, :zipcode, :phone, + :phone_source, :dob, :ssn, :consent_given_at, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index ea5ef9b3ddb..2626d798f47 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -4591,14 +4591,17 @@ def idv_session_error_visited( # Logs a Socure KYC result alongside a resolution result for later comparison. # @param [Hash] socure_result Result from Socure KYC API call # @param [Hash] resolution_result Result from resolution proofing + # @param [String,nil] phone_source Whether the phone number is from MFA or hybrid handoff def idv_socure_shadow_mode_proofing_result( socure_result:, resolution_result:, + phone_source:, **extra ) track_event( :idv_socure_shadow_mode_proofing_result, resolution_result: resolution_result.to_h, + phone_source:, socure_result: socure_result.to_h, **extra, ) diff --git a/app/services/proofing/resolution/progressive_proofer.rb b/app/services/proofing/resolution/progressive_proofer.rb index 5dc2be668d4..edb8a6f07f4 100644 --- a/app/services/proofing/resolution/progressive_proofer.rb +++ b/app/services/proofing/resolution/progressive_proofer.rb @@ -32,7 +32,7 @@ def proof( ipp_enrollment_in_progress:, current_sp: ) - @applicant_pii = applicant_pii + @applicant_pii = applicant_pii.except(:best_effort_phone_number_for_socure) @request_ip = request_ip @threatmetrix_session_id = threatmetrix_session_id @timer = timer diff --git a/app/services/proofing/socure/id_plus/proofer.rb b/app/services/proofing/socure/id_plus/proofer.rb index 91edb4c4300..09461aedf43 100644 --- a/app/services/proofing/socure/id_plus/proofer.rb +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -33,7 +33,7 @@ def initialize(config) # @param [Hash] applicant # @return [Proofing::Resolution::Result] def proof(applicant) - input = Input.new(applicant) + input = Input.new(applicant.except(:phone_source)) request = Request.new(config:, input:) diff --git a/spec/controllers/idv/in_person/verify_info_controller_spec.rb b/spec/controllers/idv/in_person/verify_info_controller_spec.rb index 22cb5cbe811..470578f81e6 100644 --- a/spec/controllers/idv/in_person/verify_info_controller_spec.rb +++ b/spec/controllers/idv/in_person/verify_info_controller_spec.rb @@ -268,6 +268,10 @@ expect(Idv::Agent).to receive(:new).with( Idp::Constants::MOCK_IDV_APPLICANT_STATE_ID_ADDRESS.merge( consent_given_at: subject.idv_session.idv_consent_given_at, + best_effort_phone_number_for_socure: { + source: :mfa, + phone: '+1 415-555-0130', + }, ), ).and_call_original put :update diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index 0062867e8fe..56e1e52f3df 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -393,7 +393,7 @@ end end - context 'when the reolution proofing job result is missing' do + context 'when the resolution proofing job result is missing' do let(:async_state) do ProofingSessionAsyncResult.new(status: ProofingSessionAsyncResult::MISSING) end @@ -491,4 +491,36 @@ end end end + + describe '#best_effort_phone' do + it 'returns nil when there is no number available' do + expect(subject.best_effort_phone).to eq(nil) + end + + context 'when there is a hybrid handoff number' do + before(:each) do + allow(subject.idv_session).to receive(:phone_for_mobile_flow).and_return('202-555-1234') + end + + it 'returns the phone number from hybrid handoff' do + expect(subject.best_effort_phone[:phone]).to eq('202-555-1234') + end + + it 'sets type to :hybrid_handoff' do + expect(subject.best_effort_phone[:source]).to eq(:hybrid_handoff) + end + end + + context 'when there was an MFA phone number provided' do + let(:user) { create(:user, :with_phone) } + + it 'returns the MFA phone number' do + expect(subject.best_effort_phone[:phone]).to eq('+1 202-555-1212') + end + + it 'sets the phone source to :mfa' do + expect(subject.best_effort_phone[:source]).to eq(:mfa) + end + end + end end diff --git a/spec/jobs/socure_shadow_mode_proofing_job_spec.rb b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb index 220ecb938fc..acc02e7e63d 100644 --- a/spec/jobs/socure_shadow_mode_proofing_job_spec.rb +++ b/spec/jobs/socure_shadow_mode_proofing_job_spec.rb @@ -18,7 +18,7 @@ end let(:applicant_pii) do - Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN end let(:encrypted_arguments) do @@ -182,20 +182,8 @@ end context 'when document capture session result is present in redis' do - it 'makes a proofing call' do - expect(job.proofer).to receive(:proof).and_call_original - perform - end - - it 'does not log an idv_socure_shadow_mode_proofing_result_missing event' do - perform - expect(analytics).not_to have_logged_event(:idv_socure_shadow_mode_proofing_result_missing) - end - - it 'logs an event' do - perform - expect(analytics).to have_logged_event( - :idv_socure_shadow_mode_proofing_result, + let(:expected_event_body) do + { user_id: user.uuid, resolution_result: { success: true, @@ -278,9 +266,52 @@ vendor_workflow: nil, verified_attributes: %i[address first_name last_name phone ssn dob].to_set, }, + } + end + + it 'makes a proofing call' do + expect(job.proofer).to receive(:proof).and_call_original + perform + end + + it 'does not log an idv_socure_shadow_mode_proofing_result_missing event' do + perform + expect(analytics).not_to have_logged_event(:idv_socure_shadow_mode_proofing_result_missing) + end + + it 'logs an event' do + perform + expect(analytics).to have_logged_event( + :idv_socure_shadow_mode_proofing_result, + expected_event_body, ) end + context 'when the user has an MFA phone number' do + let(:applicant_pii) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge( + best_effort_phone_number_for_socure: { + source: :mfa, + phone: '1 202-555-0000', + }, + ) + end + + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + JSON.generate({ applicant_pii: applicant_pii }), + ) + end + + it 'logs an event with the phone number' do + perform + expect(analytics).to have_logged_event( + :idv_socure_shadow_mode_proofing_result, + expected_event_body.merge(phone_source: 'mfa'), + ) + end + end + context 'when socure proofer raises an error' do before do allow(job.proofer).to receive(:proof).and_raise @@ -349,22 +380,71 @@ job.build_applicant(encrypted_arguments:, user_email:) end - it 'builds an applicant structure that looks right' do - expect(build_applicant).to eql( - { - first_name: 'FAKEY', - last_name: 'MCFAKERSON', - address1: '1 FAKE RD', - address2: nil, - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010-1234', + let(:expected_attributes) do + { + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010-1234', + dob: '1938-10-06', + ssn: '900-66-1234', + email: user.email, + } + end + + context 'when the user has a phone directly passed in' do + let(:applicant_pii) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge( phone: '12025551212', - dob: '1938-10-06', - ssn: '900-66-1234', - email: user.email, - }, - ) + ) + end + + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + JSON.generate({ applicant_pii: }), + ) + end + + it 'builds an applicant structure with that phone number' do + expect(build_applicant).to eql( + expected_attributes.merge(phone: '12025551212'), + ) + end + end + + context 'when the user has a hybrid-handoff phone' do + let(:applicant_pii_no_phone) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge( + best_effort_phone_number_for_socure: { + source: :hybrid_handoff, + phone: '12025556789', + }, + ) + end + + let(:encrypted_arguments) do + Encryption::Encryptors::BackgroundProofingArgEncryptor.new.encrypt( + JSON.generate({ applicant_pii: applicant_pii_no_phone }), + ) + end + + it 'builds an applicant using the hybrid handoff number' do + expect(build_applicant).to eql( + expected_attributes.merge( + phone: '12025556789', + phone_source: 'hybrid_handoff', + ), + ) + end + end + + context 'when no phone is available for the user' do + it 'does not set phone at all' do + expect(build_applicant).to eql(expected_attributes) + end end end From 3d42401ab32080461c2eebdae56678c28bb7aff5 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 26 Sep 2024 13:58:08 -0700 Subject: [PATCH 3/7] Require expiration date to pass AAMVA verification (#11283) When the attribute is present, require that it pass verification with AAMVA. changelog: User-Facing Improvements, Identity verification, Verify license expiration dates with AAMVA. --- app/services/proofing/aamva/proofer.rb | 7 +++++++ spec/services/proofing/aamva/proofer_spec.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/services/proofing/aamva/proofer.rb b/app/services/proofing/aamva/proofer.rb index 1981f344941..0ce39e7d144 100644 --- a/app/services/proofing/aamva/proofer.rb +++ b/app/services/proofing/aamva/proofer.rb @@ -28,6 +28,8 @@ class Proofer first_name ].freeze + REQUIRED_IF_PRESENT_ATTRIBUTES = [:state_id_expiration].freeze + ADDRESS_ATTRIBUTES = [ :address1, :address2, @@ -134,6 +136,11 @@ def successful?(verification_response) return false unless verification_response.verification_results[verification_attribute] end + REQUIRED_IF_PRESENT_ATTRIBUTES.each do |verification_attribute| + value = verification_response.verification_results[verification_attribute] + return false unless value.nil? || value == true + end + true end diff --git a/spec/services/proofing/aamva/proofer_spec.rb b/spec/services/proofing/aamva/proofer_spec.rb index e6b947f8d4e..cab58477c75 100644 --- a/spec/services/proofing/aamva/proofer_spec.rb +++ b/spec/services/proofing/aamva/proofer_spec.rb @@ -276,7 +276,7 @@ def self.test_not_successful let(:match_indicator_name) { 'DriverLicenseExpirationDateMatchIndicator' } when_unverified do - test_still_successful + test_not_successful test_in_requested_attributes test_not_in_verified_attributes end From de3f80fd972fa64a4953e3db2c3ebee60533de55 Mon Sep 17 00:00:00 2001 From: "Davida (she/they)" Date: Fri, 27 Sep 2024 08:24:01 -0400 Subject: [PATCH 4/7] Add app_differentiator field to analytics events (#11262) * changelog: Internal, Analytics, Add app_differentiator field to events --- app/services/analytics.rb | 15 +++++++ spec/services/analytics_spec.rb | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 8e7c59cf5c4..378a89712e7 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -134,6 +134,11 @@ def sp_request_attributes [v.name.sub('http://idmanagement.gov/ns/assurance/', ''), true] end.to_h attributes.reject! { |_key, value| value == false } + + if differentiator.present? + attributes[:app_differentiator] = differentiator + end + attributes.transform_keys! do |key| key.to_s.chomp('?').to_sym end @@ -141,6 +146,16 @@ def sp_request_attributes { sp_request: attributes } end + def differentiator + return @differentiator if defined?(@differentiator) + @differentiator ||= begin + sp_request_url = session&.dig(:sp, :request_url) + return nil if sp_request_url.blank? + + UriService.params(sp_request_url)['login_gov_app_differentiator'] + end + end + def resolved_authn_context_result return nil if sp.nil? || session[:sp].blank? return @resolved_authn_context_result if defined?(@resolved_authn_context_result) diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index 07b0dd5dc02..402e75cda4a 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -358,4 +358,82 @@ end end end + + context 'with an SP request_url saved in the session' do + context 'no request_url' do + let(:session) { { sp: { acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF } } } + + let(:expected_attributes) do + { + sp_request: { + component_values: { 'ial/1' => true }, + component_separator: ' ', + }, + } + end + + it 'includes the sp_request' do + expect(ahoy).to receive(:track). + with('Trackable Event', hash_including(expected_attributes)) + + analytics.track_event('Trackable Event') + end + end + + context 'a request_url without login_gov_app_differentiator ' do + let(:session) do + { + sp: { + acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + request_url: 'http://localhost:3000/openid_connect/authorize?whatever=something_else', + }, + } + end + + let(:expected_attributes) do + { + sp_request: { + component_values: { 'ial/1' => true }, + component_separator: ' ', + }, + } + end + + it 'includes the sp_request' do + expect(ahoy).to receive(:track). + with('Trackable Event', hash_including(expected_attributes)) + + analytics.track_event('Trackable Event') + end + end + + context 'a request_url with login_gov_app_differentiator ' do + let(:session) do + { + sp: { + acr_values: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + request_url: + 'http://localhost:3000/openid_connect/authorize?login_gov_app_differentiator=NY', + }, + } + end + + let(:expected_attributes) do + { + sp_request: { + component_values: { 'ial/1' => true }, + component_separator: ' ', + app_differentiator: 'NY', + }, + } + end + + it 'includes the sp_request' do + expect(ahoy).to receive(:track). + with('Trackable Event', hash_including(expected_attributes)) + + analytics.track_event('Trackable Event') + end + end + end end From b423e5ac5c5d8fea62041c8561eba02f2f13e6be Mon Sep 17 00:00:00 2001 From: Malick Diarra Date: Fri, 27 Sep 2024 09:47:45 -0400 Subject: [PATCH 5/7] LG-14204: Add Request id to sign up email (#11286) * changelog: Bug Fixes, Sign up, Add request id * user mailer spec fixes --- app/forms/register_user_email_form.rb | 2 +- app/mailers/user_mailer.rb | 4 ++-- spec/mailers/previews/user_mailer_preview.rb | 3 ++- spec/mailers/user_mailer_spec.rb | 10 ++++++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/forms/register_user_email_form.rb b/app/forms/register_user_email_form.rb index 6aa1cb2046a..07afaf21bc2 100644 --- a/app/forms/register_user_email_form.rb +++ b/app/forms/register_user_email_form.rb @@ -170,7 +170,7 @@ def send_sign_up_confirmed_email ) else UserMailer.with(user: existing_user, email_address: email_address_record). - signup_with_your_email.deliver_now_or_later + signup_with_your_email(request_id: request_id).deliver_now_or_later end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 48790c362c1..6b742826e28 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -73,9 +73,9 @@ def email_confirmation_instructions(token, request_id:) end end - def signup_with_your_email + def signup_with_your_email(request_id:) with_user_locale(user) do - @root_url = root_url(locale: locale_url_param) + @root_url = root_url(locale: locale_url_param, request_id: request_id) mail(to: email_address.email, subject: t('mailer.email_reuse_notice.subject')) end end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 8acb83c15f6..64abf105d1a 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -8,7 +8,8 @@ def email_confirmation_instructions end def signup_with_your_email - UserMailer.with(user: user, email_address: email_address_record).signup_with_your_email + UserMailer.with(user: user, email_address: email_address_record). + signup_with_your_email(request_id: SecureRandom.uuid) end def reset_password_instructions diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 4ec60f3f88a..61bad58336a 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -8,7 +8,11 @@ let(:is_enhanced_ipp) { false } describe '#validate_user_and_email_address' do - let(:mail) { UserMailer.with(user: user, email_address: email_address).signup_with_your_email } + let(:request_id) { '1234-abcd' } + let(:mail) do + UserMailer.with(user: user, email_address: email_address). + signup_with_your_email(request_id: request_id) + end context 'with user and email address match' do it 'does not raise an error' do @@ -210,8 +214,10 @@ end describe '#signup_with_your_email' do + let(:request_id) { '1234-abcd' } let(:mail) do - UserMailer.with(user: user, email_address: user.email_addresses.first).signup_with_your_email + UserMailer.with(user: user, email_address: user.email_addresses.first). + signup_with_your_email(request_id: request_id) end it_behaves_like 'a system email' From 2ddece00b322a3a38c577cbc172e831700200dd1 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Fri, 27 Sep 2024 13:30:00 -0400 Subject: [PATCH 6/7] LG-13462 Log the Threatmetrix response body in its own event (#11290) We use Threatmetrix for device profiling. When a user's device profiling transaction represents a review or reject status they need to undergo the fraud review process. Part of that process is looking at what was returned by Threatmetrix in the logged response body. The Threatmetrix response body is incredibly large. It causes some issues with Cloudwatch parsing the JSON written to the log. This commit adds a new event to capture the Threatmetrix response body. The plan is to verify that this new event works for the fraud analysis purposes and then stop logging it on the existing event. The change to stop logging the response body on the existing event will be in a follow-up change. [skip changelog] --- .../concerns/idv/verify_info_concern.rb | 9 +++ app/services/analytics_events.rb | 13 ++++ app/services/idv/analytics_events_enhancer.rb | 1 + .../in_person/verify_info_controller_spec.rb | 8 ++ .../idv/verify_info_controller_spec.rb | 6 ++ spec/features/idv/analytics_spec.rb | 78 ++++++++++++++----- 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index ddf84940e57..71c3a320cc9 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -203,6 +203,15 @@ def async_state_done(current_async_state) }, ) + threatmetrix_reponse_body = form_response.extra.dig( + :proofing_results, :context, :stages, :threatmetrix, :response_body + ) + if threatmetrix_reponse_body.present? + analytics.idv_threatmetrix_response_body( + response_body: threatmetrix_reponse_body, + ) + end + summarize_result_and_rate_limit(form_response) delete_async diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 2626d798f47..d7b9211ed51 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -4657,6 +4657,19 @@ def idv_start_over( ) end + # The JSON body of the response returned from Threatmetrix. PII has been removed. + # @param [Hash] response_body The response body returned by ThreatMetrix + def idv_threatmetrix_response_body( + response_body: nil, + **extra + ) + track_event( + :idv_threatmetrix_response_body, + response_body: response_body, + **extra, + ) + end + # Track when USPS auth token refresh job completed def idv_usps_auth_token_refresh_job_completed(**extra) track_event( diff --git a/app/services/idv/analytics_events_enhancer.rb b/app/services/idv/analytics_events_enhancer.rb index 92587341389..1dff6c81e89 100644 --- a/app/services/idv/analytics_events_enhancer.rb +++ b/app/services/idv/analytics_events_enhancer.rb @@ -98,6 +98,7 @@ module AnalyticsEventsEnhancer idv_sdk_selfie_image_capture_opened idv_selfie_image_added idv_session_error_visited + idv_threatmetrix_response_body idv_usps_auth_token_refresh_job_completed idv_usps_auth_token_refresh_job_network_error idv_usps_auth_token_refresh_job_started diff --git a/spec/controllers/idv/in_person/verify_info_controller_spec.rb b/spec/controllers/idv/in_person/verify_info_controller_spec.rb index 470578f81e6..5332fd07369 100644 --- a/spec/controllers/idv/in_person/verify_info_controller_spec.rb +++ b/spec/controllers/idv/in_person/verify_info_controller_spec.rb @@ -106,6 +106,7 @@ transaction_id: 1, review_status: review_status, response_body: { + session_id: 'threatmetrix_session_id', tmx_summary_reason_code: ['Identity_Negative_History'], }, }, @@ -137,6 +138,13 @@ }, ), ) + expect(@analytics).to have_logged_event( + :idv_threatmetrix_response_body, + response_body: { + session_id: 'threatmetrix_session_id', + tmx_summary_reason_code: ['Identity_Negative_History'], + }, + ) end end diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index 56e1e52f3df..f6aaf74bea4 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -235,6 +235,12 @@ ), ), ) + expect(@analytics).to have_logged_event( + :idv_threatmetrix_response_body, + response_body: hash_including( + client: threatmetrix_client_id, + ), + ) end end diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 5ebd43e173f..5e3f1f61b70 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -10,21 +10,26 @@ let(:proofing_device_profiling) { :enabled } let(:threatmetrix) { true } let(:idv_level) { 'in_person' } + let(:threatmetrix_response_body) do + { + account_lex_id: 'super-cool-test-lex-id', + 'fraudpoint.score': '500', + request_id: '1234', + request_result: 'success', + review_status: 'pass', + risk_rating: 'trusted', + session_id: 'super-cool-test-session-id', + summary_risk_score: '-6', + tmx_risk_rating: 'neutral', + tmx_summary_reason_code: ['Identity_Negative_History'], + } + end let(:threatmetrix_response) do { client: nil, errors: {}, exception: nil, - response_body: { "fraudpoint.score": '500', - request_id: '1234', - request_result: 'success', - account_lex_id: 'super-cool-test-lex-id', - session_id: 'super-cool-test-session-id', - review_status: 'pass', - risk_rating: 'trusted', - summary_risk_score: '-6', - tmx_risk_rating: 'neutral', - tmx_summary_reason_code: ['Identity_Negative_History'] }, + response_body: threatmetrix_response_body, review_status: 'pass', account_lex_id: 'super-cool-test-lex-id', session_id: 'super-cool-test-session-id', @@ -216,6 +221,11 @@ 'IdV: doc auth verify submitted' => { flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, + idv_threatmetrix_response_body: ( + if threatmetrix_response_body.present? + { response_body: threatmetrix_response_body } + end + ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results @@ -273,7 +283,7 @@ active_profile_idv_level: 'legacy_unsupervised', proofing_components: lexis_nexis_address_proofing_components }, - } + }.compact end let(:happy_hybrid_path_events) do @@ -331,6 +341,11 @@ 'IdV: doc auth verify submitted' => { flow_path: 'hybrid', step: 'verify', analytics_id: 'Doc Auth' }, + idv_threatmetrix_response_body: ( + if threatmetrix_response_body.present? + { response_body: threatmetrix_response_body } + end + ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'hybrid', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results @@ -388,7 +403,7 @@ active_profile_idv_level: 'legacy_unsupervised', proofing_components: lexis_nexis_address_proofing_components }, - } + }.compact end let(:gpo_path_events) do @@ -443,6 +458,11 @@ 'IdV: doc auth verify submitted' => { flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, + idv_threatmetrix_response_body: ( + if threatmetrix_response_body.present? + { response_body: threatmetrix_response_body } + end + ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results @@ -477,7 +497,7 @@ pending_profile_idv_level: 'legacy_unsupervised', proofing_components: gpo_letter_proofing_components, }, - } + }.compact end let(:in_person_path_events) do @@ -552,6 +572,11 @@ 'IdV: doc auth verify submitted' => { analytics_id: 'In Person Proofing', step: 'verify', flow_path: 'standard', same_address_as_id: false }, + idv_threatmetrix_response_body: ( + if threatmetrix_response_body.present? + { response_body: threatmetrix_response_body } + end + ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'In Person Proofing', step: 'verify', same_address_as_id: false, proofing_results: in_person_path_proofing_results @@ -609,7 +634,7 @@ }, 'IdV: user clicked what to bring link on ready to verify page' => {}, 'IdV: user clicked sp link on ready to verify page' => {}, - } + }.compact end let(:happy_mobile_selfie_path_events) do @@ -670,6 +695,11 @@ 'IdV: doc auth verify submitted' => { flow_path: 'standard', step: 'verify', analytics_id: 'Doc Auth' }, + idv_threatmetrix_response_body: ( + if threatmetrix_response_body.present? + { response_body: threatmetrix_response_body } + end + ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', proofing_results: base_proofing_results @@ -727,7 +757,7 @@ active_profile_idv_level: 'unsupervised_with_selfie', proofing_components: lexis_nexis_address_proofing_components }, - } + }.compact end # rubocop:enable Layout/LineLength # rubocop:enable Layout/MultilineHashKeyLineBreaks @@ -786,6 +816,7 @@ context 'proofing_device_profiling disabled' do let(:proofing_device_profiling) { :disabled } let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } let(:threatmetrix_response) do { client: 'tmx_disabled', @@ -797,7 +828,7 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: nil, + response_body: threatmetrix_response_body, } end @@ -866,6 +897,7 @@ context 'proofing_device_profiling disabled' do let(:proofing_device_profiling) { :disabled } let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } let(:threatmetrix_response) do { client: 'tmx_disabled', @@ -877,7 +909,7 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: nil, + response_body: threatmetrix_response_body, } end @@ -915,6 +947,7 @@ context 'proofing_device_profiling disabled' do let(:proofing_device_profiling) { :disabled } let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } let(:threatmetrix_response) do { client: 'tmx_disabled', @@ -926,7 +959,7 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: nil, + response_body: threatmetrix_response_body, } end @@ -976,6 +1009,7 @@ let(:proofing_device_profiling) { :disabled } let(:idv_level) { 'legacy_in_person' } let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } let(:threatmetrix_response) do { client: 'tmx_disabled', @@ -987,7 +1021,7 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: nil, + response_body: threatmetrix_response_body, } end @@ -1047,6 +1081,7 @@ def wait_for_event(event, wait) context 'proofing_device_profiling disabled' do let(:proofing_device_profiling) { :disabled } let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } let(:threatmetrix_response) do { client: 'tmx_disabled', @@ -1058,7 +1093,7 @@ def wait_for_event(event, wait) review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: nil, + response_body: threatmetrix_response_body, } end @@ -1108,6 +1143,7 @@ def wait_for_event(event, wait) context 'proofing_device_profiling disabled' do let(:proofing_device_profiling) { :disabled } let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } let(:threatmetrix_response) do { client: 'tmx_disabled', @@ -1119,7 +1155,7 @@ def wait_for_event(event, wait) review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: nil, + response_body: threatmetrix_response_body, } end From 6d74fa1a0d7d47d9cf897c25afc143ddbc007c62 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Mon, 30 Sep 2024 15:33:55 -0400 Subject: [PATCH 7/7] LG-14587 | Updates to 'You successfully verified your identity' email (#11295) changelog: User-Facing Improvements, Account verified email, Prompt user to return to SP --- app/mailers/user_mailer.rb | 11 ++- .../idv/account_verified_email_presenter.rb | 43 +++++++++ .../alert_user_about_account_verified.rb | 4 +- .../user_mailer/account_verified.html.erb | 66 +++++++++++-- config/locales/en.yml | 12 ++- config/locales/es.yml | 14 ++- config/locales/fr.yml | 14 ++- config/locales/zh.yml | 12 ++- spec/mailers/previews/user_mailer_preview.rb | 10 +- spec/mailers/user_mailer_spec.rb | 5 +- .../account_verified_email_presenter_spec.rb | 96 +++++++++++++++++++ .../alert_user_about_account_verified_spec.rb | 55 ++++++++--- 12 files changed, 296 insertions(+), 46 deletions(-) create mode 100644 app/presenters/idv/account_verified_email_presenter.rb create mode 100644 spec/presenters/idv/account_verified_email_presenter_spec.rb diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 6b742826e28..1f2e17fb19c 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -242,13 +242,16 @@ def add_email_associated_with_another_account end end - def account_verified(date_time:, sp_name:) + def account_verified(profile:) + attachments.inline['verified.png'] = + Rails.root.join('app/assets/images/email/user-signup-ial2.png').read with_user_locale(user) do - @date = I18n.l(date_time, format: :event_date) - @sp_name = sp_name + @presenter = Idv::AccountVerifiedEmailPresenter.new(profile:) + @hide_title = true + @date = I18n.l(profile.verified_at, format: :event_date) mail( to: email_address.email, - subject: t('user_mailer.account_verified.subject', sp_name: @sp_name), + subject: t('user_mailer.account_verified.subject', app_name: APP_NAME), ) end end diff --git a/app/presenters/idv/account_verified_email_presenter.rb b/app/presenters/idv/account_verified_email_presenter.rb new file mode 100644 index 00000000000..ceb89b83901 --- /dev/null +++ b/app/presenters/idv/account_verified_email_presenter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Idv + class AccountVerifiedEmailPresenter + include Rails.application.routes.url_helpers + + attr_reader :profile + + def initialize(profile:) + @profile = profile + end + + def service_provider + profile.initiating_service_provider + end + + def show_cta? + !service_provider || service_provider_homepage_url.present? + end + + def sign_in_url + service_provider_homepage_url || root_url + end + + def service_provider_homepage_url + sp_return_url_resolver.homepage_url if service_provider + end + + def sp_name + service_provider&.friendly_name || APP_NAME + end + + def url_options + {} + end + + private + + def sp_return_url_resolver + SpReturnUrlResolver.new(service_provider: service_provider) + end + end +end diff --git a/app/services/user_alerts/alert_user_about_account_verified.rb b/app/services/user_alerts/alert_user_about_account_verified.rb index cc6e5c62ca6..2b844ccb7ed 100644 --- a/app/services/user_alerts/alert_user_about_account_verified.rb +++ b/app/services/user_alerts/alert_user_about_account_verified.rb @@ -4,11 +4,9 @@ module UserAlerts class AlertUserAboutAccountVerified def self.call(profile:) user = profile.user - sp_name = profile.initiating_service_provider&.friendly_name || APP_NAME user.confirmed_email_addresses.each do |email_address| UserMailer.with(user: user, email_address: email_address).account_verified( - date_time: profile.verified_at, - sp_name: sp_name, + profile: profile, ).deliver_now_or_later end end diff --git a/app/views/user_mailer/account_verified.html.erb b/app/views/user_mailer/account_verified.html.erb index dff3fe44a73..f4f796ffd0a 100644 --- a/app/views/user_mailer/account_verified.html.erb +++ b/app/views/user_mailer/account_verified.html.erb @@ -1,15 +1,67 @@ -

+<%= image_tag( + attachments['verified.png'].url, + width: 140, + height: 177, + alt: '', + role: 'img', + class: 'float-center padding-bottom-4', + ) %> + +

<%= message.subject %>

+ +

+ <%= t('user_mailer.account_verified.greeting') %> +

+

+ <%= t('user_mailer.account_verified.intro', date: @date) %> +

+ +

+<% if @presenter.service_provider.present? %> + <% if @presenter.show_cta? %> + <%= t('user_mailer.account_verified.next_sign_in.with_sp.with_cta', sp_name: @presenter.service_provider.friendly_name) %> + <% else %> + <%= t('user_mailer.account_verified.next_sign_in.with_sp.without_cta', sp_name: @presenter.service_provider.friendly_name) %> + <% end %> +<% else %> + <%= t('user_mailer.account_verified.next_sign_in.without_sp', app_name: APP_NAME) %> +<% end %> +

+ +<% if @presenter.show_cta? %> + + + + + + +
+ + + + + + +
+ <%= link_to t('user_mailer.account_verified.sign_in'), @presenter.sign_in_url, + target: '_blank', class: 'btn-warn', rel: 'noopener' %> +
+
+

+ <%= link_to(@presenter.sign_in_url, @presenter.sign_in_url, target: '_blank', rel: 'noopener') %> +

+<% end %> + +

<%= t( - 'user_mailer.account_verified.intro_html', - sp_name: @sp_name, - app_name: APP_NAME, - date: @date, + 'user_mailer.account_verified.warning_contact_us_html', change_password_link_html: link_to( t('user_mailer.account_verified.change_password_link'), new_user_password_url, ), - contact_link_html: link_to(t('user_mailer.account_verified.contact_link'), MarketingSite.contact_url), - ) %> + contact_link_html: link_to(t('user_mailer.account_verified.contact_link', app_name: APP_NAME), MarketingSite.contact_url), + ) + %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index a94e96cf5fb..aebea9e45e9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1804,9 +1804,15 @@ user_mailer.account_reset_request.header: Your account will be deleted in %{inte user_mailer.account_reset_request.intro_html: 'As a security measure, %{app_name} requires a two-step process to delete your account:

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

Step Two: After the waiting period of %{waiting_period}, you will receive an email that will ask you to confirm the deletion of your %{app_name} account. Your account will not be deleted until you confirm.' user_mailer.account_reset_request.subject: How to delete your %{app_name} account user_mailer.account_verified.change_password_link: change your password -user_mailer.account_verified.contact_link: contact us -user_mailer.account_verified.intro_html: You successfully verified your identity with %{sp_name} on %{date} using %{app_name}. If you did not perform this action, please %{contact_link_html} and sign in to %{change_password_link_html}. -user_mailer.account_verified.subject: You verified your identity with %{sp_name}. +user_mailer.account_verified.contact_link: contact %{app_name} support +user_mailer.account_verified.greeting: Hello, +user_mailer.account_verified.intro: You successfully verified your identity on %{date}. +user_mailer.account_verified.next_sign_in.with_sp.with_cta: Next, click the button or copy the link below to access %{sp_name} and sign in. +user_mailer.account_verified.next_sign_in.with_sp.without_cta: You can now sign in from %{sp_name}’s website. +user_mailer.account_verified.next_sign_in.without_sp: Next, click the button or copy the link below to sign in to %{app_name}. +user_mailer.account_verified.sign_in: Sign in +user_mailer.account_verified.subject: You successfully verified your identity with %{app_name} +user_mailer.account_verified.warning_contact_us_html: If you did not attempt to verify your identity, please sign in to %{change_password_link_html}. To report this, %{contact_link_html}. user_mailer.add_email_associated_with_another_account.help_html: If you did not request a new email or suspect an error, please visit the %{app_name_html} %{help_link_html} or %{contact_link_html}. user_mailer.add_email_associated_with_another_account.intro_html: This email address is already associated with a %{app_name_html} account, so we can’t add it to another account. You must first delete or remove it from the account it is associated with. To do this, follow the link below and sign in with this email address. If you are not trying to add this email address to an account, you can ignore this message. user_mailer.add_email_associated_with_another_account.link_text: Go to %{app_name} diff --git a/config/locales/es.yml b/config/locales/es.yml index 6833ee083d8..4cf1827c652 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1815,10 +1815,16 @@ user_mailer.account_reset_request.cancel: '¿No desea eliminar su cuenta? Inicie user_mailer.account_reset_request.header: Su cuenta será eliminada en %{interval} user_mailer.account_reset_request.intro_html: 'Como medida de seguridad, %{app_name} requiere un proceso de dos pasos para eliminar su cuenta:

Paso uno: Hay un período de espera de %{waiting_period} si perdió el acceso a sus métodos de autenticación y necesita eliminar su cuenta. Si encuentra sus métodos de autenticación, puede iniciar sesión en su cuenta %{app_name} para cancelar esta solicitud.

Paso dos: Tras el período de espera de %{waiting_period}, recibirás un correo electrónico en el que te pediremos que confirmes la eliminación de tu cuenta %{app_name}. Tu cuenta no se eliminará hasta que lo confirmes.' user_mailer.account_reset_request.subject: Cómo eliminar su cuenta de %{app_name} -user_mailer.account_verified.change_password_link: cambie su contraseña -user_mailer.account_verified.contact_link: contáctenos -user_mailer.account_verified.intro_html: El %{date}, verificó correctamente su identidad con %{sp_name} usando %{app_name}. Si usted no efectuó esta acción, vaya a %{contact_link_html} e inicie sesión para %{change_password_link_html}. -user_mailer.account_verified.subject: Verificó su identidad con %{sp_name} +user_mailer.account_verified.change_password_link: restablecer su contraseña +user_mailer.account_verified.contact_link: contacte con el servicio de asistencia de %{app_name} +user_mailer.account_verified.greeting: 'Hola:' +user_mailer.account_verified.intro: Verificó correctamente su identidad el %{date}. +user_mailer.account_verified.next_sign_in.with_sp.with_cta: A continuación, haga clic en el botón o copie el vínculo siguiente para acceder a %{sp_name} e iniciar sesión. +user_mailer.account_verified.next_sign_in.with_sp.without_cta: Ya puede iniciar la sesión en el sitio web de %{sp_name}. +user_mailer.account_verified.next_sign_in.without_sp: Luego, haga clic en el botón o copie el vínculo siguiente para iniciar sesión en %{app_name}. +user_mailer.account_verified.sign_in: Iniciar sesión +user_mailer.account_verified.subject: Logró verificar su identidad con %{app_name} +user_mailer.account_verified.warning_contact_us_html: Si usted no intentó verificar su identidad, inicie sesión para %{change_password_link_html}. Para informar de esto, %{contact_link_html}. user_mailer.add_email_associated_with_another_account.help_html: Si no solicitó un nuevo correo electrónico o sospecha que hubo un error, visite %{help_link_html} de %{app_name_html} o %{contact_link_html}. user_mailer.add_email_associated_with_another_account.intro_html: Esta dirección de correo electrónico ya está asociada con una cuenta de %{app_name_html}, por lo que no podemos agregarla a otra cuenta. Primero, debe eliminarla o quitarla de la cuenta con la que está asociada. Para hacerlo, siga este vínculo e inicie sesión con esta dirección de correo electrónico. Si no está intentando agregar esta dirección de correo electrónico a una cuenta, puede ignorar este mensaje. user_mailer.add_email_associated_with_another_account.link_text: Ir a %{app_name} diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 2bf5eaeb887..f02e41a8902 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1803,10 +1803,16 @@ user_mailer.account_reset_request.cancel: Vous ne voulez pas supprimer votre com user_mailer.account_reset_request.header: Votre compte sera supprimé dans %{interval} user_mailer.account_reset_request.intro_html: 'Par mesure de sécurité, %{app_name} nécessite un processus en deux étapes pour supprimer votre compte:

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

Deuxième étape: après la période d’attente de %{waiting_period}, vous recevrez un e-mail qui vous demandera de confirmer la suppression de votre compte %{app_name}. Votre compte ne sera pas supprimé tant que vous n’aurez pas confirmé.' user_mailer.account_reset_request.subject: Comment supprimer votre compte %{app_name} -user_mailer.account_verified.change_password_link: changer votre mot de passe -user_mailer.account_verified.contact_link: nous contacter -user_mailer.account_verified.intro_html: Le %{date}, vous avez réussi à confirmer votre identité auprès de %{sp_name} à l’aide de %{app_name}. Si vous n’avez pas effectué cette action, veuillez %{contact_link_html} et vous connecter pour %{change_password_link_html}. -user_mailer.account_verified.subject: Vous avez confirmé votre identité avec %{sp_name}. +user_mailer.account_verified.change_password_link: réinitialiser votre mot de passe +user_mailer.account_verified.contact_link: contactez le service d’assistance de %{app_name} +user_mailer.account_verified.greeting: Bonjour, +user_mailer.account_verified.intro: Vous avez réussi à confirmer votre identité le %{date}. +user_mailer.account_verified.next_sign_in.with_sp.with_cta: Maintenant, cliquez sur le bouton ou copiez le lien ci-dessous pour accéder à %{sp_name} et vous connecter. +user_mailer.account_verified.next_sign_in.with_sp.without_cta: Vous pouvez désormais vous connecter depuis le site Web de %{sp_name}. +user_mailer.account_verified.next_sign_in.without_sp: Maintenant, cliquez sur le bouton ou copiez le lien ci-dessous pour vous connecter à %{app_name}. +user_mailer.account_verified.sign_in: Se connecter +user_mailer.account_verified.subject: Vous avez réussi à vérifier votre identité avec %{app_name} +user_mailer.account_verified.warning_contact_us_html: Si vous n’avez pas essayé de confirmer votre identité, veuillez vous connecter pour %{change_password_link_html}. Pour signaler ce problème, %{contact_link_html}. user_mailer.add_email_associated_with_another_account.help_html: Si vous n’avez pas demandé de nouvel e-mail ou suspectez une erreur, veuillez visiter le %{help_link_html} de %{app_name_html} ou %{contact_link_html}. user_mailer.add_email_associated_with_another_account.intro_html: Cette adresse e-mail est déjà associée à un compte %{app_name_html}, nous ne pouvons donc pas l’ajouter à un autre compte. Vous devez d’abord la supprimer ou la retirer du compte auquel elle est associée. Pour ce faire, suivez le lien ci-dessous et connectez-vous avec cette adresse e-mail. Si vous n’essayez pas d’ajouter cette adresse e-mail à un compte, vous pouvez ignorer ce message. user_mailer.add_email_associated_with_another_account.link_text: Allez sur %{app_name} diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 97ddc9d565a..0ffe276079f 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -1817,9 +1817,15 @@ user_mailer.account_reset_request.header: 你的账户会在%{interval}后删除 user_mailer.account_reset_request.intro_html: 作为一项安全措施,%{app_name} 要求一个两步流程来删除你的帐户:

第一步:如果你丢失了身份证实方法但需删除账户,有一个%{waiting_period} 的等待期。如果你找到了身份证实方法,可以登录你的 %{app_name} 帐户来取消这个请求。

第二步:%{waiting_period}等待期之后,你会收到一封电邮,请你确认要删除 %{app_name} 账户。只有经你确认后,你的账户才会被删除。 user_mailer.account_reset_request.subject: 如何删除你的 %{app_name} 账户 user_mailer.account_verified.change_password_link: 更改密码 -user_mailer.account_verified.contact_link: 联系我们 -user_mailer.account_verified.intro_html: 你于 %{date} 使用 %{app_name} 在 %{sp_name}成功验证了身份。如果你没有采取这一行动,请 %{contact_link_html} 并登录 %{change_password_link_html}。 -user_mailer.account_verified.subject: 你在 %{sp_name} 验证了身份。 +user_mailer.account_verified.contact_link: 请联系 %{app_name}支持 +user_mailer.account_verified.greeting: 你好, +user_mailer.account_verified.intro: 你在 %{date} 成功地验证了身份。 +user_mailer.account_verified.next_sign_in.with_sp.with_cta: 接下来请点击按钮或复制下面的连接来访问 %{sp_name} 并登录。 +user_mailer.account_verified.next_sign_in.with_sp.without_cta: 你现在可以从 %{sp_name} 的网站登录。 +user_mailer.account_verified.next_sign_in.without_sp: 接下来请点击按钮或复制下面的连接来登录 %{app_name}。 +user_mailer.account_verified.sign_in: 登录 +user_mailer.account_verified.subject: 你在 %{app_name} 成功地验证了身份 +user_mailer.account_verified.warning_contact_us_html: 如果你没有试图验证过身份,请登录来%{change_password_link_html}。要报告这件事,%{contact_link_html}。 user_mailer.add_email_associated_with_another_account.help_html: 如果你没有要求一封新电邮或怀疑有错, 请访问 %{app_name_html}的 %{help_link_html} 或者 %{contact_link_html}。 user_mailer.add_email_associated_with_another_account.intro_html: 该电邮地址已与一个 %{app_name_html}账户相关联,所以我们不能把它加到另外一个账户上。你必须首先将其从与之相关的账户中删除或去掉。要做到这一点,点击以下链接并用该电邮地址登录。如果你没有试图将此电邮地址加到一个账户,可忽略这一信息。 user_mailer.add_email_associated_with_another_account.link_text: 请到 %{app_name} diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 64abf105d1a..05b63b0aaa8 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -145,9 +145,15 @@ def add_email_associated_with_another_account end def account_verified + service_provider = ServiceProvider.find_by(friendly_name: 'Example Sinatra App') UserMailer.with(user: user, email_address: email_address_record).account_verified( - date_time: DateTime.now, - sp_name: 'Example App', + profile: unsaveable( + Profile.new( + user: user, + initiating_service_provider: service_provider, + verified_at: Time.zone.now, + ), + ), ) end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 61bad58336a..ff529f49665 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -525,9 +525,10 @@ def expect_email_body_to_have_help_and_contact_links describe '#account_verified' do let(:sp_name) { '' } let(:date_time) { Time.zone.now } + let(:profile) { create(:profile, :active) } let(:mail) do UserMailer.with(user: user, email_address: email_address). - account_verified(date_time: date_time, sp_name: sp_name) + account_verified(profile: profile) end it_behaves_like 'a system email' @@ -538,7 +539,7 @@ def expect_email_body_to_have_help_and_contact_links end it 'renders the subject' do - expect(mail.subject).to eq t('user_mailer.account_verified.subject', sp_name: sp_name) + expect(mail.subject).to eq t('user_mailer.account_verified.subject', app_name: APP_NAME) end it 'links to the forgot password page' do diff --git a/spec/presenters/idv/account_verified_email_presenter_spec.rb b/spec/presenters/idv/account_verified_email_presenter_spec.rb new file mode 100644 index 00000000000..760b22b2fd0 --- /dev/null +++ b/spec/presenters/idv/account_verified_email_presenter_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Idv::AccountVerifiedEmailPresenter do + include Rails.application.routes.url_helpers + + let(:service_provider) { create(:service_provider) } + + let(:profile) do + create( + :profile, + initiating_service_provider: service_provider, + ) + end + + subject(:presenter) { described_class.new(profile:) } + + context 'when there is no associated service provider' do + let(:service_provider) { nil } + + describe '#show_cta?' do + it 'is true' do + expect(presenter.show_cta?).to eq(true) + end + end + + describe '#sp_name' do + it 'returns our APP_NAME instead' do + expect(presenter.sp_name).to eq(APP_NAME) + end + end + + describe '#sign_in_url' do + it 'links to ourselves since there is no SP' do + expect(presenter.sign_in_url).to eq(root_url) + end + end + end + + context 'where there is a service provider' do + context 'when the service provider has no return URL' do + let(:service_provider) do + create( + :service_provider, + return_to_sp_url: nil, + friendly_name: 'My Awesome SP', + ) + end + + describe '#show_cta?' do + it 'is false' do + expect(presenter.show_cta?).to eq(false) + end + end + + describe '#sp_name' do + it 'returns the SP name' do + expect(presenter.sp_name).to eq('My Awesome SP') + end + end + + describe '#sign_in_url' do + it 'links to ourselves' do + expect(presenter.sign_in_url).to eq(root_url) + end + end + end + + context 'when the service provider does have a return URL' do + let(:service_provider) do + create( + :service_provider, + return_to_sp_url: 'https://www.example.com', + friendly_name: 'My Awesome SP', + ) + end + + describe '#show_cta?' do + it 'is true' do + expect(presenter.show_cta?).to eq(true) + end + end + + describe '#sp_name' do + it 'shows the SP name' do + expect(presenter.sp_name).to eq('My Awesome SP') + end + end + + describe '#sign_in_url' do + it 'links to the SP' do + expect(presenter.sign_in_url).to eq('https://www.example.com') + end + end + end + end +end diff --git a/spec/services/user_alerts/alert_user_about_account_verified_spec.rb b/spec/services/user_alerts/alert_user_about_account_verified_spec.rb index 41ab2c4f0a6..47dd8f362fa 100644 --- a/spec/services/user_alerts/alert_user_about_account_verified_spec.rb +++ b/spec/services/user_alerts/alert_user_about_account_verified_spec.rb @@ -20,29 +20,56 @@ described_class.call(profile: profile) expect_delivered_email_count(3) - expect_delivered_email( - to: [confirmed_email_addresses[0].email], - subject: t('user_mailer.account_verified.subject', sp_name: service_provider.friendly_name), - ) - expect_delivered_email( - to: [confirmed_email_addresses[1].email], - subject: t('user_mailer.account_verified.subject', sp_name: service_provider.friendly_name), - ) - expect_delivered_email( - to: [confirmed_email_addresses[2].email], - subject: t('user_mailer.account_verified.subject', sp_name: service_provider.friendly_name), - ) + + confirmed_email_addresses.each do |email_address| + expect_delivered_email( + to: [email_address.email], + subject: t('user_mailer.account_verified.subject', app_name: APP_NAME), + ) + end end context 'when no service provider initiated the proofing event' do let(:service_provider) { nil } - it 'sends the email with Login.gov as the initiating service provider' do + it 'sends the email linking to Login.gov' do + described_class.call(profile: profile) + + expect_delivered_email( + to: [user.confirmed_email_addresses.first.email], + subject: t('user_mailer.account_verified.subject', app_name: APP_NAME), + body: ['
', 'localhost:3000'], + ) + end + end + + context 'when a service provider with no url' do + let(:service_provider) { ServiceProvider.new } + + it 'sends an email without the call to action' do + described_class.call(profile: profile) + + email_body = last_email.text_part.decoded.squish + expect(email_body).to_not include('
') + end + end + + context 'when a service provider does have a url' do + let(:service_provider) do + create( + :service_provider, + friendly_name: 'Example App', + return_to_sp_url: 'http://example.com', + ) + end + + it 'sends an email with the call to action linking to the sp' do described_class.call(profile: profile) expect_delivered_email( to: [user.confirmed_email_addresses.first.email], - subject: t('user_mailer.account_verified.subject', sp_name: APP_NAME), + subject: t('user_mailer.account_verified.subject', app_name: APP_NAME), + body: ['
', 'http://example.com'], ) end end