From b5f3dd38a81702765769358b5ecfeac84b2e6430 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 24 Apr 2018 19:02:50 -0500 Subject: [PATCH 01/19] Remove duplicate or obselete idv specs (#2113) **Why**: The specs in this commit have either been moved into idv step specs, or are moved by this commit. This commit also reshuffles a few existing specs to make sure that our coverage at the feature level does not drop. --- spec/features/idv/account_creation_spec.rb | 21 -- spec/features/idv/flow_spec.rb | 183 ------------------ spec/features/idv/phone_spec.rb | 55 ------ spec/features/idv/previous_address_spec.rb | 30 ++- .../idv/state_id_data_spec.rb} | 30 +-- spec/features/idv/steps/phone_step_spec.rb | 11 ++ .../idv_examples/otp_delivery_method.rb | 65 ------- .../usps_verification_selection.rb | 50 ----- 8 files changed, 48 insertions(+), 397 deletions(-) delete mode 100644 spec/features/idv/account_creation_spec.rb delete mode 100644 spec/features/idv/flow_spec.rb delete mode 100644 spec/features/idv/phone_spec.rb rename spec/{support/idv_examples/state_id_data.rb => features/idv/state_id_data_spec.rb} (76%) delete mode 100644 spec/support/idv_examples/otp_delivery_method.rb delete mode 100644 spec/support/idv_examples/usps_verification_selection.rb diff --git a/spec/features/idv/account_creation_spec.rb b/spec/features/idv/account_creation_spec.rb deleted file mode 100644 index 1c9b5b72868..00000000000 --- a/spec/features/idv/account_creation_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'rails_helper' - -feature 'account creation after LOA3 request', idv_job: true do - include SamlAuthHelper - include IdvHelper - - context 'choosing USPS address verification' do - it_behaves_like 'selecting usps address verification method', :saml - it_behaves_like 'selecting usps address verification method', :oidc - end - - context 'choosing phone address verification otp delivery method' do - it_behaves_like 'idv otp delivery method selection', :saml - it_behaves_like 'idv otp delivery method selection', :oidc - end - - context 'entering state id data' do - it_behaves_like 'idv state id data entry', :saml - it_behaves_like 'idv state id data entry', :oidc - end -end diff --git a/spec/features/idv/flow_spec.rb b/spec/features/idv/flow_spec.rb deleted file mode 100644 index 5838704695f..00000000000 --- a/spec/features/idv/flow_spec.rb +++ /dev/null @@ -1,183 +0,0 @@ -require 'rails_helper' - -feature 'IdV session', idv_job: true do - include IdvHelper - - context 'landing page' do - before do - sign_in_and_2fa_user - visit verify_path - end - - scenario 'proceed to verify identity' do - click_link 'Yes' - - expect(page).to have_content(t('idv.titles.sessions')) - end - end - - context 'verification session' do - scenario 'normal flow' do - user = sign_in_and_2fa_user - - visit verify_session_path - - fill_out_idv_form_ok - click_idv_continue - - expect(page).to have_content( - t('idv.messages.sessions.success', - pii_message: t('idv.messages.sessions.pii')) - ) - - click_idv_address_choose_phone - fill_out_phone_form_ok(user.phone) - click_idv_continue - fill_in :user_password, with: user_password - click_continue - - expect(current_url).to eq verify_confirmations_url - expect(page).to have_content(t('headings.personal_key')) - click_acknowledge_personal_key - - expect(current_url).to eq(account_url) - expect(page).to have_content('José One') - expect(page).to have_content('123 Main St') - expect(user.reload.active_profile).to be_a(Profile) - end - - scenario 'profile steps is not re-entrant and are sticky on failure', :js do - user = sign_in_and_2fa_user - - visit verify_session_path - - first_ssn_value = '666-66-6666' - second_ssn_value = '666-66-1234' - good_phone_value = '415-555-9999' - good_phone_formatted = '+1 (415) 555-9999' - bad_phone_formatted = '+1 (555) 555-5555' - - # we start with blank form - expect(page).to_not have_selector("input[value='#{first_ssn_value}']") - - fill_out_idv_form_fail - click_idv_continue - - # failure reloads the form and shows warning modal - expect(current_path).to eq verify_session_result_path - expect(find('.modal-warning').text).to match t('idv.modal.sessions.heading') - click_button t('idv.modal.button.warning') - - fill_out_idv_form_ok - click_idv_continue - - # address mechanism choice - click_idv_address_choose_phone - - # success advances to next step - expect(current_path).to eq verify_phone_path - - # we start with blank form - expect(page).to_not have_selector("input[value='#{bad_phone_formatted}']") - - fill_out_phone_form_fail - click_idv_continue - - # failure reloads the same sticky form - expect(current_path).to eq verify_phone_result_path - expect(page).to have_css('.modal-warning', text: t('idv.modal.phone.heading')) - click_button t('idv.modal.button.warning') - expect(page).to have_selector("input[value='#{bad_phone_formatted}']") - - fill_out_phone_form_ok(good_phone_value) - click_idv_continue - choose_idv_otp_delivery_method_sms - enter_correct_otp_code_for_user(user) - - page.find('.accordion').click - - # success advances to next step - expect(page).to have_content(t('idv.titles.session.review')) - expect(page).to have_content(second_ssn_value) - expect(page).to_not have_content(first_ssn_value) - expect(page).to have_content(good_phone_formatted) - expect(page).to_not have_content(bad_phone_formatted) - end - - scenario 'phone step is re-entrant', :js do - phone = '(555) 555-5000' - different_phone = '(777) 777-7000' - user = sign_in_and_2fa_user - - visit verify_session_path - fill_out_idv_form_ok - click_idv_continue - click_idv_address_choose_phone - fill_out_phone_form_ok(phone) - click_idv_continue - choose_idv_otp_delivery_method_sms - - click_link t('forms.two_factor.try_again') - - expect(page.find('#idv_phone_form_phone').value).to eq("+1 #{phone}") - expect(current_path).to eq(verify_phone_path) - - fill_out_phone_form_ok(different_phone) - click_idv_continue - choose_idv_otp_delivery_method_sms - - # Verify that OTP confirmation can't be skipped - visit verify_review_path - expect(current_path).to eq login_two_factor_path(otp_delivery_preference: :sms) - - enter_correct_otp_code_for_user(user) - - page.find('.accordion').click - - expect(page).to_not have_content(phone) - expect(page).to have_content(different_phone) - end - - scenario 'closing previous address accordion clears inputs and toggles header', js: true do - _user = sign_in_and_2fa_user - - visit verify_session_path - expect(page).to have_css('.accordion-header-controls', - text: t('idv.form.previous_address_add')) - - click_accordion - expect(page).to have_css('.accordion-header', text: t('links.remove')) - - fill_out_idv_previous_address_ok - expect(find('#profile_prev_address1').value).to eq '456 Other Ave' - - click_accordion - click_accordion - - expect(find('#profile_prev_address1').value).to eq '' - end - - scenario 'attempting to skip OTP phone confirmation redirects to OTP confirmation', :js do - different_phone = '555-555-9876' - user = sign_in_live_with_2fa - visit verify_session_path - - fill_out_idv_form_ok - click_idv_continue - click_idv_address_choose_phone - fill_out_phone_form_ok(different_phone) - click_idv_continue - - # Modify URL to skip phone confirmation - visit verify_review_path - user.reload - - expect(current_path).to eq(login_two_factor_path(otp_delivery_preference: :sms)) - expect(user.profiles).to be_empty - end - end - - def click_accordion - find('.accordion-header-controls[aria-controls="previous-address"]').click - end -end diff --git a/spec/features/idv/phone_spec.rb b/spec/features/idv/phone_spec.rb deleted file mode 100644 index 29a86a6be65..00000000000 --- a/spec/features/idv/phone_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -require 'rails_helper' - -feature 'Verify phone' do - include IdvHelper - - context 'Idv phone and user phone are different', idv_job: true do - scenario 'prompts to confirm phone' do - user = create( - :user, :signed_up, - phone: '+1 (416) 555-0190', - password: Features::SessionHelper::VALID_PASSWORD - ) - - sign_in_and_2fa_user(user) - visit verify_session_path - complete_idv_profile_with_phone('555-555-0000') - - fill_in 'code', with: 'not a valid code 😟' - click_submit_default - expect(page).to have_link t('forms.two_factor.try_again'), href: verify_phone_path - - enter_correct_otp_code_for_user(user) - fill_in :user_password, with: user_password - click_continue - click_acknowledge_personal_key - - expect(current_path).to eq account_path - end - - scenario 'user cannot re-enter phone step and change phone after confirmation', :idv_job do - user = sign_in_and_2fa_user - - visit verify_session_path - fill_out_idv_form_ok - click_idv_continue - click_idv_address_choose_phone - fill_out_phone_form_ok - click_idv_continue - choose_idv_otp_delivery_method_sms - enter_correct_otp_code_for_user(user) - - visit verify_phone_path - expect(current_path).to eq(verify_review_path) - end - end - - def complete_idv_profile_with_phone(phone) - fill_out_idv_form_ok - click_idv_continue - click_idv_address_choose_phone - fill_out_phone_form_ok(phone) - click_idv_continue - choose_idv_otp_delivery_method_sms - end -end diff --git a/spec/features/idv/previous_address_spec.rb b/spec/features/idv/previous_address_spec.rb index 3fae19126b1..16acc6e88ca 100644 --- a/spec/features/idv/previous_address_spec.rb +++ b/spec/features/idv/previous_address_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' feature 'IdV with previous address filled in', idv_job: true do - include IdvHelper + include IdvStepHelper let(:bad_zipcode) { '00000' } let(:current_address) { '123 Main St' } @@ -43,11 +43,35 @@ def expect_current_address_in_profile(user) end it 'fails when either address has bad value, prefers current address in profile' do - user = sign_in_and_2fa_user - visit verify_session_path + user = user_with_2fa + start_idv_from_sp + complete_idv_steps_before_profile_step(user) expect_bad_previous_address_to_fail expect_bad_current_address_to_fail expect_current_address_in_profile(user) end + + it 'closing previous address accordion clears inputs and toggles header', :js do + start_idv_from_sp + complete_idv_steps_before_profile_step + + expect(page).to have_css('.accordion-header-controls', + text: t('idv.form.previous_address_add')) + + click_accordion + expect(page).to have_css('.accordion-header', text: t('links.remove')) + + fill_out_idv_previous_address_ok + expect(find('#profile_prev_address1').value).to eq '456 Other Ave' + + click_accordion + click_accordion + + expect(find('#profile_prev_address1').value).to eq '' + end + + def click_accordion + find('.accordion-header-controls[aria-controls="previous-address"]').click + end end diff --git a/spec/support/idv_examples/state_id_data.rb b/spec/features/idv/state_id_data_spec.rb similarity index 76% rename from spec/support/idv_examples/state_id_data.rb rename to spec/features/idv/state_id_data_spec.rb index c77563a33c0..821ee0468fc 100644 --- a/spec/support/idv_examples/state_id_data.rb +++ b/spec/features/idv/state_id_data_spec.rb @@ -1,10 +1,15 @@ -shared_examples 'idv state id data entry' do |sp| - it 'renders an error for unverifiable state id number', :email do - visit_idp_from_sp_with_loa3(sp) - register_user +require 'rails_helper' + +feature 'idv state id data entry', :idv_job do + include IdvStepHelper - visit verify_session_path + before do + start_idv_from_sp + complete_idv_steps_before_profile_step fill_out_idv_form_ok + end + + it 'renders an error for unverifiable state id number', :email do fill_in :profile_state_id_number, with: '000000000' click_idv_continue @@ -16,11 +21,6 @@ expect(Idv::ProfileJob).to_not receive(:perform_now) expect(Idv::ProfileJob).to_not receive(:perform_later) - visit_idp_from_sp_with_loa3(sp) - register_user - - visit verify_session_path - fill_out_idv_form_ok fill_in :profile_state_id_number, with: '' click_idv_continue @@ -32,11 +32,6 @@ expect(Idv::ProfileJob).to_not receive(:perform_now) expect(Idv::ProfileJob).to_not receive(:perform_later) - visit_idp_from_sp_with_loa3(sp) - register_user - - visit verify_session_path - fill_out_idv_form_ok select 'Alabama', from: 'profile_state' click_idv_continue @@ -45,11 +40,6 @@ end it 'allows selection of different state id types', :email do - visit_idp_from_sp_with_loa3(sp) - register_user - - visit verify_session_path - fill_out_idv_form_ok select t('idv.form.state_id_type.drivers_permit'), from: 'profile_state_id_type' click_idv_continue diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index eec7853c11b..be48d9f2acc 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -13,6 +13,17 @@ expect(page).to have_content(t('idv.titles.otp_delivery_method')) expect(page).to have_current_path(verify_otp_delivery_method_path) end + + it 'redirects to the confirmation step when the phone matches the 2fa phone number' do + user = user_with_2fa + start_idv_from_sp + complete_idv_steps_before_phone_step(user) + fill_out_phone_form_ok(user.phone) + click_idv_continue + + expect(page).to have_content(t('idv.titles.session.review')) + expect(page).to have_current_path(verify_review_path) + end end context 'after submitting valid information' do diff --git a/spec/support/idv_examples/otp_delivery_method.rb b/spec/support/idv_examples/otp_delivery_method.rb deleted file mode 100644 index 246fe0aa446..00000000000 --- a/spec/support/idv_examples/otp_delivery_method.rb +++ /dev/null @@ -1,65 +0,0 @@ -shared_examples 'idv otp delivery method selection' do |sp| - let(:phone) { '555-123-4567' } - - before do - visit_idp_from_sp_with_loa3(sp) - register_user - click_idv_begin - fill_out_idv_form_ok - click_idv_continue - click_idv_address_choose_phone - fill_out_phone_form_ok(phone) - click_idv_continue - end - - scenario 'selecting sms delivery method sends sems', :email do - allow(SmsOtpSenderJob).to receive(:perform_later) - choose_idv_otp_delivery_method_sms - - expect(SmsOtpSenderJob).to have_received(:perform_later) - expect(current_path).to eq login_two_factor_path(otp_delivery_preference: :sms) - end - - scenario 'selecting voice delivery method sends voice call', :email do - allow(VoiceOtpSenderJob).to receive(:perform_later) - choose_idv_otp_delivery_method_voice - - expect(VoiceOtpSenderJob).to have_received(:perform_later) - expect(current_path).to eq login_two_factor_path(otp_delivery_preference: :voice) - end - - scenario 'choosing to enter a different phone sends an OTP to that phone', :email do - different_phone = '9876543210' - - choose_idv_otp_delivery_method_sms - click_link t('forms.two_factor.try_again') - - expect(current_path).to eq verify_phone_path - - fill_out_phone_form_ok(different_phone) - click_idv_continue - - allow(SmsOtpSenderJob).to receive(:perform_later) - choose_idv_otp_delivery_method_sms - - expect(SmsOtpSenderJob).to have_received(:perform_later). - with(hash_including(phone: different_phone)) - end - - context 'with a phone number that does not support voice calling' do - let(:phone) { '242-555-5000' } - - scenario 'voice call option is disabled', :email do - voice_radio_button = page.find( - '#otp_delivery_selection_form_otp_delivery_preference_voice', - visible: false - ) - - expect(voice_radio_button.disabled?).to eq(true) - expect(page).to have_content t( - 'devise.two_factor_authentication.otp_delivery_preference.phone_unsupported', - location: 'Bahamas' - ) - end - end -end diff --git a/spec/support/idv_examples/usps_verification_selection.rb b/spec/support/idv_examples/usps_verification_selection.rb deleted file mode 100644 index 7c4031081cf..00000000000 --- a/spec/support/idv_examples/usps_verification_selection.rb +++ /dev/null @@ -1,50 +0,0 @@ -shared_examples 'selecting usps address verification method' do |sp| - it 'allows the user to select verification via USPS letter', email: true do - visit_idp_from_sp_with_loa3(sp) - - user = register_user - - click_idv_begin - fill_out_idv_form_ok - click_idv_continue - - click_idv_address_choose_usps - click_on t('idv.buttons.mail.send') - - expect(current_path).to eq verify_review_path - expect(page).to_not have_content t('idv.messages.phone.phone_of_record') - - fill_in :user_password, with: user_password - - expect { click_continue }. - to change { UspsConfirmation.count }.from(0).to(1) - - expect(current_path).to eq verify_confirmations_path - click_acknowledge_personal_key - - user.reload - - expect(user.events.account_verified.size).to be(0) - expect(user.profiles.count).to eq 1 - - profile = user.profiles.first - - expect(profile.active?).to eq false - expect(profile.deactivation_reason).to eq 'verification_pending' - expect(profile.phone_confirmed).to eq false - - usps_confirmation_entry = UspsConfirmation.last.decrypted_entry - - expect(current_path).to eq(verify_come_back_later_path) - - if sp == :saml - expect(page).to have_link(t('idv.buttons.continue_plain')) - expect(usps_confirmation_entry.issuer). - to eq('https://rp1.serviceprovider.com/auth/saml/metadata') - elsif sp == :oidc - expect(page).to have_link(t('idv.buttons.continue_plain')) - expect(usps_confirmation_entry.issuer). - to eq('urn:gov:gsa:openidconnect:sp:server') - end - end -end From 1e366f44aeb283dad657c9bc6ff6722fdf34f509 Mon Sep 17 00:00:00 2001 From: David Corwin Date: Wed, 25 Apr 2018 08:38:42 -0700 Subject: [PATCH 02/19] Decouple Proofer gem from IdP (#2116) **WHY** To make it easier to make changes to the proofer logic **HOW** - Isolate all references to the gem to Idv::Agent - Push proofer specific logic to Idv::Agent --- .reek | 1 + app/controllers/verify/phone_controller.rb | 5 +- app/controllers/verify/sessions_controller.rb | 5 +- app/jobs/idv/phone_job.rb | 15 -- app/jobs/idv/profile_job.rb | 66 ------ app/jobs/idv/proofer_job.rb | 39 +--- app/services/idv/agent.rb | 56 ++++- app/services/idv/profile_maker.rb | 5 +- app/services/idv/session.rb | 6 +- app/services/idv/submit_idv_job.rb | 18 +- app/services/idv/vendor_result.rb | 2 +- .../verify/confirmations_controller_spec.rb | 10 +- .../verify/phone_controller_spec.rb | 3 +- .../verify/sessions_controller_spec.rb | 2 +- spec/features/idv/state_id_data_spec.rb | 8 +- spec/jobs/idv/phone_job_spec.rb | 84 ------- spec/jobs/idv/profile_job_spec.rb | 139 ------------ spec/jobs/idv/proofer_job_spec.rb | 93 ++++++++ spec/rails_helper.rb | 6 +- spec/services/idv/agent_spec.rb | 205 ++++++++++++++++++ spec/services/idv/phone_step_spec.rb | 2 +- spec/services/idv/profile_maker_spec.rb | 4 +- spec/services/idv/profile_step_spec.rb | 2 +- spec/services/idv/submit_idv_job_spec.rb | 41 +--- spec/services/idv/vendor_result_spec.rb | 11 +- .../vendor_validator_result_storage_spec.rb | 2 +- spec/support/controller_helper.rb | 2 +- spec/support/idv/proofer_job_helper.rb | 8 - spec/support/idv_examples/failed_idv_job.rb | 12 +- 29 files changed, 417 insertions(+), 435 deletions(-) delete mode 100644 app/jobs/idv/phone_job.rb delete mode 100644 app/jobs/idv/profile_job.rb delete mode 100644 spec/jobs/idv/phone_job_spec.rb delete mode 100644 spec/jobs/idv/profile_job_spec.rb create mode 100644 spec/jobs/idv/proofer_job_spec.rb create mode 100644 spec/services/idv/agent_spec.rb delete mode 100644 spec/support/idv/proofer_job_helper.rb diff --git a/.reek b/.reek index 7c0a066a100..185a27f7ef0 100644 --- a/.reek +++ b/.reek @@ -5,6 +5,7 @@ ControlParameter: - CustomDeviseFailureApp#i18n_message - OpenidConnectRedirector#initialize - NoRetryJobs#call + - Idv::Agent#proof_one DuplicateMethodCall: exclude: - ApplicationController#disable_caching diff --git a/app/controllers/verify/phone_controller.rb b/app/controllers/verify/phone_controller.rb index 970ff7b0fbf..d04c6ca6090 100644 --- a/app/controllers/verify/phone_controller.rb +++ b/app/controllers/verify/phone_controller.rb @@ -55,8 +55,9 @@ def phone_confirmation_required? def submit_idv_job Idv::SubmitIdvJob.new( idv_session: idv_session, - vendor_params: idv_session.params[:phone] - ).submit_phone_job + vendor_params: { phone: idv_session.params[:phone] }, + stages: [:phone] + ).submit end def step_name diff --git a/app/controllers/verify/sessions_controller.rb b/app/controllers/verify/sessions_controller.rb index 909d7e4b8e8..b263328062e 100644 --- a/app/controllers/verify/sessions_controller.rb +++ b/app/controllers/verify/sessions_controller.rb @@ -52,8 +52,9 @@ def destroy def submit_idv_job Idv::SubmitIdvJob.new( - idv_session: idv_session, vendor_params: idv_session.vendor_params - ).submit_profile_job + idv_session: idv_session, vendor_params: idv_session.vendor_params, + stages: %i[profile state_id] + ).submit end def confirm_step_needed diff --git a/app/jobs/idv/phone_job.rb b/app/jobs/idv/phone_job.rb deleted file mode 100644 index 101084bdfc3..00000000000 --- a/app/jobs/idv/phone_job.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Idv - class PhoneJob < ProoferJob - def verify_identity_with_vendor - confirmation = agent.submit_phone(vendor_params) - result = extract_result(confirmation) - store_result(result) - end - - private - - def vendor - Figaro.env.phone_proofing_vendor.to_sym - end - end -end diff --git a/app/jobs/idv/profile_job.rb b/app/jobs/idv/profile_job.rb deleted file mode 100644 index bee7e03b7b3..00000000000 --- a/app/jobs/idv/profile_job.rb +++ /dev/null @@ -1,66 +0,0 @@ -module Idv - class ProfileJob < ProoferJob - attr_accessor :resolution, :confirmation - - def verify_identity_with_vendor - self.resolution = resolution_agent.start(vendor_params_hash) - - return build_and_store_result unless resolution.success? - - self.confirmation = confirmation_agent.submit_state_id(state_id_vendor_params) - build_and_store_result - end - - private - - def confirmation_agent - Idv::Agent.new( - vendor: Figaro.env.state_id_proofing_vendor.to_sym, - applicant: applicant - ) - end - - def resolution_agent - Idv::Agent.new( - vendor: Figaro.env.profile_proofing_vendor.to_sym, - applicant: applicant - ) - end - - def result_errors - resolution_errors = resolution.errors - confirmation_errors = confirmation&.errors || {} - resolution_errors.merge(confirmation_errors) - end - - def result_reasons - resolution_reasons = resolution.vendor_resp.reasons - confirmation_reasons = confirmation&.vendor_resp&.reasons || [] - resolution_reasons + confirmation_reasons - end - - def result_successful? - return confirmation.success? if confirmation.present? - resolution.success? - end - - def state_id_vendor_params - vendor_params_hash.merge(state_id_jurisdiction: vendor_params_hash[:state]) - end - - def build_and_store_result - resolution_vendor_resp = resolution.vendor_resp - result = Idv::VendorResult.new( - success: result_successful?, - errors: result_errors, - reasons: result_reasons, - normalized_applicant: resolution_vendor_resp.normalized_applicant - ) - store_result(result) - end - - def vendor_params_hash - vendor_params.with_indifferent_access - end - end -end diff --git a/app/jobs/idv/proofer_job.rb b/app/jobs/idv/proofer_job.rb index b0d6865db85..18e921974da 100644 --- a/app/jobs/idv/proofer_job.rb +++ b/app/jobs/idv/proofer_job.rb @@ -2,47 +2,30 @@ module Idv class ProoferJob < ApplicationJob queue_as :idv - attr_reader :result_id, :applicant, :vendor_params + attr_reader :result_id, :applicant, :stages - def perform(result_id:, vendor_params:, applicant_json:) + def perform(result_id:, applicant_json:, stages:) @result_id = result_id - @vendor_params = vendor_params - @applicant = applicant_from_json(applicant_json) - perform_identity_proofing - end - - def verify_identity_with_vendor - raise NotImplementedError, "subclass must implement #{__method__}" + @applicant = from_json(applicant_json) + @stages = from_json(stages).map(&:to_sym) + verify_identity_with_vendor end private - def agent - Idv::Agent.new(applicant: applicant, vendor: vendor) - end - - def applicant_from_json(applicant_json) - applicant_attributes = JSON.parse(applicant_json, symbolize_names: true) - Proofer::Applicant.new(applicant_attributes) + def from_json(applicant_json) + JSON.parse(applicant_json, symbolize_names: true) end - def perform_identity_proofing - verify_identity_with_vendor + def verify_identity_with_vendor + agent = Idv::Agent.new(applicant) + result = agent.proof(*stages) + store_result(Idv::VendorResult.new(result)) rescue StandardError store_failed_job_result raise end - def extract_result(confirmation) - vendor_resp = confirmation.vendor_resp - - Idv::VendorResult.new( - success: confirmation.success?, - errors: confirmation.errors, - reasons: vendor_resp.reasons - ) - end - def store_failed_job_result job_failed_result = Idv::VendorResult.new(errors: { job_failed: true }) VendorValidatorResultStorage.new.store(result_id: result_id, result: job_failed_result) diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb index 7a755992443..f6da1e38404 100644 --- a/app/services/idv/agent.rb +++ b/app/services/idv/agent.rb @@ -1,13 +1,61 @@ module Idv class Agent - delegate :vendor, :start, :submit_phone, :submit_state_id, to: :agent + class << self + def proofer_attribute?(key) + Proofer::Applicant.method_defined?(key) + end + end + + def initialize(applicant) + @applicant = Proofer::Applicant.new(applicant) + end + + def proof(*stages) + results = { errors: {}, normalized_applicant: {}, reasons: [], success: false } + + stages.each do |stage| + proofer_result = proof_one(stage) + results = merge_results(results, proofer_result) + break unless proofer_result.success? + end + + results + end + + def proof_one(stage) + applicant_hash = @applicant.to_hash.with_indifferent_access - def initialize(vendor:, applicant:) - self.agent = Proofer::Agent.new(applicant: applicant, vendor: vendor, kbv: false) + case stage + + when :phone + get_agent(:phone_proofing_vendor).submit_phone(@applicant.phone) + + when :profile + get_agent(:profile_proofing_vendor).start(applicant_hash) + + when :state_id + get_agent(:state_id_proofing_vendor). + submit_state_id(applicant_hash.merge(state_id_jurisdiction: @applicant.state)) + end end private - attr_accessor :agent + def merge_results(results, proofer_result) + vr = proofer_result.vendor_resp + + normalized_applicant = vr.try(:normalized_applicant) || {} + + { + errors: results[:errors].merge(proofer_result.errors), + normalized_applicant: results[:normalized_applicant].merge(normalized_applicant), + reasons: results[:reasons] + vr.reasons, + success: proofer_result.success?, + } + end + + def get_agent(vendor) + Proofer::Agent.new(applicant: @applicant, vendor: Figaro.env.send(vendor).to_sym, kbv: false) + end end end diff --git a/app/services/idv/profile_maker.rb b/app/services/idv/profile_maker.rb index d919337d3b3..d469df7f8f8 100644 --- a/app/services/idv/profile_maker.rb +++ b/app/services/idv/profile_maker.rb @@ -3,7 +3,10 @@ class ProfileMaker attr_reader :pii_attributes def initialize(applicant:, user:, normalized_applicant:, phone_confirmed:) - self.pii_attributes = pii_from_applicant(applicant, normalized_applicant) + self.pii_attributes = pii_from_applicant( + OpenStruct.new(applicant), + OpenStruct.new(normalized_applicant) + ) self.user = user self.phone_confirmed = phone_confirmed end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 74d567bd796..8e6af73b0dd 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -126,7 +126,7 @@ def session end def applicant_params - params.select { |key, _value| Proofer::Applicant.method_defined?(key) } + params.select { |key, _value| Idv::Agent.proofer_attribute?(key) } end def applicant_params_ascii @@ -135,8 +135,8 @@ def applicant_params_ascii def profile_maker @_profile_maker ||= Idv::ProfileMaker.new( - applicant: Proofer::Applicant.new(applicant_params), - normalized_applicant: Proofer::Applicant.new(normalized_applicant_params), + applicant: applicant_params, + normalized_applicant: normalized_applicant_params, phone_confirmed: vendor_phone_confirmation || false, user: current_user ) diff --git a/app/services/idv/submit_idv_job.rb b/app/services/idv/submit_idv_job.rb index d686bda4b9a..dc31941ded5 100644 --- a/app/services/idv/submit_idv_job.rb +++ b/app/services/idv/submit_idv_job.rb @@ -1,29 +1,25 @@ module Idv class SubmitIdvJob - def initialize(idv_session:, vendor_params:) + def initialize(idv_session:, vendor_params:, stages:) @idv_session = idv_session @vendor_params = vendor_params + @stages = stages end - def submit_profile_job + def submit update_idv_session - ProfileJob.perform_later(proofer_job_params) - end - - def submit_phone_job - update_idv_session - PhoneJob.perform_later(proofer_job_params) + ProoferJob.perform_later(proofer_job_params) end private - attr_reader :idv_session, :vendor_params + attr_reader :idv_session, :vendor_params, :stages def proofer_job_params { result_id: result_id, - vendor_params: vendor_params, - applicant_json: idv_session.applicant.to_json, + applicant_json: idv_session.applicant.merge(vendor_params).to_json, + stages: stages.to_json, } end diff --git a/app/services/idv/vendor_result.rb b/app/services/idv/vendor_result.rb index f8457614dfb..79af9f26412 100644 --- a/app/services/idv/vendor_result.rb +++ b/app/services/idv/vendor_result.rb @@ -6,7 +6,7 @@ def self.new_from_json(json) parsed = JSON.parse(json, symbolize_names: true) applicant = parsed[:normalized_applicant] - parsed[:normalized_applicant] = Proofer::Applicant.new(applicant) if applicant + parsed[:normalized_applicant] = applicant if applicant new(**parsed) end diff --git a/spec/controllers/verify/confirmations_controller_spec.rb b/spec/controllers/verify/confirmations_controller_spec.rb index ea2b7368f57..cc1a4c6f183 100644 --- a/spec/controllers/verify/confirmations_controller_spec.rb +++ b/spec/controllers/verify/confirmations_controller_spec.rb @@ -13,7 +13,7 @@ def stub_idv_session ) idv_session.applicant = idv_session.vendor_params idv_session.normalized_applicant_params = { first_name: 'Somebody' } - idv_session.resolution_successful = resolution.success? + idv_session.resolution_successful = true user.unlock_user_access_key(password) profile_maker = Idv::ProfileMaker.new( applicant: applicant, @@ -32,7 +32,7 @@ def stub_idv_session let(:password) { 'sekrit phrase' } let(:user) { create(:user, :signed_up, password: password) } let(:applicant) do - Proofer::Applicant.new( + { first_name: 'Some', last_name: 'One', address1: '123 Any St', @@ -40,11 +40,9 @@ def stub_idv_session city: 'Anywhere', state: 'KS', zipcode: '66666' - ) + } end - let(:normalized_applicant) { Proofer::Applicant.new first_name: 'Somebody' } - let(:agent) { Proofer::Agent.new vendor: :mock } - let(:resolution) { agent.start applicant } + let(:normalized_applicant) { { first_name: 'Somebody' } } let(:profile) { subject.idv_session.profile } describe 'before_actions' do diff --git a/spec/controllers/verify/phone_controller_spec.rb b/spec/controllers/verify/phone_controller_spec.rb index e312a0505a6..a808712bd4e 100644 --- a/spec/controllers/verify/phone_controller_spec.rb +++ b/spec/controllers/verify/phone_controller_spec.rb @@ -272,7 +272,8 @@ expect(Idv::SubmitIdvJob).to receive(:new).with( idv_session: subject.idv_session, - vendor_params: normalized_phone + vendor_params: { phone: normalized_phone }, + stages: [:phone] ).and_call_original put :create, params: { idv_phone_form: { phone: good_phone } } diff --git a/spec/controllers/verify/sessions_controller_spec.rb b/spec/controllers/verify/sessions_controller_spec.rb index 8dd0024b187..10e40d18b73 100644 --- a/spec/controllers/verify/sessions_controller_spec.rb +++ b/spec/controllers/verify/sessions_controller_spec.rb @@ -30,7 +30,7 @@ let(:idv_session) do Idv::Session.new(user_session: subject.user_session, current_user: user, issuer: nil) end - let(:normalized_applicant) { Proofer::Applicant.new(user_attrs) } + let(:normalized_applicant) { user_attrs } describe 'before_actions' do it 'includes before_actions from AccountStateChecker' do diff --git a/spec/features/idv/state_id_data_spec.rb b/spec/features/idv/state_id_data_spec.rb index 821ee0468fc..b501dc0fb32 100644 --- a/spec/features/idv/state_id_data_spec.rb +++ b/spec/features/idv/state_id_data_spec.rb @@ -18,8 +18,8 @@ end it 'renders an error for blank state id number and does not submit a job', :email do - expect(Idv::ProfileJob).to_not receive(:perform_now) - expect(Idv::ProfileJob).to_not receive(:perform_later) + expect(Idv::ProoferJob).to_not receive(:perform_now) + expect(Idv::ProoferJob).to_not receive(:perform_later) fill_in :profile_state_id_number, with: '' click_idv_continue @@ -29,8 +29,8 @@ end it 'renders an error for unsupported jurisdiction and does not submit a job', :email do - expect(Idv::ProfileJob).to_not receive(:perform_now) - expect(Idv::ProfileJob).to_not receive(:perform_later) + expect(Idv::ProoferJob).to_not receive(:perform_now) + expect(Idv::ProoferJob).to_not receive(:perform_later) select 'Alabama', from: 'profile_state' click_idv_continue diff --git a/spec/jobs/idv/phone_job_spec.rb b/spec/jobs/idv/phone_job_spec.rb deleted file mode 100644 index 72cfd474887..00000000000 --- a/spec/jobs/idv/phone_job_spec.rb +++ /dev/null @@ -1,84 +0,0 @@ -require 'rails_helper' - -describe Idv::PhoneJob do - include ProoferJobHelper - - describe '#perform' do - let(:result_id) { SecureRandom.uuid } - let(:applicant_json) { { first_name: 'Jean-Luc', last_name: 'Picard' }.to_json } - let(:vendor_params) { '5555550000' } - - context 'when verification succeeds' do - it 'should save a successful result' do - Idv::PhoneJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json, - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(true) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.reasons).to eq(['Good number']) - expect(result.errors).to eq({}) - end - end - - context 'when verification fails' do - let(:vendor_params) { '5555555555' } - - it 'should save an unsuccessful result' do - Idv::PhoneJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json, - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.reasons).to eq(['Bad number']) - expect(result.errors).to eq(phone: 'The phone number could not be verified.') - end - end - - context 'when the idv agent raises' do - before do - agent = instance_double(Idv::Agent) - allow(agent).to receive(:submit_phone).and_raise(RuntimeError, '🔥🔥🔥') - allow(Idv::Agent).to receive(:new).and_return(agent) - end - - it 'should rescue from errors and save a failed job result' do - expect do - Idv::PhoneJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json, - ) - end.to raise_error(RuntimeError, '🔥🔥🔥') - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(true) - end - end - - it 'selects the proofer vendor based on the config' do - mock_proofer_job_agent(config: :phone_proofing_vendor, vendor: 'fancy_vendor') - - Idv::PhoneJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json, - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(Idv::Agent).to have_received(:new).with(hash_including(vendor: :fancy_vendor)) - expect(result).to be_a(Idv::VendorResult) - end - end -end diff --git a/spec/jobs/idv/profile_job_spec.rb b/spec/jobs/idv/profile_job_spec.rb deleted file mode 100644 index 082f4b35629..00000000000 --- a/spec/jobs/idv/profile_job_spec.rb +++ /dev/null @@ -1,139 +0,0 @@ -require 'rails_helper' - -describe Idv::ProfileJob do - include ProoferJobHelper - - describe '#perform' do - let(:result_id) { SecureRandom.uuid } - let(:applicant_json) { { first_name: 'Jean-Luc', last_name: 'Picard' }.to_json } - let(:vendor_params) do - { - dob: '07/13/2035', - state: 'VA', - state_id_number: '123456789', - state_id_type: 'drivers_license', - } - end - - it 'uses the state vendor params as the state id jurisdiction' do - agent = Idv::Agent.new(vendor: :mock, applicant: {}) - allow(Idv::Agent).to receive(:new).and_return(agent).twice - - expect(agent).to receive(:submit_state_id). - with(hash_including(state_id_jurisdiction: vendor_params[:state])). - and_call_original - - Idv::ProfileJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result).to be_a(Idv::VendorResult) - expect(result.success?).to eq(true) - end - - context 'when resolution and state id confirmation succeed' do - it 'should save a successful result' do - Idv::ProfileJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(true) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.normalized_applicant.first_name).to eq('JEAN-LUC') - expect(result.normalized_applicant.last_name).to eq('PICARD') - expect(result.reasons).to eq(['Everything looks good', 'valid state ID']) - expect(result.errors).to eq({}) - end - end - - context 'when resolution fails' do - it 'should save an unsuccessful result and not call state id proofer' do - applicant = Proofer::Applicant.new(first_name: 'Bad') - agent = Idv::Agent.new(vendor: :mock, applicant: applicant) - allow(agent).to receive(:start).and_call_original - allow(agent).to receive(:submit_state_id).and_call_original - allow(Idv::Agent).to receive(:new).and_return(agent).twice - - Idv::ProfileJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.reasons).to eq(['The name was suspicious']) - expect(result.errors).to eq(first_name: 'Unverified first name.') - expect(agent).to have_received(:start) - expect(agent).to_not have_received(:submit_state_id) - end - end - - context 'when state id confirmation fails' do - let(:vendor_params) { super().merge(state_id_number: '000000000') } - - it 'saves an unsuccessful result' do - Idv::ProfileJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.reasons).to eq(['Everything looks good', 'invalid state id number']) - expect(result.errors).to eq(state_id_number: 'The state ID number could not be verified') - end - end - - context 'when the idv agent raises' do - before do - agent = instance_double(Idv::Agent) - allow(agent).to receive(:start).and_raise(RuntimeError, '🔥🔥🔥') - allow(Idv::Agent).to receive(:new).and_return(agent) - end - - it 'should rescue from errors and save a failed job result' do - expect do - Idv::ProfileJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json - ) - end.to raise_error(RuntimeError, '🔥🔥🔥') - result = VendorValidatorResultStorage.new.load(result_id) - - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(true) - end - end - - it 'selects the vendors based on the config' do - mock_proofer_job_agent(config: :profile_proofing_vendor, vendor: 'fancy_vendor') - mock_proofer_job_agent(config: :state_id_proofing_vendor, vendor: 'fancier_vendor') - - Idv::ProfileJob.perform_now( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant_json - ) - result = VendorValidatorResultStorage.new.load(result_id) - - expect(Idv::Agent).to have_received(:new).with(hash_including(vendor: :fancy_vendor)) - expect(Idv::Agent).to have_received(:new).with(hash_including(vendor: :fancier_vendor)) - expect(result).to be_a(Idv::VendorResult) - end - end -end diff --git a/spec/jobs/idv/proofer_job_spec.rb b/spec/jobs/idv/proofer_job_spec.rb new file mode 100644 index 00000000000..3a42248b9e5 --- /dev/null +++ b/spec/jobs/idv/proofer_job_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +describe Idv::ProoferJob do + describe '#perform' do + let(:result_id) { SecureRandom.uuid } + let(:applicant) { { first_name: 'Jean-Luc', last_name: 'Picard' } } + let(:stages) { %i[phone] } + let(:agent) { instance_double(Idv::Agent) } + let(:proofer_results) { { } } + + before do + allow(agent).to receive(:proof).and_return(proofer_results) + allow(Idv::Agent).to receive(:new).and_return(agent) + end + + subject do + Idv::ProoferJob.perform_now( + result_id: result_id, + applicant_json: applicant.to_json, + stages: stages.to_json + ) + end + + shared_examples 'a proofer job' do + it 'uses the Idv::Agent' do + subject + + expect(Idv::Agent).to have_received(:new).with(applicant) + expect(agent).to have_received(:proof).with(*stages) + end + end + + context 'when verification succeeds' do + + let(:proofer_results) { { success: true, reasons: ['a reason'] } } + + it_behaves_like 'a proofer job' + + it 'should save a successful result' do + subject + + result = VendorValidatorResultStorage.new.load(result_id) + + expect(result.success?).to eq(true) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(false) + expect(result.reasons).to eq(['a reason']) + expect(result.errors).to eq({}) + end + end + + context 'when verification fails' do + + let(:proofer_results) do + { + success: false, + reasons: ['Bad number'], + errors: { phone: 'The phone number could not be verified.' } + } + end + + it_behaves_like 'a proofer job' + + it 'should save an unsuccessful result' do + subject + + result = VendorValidatorResultStorage.new.load(result_id) + + expect(result.success?).to eq(false) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(false) + expect(result.reasons).to eq(['Bad number']) + expect(result.errors).to eq(phone: 'The phone number could not be verified.') + end + end + + context 'when the idv agent raises' do + before do + allow(agent).to receive(:proof).and_raise(RuntimeError, '🔥🔥🔥') + end + + it 'should rescue from errors and save a failed job result' do + expect { subject }.to raise_error(RuntimeError, '🔥🔥🔥') + + result = VendorValidatorResultStorage.new.load(result_id) + + expect(result.success?).to eq(false) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(true) + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 13e7c12f2c6..550fe7b0291 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -60,10 +60,8 @@ end config.before(:each, idv_job: true) do - [Idv::ProfileJob, Idv::PhoneJob].each do |job_class| - allow(job_class).to receive(:perform_later) do |*args| - job_class.perform_now(*args) - end + allow(Idv::ProoferJob).to receive(:perform_later) do |*args| + Idv::ProoferJob.perform_now(*args) end end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb new file mode 100644 index 00000000000..2d2ef7f7ec5 --- /dev/null +++ b/spec/services/idv/agent_spec.rb @@ -0,0 +1,205 @@ +require 'rails_helper' +require 'ostruct' + +describe Idv::Agent do + describe '.proofer_attribute?' do + it 'returns whether the attribute is available in Proofer::Applicant' do + key = 'foobarbaz' + expect(Proofer::Applicant).to receive(:method_defined?).with(key) + Idv::Agent.proofer_attribute?(key) + end + end + + describe 'instance' do + + let(:applicant) { { } } + + let(:agent) { Idv::Agent.new(applicant) } + + describe '#get_agent' do + let(:vendor_key) { :some_vendor_key } + let(:vendor) { 'some_vendor' } + let(:foo) { double } + + it 'returns a new Proofer::Agent with the correct data' do + allow(Figaro.env).to receive(vendor_key).and_return(vendor) + expect(Proofer::Agent).to receive(:new).with( + applicant: agent.instance_variable_get(:@applicant), + vendor: vendor.to_sym, + kbv: false + ).and_return(foo) + + result = agent.send(:get_agent, vendor_key) + + expect(result).to eq(foo) + end + end + + describe '#merge_results' do + let(:orig_results) do + { + errors: { foo: 'bar', bar: 'baz' }, + normalized_applicant: {}, + reasons: ['reason 1'], + success: true, + } + end + + let(:new_result) do + Proofer::Resolution.new( + vendor_resp: OpenStruct.new( + normalized_applicant: { first_name: 'Homer' }, + reasons: ['reason 2'] + ), + errors: { foo: 'blarg', baz: 'foo' }, + success: false, + ) + end + + let(:merged_results) { agent.send(:merge_results, orig_results, new_result) } + + it 'merges errors' do + expect(merged_results[:errors]).to eq(orig_results[:errors].merge(new_result.errors)) + end + + it 'concatenates reasons' do + expect(merged_results[:reasons]).to eq(orig_results[:reasons] + new_result.vendor_resp.reasons) + end + + it 'merges normalized applicant' do + expect(merged_results[:normalized_applicant]).to eq(new_result.vendor_resp.normalized_applicant) + end + + it 'keeps the last success' do + expect(merged_results[:success]).to eq(false) + end + end + + describe '#proof_one' do + let(:applicant) { { phone: '1112223333', state: 'WA' } } + + before do + proofer_agent = instance_double('Proofer::Agent') + expect(proofer_agent).to receive(method).with(data) + expect(agent).to receive(:get_agent).with(vendor_key).and_return(proofer_agent) + end + + context ':phone stage' do + let(:stage) { :phone } + let(:method) { :submit_phone } + let(:vendor_key) { :phone_proofing_vendor } + let(:data) { applicant[:phone] } + + it 'gets the agent for the `:phone_proofing_vendor` and calls `submit_phone`' do + agent.proof_one(stage) + end + end + + context ':profile stage' do + let(:stage) { :profile } + let(:method) { :start } + let(:vendor_key) { :profile_proofing_vendor } + let(:data) { applicant } + + it 'gets the agent for the `:profile_proofing_vendor` and calls `start`' do + agent.proof_one(stage) + end + end + + context ':state_id stage' do + let(:stage) { :state_id } + let(:method) { :submit_state_id } + let(:vendor_key) { :state_id_proofing_vendor } + let(:data) { applicant.merge(state_id_jurisdiction: applicant[:state]) } + + it 'gets the agent for the `:state_id_proofing_vendor` and calls `submit_state_id`' do + agent.proof_one(stage) + end + end + end + + describe '#proof' do + let(:profile_resolution) do + Proofer::Resolution.new( + vendor_resp: OpenStruct.new( + normalized_applicant: { first_name: 'Homer' }, + reasons: ['reason 1'] + ), + success: true + ) + end + let(:state_id_resolution) do + Proofer::Resolution.new( + vendor_resp: OpenStruct.new( + normalized_applicant: { }, + reasons: ['reason 2'] + ), + success: true + ) + end + let(:failed_resolution) do + Proofer::Resolution.new( + vendor_resp: OpenStruct.new( + normalized_applicant: { }, + reasons: ['bah humbug'] + ), + success: false, + errors: { + bad: 'stuff' + } + ) + end + + before do + allow(agent).to receive(:proof_one) do |stage| + case stage + when :profile + profile_resolution + when :state_id + state_id_resolution + when :failed + failed_resolution + end + end + end + + context 'when all stages succeed' do + + let(:stages) { [:profile, :state_id] } + + it 'calls #proof_one for each stage and returns merged results' do + stages.each do |stage| + expect(agent).to receive(:proof_one).with(stage) + end + + results = agent.proof(*stages) + + expect(results).to eq({ + errors: {}, + normalized_applicant: profile_resolution.vendor_resp.normalized_applicant, + reasons: profile_resolution.vendor_resp.reasons + state_id_resolution.vendor_resp.reasons, + success: true + }) + end + end + + context 'when the fist stage fails' do + let(:stages) { [:failed, :state_id] } + + it 'calls #proof_one only for the first stage and returns merged results' do + expect(agent).to receive(:proof_one).with(stages.first) + expect(agent).not_to receive(:proof_one).with(stages.second) + + results = agent.proof(*stages) + + expect(results).to eq({ + errors: failed_resolution.errors, + normalized_applicant: {}, + reasons: failed_resolution.vendor_resp.reasons, + success: false + }) + end + end + end + end +end diff --git a/spec/services/idv/phone_step_spec.rb b/spec/services/idv/phone_step_spec.rb index 65e084ad2e0..abe81f35ffe 100644 --- a/spec/services/idv/phone_step_spec.rb +++ b/spec/services/idv/phone_step_spec.rb @@ -6,7 +6,7 @@ let(:user) { build(:user) } let(:idv_session) do idvs = Idv::Session.new(user_session: {}, current_user: user, issuer: nil) - idvs.applicant = Proofer::Applicant.new first_name: 'Some' + idvs.applicant = { first_name: 'Some' } idvs end let(:idv_form_params) { { phone: '555-555-0000', phone_confirmed_at: nil } } diff --git a/spec/services/idv/profile_maker_spec.rb b/spec/services/idv/profile_maker_spec.rb index 9fe23b1eae3..7775f528eae 100644 --- a/spec/services/idv/profile_maker_spec.rb +++ b/spec/services/idv/profile_maker_spec.rb @@ -3,8 +3,8 @@ describe Idv::ProfileMaker do describe '#save_profile' do it 'creates Profile with encrypted PII' do - applicant = Proofer::Applicant.new first_name: 'Some', last_name: 'One' - normalized_applicant = Proofer::Applicant.new first_name: 'Somebody', last_name: 'Oneatatime' + applicant = { first_name: 'Some', last_name: 'One' } + normalized_applicant = { first_name: 'Somebody', last_name: 'Oneatatime' } user = create(:user, :signed_up) user.unlock_user_access_key(user.password) diff --git a/spec/services/idv/profile_step_spec.rb b/spec/services/idv/profile_step_spec.rb index cc7655d0599..fa08d81f218 100644 --- a/spec/services/idv/profile_step_spec.rb +++ b/spec/services/idv/profile_step_spec.rb @@ -43,7 +43,7 @@ def build_step(params, vendor_validator_result) success: true, errors: {}, reasons: reasons, - normalized_applicant: Proofer::Applicant.new(first_name: 'Some') + normalized_applicant: { first_name: 'Some' } ) ) diff --git a/spec/services/idv/submit_idv_job_spec.rb b/spec/services/idv/submit_idv_job_spec.rb index 7d4530bc8d3..90be6cd10f1 100644 --- a/spec/services/idv/submit_idv_job_spec.rb +++ b/spec/services/idv/submit_idv_job_spec.rb @@ -4,7 +4,8 @@ subject(:service) do Idv::SubmitIdvJob.new( idv_session: idv_session, - vendor_params: vendor_params + vendor_params: vendor_params, + stages: stages ) end @@ -21,17 +22,18 @@ end let(:user) { build(:user) } - let(:applicant) { Proofer::Applicant.new(first_name: 'Greatest') } + let(:applicant) { { first_name: 'Greatest' } } let(:result_id) { 'abcdef' } let(:vendor_params) { { dob: '01/01/1985' } } + let(:stages) { %i[profile] } - describe '#submit_profile_job' do - it 'generates a UUID and enqueues a Idv::ProfileJob and saves the UUID in the session' do - expect(Idv::ProfileJob).to receive(:perform_later). + describe '#submit' do + it 'generates a UUID and enqueues a Idv::ProoferJob and saves the UUID in the session' do + expect(Idv::ProoferJob).to receive(:perform_later). with( result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant.to_json + applicant_json: applicant.merge(vendor_params).to_json, + stages: stages.to_json ) expect(idv_session.async_result_id).to eq(nil) @@ -39,30 +41,7 @@ expect(SecureRandom).to receive(:uuid).and_return(result_id).once - service.submit_profile_job - - expect(idv_session.async_result_id).to eq(result_id) - expect(idv_session.async_result_started_at).to be_within(1).of(Time.zone.now.to_i) - end - end - - describe '#submit_phone_job' do - let(:vendor_params) { '5555550000' } - - it 'generates a UUID and enqueues a Idv::PhoneJob and saves the UUID in the session' do - expect(Idv::PhoneJob).to receive(:perform_later). - with( - result_id: result_id, - vendor_params: vendor_params, - applicant_json: applicant.to_json - ) - - expect(idv_session.async_result_id).to eq(nil) - expect(idv_session.async_result_started_at).to eq(nil) - - expect(SecureRandom).to receive(:uuid).and_return(result_id).once - - service.submit_phone_job + service.submit expect(idv_session.async_result_id).to eq(result_id) expect(idv_session.async_result_started_at).to be_within(1).of(Time.zone.now.to_i) diff --git a/spec/services/idv/vendor_result_spec.rb b/spec/services/idv/vendor_result_spec.rb index 7d31b33dea1..ca065bb6f64 100644 --- a/spec/services/idv/vendor_result_spec.rb +++ b/spec/services/idv/vendor_result_spec.rb @@ -4,12 +4,7 @@ let(:success) { true } let(:errors) { { foo: ['is not valid'] } } let(:reasons) { %w[foo bar baz] } - let(:normalized_applicant) do - Proofer::Applicant.new( - last_name: 'Ever', - first_name: 'Greatest' - ) - end + let(:normalized_applicant) { { last_name: 'Ever', first_name: 'Greatest' } } let(:timed_out) { false } subject(:vendor_result) do @@ -39,7 +34,7 @@ json = vendor_result.to_json parsed = JSON.parse(json, symbolize_names: true) - expect(parsed[:normalized_applicant][:last_name]).to eq(normalized_applicant.last_name) + expect(parsed[:normalized_applicant][:last_name]).to eq(normalized_applicant[:last_name]) end end @@ -53,7 +48,7 @@ end it 'turns applicant into a full object' do - expect(new_from_json.normalized_applicant.last_name).to eq(normalized_applicant.last_name) + expect(new_from_json.normalized_applicant[:last_name]).to eq(normalized_applicant[:last_name]) end context 'without an applicant' do diff --git a/spec/services/vendor_validator_result_storage_spec.rb b/spec/services/vendor_validator_result_storage_spec.rb index 3ba45b313a9..7261080a27c 100644 --- a/spec/services/vendor_validator_result_storage_spec.rb +++ b/spec/services/vendor_validator_result_storage_spec.rb @@ -7,7 +7,7 @@ let(:original_result) do Idv::VendorResult.new( success: false, - normalized_applicant: Proofer::Applicant.new(first_name: 'First') + normalized_applicant: { first_name: 'First' } ) end diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index 0d111dddfd5..88544ad25b7 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -36,7 +36,7 @@ def stub_verify_steps_one_and_two(user) user_session = {} stub_sign_in(user) idv_session = Idv::Session.new(user_session: user_session, current_user: user, issuer: nil) - idv_session.applicant = Proofer::Applicant.new first_name: 'Some', last_name: 'One' + idv_session.applicant = { first_name: 'Some', last_name: 'One' } allow(subject).to receive(:confirm_idv_session_started).and_return(true) allow(subject).to receive(:confirm_idv_attempts_allowed).and_return(true) allow(subject).to receive(:idv_session).and_return(idv_session) diff --git a/spec/support/idv/proofer_job_helper.rb b/spec/support/idv/proofer_job_helper.rb deleted file mode 100644 index 9005a160d1c..00000000000 --- a/spec/support/idv/proofer_job_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -module ProoferJobHelper - def mock_proofer_job_agent(config:, vendor:) - allow(Figaro.env).to receive(config).and_return(vendor) - - agent = Idv::Agent.new(applicant: Proofer::Applicant.new({}), vendor: :mock) - allow(Idv::Agent).to receive(:new).and_return(agent) - end -end diff --git a/spec/support/idv_examples/failed_idv_job.rb b/spec/support/idv_examples/failed_idv_job.rb index dae5c935c0c..f7d8a02d6c3 100644 --- a/spec/support/idv_examples/failed_idv_job.rb +++ b/spec/support/idv_examples/failed_idv_job.rb @@ -1,8 +1,5 @@ shared_examples 'failed idv job' do |step| - let(:idv_job_class) do - return Idv::ProfileJob if step == :profile - return Idv::PhoneJob if step == :phone - end + let(:idv_job_class) { Idv::ProoferJob } let(:step_locale_key) do return :sessions if step == :profile step @@ -85,12 +82,7 @@ end def stub_idv_job_to_raise_error_in_background(idv_job_class) - allow(idv_job_class).to receive(:new).and_wrap_original do |new, *args| - idv_job = new.call(*args) - allow(idv_job).to receive(:verify_identity_with_vendor). - and_raise('this is a test error') - idv_job - end + allow(Idv::Agent).to receive(:new).and_raise('this is a test error') allow(idv_job_class).to receive(:perform_now).and_wrap_original do |perform_now, *args| begin perform_now.call(*args) From 88cc90c74875502c2d13a5bc8e5776623df290b8 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 25 Apr 2018 13:57:25 -0400 Subject: [PATCH 03/19] LG-209 Delete ahoy_visit cookie after sign out **Why**: To tie each `visit_id` to a session. This will make it easier to see what each user did during each session. Previously, the `visit_id` would only change if the user was inactive for 8 minutes. So, if the user signed in, then signed out, then signed back in, all of those events in between would have the same `visit_id`. --- app/controllers/application_controller.rb | 5 +++++ .../openid_connect/authorization_controller.rb | 3 ++- spec/controllers/application_controller_spec.rb | 8 ++++++++ .../openid_connect/authorization_controller_spec.rb | 9 ++++++--- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index edf015031bc..27a4cd2ea96 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -70,6 +70,11 @@ def default_url_options { locale: locale_url_param, host: Figaro.env.domain_name } end + def sign_out + request.cookie_jar.delete('ahoy_visit') + super + end + private # These attributes show up in New Relic traces for all requests. diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index 9351664c872..0b0038093a5 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -36,7 +36,8 @@ def profile_or_identity_needs_verification? end def track_authorize_analytics(result) - analytics_attributes = result.to_h.except(:redirect_uri) + analytics_attributes = result.to_h.except(:redirect_uri). + merge(user_fully_authenticated: user_fully_authenticated?) analytics.track_event( Analytics::OPENID_CONNECT_REQUEST_AUTHORIZATION, analytics_attributes diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 490bb5793ce..f6284e64ebf 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -255,4 +255,12 @@ def index end end end + + describe '#sign_out' do + it 'deletes the ahoy_visit cookie when signing out' do + expect(request.cookie_jar).to receive(:delete).with('ahoy_visit') + + subject.sign_out + end + end end diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index 75339bb93f0..59bb08f235c 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -44,7 +44,8 @@ with(Analytics::OPENID_CONNECT_REQUEST_AUTHORIZATION, success: true, client_id: client_id, - errors: {}) + errors: {}, + user_fully_authenticated: true) action end @@ -128,7 +129,8 @@ with(Analytics::OPENID_CONNECT_REQUEST_AUTHORIZATION, success: false, client_id: client_id, - errors: hash_including(:prompt)) + errors: hash_including(:prompt), + user_fully_authenticated: true) action end @@ -148,7 +150,8 @@ with(Analytics::OPENID_CONNECT_REQUEST_AUTHORIZATION, success: false, client_id: nil, - errors: hash_including(:client_id)) + errors: hash_including(:client_id), + user_fully_authenticated: true) action end From 5f12db6e954b2afd35fe3e99b41b80a59a9f92d8 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 26 Apr 2018 23:16:19 -0400 Subject: [PATCH 04/19] Remove temporary mailer **Why**: We no longer need it. Emails were sent earlier today. --- app/mailers/user_mailer.rb | 4 --- .../user_mailer/reset_password.html.slim | 32 ------------------- 2 files changed, 36 deletions(-) delete mode 100644 app/views/user_mailer/reset_password.html.slim diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 9d432473f5b..1f41ae6dee3 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -25,8 +25,4 @@ def account_does_not_exist(email, request_id) @sign_up_email_url = sign_up_email_url(request_id: request_id, locale: locale_url_param) mail(to: email, subject: t('user_mailer.account_does_not_exist.subject')) end - - def reset_password(email) - mail(to: email, subject: 'Please reset your password') - end end diff --git a/app/views/user_mailer/reset_password.html.slim b/app/views/user_mailer/reset_password.html.slim deleted file mode 100644 index aebc044cc3b..00000000000 --- a/app/views/user_mailer/reset_password.html.slim +++ /dev/null @@ -1,32 +0,0 @@ -p.lead == "During a login.gov security review, we found that your account \ - password was the same as your email address. For your security, \ - we have disabled your old password and you must now reset your \ - password. Although we did not see any suspicious activity on your \ - account, we disabled your password to be extra careful." - -p.lead == "To continue using your login.gov account, please reset your password \ - using the link below. Please note that login.gov needs your password to \ - be different from your email address." - -table.button.expanded.large.radius - tbody - tr - td - table - tbody - tr - td - center - = link_to t('mailer.reset_password.link_text'), - forgot_password_url, - target: '_blank', class: 'float-center', align: 'center' - td.expander - -p - = link_to forgot_password_url, forgot_password_url, target: '_blank' - -table.spacer - tbody - tr - td.s10 height="10px" - |   From 3dd6ade14ec39a87224a1ccccf34d06b505fbe6e Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Thu, 26 Apr 2018 23:32:47 -0400 Subject: [PATCH 05/19] LG-225 Remove Agency Based UUID feature flags to prevent reverting to old SP UUIDs **Why**: To prevent reverting to old sp based uuids which would break agencies. This feature gave us the flexibility to turn the agency based uuids on for slow roll or rollback in the event of failures. However once the agencies migrated to the new uuid structure and the rollout is error free it should not be turned off again because users would then be associated with the wrong uuids. **How**: Assume every logic path for the two feature flags is now true and remove unneccessary code in addition to removing the actual feature flags. --- .../concerns/saml_idp_logout_concern.rb | 6 +-- app/forms/openid_connect_logout_form.rb | 6 +-- app/models/agency_identity.rb | 4 -- .../openid_connect_user_info_presenter.rb | 6 +-- app/services/agency_identity_linker.rb | 11 ++-- app/services/attribute_asserter.rb | 6 +-- app/services/identity_linker.rb | 2 +- config/application.yml.example | 6 --- config/initializers/figaro.rb | 1 - lib/feature_management.rb | 8 --- .../openid_connect/openid_connect_spec.rb | 5 -- spec/features/saml/loa1_sso_spec.rb | 13 ----- spec/features/saml/sp_initiated_slo_spec.rb | 1 - spec/forms/openid_connect_logout_form_spec.rb | 12 ----- spec/lib/feature_management_spec.rb | 54 ------------------- spec/models/agency_identity_spec.rb | 14 ----- spec/services/agency_identity_linker_spec.rb | 2 - spec/services/link_agency_identities_spec.rb | 2 - spec/support/idv_examples/sp_handoff.rb | 2 - .../idv_examples/sp_requested_attributes.rb | 2 - 20 files changed, 9 insertions(+), 154 deletions(-) diff --git a/app/controllers/concerns/saml_idp_logout_concern.rb b/app/controllers/concerns/saml_idp_logout_concern.rb index 3354e6996bc..2737d4d01e2 100644 --- a/app/controllers/concerns/saml_idp_logout_concern.rb +++ b/app/controllers/concerns/saml_idp_logout_concern.rb @@ -49,11 +49,7 @@ def name_id_user def sp_slo_identity @_sp_slo_identity ||= begin - if FeatureManagement.enable_agency_based_uuids? - AgencyIdentityLinker.sp_identity_from_uuid(name_id) - else - Identity.includes(:user).find_by(uuid: name_id) - end + AgencyIdentityLinker.sp_identity_from_uuid(name_id) end end diff --git a/app/forms/openid_connect_logout_form.rb b/app/forms/openid_connect_logout_form.rb index 2a22a83f781..225d412ee3a 100644 --- a/app/forms/openid_connect_logout_form.rb +++ b/app/forms/openid_connect_logout_form.rb @@ -55,11 +55,7 @@ def load_identity def identity_from_payload(payload) uuid = payload[:sub] sp = payload[:aud] - if FeatureManagement.enable_agency_based_uuids? - AgencyIdentityLinker.sp_identity_from_uuid_and_sp(uuid, sp) - else - Identity.where(uuid: uuid, service_provider: sp).first - end + AgencyIdentityLinker.sp_identity_from_uuid_and_sp(uuid, sp) end def build_openid_connect_redirector diff --git a/app/models/agency_identity.rb b/app/models/agency_identity.rb index fee7a835973..b36d11bf973 100644 --- a/app/models/agency_identity.rb +++ b/app/models/agency_identity.rb @@ -2,8 +2,4 @@ class AgencyIdentity < ApplicationRecord belongs_to :user belongs_to :agency validates :uuid, presence: true - - def agency_enabled? - !FeatureManagement.agencies_with_agency_based_uuids.index(agency_id).nil? - end end diff --git a/app/presenters/openid_connect_user_info_presenter.rb b/app/presenters/openid_connect_user_info_presenter.rb index e229c6a9114..95ee6b49932 100644 --- a/app/presenters/openid_connect_user_info_presenter.rb +++ b/app/presenters/openid_connect_user_info_presenter.rb @@ -21,11 +21,7 @@ def user_info private def uuid_from_sp_identity(identity) - if FeatureManagement.enable_agency_based_uuids? - AgencyIdentityLinker.new(identity).link_identity.uuid - else - identity.uuid - end + AgencyIdentityLinker.new(identity).link_identity.uuid end # rubocop:disable Metrics/AbcSize diff --git a/app/services/agency_identity_linker.rb b/app/services/agency_identity_linker.rb index 78f74147c43..18ac76844a4 100644 --- a/app/services/agency_identity_linker.rb +++ b/app/services/agency_identity_linker.rb @@ -5,14 +5,13 @@ def initialize(sp_identity) end def link_identity - ai = find_or_create_agency_identity - return ai if ai&.agency_enabled? - AgencyIdentity.new(user_id: @sp_identity.user_id, uuid: @sp_identity.uuid) + find_or_create_agency_identity || + AgencyIdentity.new(user_id: @sp_identity.user_id, uuid: @sp_identity.uuid) end def self.sp_identity_from_uuid_and_sp(uuid, service_provider) ai = AgencyIdentity.where(uuid: uuid).first - criteria = if ai&.agency_enabled? + criteria = if ai { user_id: ai.user_id, service_provider: service_provider } else { uuid: uuid, service_provider: service_provider } @@ -31,9 +30,7 @@ def self.sp_identity_from_uuid(uuid) private def find_or_create_agency_identity - ai = agency_identity - return ai if ai - create_agency_identity_for_sp + agency_identity || create_agency_identity_for_sp end def create_agency_identity_for_sp diff --git a/app/services/attribute_asserter.rb b/app/services/attribute_asserter.rb index a286c97d87d..5cad70e2a7c 100644 --- a/app/services/attribute_asserter.rb +++ b/app/services/attribute_asserter.rb @@ -53,11 +53,7 @@ def add_bundle(attrs) def uuid_getter_function lambda do |principal| identity = principal.decorate.active_identity_for(service_provider) - if FeatureManagement.enable_agency_based_uuids? - AgencyIdentityLinker.new(identity).link_identity.uuid - else - identity.uuid - end + AgencyIdentityLinker.new(identity).link_identity.uuid end end diff --git a/app/services/identity_linker.rb b/app/services/identity_linker.rb index 6c9a0416a17..afffdd037c9 100644 --- a/app/services/identity_linker.rb +++ b/app/services/identity_linker.rb @@ -9,7 +9,7 @@ def initialize(user, provider) def link_identity(**extra_attrs) attributes = merged_attributes(extra_attrs) identity.update!(attributes) - AgencyIdentityLinker.new(identity).link_identity if FeatureManagement.enable_agency_based_uuids? + AgencyIdentityLinker.new(identity).link_identity identity end diff --git a/config/application.yml.example b/config/application.yml.example index 6529c9f3d7d..31bd097b1ce 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -63,7 +63,6 @@ development: aamva_public_key: '123abc' aamva_private_key: '123abc' aamva_verification_url: 'https://example.org:12345/verification/url' - agencies_with_agency_based_uuids: '1,2,3,4,5' async_job_refresh_interval_seconds: '5' async_job_refresh_max_wait_seconds: '15' attribute_cost: '4000$8$4$' # SCrypt::Engine.calibrate(max_time: 0.5) @@ -87,7 +86,6 @@ development: database_timeout: '5000' database_username: '' domain_name: 'localhost:3000' - enable_agency_based_uuids: 'true' enable_identity_verification: 'true' enable_rate_limiting: 'false' enable_test_routes: 'true' @@ -161,7 +159,6 @@ production: aamva_public_key: # Base64 encoded public key for AAMVA aamva_private_key: # Base64 encoded private key for AAMVA aamva_verification_url: # DLDV Verification URL - agencies_with_agency_based_uuids: '1,2,3,4,5' async_job_refresh_interval_seconds: '5' async_job_refresh_max_wait_seconds: '15' attribute_cost: '4000$8$4$' # SCrypt::Engine.calibrate(max_time: 0.5) @@ -176,7 +173,6 @@ production: disable_email_sending: 'false' dashboard_api_token: domain_name: 'login.gov' - enable_agency_based_uuids: 'true' enable_identity_verification: 'false' enable_rate_limiting: 'true' enable_test_routes: 'false' @@ -248,7 +244,6 @@ test: aamva_public_key: '123abc' aamva_private_key: '123abc' aamva_verification_url: 'https://example.org:12345/verification/url' - agencies_with_agency_based_uuids: '1,2,3,4,5' async_job_refresh_interval_seconds: '1' async_job_refresh_max_wait_seconds: '15' attribute_cost: '800$8$1$' # SCrypt::Engine.calibrate(max_time: 0.01) @@ -271,7 +266,6 @@ test: database_timeout: '5000' database_username: '' dashboard_api_token: '123ABC' - enable_agency_based_uuids: 'true' enable_identity_verification: 'true' enable_rate_limiting: 'true' enable_test_routes: 'true' diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index 3d648127799..f1b96a97a04 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -4,7 +4,6 @@ 'attribute_cost', 'attribute_encryption_key', 'domain_name', - 'enable_agency_based_uuids', 'enable_identity_verification', 'enable_rate_limiting', 'enable_test_routes', diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 3ab08793df4..1e299d4a06e 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -68,14 +68,6 @@ def self.no_pii_mode? enable_identity_verification? && Figaro.env.profile_proofing_vendor == :mock end - def self.enable_agency_based_uuids? - Figaro.env.enable_agency_based_uuids == 'true' - end - - def self.agencies_with_agency_based_uuids - (Figaro.env.agencies_with_agency_based_uuids || '').split(',').map(&:to_i) - end - def self.enable_saml_cert_rotation? Figaro.env.saml_secret_rotation_enabled == 'true' end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 0d4fce612c3..778fea3ed32 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -8,11 +8,6 @@ oidc_end_client_secret_jwt(prompt: 'select_account') end - it 'succeeds with new agency based uuids' do - allow(FeatureManagement).to receive(:enable_agency_based_uuids?).and_return(true) - oidc_end_client_secret_jwt(prompt: 'select_account') - end - it 'succeeds in returning back to sp with prompt select_account and prior session' do user = oidc_end_client_secret_jwt(prompt: 'select_account') oidc_end_client_secret_jwt(prompt: 'select_account', user: user, redirs_to: '/auth/result') diff --git a/spec/features/saml/loa1_sso_spec.rb b/spec/features/saml/loa1_sso_spec.rb index 087eb5f24f5..dc593198337 100644 --- a/spec/features/saml/loa1_sso_spec.rb +++ b/spec/features/saml/loa1_sso_spec.rb @@ -175,19 +175,6 @@ end end - context 'fully signed up user is signed in with email/pwd and new agency based uuids' do - it 'prompts to enter OTP' do - allow(FeatureManagement).to receive(:enable_agency_based_uuids?).and_return(true) - user = create(:user, :signed_up) - sign_in_user(user) - - saml_authn_request = auth_request.create(saml_settings) - visit saml_authn_request - - expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') - end - end - context 'user that has not yet set up 2FA is signed in with email and password only' do it 'prompts to set up 2FA' do sign_in_user diff --git a/spec/features/saml/sp_initiated_slo_spec.rb b/spec/features/saml/sp_initiated_slo_spec.rb index c6bf4107a24..187c999de90 100644 --- a/spec/features/saml/sp_initiated_slo_spec.rb +++ b/spec/features/saml/sp_initiated_slo_spec.rb @@ -88,7 +88,6 @@ let(:user) { create(:user, :signed_up) } before do - allow(FeatureManagement).to receive(:enable_agency_based_uuids?).and_return(true) sign_in_and_2fa_user(user) visit sp1_authnrequest diff --git a/spec/forms/openid_connect_logout_form_spec.rb b/spec/forms/openid_connect_logout_form_spec.rb index 642943d8f96..1a385df42ed 100644 --- a/spec/forms/openid_connect_logout_form_spec.rb +++ b/spec/forms/openid_connect_logout_form_spec.rb @@ -45,11 +45,6 @@ it 'has a successful response' do expect(result).to be_success end - - it 'has a successful response when agency based uuids are enabled' do - allow(FeatureManagement).to receive(:enable_agency_based_uuids?).and_return(true) - expect(result).to be_success - end end context 'with an invalid form' do @@ -118,13 +113,6 @@ expect(form.errors[:id_token_hint]). to include(t('openid_connect.logout.errors.id_token_hint')) end - - it 'is not valid when agency based uuids are enabled' do - allow(FeatureManagement).to receive(:enable_agency_based_uuids?).and_return(true) - expect(valid?).to eq(false) - expect(form.errors[:id_token_hint]). - to include(t('openid_connect.logout.errors.id_token_hint')) - end end context 'with an expired, but otherwise valid id_token_hint' do diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index d23db1d1b81..68e2b83d086 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -226,58 +226,4 @@ end end end - - describe '#enable_agency_based_uuids?' do - context 'when enabled' do - before do - allow(Figaro.env).to receive(:enable_agency_based_uuids).and_return('true') - end - - it 'enables the feature' do - expect(FeatureManagement.enable_agency_based_uuids?).to eq(true) - end - end - - context 'when disabled' do - before do - allow(Figaro.env).to receive(:enable_agency_based_uuids).and_return('false') - end - - it 'disables the feature' do - expect(FeatureManagement.enable_agency_based_uuids?).to eq(false) - end - end - end - - describe 'agencies_with_agency_based_uuids' do - context 'when multiple agencies are enabled' do - before do - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1,2,3') - end - - it 'it returns an array of agencies' do - expect(FeatureManagement.agencies_with_agency_based_uuids).to eq([1, 2, 3]) - end - end - - context 'when one agency is enabled' do - before do - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1') - end - - it 'returns an array containing a single agency' do - expect(FeatureManagement.agencies_with_agency_based_uuids).to eq([1]) - end - end - - context 'when blank' do - before do - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('') - end - - it 'returns an empty array' do - expect(FeatureManagement.agencies_with_agency_based_uuids).to eq([]) - end - end - end end diff --git a/spec/models/agency_identity_spec.rb b/spec/models/agency_identity_spec.rb index f3383a614a2..36f0974cf49 100644 --- a/spec/models/agency_identity_spec.rb +++ b/spec/models/agency_identity_spec.rb @@ -9,18 +9,4 @@ it { is_expected.to validate_presence_of(:uuid) } end - - describe '#agency_enabled?' do - it 'returns true if the agency is enabled' do - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1') - ai = AgencyIdentity.new(agency_id: 1, user_id: 1, uuid: 'UUID1') - expect(ai.agency_enabled?).to eq(true) - end - - it 'returns false if the agency is disabled' do - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('') - ai = AgencyIdentity.new(agency_id: 1, user_id: 1, uuid: 'UUID1') - expect(ai.agency_enabled?).to eq(false) - end - end end diff --git a/spec/services/agency_identity_linker_spec.rb b/spec/services/agency_identity_linker_spec.rb index a10950b7a15..1f99627e550 100644 --- a/spec/services/agency_identity_linker_spec.rb +++ b/spec/services/agency_identity_linker_spec.rb @@ -106,8 +106,6 @@ end def init_env(user) - allow(Figaro.env).to receive(:enable_agency_based_uuids).and_return('true') - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1,2,3') Identity.where(user_id: user.id).delete_all AgencyIdentity.where(user_id: user.id).delete_all end diff --git a/spec/services/link_agency_identities_spec.rb b/spec/services/link_agency_identities_spec.rb index 4b5b928d40b..0567bc90c76 100644 --- a/spec/services/link_agency_identities_spec.rb +++ b/spec/services/link_agency_identities_spec.rb @@ -78,8 +78,6 @@ end def init_env(user) - allow(Figaro.env).to receive(:enable_agency_based_uuids).and_return('true') - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1,2,3') AgencySeeder.new(rails_env: Rails.env, deploy_env: Rails.env).run Identity.where(user_id: user.id).delete_all AgencyIdentity.where(user_id: user.id).delete_all diff --git a/spec/support/idv_examples/sp_handoff.rb b/spec/support/idv_examples/sp_handoff.rb index b8f5d608796..de1dca459ce 100644 --- a/spec/support/idv_examples/sp_handoff.rb +++ b/spec/support/idv_examples/sp_handoff.rb @@ -3,8 +3,6 @@ include IdvHelper before do - allow(Figaro.env).to receive(:enable_agency_based_uuids).and_return('true') - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1,2,3') allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) end diff --git a/spec/support/idv_examples/sp_requested_attributes.rb b/spec/support/idv_examples/sp_requested_attributes.rb index 2c4710edb17..230ccd59396 100644 --- a/spec/support/idv_examples/sp_requested_attributes.rb +++ b/spec/support/idv_examples/sp_requested_attributes.rb @@ -3,8 +3,6 @@ include IdvHelper before do - allow(Figaro.env).to receive(:enable_agency_based_uuids).and_return('true') - allow(Figaro.env).to receive(:agencies_with_agency_based_uuids).and_return('1,2,3') allow(FeatureManagement).to receive(:prefill_otp_codes?).and_return(true) end From 57df6f3e7951ac484e436ebf74dc4fc7b9a7b76b Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 27 Apr 2018 16:41:49 -0400 Subject: [PATCH 06/19] LG-223 Stop ahoy gem from excluding events in non-prod **Why**: Events being suppressed in integration environments make it difficult to troubleshoot agency integration issues since ahoy identifies certain agency POSTs as bots. **How**: Check if the agency integration dashboard is enabled and do not let ahoy exclude the request. --- config/initializers/ahoy.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/initializers/ahoy.rb b/config/initializers/ahoy.rb index 25a77515846..3b97e468389 100644 --- a/config/initializers/ahoy.rb +++ b/config/initializers/ahoy.rb @@ -20,6 +20,7 @@ def track_event(data) def exclude? return if FeatureManagement.enable_load_testing_mode? + return if FeatureManagement.use_dashboard_service_providers? super end From 0401ada13b9aa20a67707aa0e0010d80f9cb9654 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 27 Apr 2018 11:37:44 -0400 Subject: [PATCH 07/19] LG-226 Fix Double Render Error with Bad Saml Packet on Logout **Why**: This error and a stack trace appear in the logs and make it difficult to diagnose saml logout issues with agency integration. Moreover we throw a 500 error when the saml idp gem is trying to return a 403 / forbidden error. **How**: Check the return value of the gem's validation of the saml packet and return from the concern. Then check if there was a valid render in the controller. --- .../concerns/saml_idp_logout_concern.rb | 1 - app/controllers/saml_idp_controller.rb | 24 ++++++++++++++++--- spec/controllers/saml_idp_controller_spec.rb | 23 ++++++++++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/saml_idp_logout_concern.rb b/app/controllers/concerns/saml_idp_logout_concern.rb index 3354e6996bc..daba381c4fc 100644 --- a/app/controllers/concerns/saml_idp_logout_concern.rb +++ b/app/controllers/concerns/saml_idp_logout_concern.rb @@ -85,7 +85,6 @@ def prepare_saml_logout_response end def prepare_saml_logout_request - validate_saml_request return if slo_session[:logout_response] # store originating SP's logout response in the user session # for final step in SLO diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 70390920203..de8e4b4f80f 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -11,6 +11,7 @@ class SamlIdpController < ApplicationController include VerifySPAttributesConcern skip_before_action :verify_authenticity_token + before_action :validate_saml_logout_request, only: :logout def auth return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? @@ -26,7 +27,6 @@ def metadata end def logout - track_logout_event prepare_saml_logout_response_and_request return handle_saml_logout_response if slo.successful_saml_response? @@ -38,6 +38,21 @@ def logout private + def validate_saml_logout_request(raw_saml_request = params[:SAMLRequest]) + request_valid = saml_request_valid?(raw_saml_request) + + track_logout_event(request_valid) + return unless raw_saml_request + + head :bad_request unless request_valid + end + + def saml_request_valid?(saml_request) + return false unless saml_request + decode_request(saml_request) + valid_saml_request? + end + def saml_metadata if SamlCertRotationManager.use_new_secrets_for_request?(request) cert_rotation_saml_metadata @@ -96,11 +111,14 @@ def render_template_for(message, action_url, type) ) end - def track_logout_event + def track_logout_event(saml_request_valid) + saml_request = params[:SAMLRequest] result = { - sp_initiated: params[:SAMLRequest].present?, + sp_initiated: saml_request.present?, oidc: false, } + result[:saml_request_valid] = saml_request_valid + analytics.track_event(Analytics::LOGOUT_INITIATED, result) end end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 267542dc792..fd6b2f6da55 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -19,7 +19,7 @@ it 'tracks the event when idp-initiated' do stub_analytics - result = { sp_initiated: false, oidc: false } + result = { sp_initiated: false, oidc: false, saml_request_valid: false } expect(@analytics).to receive(:track_event).with(Analytics::LOGOUT_INITIATED, result) @@ -29,7 +29,16 @@ it 'tracks the event when sp-initiated' do allow(controller).to receive(:saml_request).and_return(FakeSamlRequest.new) stub_analytics - result = { sp_initiated: true, oidc: false } + result = { sp_initiated: true, oidc: false, saml_request_valid: true } + + expect(@analytics).to receive(:track_event).with(Analytics::LOGOUT_INITIATED, result) + + delete :logout, params: { SAMLRequest: 'foo' } + end + + it 'tracks the event when sp-initiated and the saml request is not valid' do + stub_analytics + result = { sp_initiated: true, oidc: false, saml_request_valid: false } expect(@analytics).to receive(:track_event).with(Analytics::LOGOUT_INITIATED, result) @@ -38,6 +47,16 @@ end describe 'POST /api/saml/logout' do + context 'when there is an invalid SAML packet' do + let(:user) { create(:user, :signed_up) } + + it 'responds with "400 Bad Request"' do + sign_in user + + post :logout, params: { SAMLRequest: 'foo' } + expect(response.status).to eq(400) + end + end context 'when SAML response is not successful' do let(:user) { create(:user, :signed_up) } From 17130437760d155b3385c30c7fe7048d0cebaf90 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Fri, 27 Apr 2018 14:17:28 -0400 Subject: [PATCH 08/19] LG-228 Make migrations safer and more resilient **Why**: We recently ran into an issue while deploying RC 56 to production due to a migration needing more time than allowed by our `statement_timeout`. **How**: - Add the `strong_migrations` gem, which will check your migrations for unsafe usage and best practices. Read more here: https://github.com/ankane/strong_migrations - Allow configuring the statement_timeout in `database.yml` via Figaro - Allow overriding the statement_timeout via an ENV var for migrations. Code shamelessly copied from the Slowpoke gem: https://github.com/ankane/slowpoke - Add a `deploy/migrate` script so that it is maintained in this repo as opposed to the devops repo. --- Gemfile | 1 + Gemfile.lock | 3 +++ config/application.yml.example | 3 +++ config/database.yml | 2 +- config/initializers/figaro.rb | 1 + config/initializers/safe_migrations.rb | 5 +++++ deploy/migrate | 24 +++++++++++++++++++++++ lib/deploy/migration_statement_timeout.rb | 13 ++++++++++++ spec/spec_helper.rb | 1 + 9 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 config/initializers/safe_migrations.rb create mode 100755 deploy/migrate create mode 100644 lib/deploy/migration_statement_timeout.rb diff --git a/Gemfile b/Gemfile index 5c4f6c99b9e..fe4f173dda4 100644 --- a/Gemfile +++ b/Gemfile @@ -85,6 +85,7 @@ group :development, :test do gem 'pry-byebug' gem 'rspec-rails', '~> 3.5.2' gem 'slim_lint' + gem 'strong_migrations' gem 'thin' end diff --git a/Gemfile.lock b/Gemfile.lock index 63a407d0453..b761b4dc7a6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -579,6 +579,8 @@ GEM activesupport (>= 4.0) sprockets (>= 3.0.0) stringex (2.8.4) + strong_migrations (0.2.2) + activerecord (>= 3.2.0) sysexits (1.2.0) systemu (2.6.5) temple (0.8.0) @@ -744,6 +746,7 @@ DEPENDENCIES slim-rails slim_lint stringex + strong_migrations thin timecop twilio-ruby diff --git a/config/application.yml.example b/config/application.yml.example index 6529c9f3d7d..d13de03f3ea 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -84,6 +84,7 @@ development: database_pool_worker: '5' database_readonly_password: '' database_readonly_username: '' + database_statement_timeout: '2500' database_timeout: '5000' database_username: '' domain_name: 'localhost:3000' @@ -175,6 +176,7 @@ production: basic_auth_password: disable_email_sending: 'false' dashboard_api_token: + database_statement_timeout: '2500' domain_name: 'login.gov' enable_agency_based_uuids: 'true' enable_identity_verification: 'false' @@ -268,6 +270,7 @@ test: database_pool_worker: database_readonly_password: '' database_readonly_username: '' + database_statement_timeout: '2500' database_timeout: '5000' database_username: '' dashboard_api_token: '123ABC' diff --git a/config/database.yml b/config/database.yml index 2466c51ed14..906a3d49d5b 100644 --- a/config/database.yml +++ b/config/database.yml @@ -21,7 +21,7 @@ defaults: &defaults checkout_timeout: 5 reaping_frequency: 10 variables: - statement_timeout: 2500 # ms + statement_timeout: <%= Figaro.env.database_statement_timeout.to_i %> development: <<: *defaults diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index 3d648127799..cccc252c8ea 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -3,6 +3,7 @@ Figaro.require_keys( 'attribute_cost', 'attribute_encryption_key', + 'database_statement_timeout', 'domain_name', 'enable_agency_based_uuids', 'enable_identity_verification', diff --git a/config/initializers/safe_migrations.rb b/config/initializers/safe_migrations.rb new file mode 100644 index 00000000000..2be30e06c41 --- /dev/null +++ b/config/initializers/safe_migrations.rb @@ -0,0 +1,5 @@ +require 'deploy/migration_statement_timeout' + +ActiveSupport.on_load(:active_record) do + ActiveRecord::Migration.prepend(Deploy::MigrationStatementTimeout) +end diff --git a/deploy/migrate b/deploy/migrate new file mode 100755 index 00000000000..c2d2b17e5f3 --- /dev/null +++ b/deploy/migrate @@ -0,0 +1,24 @@ +#!/bin/bash + +# This script is called by identity-devops cookbooks as part of the deployment +# process. It runs any pending migrations. + +set -euo pipefail + +echo "deploy/migrate starting" +echo "HOME: ${HOME-}" +cd "$(dirname "$0")/.." + +set -x + +id +which bundle + +export RAILS_ENV=production +export MIGRATION_STATEMENT_TIMEOUT=60000 + +bundle exec rake db:create db:migrate db:seed --trace + +set +x + +echo "deploy/migrate finished" diff --git a/lib/deploy/migration_statement_timeout.rb b/lib/deploy/migration_statement_timeout.rb new file mode 100644 index 00000000000..055d33d61e5 --- /dev/null +++ b/lib/deploy/migration_statement_timeout.rb @@ -0,0 +1,13 @@ +module Deploy + module MigrationStatementTimeout + def connection + connection = super + new_statement_timeout = ENV['MIGRATION_STATEMENT_TIMEOUT'] + if new_statement_timeout && !@migration_statement_timeout_set + connection.execute("SET statement_timeout = #{new_statement_timeout.to_i}") + @migration_statement_timeout_set = true + end + connection + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 320d249d6a6..f331654de53 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,7 @@ add_filter '/config/' add_filter '/lib/rspec/formatters/user_flow_formatter.rb' add_filter '/lib/user_flow_exporter.rb' + add_filter '/lib/deploy/migration_statement_timeout.rb' end end From 541bac510e5a7b95b494462a4c7b8bca39a70106 Mon Sep 17 00:00:00 2001 From: David Corwin Date: Tue, 1 May 2018 14:54:03 -0700 Subject: [PATCH 09/19] Fix flaky `remember_device_spec.rb` example (#2132) **WHY** The "remember device" logic requires the user to remember the device again whenever the user changes their phone number. This is determined by confirming that the phone confirmation time is AFTER the create time of the remember device cookie. In the spec, the phone number change MAY effectively occur at the same time as the initial remembering of the device, so the "remember device" logic sees the cookie as valid and does not perform the expected redirect. **HOW** Sleep for 1 second before changing the phone number. --- spec/support/shared_examples/remember_device.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/support/shared_examples/remember_device.rb b/spec/support/shared_examples/remember_device.rb index 8d1a65da850..d45f13b64e3 100644 --- a/spec/support/shared_examples/remember_device.rb +++ b/spec/support/shared_examples/remember_device.rb @@ -19,6 +19,9 @@ it 'requires 2FA on sign in after phone number is changed' do user = remember_device_and_sign_out_user + # Ensure that at least 1 second has passed since last `remember device` + sleep(1) + sign_in_user(user) visit manage_phone_path fill_in 'user_phone_form_phone', with: '5551230000' From 664c2d4dfe0c00cad31af35564241746882ada40 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Tue, 1 May 2018 22:47:07 -0400 Subject: [PATCH 10/19] LG-203 Make MFA event properties consistent **Why**: The name of the property used to track the type of multi-factor authentication used (SMS, Voice, TOTP, Personal Key) was inconsistent. For Voice and SMS, it was `method`, and `multi_factor_auth_method` for the other two. **How**: Use `multi_factor_auth_method` for all four. --- .../otp_verification_controller.rb | 2 +- .../personal_key_verification_controller.rb | 9 +-------- .../otp_verification_controller_spec.rb | 18 +++++++++--------- ...ersonal_key_verification_controller_spec.rb | 2 +- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 5f18c713cfe..71ae6bbf3fe 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -53,7 +53,7 @@ def form_params def analytics_properties { context: context, - method: params[:otp_delivery_preference], + multi_factor_auth_method: params[:otp_delivery_preference], confirmation_for_phone_change: confirmation_for_phone_change?, } end diff --git a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb index f074da2535e..8811fcaf4ed 100644 --- a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb +++ b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb @@ -6,7 +6,7 @@ class PersonalKeyVerificationController < ApplicationController def show analytics.track_event( - Analytics::MULTI_FACTOR_AUTH_ENTER_PERSONAL_KEY_VISIT, analytics_properties + Analytics::MULTI_FACTOR_AUTH_ENTER_PERSONAL_KEY_VISIT, context: context ) @personal_key_form = PersonalKeyForm.new(current_user) @@ -66,12 +66,5 @@ def handle_valid_otp redirect_to manage_personal_key_url reset_otp_session_data end - - def analytics_properties - { - context: context, - method: 'personal key', - } - end end end diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index 3ab52e3d4e0..0080eab374f 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -39,7 +39,7 @@ stub_analytics analytics_hash = { context: 'authentication', - method: 'sms', + multi_factor_auth_method: 'sms', confirmation_for_phone_change: false, } @@ -79,7 +79,7 @@ errors: {}, confirmation_for_phone_change: false, context: 'authentication', - method: 'sms', + multi_factor_auth_method: 'sms', } stub_analytics @@ -125,7 +125,7 @@ errors: {}, confirmation_for_phone_change: false, context: 'authentication', - method: 'sms', + multi_factor_auth_method: 'sms', } stub_analytics @@ -169,7 +169,7 @@ errors: {}, confirmation_for_phone_change: false, context: 'authentication', - method: 'sms', + multi_factor_auth_method: 'sms', } stub_analytics @@ -298,7 +298,7 @@ errors: {}, confirmation_for_phone_change: true, context: 'confirmation', - method: 'sms', + multi_factor_auth_method: 'sms', } expect(@analytics).to have_received(:track_event). @@ -340,7 +340,7 @@ errors: {}, confirmation_for_phone_change: true, context: 'confirmation', - method: 'sms', + multi_factor_auth_method: 'sms', } expect(@analytics).to have_received(:track_event). @@ -376,7 +376,7 @@ success: true, errors: {}, context: 'confirmation', - method: 'sms', + multi_factor_auth_method: 'sms', confirmation_for_phone_change: false, } @@ -467,7 +467,7 @@ errors: {}, confirmation_for_phone_change: false, context: 'idv', - method: 'sms', + multi_factor_auth_method: 'sms', } expect(@analytics).to have_received(:track_event). @@ -533,7 +533,7 @@ errors: {}, confirmation_for_phone_change: false, context: 'idv', - method: 'sms', + multi_factor_auth_method: 'sms', } expect(@analytics).to have_received(:track_event). diff --git a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb index c91467a1e3b..3615ee49fb1 100644 --- a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb @@ -18,7 +18,7 @@ it 'tracks the page visit' do stub_sign_in_before_2fa stub_analytics - analytics_hash = { context: 'authentication', method: 'personal key' } + analytics_hash = { context: 'authentication' } expect(@analytics).to receive(:track_event). with(Analytics::MULTI_FACTOR_AUTH_ENTER_PERSONAL_KEY_VISIT, analytics_hash) From b5735187a5007bb5805d3135ac39f94e0ddcff6b Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 2 May 2018 15:55:28 -0400 Subject: [PATCH 11/19] LG-232 Localize password changed event text **Why**: So that the proper text can appear in the Account History --- config/locales/event_types/en.yml | 1 + config/locales/event_types/es.yml | 1 + config/locales/event_types/fr.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/config/locales/event_types/en.yml b/config/locales/event_types/en.yml index 069c67f6bf9..b8838140c9b 100644 --- a/config/locales/event_types/en.yml +++ b/config/locales/event_types/en.yml @@ -9,6 +9,7 @@ en: authenticator_enabled: Authenticator app enabled eastern_timestamp: "%{timestamp} (Eastern)" email_changed: Email address changed + password_changed: Password changed phone_changed: Phone number changed phone_confirmed: Phone confirmed usps_mail_sent: Letter sent diff --git a/config/locales/event_types/es.yml b/config/locales/event_types/es.yml index 337608d843e..469ef35c4f8 100644 --- a/config/locales/event_types/es.yml +++ b/config/locales/event_types/es.yml @@ -9,6 +9,7 @@ es: authenticator_enabled: App de autenticación permitido eastern_timestamp: "%{timestamp} (hora del Este)" email_changed: Email cambiado + password_changed: Contraseña cambiada phone_changed: Número de teléfono cambiado phone_confirmed: Teléfono confirmado usps_mail_sent: Carta enviada diff --git a/config/locales/event_types/fr.yml b/config/locales/event_types/fr.yml index ae42f75efce..871b417199a 100644 --- a/config/locales/event_types/fr.yml +++ b/config/locales/event_types/fr.yml @@ -9,6 +9,7 @@ fr: authenticator_enabled: Application d'authentification activée eastern_timestamp: "%{timestamp} (Eastern)" email_changed: Adresse courriel modifiée + password_changed: Mot de passe modifié phone_changed: Numéro de téléphone modifié phone_confirmed: Numéro de téléphone confirmé usps_mail_sent: Lettre envoyée From 868ee8667d6c7728345fcfeb464af2b1dbc073db Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Tue, 1 May 2018 22:17:55 -0400 Subject: [PATCH 12/19] LG-227 Improve OIDC token code error message **Why**: Some agency developers have been sending requests to our OIDC token endpoint with an invalid code, and received a generic "Code invalid code" error message. **How**: Explain why the code is invalid and point developers to our documentation. --- config/locales/openid_connect/en.yml | 3 ++- config/locales/openid_connect/es.yml | 3 ++- config/locales/openid_connect/fr.yml | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/config/locales/openid_connect/en.yml b/config/locales/openid_connect/en.yml index 8b81ddee839..1215f67e68b 100644 --- a/config/locales/openid_connect/en.yml +++ b/config/locales/openid_connect/en.yml @@ -16,7 +16,8 @@ en: invalid_aud: Invalid audience claim, expected %{url} invalid_authentication: Client must authenticate via PKCE or private_key_jwt, missing either code_challenge or client_assertion - invalid_code: invalid code + invalid_code: is invalid either because it expired, or it doesn't match any + user. Please see our documentation at https://developers.login.gov/oidc/#token invalid_code_verifier: code_verifier did not match code_challenge user_info: errors: diff --git a/config/locales/openid_connect/es.yml b/config/locales/openid_connect/es.yml index 22e1460ea31..da60578524a 100644 --- a/config/locales/openid_connect/es.yml +++ b/config/locales/openid_connect/es.yml @@ -16,7 +16,8 @@ es: invalid_aud: Solicitud de audiencia no válida, esperada %{url} invalid_authentication: El cliente debe autenticarse a través de PKCE o private_key_jwt, faltando code_challenge o client_assertion - invalid_code: código inválido + invalid_code: no es válido porque ha caducado o no coincide con ningún usuario. + Consulte nuestra documentación en https://developers.login.gov/oidc/#token invalid_code_verifier: code_verifier no coincide con code_challenge user_info: errors: diff --git a/config/locales/openid_connect/fr.yml b/config/locales/openid_connect/fr.yml index 8c07af7b1ad..d3ac6b746d5 100644 --- a/config/locales/openid_connect/fr.yml +++ b/config/locales/openid_connect/fr.yml @@ -16,7 +16,9 @@ fr: invalid_aud: Affirmation liée à l'auditoire non valide, attendu %{url} invalid_authentication: Le client doit s'authentifier par PKCE ou private_key_jwt, code_challenge ou client_assertion manquant - invalid_code: code non valide + invalid_code: est non valide soit parce qu'il est périmé, soit parce qu'il + ne correspond à aucun utilisateur. Veuillez consulter notre documentation + à https://developers.login.gov/oidc/#token invalid_code_verifier: code_verifier ne correspondait pas à code_challenge user_info: errors: From 86518cea31396bd0b989f8495f48e4353b35ac38 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 2 May 2018 20:31:41 -0400 Subject: [PATCH 13/19] LG-236 Sanitize query parameters **Why**: To adhere to best practices --- app/views/shared/_footer_lite.html.slim | 5 +++-- spec/features/visitors/i18n_spec.rb | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/views/shared/_footer_lite.html.slim b/app/views/shared/_footer_lite.html.slim index 69f3db61956..1360280af59 100644 --- a/app/views/shared/_footer_lite.html.slim +++ b/app/views/shared/_footer_lite.html.slim @@ -1,4 +1,5 @@ - show_language_dropdown = I18n.available_locales.count > 1 +- sanitized_requested_url = request.query_parameters.slice(:request_id) footer.footer.bg-light-blue.sm-bg-navy - if show_language_dropdown @@ -17,7 +18,7 @@ footer.footer.bg-light-blue.sm-bg-navy - I18n.available_locales.each do |locale| li.border-bottom = link_to t("i18n.locale.#{locale}"), - request.query_parameters.merge(locale: locale), + sanitized_requested_url.merge(locale: locale), class: 'block py-12p px2 text-decoration-none blue fs-13p' .container.py1.px2.lg-px0(class="#{'sm-py0' if show_language_dropdown}") @@ -38,7 +39,7 @@ footer.footer.bg-light-blue.sm-bg-navy - I18n.available_locales.each do |locale| li.border-bottom.border-navy = link_to t("i18n.locale.#{locale}"), - request.query_parameters.merge(locale: locale), + sanitized_requested_url.merge(locale: locale), class: 'block pl-24p py2 text-decoration-none white' = link_to t('links.help'), MarketingSite.help_url, class: 'caps h6 blue sm-white text-decoration-none mr3', target: '_blank' diff --git a/spec/features/visitors/i18n_spec.rb b/spec/features/visitors/i18n_spec.rb index f6de7dbb04a..e6c8ab9afc1 100644 --- a/spec/features/visitors/i18n_spec.rb +++ b/spec/features/visitors/i18n_spec.rb @@ -79,4 +79,17 @@ expect(page).to have_content t('headings.sign_in_without_sp', locale: 'es') end end + + context 'visit homepage with host parameter' do + it 'does not include the host parameter in the language link URLs' do + visit '/fr?host=test.com' + + %w[en es fr].each do |locale| + expect(page).to_not have_link( + t("i18n.locale.#{locale}"), + href: "http://test.com/#{locale}" + ) + end + end + end end From 317332f8b2cc1e02ceb051d2627c47d7e5a4f749 Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Wed, 2 May 2018 21:14:43 -0400 Subject: [PATCH 14/19] LG-234 Update link to auth app help page **Why**: The word "authenticator" has been replaced with "authentication". --- app/services/marketing_site.rb | 4 ++-- app/views/users/totp_setup/new.html.slim | 2 +- spec/services/marketing_site_spec.rb | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/services/marketing_site.rb b/app/services/marketing_site.rb index 776fdb339cd..fecfbe8faf6 100644 --- a/app/services/marketing_site.rb +++ b/app/services/marketing_site.rb @@ -22,7 +22,7 @@ def self.help_url URI.join(BASE_URL, locale_segment, 'help').to_s end - def self.help_authenticator_app_url - URI.join(BASE_URL, locale_segment, 'help/signing-in/what-is-an-authenticator-app/').to_s + def self.help_authentication_app_url + URI.join(BASE_URL, locale_segment, 'help/signing-in/what-is-an-authentication-app/').to_s end end diff --git a/app/views/users/totp_setup/new.html.slim b/app/views/users/totp_setup/new.html.slim index 7d8ccdc29db..0cf0000d630 100644 --- a/app/views/users/totp_setup/new.html.slim +++ b/app/views/users/totp_setup/new.html.slim @@ -1,6 +1,6 @@ - title t('titles.totp_setup.new') - help_link = link_to t('links.what_is_totp'), - MarketingSite.help_authenticator_app_url, target: :_blank + MarketingSite.help_authentication_app_url, target: :_blank - btn_cls = 'btn btn-primary p0 w-60p bg-light-blue blue h6 regular border-box center' h1.h3.my0 = t('headings.totp_setup.new') diff --git a/spec/services/marketing_site_spec.rb b/spec/services/marketing_site_spec.rb index 80127eac095..b7d60fa8d74 100644 --- a/spec/services/marketing_site_spec.rb +++ b/spec/services/marketing_site_spec.rb @@ -57,19 +57,19 @@ end end - describe '.help_authenticator_app_url' do - it 'points to the authenticator app section of the help page' do - expect(MarketingSite.help_authenticator_app_url).to eq( - 'https://www.login.gov/help/signing-in/what-is-an-authenticator-app/' + describe '.help_authentication_app_url' do + it 'points to the authentication app section of the help page' do + expect(MarketingSite.help_authentication_app_url).to eq( + 'https://www.login.gov/help/signing-in/what-is-an-authentication-app/' ) end context 'when the user has set their locale to :es' do before { I18n.locale = :es } - it 'points to the authenticator app section of the help page with the locale appended' do - expect(MarketingSite.help_authenticator_app_url).to eq( - 'https://www.login.gov/es/help/signing-in/what-is-an-authenticator-app/' + it 'points to the authentication app section of the help page with the locale appended' do + expect(MarketingSite.help_authentication_app_url).to eq( + 'https://www.login.gov/es/help/signing-in/what-is-an-authentication-app/' ) end end From f1184311605cee4e8fd5a021601dfbf2c525c0ff Mon Sep 17 00:00:00 2001 From: Moncef Belyamani Date: Thu, 3 May 2018 21:43:24 -0400 Subject: [PATCH 15/19] LG-238 Use XHR transport mechanism for GA **Why**: It's more secure than the default transport mechanism for Google Analytics. See this post for more details: https://githubengineering.com/githubs-post-csp-journey/ --- app/views/shared/google_analytics/_page_tracking.html.erb | 1 + config/initializers/secure_headers.rb | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/shared/google_analytics/_page_tracking.html.erb b/app/views/shared/google_analytics/_page_tracking.html.erb index 53df7f0f696..526e3c49f70 100644 --- a/app/views/shared/google_analytics/_page_tracking.html.erb +++ b/app/views/shared/google_analytics/_page_tracking.html.erb @@ -12,5 +12,6 @@ ga('create', analyticsKey, 'auto'); ga('set', 'anonymizeIp', true); ga('set', 'forceSSL', true); + ga('set', 'transport', 'xhr'); ga('send', 'pageview'); <% end %> diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index 4cb72d23186..96561e7393d 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -16,9 +16,10 @@ "'self'", '*.newrelic.com', '*.nr-data.net', + '*.google-analytics.com', ], font_src: ["'self'", 'data:'], - img_src: ["'self'", 'data:', '*.google-analytics.com'], + img_src: ["'self'", 'data:'], media_src: ["'self'"], object_src: ["'none'"], script_src: [ From 590c53fa4fc1c12062129e16eb0ff6f0c39c7950 Mon Sep 17 00:00:00 2001 From: David Corwin Date: Fri, 4 May 2018 11:22:25 -0700 Subject: [PATCH 16/19] Use new proofer gem api (#2126) --- .reek | 5 +- Gemfile | 4 +- Gemfile.lock | 14 +- app/controllers/verify/phone_controller.rb | 10 +- app/controllers/verify/sessions_controller.rb | 10 +- app/forms/idv/profile_form.rb | 7 +- app/jobs/idv/proofer_job.rb | 12 +- app/services/idv/agent.rb | 43 +-- app/services/idv/job.rb | 18 ++ app/services/idv/profile_step.rb | 8 +- app/services/idv/proofer.rb | 92 ++++++ app/services/idv/submit_idv_job.rb | 35 --- app/services/idv/upcase_vendor_env_vars.rb | 26 -- app/services/idv/vendor_result.rb | 9 +- config/application.yml.example | 11 +- config/initializers/figaro.rb | 3 - config/initializers/proofer.rb | 11 +- lib/config_validator.rb | 16 -- lib/proofer_mocks/address_mock.rb | 12 + lib/proofer_mocks/resolution_mock.rb | 21 ++ lib/proofer_mocks/state_id_mock.rb | 40 +++ .../verify/phone_controller_spec.rb | 11 +- .../verify/sessions_controller_spec.rb | 14 +- spec/features/idv/previous_address_spec.rb | 11 +- spec/jobs/idv/proofer_job_spec.rb | 146 +++++----- spec/lib/config_validator_spec.rb | 18 +- spec/services/idv/agent_spec.rb | 197 ++++--------- .../{submit_idv_job_spec.rb => job_spec.rb} | 32 +-- spec/services/idv/profile_step_spec.rb | 30 +- spec/services/idv/proofer_spec.rb | 261 ++++++++++++++++++ .../idv/upcase_vendor_env_vars_spec.rb | 26 -- spec/services/idv/vendor_result_spec.rb | 6 +- .../vendor_validator_result_storage_spec.rb | 2 +- 33 files changed, 676 insertions(+), 485 deletions(-) create mode 100644 app/services/idv/job.rb create mode 100644 app/services/idv/proofer.rb delete mode 100644 app/services/idv/submit_idv_job.rb delete mode 100644 app/services/idv/upcase_vendor_env_vars.rb create mode 100644 lib/proofer_mocks/address_mock.rb create mode 100644 lib/proofer_mocks/resolution_mock.rb create mode 100644 lib/proofer_mocks/state_id_mock.rb rename spec/services/idv/{submit_idv_job_spec.rb => job_spec.rb} (54%) create mode 100644 spec/services/idv/proofer_spec.rb delete mode 100644 spec/services/idv/upcase_vendor_env_vars_spec.rb diff --git a/.reek b/.reek index 185a27f7ef0..1faf719ec02 100644 --- a/.reek +++ b/.reek @@ -5,7 +5,6 @@ ControlParameter: - CustomDeviseFailureApp#i18n_message - OpenidConnectRedirector#initialize - NoRetryJobs#call - - Idv::Agent#proof_one DuplicateMethodCall: exclude: - ApplicationController#disable_caching @@ -18,6 +17,7 @@ DuplicateMethodCall: - UserFlowExporter#self.massage_assets - BasicAuthUrl#build - fallback_to_english + - Idv::Proofer#load_vendors! FeatureEnvy: exclude: - ActiveJob::Logging::LogSubscriber#json_for @@ -43,6 +43,7 @@ FeatureEnvy: - UserEncryptedAttributeOverrides#find_with_email - Utf8Sanitizer#event_attributes - Utf8Sanitizer#remote_ip + - Idv::Proofer#validate_vendors InstanceVariableAssumption: exclude: - User @@ -90,6 +91,8 @@ TooManyStatements: - UserFlowExporter#self.massage_assets - UserFlowExporter#self.massage_html - UserFlowExporter#self.run + - Idv::Agent#proof + - Idv::Proofer#configure_vendors TooManyMethods: exclude: - Users::ConfirmationsController diff --git a/Gemfile b/Gemfile index fe4f173dda4..63cfa567f89 100644 --- a/Gemfile +++ b/Gemfile @@ -31,7 +31,7 @@ gem 'pg' gem 'phonelib' gem 'phony_rails' gem 'premailer-rails' -gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v1.1.3' +gem 'proofer', github: '18F/identity-proofer-gem', tag: 'v2.3.0' gem 'rack-attack' gem 'rack-cors', require: 'rack/cors' gem 'rack-headers_filter' @@ -110,6 +110,6 @@ group :test do end group :production do - gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v1.0.3' + gem 'aamva', git: 'git@github.com:18F/identity-aamva-api-client-gem', tag: 'v2.1.0' gem 'equifax', git: 'git@github.com:18F/identity-equifax-api-client-gem.git', tag: 'v1.1.0' end diff --git a/Gemfile.lock b/Gemfile.lock index b761b4dc7a6..8ae312e2fa9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: git@github.com:18F/identity-aamva-api-client-gem - revision: f0e5a89e04955097084bb6c093f05c782c150c53 - tag: v1.0.3 + revision: 32297c9700b5dd9eaf32c3cf59cdf65efd90ca32 + tag: v2.1.0 specs: - aamva (0.1.0) + aamva (2.1.0) dotenv hashie httpi @@ -32,10 +32,10 @@ GIT GIT remote: https://github.com/18F/identity-proofer-gem.git - revision: cdf16d24294f183160b93b8d418b294fe836e66d - tag: v1.1.3 + revision: e5aeee957fd0a054cea826bebb91b25e9a6d5e86 + tag: v2.3.0 specs: - proofer (1.1.3) + proofer (2.3.0) GIT remote: https://github.com/18F/redis-session-store.git @@ -645,7 +645,7 @@ GEM whenever (0.10.0) chronic (>= 0.6.3) xml-simple (1.1.5) - xmldsig (0.6.5) + xmldsig (0.6.6) nokogiri (>= 1.6.8, < 2.0.0) xmlenc (0.6.9) activemodel (>= 3.0.0) diff --git a/app/controllers/verify/phone_controller.rb b/app/controllers/verify/phone_controller.rb index d04c6ca6090..292f2a30768 100644 --- a/app/controllers/verify/phone_controller.rb +++ b/app/controllers/verify/phone_controller.rb @@ -17,7 +17,7 @@ def create analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_FORM, result.to_h) if result.success? - submit_idv_job + Idv::Job.submit(idv_session, [:address]) redirect_to verify_phone_result_url else @view_model = view_model @@ -52,14 +52,6 @@ def phone_confirmation_required? idv_session.user_phone_confirmation != true end - def submit_idv_job - Idv::SubmitIdvJob.new( - idv_session: idv_session, - vendor_params: { phone: idv_session.params[:phone] }, - stages: [:phone] - ).submit - end - def step_name :phone end diff --git a/app/controllers/verify/sessions_controller.rb b/app/controllers/verify/sessions_controller.rb index b263328062e..985279a1e77 100644 --- a/app/controllers/verify/sessions_controller.rb +++ b/app/controllers/verify/sessions_controller.rb @@ -24,7 +24,7 @@ def create analytics.track_event(Analytics::IDV_BASIC_INFO_SUBMITTED_FORM, result.to_h) if result.success? - submit_idv_job + Idv::Job.submit(idv_session, %i[resolution state_id]) redirect_to verify_session_result_url else process_failure @@ -50,13 +50,6 @@ def destroy private - def submit_idv_job - Idv::SubmitIdvJob.new( - idv_session: idv_session, vendor_params: idv_session.vendor_params, - stages: %i[profile state_id] - ).submit - end - def confirm_step_needed redirect_to verify_address_url if idv_session.profile_confirmation == true end @@ -108,6 +101,7 @@ def idv_form def initialize_idv_session idv_session.params = profile_params.to_h + idv_session.params[:state_id_jurisdiction] = profile_params[:state] idv_session.applicant = idv_session.vendor_params end diff --git a/app/forms/idv/profile_form.rb b/app/forms/idv/profile_form.rb index b0c22422073..64f171c39fc 100644 --- a/app/forms/idv/profile_form.rb +++ b/app/forms/idv/profile_form.rb @@ -4,7 +4,12 @@ class ProfileForm include FormProfileValidator include FormStateIdValidator - PROFILE_ATTRIBUTES = [:state_id_number, :state_id_type, *Pii::Attributes.members].freeze + PROFILE_ATTRIBUTES = [ + :state_id_number, + :state_id_type, + :state_id_jurisdiction, + *Pii::Attributes.members, + ].freeze attr_reader :user attr_accessor(*PROFILE_ATTRIBUTES) diff --git a/app/jobs/idv/proofer_job.rb b/app/jobs/idv/proofer_job.rb index 18e921974da..d2d51c5fd1d 100644 --- a/app/jobs/idv/proofer_job.rb +++ b/app/jobs/idv/proofer_job.rb @@ -20,14 +20,16 @@ def from_json(applicant_json) def verify_identity_with_vendor agent = Idv::Agent.new(applicant) result = agent.proof(*stages) - store_result(Idv::VendorResult.new(result)) - rescue StandardError - store_failed_job_result + store_result(Idv::VendorResult.new(result.to_h)) + rescue StandardError => error + store_failed_job_result(error) raise end - def store_failed_job_result - job_failed_result = Idv::VendorResult.new(errors: { job_failed: true }) + def store_failed_job_result(error) + job_failed_result = Idv::VendorResult.new( + errors: { job_failed: true, message: error.message } + ) VendorValidatorResultStorage.new.store(result_id: result_id, result: job_failed_result) end diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb index f6da1e38404..8199a37b9db 100644 --- a/app/services/idv/agent.rb +++ b/app/services/idv/agent.rb @@ -2,19 +2,20 @@ module Idv class Agent class << self def proofer_attribute?(key) - Proofer::Applicant.method_defined?(key) + Idv::Proofer.attribute?(key) end end def initialize(applicant) - @applicant = Proofer::Applicant.new(applicant) + @applicant = applicant.symbolize_keys! end def proof(*stages) - results = { errors: {}, normalized_applicant: {}, reasons: [], success: false } + results = { errors: {}, messages: [], exception: nil, success: false } stages.each do |stage| - proofer_result = proof_one(stage) + vendor = Idv::Proofer.get_vendor(stage).new + proofer_result = vendor.proof(@applicant) results = merge_results(results, proofer_result) break unless proofer_result.success? end @@ -22,40 +23,12 @@ def proof(*stages) results end - def proof_one(stage) - applicant_hash = @applicant.to_hash.with_indifferent_access - - case stage - - when :phone - get_agent(:phone_proofing_vendor).submit_phone(@applicant.phone) - - when :profile - get_agent(:profile_proofing_vendor).start(applicant_hash) - - when :state_id - get_agent(:state_id_proofing_vendor). - submit_state_id(applicant_hash.merge(state_id_jurisdiction: @applicant.state)) - end - end - private def merge_results(results, proofer_result) - vr = proofer_result.vendor_resp - - normalized_applicant = vr.try(:normalized_applicant) || {} - - { - errors: results[:errors].merge(proofer_result.errors), - normalized_applicant: results[:normalized_applicant].merge(normalized_applicant), - reasons: results[:reasons] + vr.reasons, - success: proofer_result.success?, - } - end - - def get_agent(vendor) - Proofer::Agent.new(applicant: @applicant, vendor: Figaro.env.send(vendor).to_sym, kbv: false) + results.merge(proofer_result.to_h) do |key, orig, current| + key == :messages ? orig + current : current + end end end end diff --git a/app/services/idv/job.rb b/app/services/idv/job.rb new file mode 100644 index 00000000000..91e28de29a4 --- /dev/null +++ b/app/services/idv/job.rb @@ -0,0 +1,18 @@ +module Idv + module Job + class << self + def submit(idv_session, stages) + result_id = SecureRandom.uuid + + idv_session.async_result_id = result_id + idv_session.async_result_started_at = Time.zone.now.to_i + + Idv::ProoferJob.perform_later( + result_id: result_id, + applicant_json: idv_session.vendor_params.to_json, + stages: stages.to_json + ) + end + end + end +end diff --git a/app/services/idv/profile_step.rb b/app/services/idv/profile_step.rb index aba09faf599..6045c348a9e 100644 --- a/app/services/idv/profile_step.rb +++ b/app/services/idv/profile_step.rb @@ -31,19 +31,19 @@ def increment_attempts_count def update_idv_session idv_session.profile_confirmation = true - idv_session.normalized_applicant_params = vendor_validator_result.normalized_applicant.to_hash + idv_session.normalized_applicant_params = vendor_result idv_session.resolution_successful = true end def extra_analytics_attributes { idv_attempts_exceeded: attempts_exceeded?, - vendor: { reasons: vendor_reasons }, + vendor: { messages: vendor_validator_result.messages }, } end - def vendor_reasons - vendor_validator_result.reasons + def vendor_result + vendor_validator_result.normalized_applicant&.to_hash end end end diff --git a/app/services/idv/proofer.rb b/app/services/idv/proofer.rb new file mode 100644 index 00000000000..327a64f2be4 --- /dev/null +++ b/app/services/idv/proofer.rb @@ -0,0 +1,92 @@ +module Idv + module Proofer + ATTRIBUTES = %i[ + uuid + first_name last_name middle_name gen + address1 address2 city state zipcode + prev_address1 prev_address2 prev_city prev_state prev_zipcode + ssn dob phone email + ccn mortgage home_equity_line auto_loan + bank_account bank_account_type bank_routing + state_id_number state_id_type state_id_jurisdiction + ].freeze + + STAGES = %i[resolution state_id address].freeze + + class << self + attr_accessor :configuration + + @vendors = {} + + def attribute?(key) + ATTRIBUTES.include?(key&.to_sym) + end + + def init + @vendors = configure_vendors(STAGES, configuration) + end + + def get_vendor(stage) + @vendors[stage] + end + + def configure + self.configuration ||= Configuration.new + yield(configuration) + end + + class Configuration + attr_accessor :mock_fallback, :raise_on_missing_proofers, :vendors + def initialize + @mock_fallback = false + @raise_on_missing_proofers = true + @vendors = [] + end + end + + def configure_vendors(stages, config) + external_vendors = loaded_vendors + available_external_vendors = available_vendors(config.vendors, external_vendors) + require_mock_vendors if config.mock_fallback + mock_vendors = loaded_vendors - external_vendors + + vendors = assign_vendors(stages, available_external_vendors, mock_vendors) + + validate_vendors(stages, vendors) if config.raise_on_missing_proofers + + vendors + end + + private + + def loaded_vendors + ::Proofer::Base.subclasses + end + + def available_vendors(configured_vendors, vendors) + vendors.select { |vendor| configured_vendors.include?(vendor.vendor_name) } + end + + def require_mock_vendors + Dir[Rails.root.join('lib', 'proofer_mocks', '*')].each { |file| require file } + end + + def assign_vendors(stages, external_vendors, mock_vendors) + stages.each_with_object({}) do |stage, vendors| + vendor = stage_vendor(stage, external_vendors) || stage_vendor(stage, mock_vendors) + vendors[stage] = vendor if vendor + end + end + + def stage_vendor(stage, vendors) + vendors.find { |vendor| stage == vendor.supported_stage&.to_sym } + end + + def validate_vendors(stages, vendors) + missing_stages = stages - vendors.keys + return if missing_stages.empty? + raise "No proofer vendor configured for stage(s): #{missing_stages.join(', ')}" + end + end + end +end diff --git a/app/services/idv/submit_idv_job.rb b/app/services/idv/submit_idv_job.rb deleted file mode 100644 index dc31941ded5..00000000000 --- a/app/services/idv/submit_idv_job.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Idv - class SubmitIdvJob - def initialize(idv_session:, vendor_params:, stages:) - @idv_session = idv_session - @vendor_params = vendor_params - @stages = stages - end - - def submit - update_idv_session - ProoferJob.perform_later(proofer_job_params) - end - - private - - attr_reader :idv_session, :vendor_params, :stages - - def proofer_job_params - { - result_id: result_id, - applicant_json: idv_session.applicant.merge(vendor_params).to_json, - stages: stages.to_json, - } - end - - def result_id - @_result_id ||= SecureRandom.uuid - end - - def update_idv_session - idv_session.async_result_id = result_id - idv_session.async_result_started_at = Time.zone.now.to_i - end - end -end diff --git a/app/services/idv/upcase_vendor_env_vars.rb b/app/services/idv/upcase_vendor_env_vars.rb deleted file mode 100644 index b137b94a019..00000000000 --- a/app/services/idv/upcase_vendor_env_vars.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Idv - class UpcaseVendorEnvVars - def call - available_vendors.each do |vendor| - upcase_env_vars(vendor) - end - end - - private - - def available_vendors - env = Figaro.env - [ - env.profile_proofing_vendor, - env.phone_proofing_vendor, - env.state_id_proofing_vendor, - ] - end - - def upcase_env_vars(vendor) - ENV.keys.grep(/^#{vendor}_/).each do |env_var_name| - ENV[env_var_name.upcase] = ENV[env_var_name] - end - end - end -end diff --git a/app/services/idv/vendor_result.rb b/app/services/idv/vendor_result.rb index 79af9f26412..db26e2d1139 100644 --- a/app/services/idv/vendor_result.rb +++ b/app/services/idv/vendor_result.rb @@ -1,6 +1,6 @@ module Idv class VendorResult - attr_reader :success, :errors, :reasons, :normalized_applicant, :timed_out + attr_reader :success, :errors, :messages, :normalized_applicant, :timed_out, :exception def self.new_from_json(json) parsed = JSON.parse(json, symbolize_names: true) @@ -11,13 +11,14 @@ def self.new_from_json(json) new(**parsed) end - def initialize(success: nil, errors: {}, reasons: [], - normalized_applicant: nil, timed_out: nil) + def initialize(success: nil, errors: {}, messages: [], + normalized_applicant: nil, timed_out: nil, exception: nil) @success = success @errors = errors - @reasons = reasons + @messages = messages @normalized_applicant = normalized_applicant @timed_out = timed_out + @exception = exception end def success? diff --git a/config/application.yml.example b/config/application.yml.example index fa8474743bb..2b90ee3c880 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -121,8 +121,7 @@ development: otp_valid_for: '10' password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' password_strength_enabled: 'true' - phone_proofing_vendor: 'mock' - profile_proofing_vendor: 'mock' + proofer_mock_fallback: 'true' rack_mini_profiler: 'off' reauthn_window: '120' redis_url: 'redis://localhost:6379/0' @@ -142,7 +141,6 @@ development: service_timeout: '30' session_encryption_key: '27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120' session_timeout_in_minutes: '15' - state_id_proofing_vendor: 'mock' telephony_disabled: 'true' twilio_numbers: '["9999999999","2222222222"]' twilio_sid: 'sid1' @@ -211,8 +209,6 @@ production: participate_in_dap: 'false' # pair with google_analytics_key password_pepper: # generate via `rake secret` password_strength_enabled: 'true' - phone_proofing_vendor: 'mock' - profile_proofing_vendor: 'mock' reauthn_window: '120' redis_url: 'redis://redis.login.gov.internal:6379' redis_throttle_url: 'redis://redis.login.gov.internal:6379/1' @@ -230,7 +226,6 @@ production: secret_key_base: # generate via `rake secret` session_encryption_key: # generate via `rake secret` session_timeout_in_minutes: '15' - state_id_proofing_vendor: 'mock' twilio_numbers: # Add JSON encoded array of phone numbers twilio_sid: # Twilio SID twilio_auth_token: # Twilio auth token @@ -301,8 +296,7 @@ test: otp_valid_for: '10' password_pepper: 'f22d4b2cafac9066fe2f4416f5b7a32c6942d82f7e00740c7594a095fa8de8db17c05314be7b18a5d6dd5683e73eadf6cc95aa633e5ad9a701edb95192a6a105' password_strength_enabled: 'false' - phone_proofing_vendor: 'mock' - profile_proofing_vendor: 'mock' + proofer_mock_fallback: 'true' reauthn_window: '120' redis_url: 'redis://localhost:6379/0' redis_throttle_url: 'redis://localhost:6379/1' @@ -320,7 +314,6 @@ test: secret_key_base: 'test_secret_key_base' session_encryption_key: '27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120' session_timeout_in_minutes: '15' - state_id_proofing_vendor: 'mock' twilio_numbers: '["9999999999","2222222222"]' twilio_sid: 'sid1' twilio_auth_token: 'token1' diff --git a/config/initializers/figaro.rb b/config/initializers/figaro.rb index d28c2727f3d..79828328993 100644 --- a/config/initializers/figaro.rb +++ b/config/initializers/figaro.rb @@ -29,8 +29,6 @@ 'password_max_attempts', 'password_pepper', 'password_strength_enabled', - 'phone_proofing_vendor', - 'profile_proofing_vendor', 'queue_health_check_dead_interval_seconds', 'queue_health_check_frequency_seconds', 'reauthn_window', @@ -46,7 +44,6 @@ 'service_timeout', 'session_encryption_key', 'session_timeout_in_minutes', - 'state_id_proofing_vendor', 'twilio_numbers', 'twilio_sid', 'twilio_auth_token', diff --git a/config/initializers/proofer.rb b/config/initializers/proofer.rb index 912e0d838dd..5bd72aae308 100644 --- a/config/initializers/proofer.rb +++ b/config/initializers/proofer.rb @@ -1,2 +1,9 @@ -# gems require UPPER case ENV variables so translate -Idv::UpcaseVendorEnvVars.new.call +if FeatureManagement.enable_identity_verification? + Idv::Proofer.configure do |config| + config.mock_fallback = Figaro.env.proofer_mock_fallback == 'true' + config.raise_on_missing_proofers = Figaro.env.proofer_raise_on_missing_proofers == 'false' + config.vendors = JSON.parse(Figaro.env.proofer_vendors || '[]') + end + + Idv::Proofer.init +end diff --git a/lib/config_validator.rb b/lib/config_validator.rb index 3779fe740b7..ebe369d9f23 100644 --- a/lib/config_validator.rb +++ b/lib/config_validator.rb @@ -1,14 +1,8 @@ class ConfigValidator ENV_PREFIX = Figaro::Application::FIGARO_ENV_PREFIX - NON_EMPTY_KEYS = %w[ - phone_proofing_vendor - profile_proofing_vendor - state_id_proofing_vendor - ].freeze def validate(env = ENV) validate_boolean_keys(env) - validate_non_empty_keys(env) end private @@ -32,10 +26,6 @@ def candidate_key?(env, key) env.include?(key) and env.include?(ENV_PREFIX + key) end - def empty_keys_warning(empty_keys) - 'These configs are required and were empty: ' + empty_keys.join(', ') - end - def keys_with_bad_boolean_values(env, keys) # Configuration settings for boolean values need to be "true/false" # and not "yes/no". @@ -48,10 +38,4 @@ def validate_boolean_keys(env) return unless bad_keys.any? raise boolean_warning(bad_keys).tr("\n", ' ') end - - def validate_non_empty_keys(env) - empty_keys = NON_EMPTY_KEYS - env.keys - return if empty_keys.empty? - raise empty_keys_warning(empty_keys) - end end diff --git a/lib/proofer_mocks/address_mock.rb b/lib/proofer_mocks/address_mock.rb new file mode 100644 index 00000000000..8faeef62d18 --- /dev/null +++ b/lib/proofer_mocks/address_mock.rb @@ -0,0 +1,12 @@ +class AddressMock < Proofer::Base + attributes :phone + + stage :address + + proof do |applicant, result| + plain_phone = applicant[:phone].gsub(/\D/, '').gsub(/\A1/, '') + if plain_phone == '5555555555' + result.add_error(:phone, 'The phone number could not be verified.') + end + end +end diff --git a/lib/proofer_mocks/resolution_mock.rb b/lib/proofer_mocks/resolution_mock.rb new file mode 100644 index 00000000000..ac907d9ee42 --- /dev/null +++ b/lib/proofer_mocks/resolution_mock.rb @@ -0,0 +1,21 @@ +class ResolutionMock < Proofer::Base + attributes :first_name, :ssn, :zipcode + + stage :resolution + + proof do |applicant, result| + first_name = applicant[:first_name] + + raise 'Failed to contact proofing vendor' if first_name =~ /Fail/i + + if first_name =~ /Bad/i + result.add_error(:first_name, 'Unverified first name.') + + elsif applicant[:ssn] =~ /6666/ + result.add_error(:ssn, 'Unverified SSN.') + + elsif applicant[:zipcode] == '00000' + result.add_error(:zipcode, 'Unverified ZIP code.') + end + end +end diff --git a/lib/proofer_mocks/state_id_mock.rb b/lib/proofer_mocks/state_id_mock.rb new file mode 100644 index 00000000000..0ea04dc31a5 --- /dev/null +++ b/lib/proofer_mocks/state_id_mock.rb @@ -0,0 +1,40 @@ +class StateIdMock < Proofer::Base + SUPPORTED_STATES = %w[ + AR AZ CO DC DE FL IA ID IL IN KY MA MD ME MI MS MT ND NE NJ NM PA SD TX VA WA WI WY + ].freeze + + SUPPORTED_STATE_ID_TYPES = %w[ + drivers_license drivers_permit state_id_card + ].freeze + + attributes :state_id_number, :state_id_type, :state_id_jurisdiction + + stage :state_id + + proof do |applicant, result| + if state_not_supported?(applicant[:state_id_jurisdiction]) + result.add_error(:state_id_jurisdiction, 'The jurisdiction could not be verified') + + elsif invalid_state_id_number?(applicant[:state_id_number]) + result.add_error(:state_id_number, 'The state ID number could not be verified') + + elsif invalid_state_id_type?(applicant[:state_id_type]) + result.add_error(:state_id_type, 'The state ID type could not be verified') + end + end + + private + + def state_not_supported?(state_id_jurisdiction) + !SUPPORTED_STATES.include? state_id_jurisdiction + end + + def invalid_state_id_number?(state_id_number) + state_id_number =~ /\A0*\z/ + end + + def invalid_state_id_type?(state_id_type) + !SUPPORTED_STATE_ID_TYPES.include?(state_id_type) || + state_id_type.nil? + end +end diff --git a/spec/controllers/verify/phone_controller_spec.rb b/spec/controllers/verify/phone_controller_spec.rb index a808712bd4e..5cb5eb84e5b 100644 --- a/spec/controllers/verify/phone_controller_spec.rb +++ b/spec/controllers/verify/phone_controller_spec.rb @@ -77,7 +77,7 @@ end it 'tracks form error and does not make a vendor API call' do - expect(Idv::SubmitIdvJob).to_not receive(:submit_phone_job) + expect(Idv::Job).to_not receive(:submit) put :create, params: { idv_phone_form: { phone: '703' } } @@ -270,11 +270,10 @@ user = build(:user, phone: good_phone, phone_confirmed_at: Time.zone.now) stub_verify_steps_one_and_two(user) - expect(Idv::SubmitIdvJob).to receive(:new).with( - idv_session: subject.idv_session, - vendor_params: { phone: normalized_phone }, - stages: [:phone] - ).and_call_original + subject.params = { phone: normalized_phone } + expect(Idv::Job).to receive(:submit). + with(subject.idv_session, [:address]). + and_call_original put :create, params: { idv_phone_form: { phone: good_phone } } end diff --git a/spec/controllers/verify/sessions_controller_spec.rb b/spec/controllers/verify/sessions_controller_spec.rb index 10e40d18b73..7f50ba999d2 100644 --- a/spec/controllers/verify/sessions_controller_spec.rb +++ b/spec/controllers/verify/sessions_controller_spec.rb @@ -194,7 +194,7 @@ success: false, errors: { timed_out: ['Timed out waiting for vendor response'] }, idv_attempts_exceeded: false, - vendor: { reasons: [] }, + vendor: { messages: [] }, } expect(@analytics).to have_received(:track_event).with( @@ -221,7 +221,7 @@ Idv::VendorResult.new( success: false, errors: { first_name: ['Unverified first name.'] }, - reasons: ['The name was suspicious'] + messages: ['The name was suspicious'] ) end @@ -242,7 +242,7 @@ errors: { first_name: ['Unverified first name.'], }, - vendor: { reasons: ['The name was suspicious'] }, + vendor: { messages: ['The name was suspicious'] }, } expect(@analytics).to have_received(:track_event). @@ -296,7 +296,7 @@ Idv::VendorResult.new( success: false, errors: { agent: [exception_msg] }, - reasons: [exception_msg] + messages: [exception_msg] ) end @@ -309,7 +309,7 @@ errors: { agent: [exception_msg], }, - vendor: { reasons: [exception_msg] }, + vendor: { messages: [exception_msg] }, } expect(@analytics).to have_received(:track_event). @@ -322,7 +322,7 @@ let(:result) do Idv::VendorResult.new( success: true, - reasons: ['Everything looks good'], + messages: ['Everything looks good'], normalized_applicant: normalized_applicant ) end @@ -334,7 +334,7 @@ success: true, idv_attempts_exceeded: false, errors: {}, - vendor: { reasons: ['Everything looks good'] }, + vendor: { messages: ['Everything looks good'] }, } expect(@analytics).to have_received(:track_event). diff --git a/spec/features/idv/previous_address_spec.rb b/spec/features/idv/previous_address_spec.rb index 16acc6e88ca..eb3f75f6ceb 100644 --- a/spec/features/idv/previous_address_spec.rb +++ b/spec/features/idv/previous_address_spec.rb @@ -12,14 +12,6 @@ def expect_to_stay_on_verify_session_page expect(page).to have_selector("input[value='#{bad_zipcode}']") end - def expect_bad_previous_address_to_fail - fill_out_idv_form_ok - fill_out_idv_previous_address_fail - click_idv_continue - - expect_to_stay_on_verify_session_page - end - def expect_bad_current_address_to_fail fill_out_idv_previous_address_ok fill_out_idv_form_fail @@ -42,12 +34,11 @@ def expect_current_address_in_profile(user) expect(page).to_not have_content(previous_address) end - it 'fails when either address has bad value, prefers current address in profile' do + it 'fails when current address has bad value, prefers current address in profile' do user = user_with_2fa start_idv_from_sp complete_idv_steps_before_profile_step(user) - expect_bad_previous_address_to_fail expect_bad_current_address_to_fail expect_current_address_in_profile(user) end diff --git a/spec/jobs/idv/proofer_job_spec.rb b/spec/jobs/idv/proofer_job_spec.rb index 3a42248b9e5..58287c838af 100644 --- a/spec/jobs/idv/proofer_job_spec.rb +++ b/spec/jobs/idv/proofer_job_spec.rb @@ -2,91 +2,113 @@ describe Idv::ProoferJob do describe '#perform' do - let(:result_id) { SecureRandom.uuid } - let(:applicant) { { first_name: 'Jean-Luc', last_name: 'Picard' } } - let(:stages) { %i[phone] } - let(:agent) { instance_double(Idv::Agent) } - let(:proofer_results) { { } } - - before do - allow(agent).to receive(:proof).and_return(proofer_results) - allow(Idv::Agent).to receive(:new).and_return(agent) - end + context 'without mocking the agent' do + let(:result_id) { SecureRandom.uuid } + let(:applicant) { { first_name: 'Jean-Luc', ssn: '123456789', zipcode: '11111' } } + let(:stages) { %i[resolution] } + + it 'works' do + Idv::ProoferJob.perform_now( + result_id: result_id, + applicant_json: applicant.to_json, + stages: stages.to_json + ) + + result = VendorValidatorResultStorage.new.load(result_id) - subject do - Idv::ProoferJob.perform_now( - result_id: result_id, - applicant_json: applicant.to_json, - stages: stages.to_json - ) + expect(result.success?).to eq(true) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(false) + expect(result.messages).to be_empty + expect(result.errors).to be_empty + end end - shared_examples 'a proofer job' do - it 'uses the Idv::Agent' do - subject + context 'when mocking the agent' do + let(:result_id) { SecureRandom.uuid } + let(:applicant) { { first_name: 'Jean-Luc', last_name: 'Picard' } } + let(:stages) { %i[phone] } + let(:agent) { instance_double(Idv::Agent) } + let(:proofer_results) { {} } - expect(Idv::Agent).to have_received(:new).with(applicant) - expect(agent).to have_received(:proof).with(*stages) + before do + allow(agent).to receive(:proof).and_return(proofer_results) + allow(Idv::Agent).to receive(:new).and_return(agent) end - end - context 'when verification succeeds' do + subject do + Idv::ProoferJob.perform_now( + result_id: result_id, + applicant_json: applicant.to_json, + stages: stages.to_json + ) + end - let(:proofer_results) { { success: true, reasons: ['a reason'] } } + shared_examples 'a proofer job' do + it 'uses the Idv::Agent' do + subject - it_behaves_like 'a proofer job' + expect(Idv::Agent).to have_received(:new).with(applicant) + expect(agent).to have_received(:proof).with(*stages) + end + end - it 'should save a successful result' do - subject + context 'when verification succeeds' do + let(:proofer_results) { { success: true, messages: ['a reason'] } } - result = VendorValidatorResultStorage.new.load(result_id) + it_behaves_like 'a proofer job' - expect(result.success?).to eq(true) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.reasons).to eq(['a reason']) - expect(result.errors).to eq({}) - end - end + it 'should save a successful result' do + subject - context 'when verification fails' do + result = VendorValidatorResultStorage.new.load(result_id) - let(:proofer_results) do - { - success: false, - reasons: ['Bad number'], - errors: { phone: 'The phone number could not be verified.' } - } + expect(result.success?).to eq(true) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(false) + expect(result.messages).to eq(['a reason']) + expect(result.errors).to be_empty + end end - it_behaves_like 'a proofer job' + context 'when verification fails' do + let(:proofer_results) do + { + success: false, + messages: ['Bad number'], + errors: { phone: 'The phone number could not be verified.' }, + } + end - it 'should save an unsuccessful result' do - subject + it_behaves_like 'a proofer job' - result = VendorValidatorResultStorage.new.load(result_id) + it 'should save an unsuccessful result' do + subject - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(false) - expect(result.reasons).to eq(['Bad number']) - expect(result.errors).to eq(phone: 'The phone number could not be verified.') - end - end + result = VendorValidatorResultStorage.new.load(result_id) - context 'when the idv agent raises' do - before do - allow(agent).to receive(:proof).and_raise(RuntimeError, '🔥🔥🔥') + expect(result.success?).to eq(false) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(false) + expect(result.messages).to eq(['Bad number']) + expect(result.errors).to eq(phone: 'The phone number could not be verified.') + end end - it 'should rescue from errors and save a failed job result' do - expect { subject }.to raise_error(RuntimeError, '🔥🔥🔥') + context 'when the idv agent raises' do + before do + allow(agent).to receive(:proof).and_raise(RuntimeError, '🔥🔥🔥') + end - result = VendorValidatorResultStorage.new.load(result_id) + it 'should rescue from errors and save a failed job result' do + expect { subject }.to raise_error(RuntimeError, '🔥🔥🔥') - expect(result.success?).to eq(false) - expect(result.timed_out?).to eq(false) - expect(result.job_failed?).to eq(true) + result = VendorValidatorResultStorage.new.load(result_id) + + expect(result.success?).to eq(false) + expect(result.timed_out?).to eq(false) + expect(result.job_failed?).to eq(true) + end end end end diff --git a/spec/lib/config_validator_spec.rb b/spec/lib/config_validator_spec.rb index 86101d6afeb..554a48cb50e 100644 --- a/spec/lib/config_validator_spec.rb +++ b/spec/lib/config_validator_spec.rb @@ -2,13 +2,7 @@ describe ConfigValidator do describe '#validate' do - let(:env) do - { - 'phone_proofing_vendor' => 'mock', - 'profile_proofing_vendor' => 'mock', - 'state_id_proofing_vendor' => 'mock', - } - end + let(:env) { {} } it 'raises if one or more candidate key values is set to yes or no' do env.merge!( @@ -30,16 +24,6 @@ ) end - it 'raises if a non empty key is empty' do - env.delete('phone_proofing_vendor') - mimic_figaro - - expect { ConfigValidator.new.validate(env) }.to raise_error( - RuntimeError, - 'These configs are required and were empty: phone_proofing_vendor' - ) - end - def mimic_figaro # Figaro sets 2 environment variables for each configuration: # 1 with and 1 without the Figaro prefix. Settings that don't diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 2d2ef7f7ec5..65f20593abb 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -3,201 +3,104 @@ describe Idv::Agent do describe '.proofer_attribute?' do - it 'returns whether the attribute is available in Proofer::Applicant' do - key = 'foobarbaz' - expect(Proofer::Applicant).to receive(:method_defined?).with(key) + it 'returns whether the attribute is available in Idv::Proofer::ATTRIBUTES' do + key = :foobarbaz + expect(Idv::Proofer).to receive(:attribute?).with(key) Idv::Agent.proofer_attribute?(key) end end describe 'instance' do - - let(:applicant) { { } } + let(:applicant) { { foo: 'bar' } } let(:agent) { Idv::Agent.new(applicant) } - describe '#get_agent' do - let(:vendor_key) { :some_vendor_key } - let(:vendor) { 'some_vendor' } - let(:foo) { double } - - it 'returns a new Proofer::Agent with the correct data' do - allow(Figaro.env).to receive(vendor_key).and_return(vendor) - expect(Proofer::Agent).to receive(:new).with( - applicant: agent.instance_variable_get(:@applicant), - vendor: vendor.to_sym, - kbv: false - ).and_return(foo) - - result = agent.send(:get_agent, vendor_key) - - expect(result).to eq(foo) - end - end - describe '#merge_results' do let(:orig_results) do { errors: { foo: 'bar', bar: 'baz' }, - normalized_applicant: {}, - reasons: ['reason 1'], + messages: ['reason 1'], success: true, + exception: StandardError.new, } end let(:new_result) do - Proofer::Resolution.new( - vendor_resp: OpenStruct.new( - normalized_applicant: { first_name: 'Homer' }, - reasons: ['reason 2'] - ), + { errors: { foo: 'blarg', baz: 'foo' }, + messages: ['reason 2'], success: false, - ) + exception: StandardError.new, + } end let(:merged_results) { agent.send(:merge_results, orig_results, new_result) } - it 'merges errors' do - expect(merged_results[:errors]).to eq(orig_results[:errors].merge(new_result.errors)) + it 'keeps the last errors' do + expect(merged_results[:errors]).to eq(new_result[:errors]) end - it 'concatenates reasons' do - expect(merged_results[:reasons]).to eq(orig_results[:reasons] + new_result.vendor_resp.reasons) - end - - it 'merges normalized applicant' do - expect(merged_results[:normalized_applicant]).to eq(new_result.vendor_resp.normalized_applicant) + it 'concatenates messages' do + expect(merged_results[:messages]).to eq(orig_results[:messages] + new_result[:messages]) end it 'keeps the last success' do expect(merged_results[:success]).to eq(false) end - end - - describe '#proof_one' do - let(:applicant) { { phone: '1112223333', state: 'WA' } } - - before do - proofer_agent = instance_double('Proofer::Agent') - expect(proofer_agent).to receive(method).with(data) - expect(agent).to receive(:get_agent).with(vendor_key).and_return(proofer_agent) - end - - context ':phone stage' do - let(:stage) { :phone } - let(:method) { :submit_phone } - let(:vendor_key) { :phone_proofing_vendor } - let(:data) { applicant[:phone] } - - it 'gets the agent for the `:phone_proofing_vendor` and calls `submit_phone`' do - agent.proof_one(stage) - end - end - - context ':profile stage' do - let(:stage) { :profile } - let(:method) { :start } - let(:vendor_key) { :profile_proofing_vendor } - let(:data) { applicant } - - it 'gets the agent for the `:profile_proofing_vendor` and calls `start`' do - agent.proof_one(stage) - end - end - context ':state_id stage' do - let(:stage) { :state_id } - let(:method) { :submit_state_id } - let(:vendor_key) { :state_id_proofing_vendor } - let(:data) { applicant.merge(state_id_jurisdiction: applicant[:state]) } - - it 'gets the agent for the `:state_id_proofing_vendor` and calls `submit_state_id`' do - agent.proof_one(stage) - end + it 'keeps the last exception' do + expect(merged_results[:exception]).to eq(new_result[:exception]) end end describe '#proof' do - let(:profile_resolution) do - Proofer::Resolution.new( - vendor_resp: OpenStruct.new( - normalized_applicant: { first_name: 'Homer' }, - reasons: ['reason 1'] - ), - success: true - ) - end - let(:state_id_resolution) do - Proofer::Resolution.new( - vendor_resp: OpenStruct.new( - normalized_applicant: { }, - reasons: ['reason 2'] - ), - success: true - ) - end - let(:failed_resolution) do - Proofer::Resolution.new( - vendor_resp: OpenStruct.new( - normalized_applicant: { }, - reasons: ['bah humbug'] - ), - success: false, - errors: { - bad: 'stuff' - } - ) - end + let(:resolution_message) { 'reason 1' } + let(:state_id_message) { 'reason 2' } + let(:failed_message) { 'bah humbug' } + let(:error) { { bad: 'stuff' } } + + subject { agent.proof(*stages) } before do - allow(agent).to receive(:proof_one) do |stage| - case stage - when :profile - profile_resolution - when :state_id - state_id_resolution - when :failed - failed_resolution + allow(Idv::Proofer).to receive(:get_vendor) do |stage| + logic = case stage + when :resolution + proc { |_, r| r.add_message('reason 1') } + when :state_id + proc { |_, r| r.add_message('reason 2') } + when :failed + proc { |_, r| r.add_message('bah humbug').add_error(:bad, 'stuff') } + end + Class.new(Proofer::Base) do + attributes(:foo) + proof(&logic) end end end context 'when all stages succeed' do + let(:stages) { %i[resolution state_id] } - let(:stages) { [:profile, :state_id] } - - it 'calls #proof_one for each stage and returns merged results' do - stages.each do |stage| - expect(agent).to receive(:proof_one).with(stage) - end - - results = agent.proof(*stages) - - expect(results).to eq({ + it 'results from all stages are included' do + expect(subject.to_h).to eq( errors: {}, - normalized_applicant: profile_resolution.vendor_resp.normalized_applicant, - reasons: profile_resolution.vendor_resp.reasons + state_id_resolution.vendor_resp.reasons, - success: true - }) + messages: [resolution_message, state_id_message], + success: true, + exception: nil, + ) end end context 'when the fist stage fails' do - let(:stages) { [:failed, :state_id] } - - it 'calls #proof_one only for the first stage and returns merged results' do - expect(agent).to receive(:proof_one).with(stages.first) - expect(agent).not_to receive(:proof_one).with(stages.second) - - results = agent.proof(*stages) - - expect(results).to eq({ - errors: failed_resolution.errors, - normalized_applicant: {}, - reasons: failed_resolution.vendor_resp.reasons, - success: false - }) + let(:stages) { %i[failed state_id] } + + it 'only the results from the first stage are included' do + expect(subject.to_h).to eq( + errors: { bad: ['stuff'] }, + messages: [failed_message], + success: false, + exception: nil, + ) end end end diff --git a/spec/services/idv/submit_idv_job_spec.rb b/spec/services/idv/job_spec.rb similarity index 54% rename from spec/services/idv/submit_idv_job_spec.rb rename to spec/services/idv/job_spec.rb index 90be6cd10f1..bfb37ac5690 100644 --- a/spec/services/idv/submit_idv_job_spec.rb +++ b/spec/services/idv/job_spec.rb @@ -1,38 +1,22 @@ require 'rails_helper' -RSpec.describe Idv::SubmitIdvJob do - subject(:service) do - Idv::SubmitIdvJob.new( - idv_session: idv_session, - vendor_params: vendor_params, - stages: stages - ) - end - +RSpec.describe Idv::Job do let(:idv_session) do - Idv::Session.new( - current_user: user, - issuer: nil, - user_session: { - idv: { - applicant: applicant, - }, - } - ) + Idv::Session. + new(current_user: build(:user), issuer: nil, user_session: {}). + tap { |session| session.params = applicant } end - let(:user) { build(:user) } - let(:applicant) { { first_name: 'Greatest' } } + let(:applicant) { { first_name: 'Greatest', dob: '01/01/1985' } } let(:result_id) { 'abcdef' } - let(:vendor_params) { { dob: '01/01/1985' } } - let(:stages) { %i[profile] } + let(:stages) { %i[resolution] } describe '#submit' do it 'generates a UUID and enqueues a Idv::ProoferJob and saves the UUID in the session' do expect(Idv::ProoferJob).to receive(:perform_later). with( result_id: result_id, - applicant_json: applicant.merge(vendor_params).to_json, + applicant_json: idv_session.vendor_params.to_json, stages: stages.to_json ) @@ -41,7 +25,7 @@ expect(SecureRandom).to receive(:uuid).and_return(result_id).once - service.submit + Idv::Job.submit(idv_session, stages) expect(idv_session.async_result_id).to eq(result_id) expect(idv_session.async_result_started_at).to be_within(1).of(Time.zone.now.to_i) diff --git a/spec/services/idv/profile_step_spec.rb b/spec/services/idv/profile_step_spec.rb index fa08d81f218..3500e685012 100644 --- a/spec/services/idv/profile_step_spec.rb +++ b/spec/services/idv/profile_step_spec.rb @@ -31,10 +31,10 @@ def build_step(params, vendor_validator_result) describe '#submit' do it 'succeeds with good params' do - reasons = ['Everything looks good'] + messages = ['Everything looks good'] extra = { idv_attempts_exceeded: false, - vendor: { reasons: reasons }, + vendor: { messages: messages }, } step = build_step( @@ -42,7 +42,7 @@ def build_step(params, vendor_validator_result) Idv::VendorResult.new( success: true, errors: {}, - reasons: reasons, + messages: messages, normalized_applicant: { first_name: 'Some' } ) ) @@ -57,16 +57,16 @@ def build_step(params, vendor_validator_result) end it 'fails with invalid SSN' do - reasons = ['The SSN was suspicious'] + messages = ['The SSN was suspicious'] errors = { ssn: ['Unverified SSN.'] } extra = { idv_attempts_exceeded: false, - vendor: { reasons: reasons }, + vendor: { messages: messages }, } step = build_step( user_attrs.merge(ssn: '666-66-6666'), - Idv::VendorResult.new(success: false, errors: errors, reasons: reasons) + Idv::VendorResult.new(success: false, errors: errors, messages: messages) ) result = step.submit @@ -80,15 +80,15 @@ def build_step(params, vendor_validator_result) it 'fails with invalid first name' do errors = { first_name: ['Unverified first name.'] } - reasons = ['The name was suspicious'] + messages = ['The name was suspicious'] extra = { idv_attempts_exceeded: false, - vendor: { reasons: reasons }, + vendor: { messages: messages }, } step = build_step( user_attrs.merge(first_name: 'Bad'), - Idv::VendorResult.new(success: false, errors: errors, reasons: reasons) + Idv::VendorResult.new(success: false, errors: errors, messages: messages) ) result = step.submit @@ -101,16 +101,16 @@ def build_step(params, vendor_validator_result) end it 'fails with invalid ZIP code on current address' do - reasons = ['The ZIP code was suspicious'] + messages = ['The ZIP code was suspicious'] errors = { zipcode: ['Unverified ZIP code.'] } extra = { idv_attempts_exceeded: false, - vendor: { reasons: reasons }, + vendor: { messages: messages }, } step = build_step( user_attrs.merge(zipcode: '00000'), - Idv::VendorResult.new(success: false, errors: errors, reasons: reasons) + Idv::VendorResult.new(success: false, errors: errors, messages: messages) ) result = step.submit @@ -123,16 +123,16 @@ def build_step(params, vendor_validator_result) end it 'fails with invalid ZIP code on previous address' do - reasons = ['The ZIP code was suspicious'] + messages = ['The ZIP code was suspicious'] errors = { zipcode: ['Unverified ZIP code.'] } extra = { idv_attempts_exceeded: false, - vendor: { reasons: reasons }, + vendor: { messages: messages }, } step = build_step( user_attrs.merge(prev_zipcode: '00000'), - Idv::VendorResult.new(success: false, errors: errors, reasons: reasons) + Idv::VendorResult.new(success: false, errors: errors, messages: messages) ) result = step.submit diff --git a/spec/services/idv/proofer_spec.rb b/spec/services/idv/proofer_spec.rb new file mode 100644 index 00000000000..c1e73dbfb7d --- /dev/null +++ b/spec/services/idv/proofer_spec.rb @@ -0,0 +1,261 @@ +require 'rails_helper' + +describe Idv::Proofer do + describe '.attribute?' do + subject { described_class.attribute?(attribute) } + + context 'when the attribute exists' do + context 'and is passed as a string' do + let(:attribute) { 'last_name' } + + it { is_expected.to eq(true) } + end + + context 'and is passed as a symbol' do + let(:attribute) { :last_name } + + it { is_expected.to eq(true) } + end + end + + context 'when the attribute does not exist' do + context 'and is passed as a string' do + let(:attribute) { 'fooobar' } + + it { is_expected.to eq(false) } + end + + context 'and is passed as a symbol' do + let(:attribute) { :fooobar } + + it { is_expected.to eq(false) } + end + end + end + + describe '.loaded_vendors' do + subject { described_class.send(:loaded_vendors) } + + it 'returns all of the subclasses of Proofer::Base' do + subclasses = ['foo'] + expect(::Proofer::Base).to receive(:subclasses).and_return(subclasses) + expect(subject).to eq(subclasses) + end + end + + describe '.available_vendors' do + subject { described_class.send(:available_vendors, configured_vendors, vendors) } + + let(:vendors) do + [ + class_double('Proofer::Base', vendor_name: 'foo'), + class_double('Proofer::Base', vendor_name: 'baz'), + ] + end + + let(:configured_vendors) { %w[foo bar] } + + it 'selects only the vendors that have been configured' do + available_vendors = [vendors.first] + expect(subject).to eq(available_vendors) + end + end + + describe '.require_mock_vendors' do + subject { described_class.send(:require_mock_vendors) } + + it 'requires all of the mock vendors' do + Dir[Rails.root.join('lib', 'proofer_mocks', '*')].each do |file| + expect_any_instance_of(Object).to receive(:require).with(file) + end + + subject + end + end + + describe '.assign_vendors' do + subject { described_class.send(:assign_vendors, stages, external_vendors, mock_vendors) } + + let(:stages) { %i[resolution state_id address] } + + let(:external_vendors) do + [ + class_double('Proofer::Base', supported_stage: :resolution), + class_double('Proofer::Base', supported_stage: :foo), + ] + end + + let(:mock_vendors) do + [ + class_double('Proofer::Base', supported_stage: :resolution), + class_double('Proofer::Base', supported_stage: 'state_id'), + class_double('Proofer::Base', supported_stage: :baz), + ] + end + + it 'maps stages to vendors, falling back to mock vendors' do + assigned_vendors = { + resolution: external_vendors.first, + state_id: mock_vendors.second, + } + expect(subject).to eq(assigned_vendors) + end + end + + describe '.stage_vendor' do + subject { described_class.send(:stage_vendor, stage, vendors) } + + let(:stage) { :foo } + + context 'when supported_stage is a string' do + let(:vendors) do + [ + class_double('Proofer::Base', supported_stage: :resolution), + class_double('Proofer::Base', supported_stage: 'foo'), + ] + end + + it 'selects the vendor for the stage' do + expect(subject).to eq(vendors.second) + end + end + + context 'when supported_stage is a symbol' do + let(:vendors) do + [ + class_double('Proofer::Base', supported_stage: :resolution), + class_double('Proofer::Base', supported_stage: :foo), + ] + end + + it 'selects the vendor for the stage' do + expect(subject).to eq(vendors.second) + end + end + + context 'when no vendor exists' do + let(:vendors) do + [ + class_double('Proofer::Base', supported_stage: :resolution), + ] + end + + it 'is nil' do + expect(subject).to be_nil + end + end + end + + describe '.validate_vendors' do + subject { described_class.send(:validate_vendors, stages, vendors) } + + let(:stages) { %i[foo] } + + context 'when there are vendors for all stages' do + let(:vendors) { { foo: class_double('Proofer::Base') } } + + it 'does not raise an error' do + expect { subject }.not_to raise_error + end + end + + context 'when there are stages without vendors' do + let(:vendors) { { bar: class_double('Proofer::Base') } } + + it 'does raises an error' do + expect { subject }.to raise_error("No proofer vendor configured for stage(s): foo") + end + end + end + + describe '.configure_vendors' do + subject { described_class.configure_vendors(stages, config) } + + let(:stages) { %i[foo] } + + let(:config) { double } + + let(:configured_vendors) { %w[vendor1 vendor2] } + + let(:loaded_vendors) do + [ + class_double('Proofer::Base', supported_stage: :foo, vendor_name: 'vendor3'), + class_double('Proofer::Base', supported_stage: :foo, vendor_name: 'vendor1'), + class_double('Proofer::Base', supported_stage: :bar, vendor_name: 'vendor2'), + ] + end + + let(:mock_vendors) do + [ + class_double('Proofer::Base', supported_stage: :foo), + class_double('Proofer::Base', supported_stage: :baz), + ] + end + + before do + expect(config).to receive(:vendors).and_return(configured_vendors) + end + + context 'default configuration' do + before do + expect(config).to receive(:mock_fallback).and_return(false) + expect(config).to receive(:raise_on_missing_proofers).and_return(true) + expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) + end + + context 'when a stage is missing an external vendor' do + let(:stages) { %i[foo baz] } + + it 'raises' do + expect { subject }.to raise_error("No proofer vendor configured for stage(s): baz") + end + end + + context 'when all stages have vendors' do + it 'maps the vendors, ignoring non-configured ones' do + expect(subject).to eq({ foo: loaded_vendors.second }) + end + end + end + + context 'when mock_fallback is enabled' do + before do + expect(config).to receive(:mock_fallback).and_return(true) + expect(config).to receive(:raise_on_missing_proofers).and_return(true) + expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, mock_vendors) + end + + context 'when a stage is missing an external vendor' do + let(:stages) { %i[foo baz] } + + it 'does not raise' do + expect { subject }.not_to raise_error + end + + it 'returns the mapped vendors with the mock fallback' do + expect(subject).to eq({ foo: loaded_vendors.second, baz: mock_vendors.second }) + end + end + end + + context 'when raise_on_missing_proofers is disabled' do + before do + expect(config).to receive(:mock_fallback).and_return(false) + expect(config).to receive(:raise_on_missing_proofers).and_return(false) + expect(described_class).to receive(:loaded_vendors).and_return(loaded_vendors, loaded_vendors) + end + + context 'when a stage is missing an external vendor' do + let(:stages) { %i[foo baz] } + + it 'does not raise' do + expect { subject }.not_to raise_error + end + + it 'returns the mapped vendors missing the stage' do + expect(subject).to eq({ foo: loaded_vendors.second }) + end + end + end + end +end diff --git a/spec/services/idv/upcase_vendor_env_vars_spec.rb b/spec/services/idv/upcase_vendor_env_vars_spec.rb deleted file mode 100644 index 30912329e0c..00000000000 --- a/spec/services/idv/upcase_vendor_env_vars_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'rails_helper' - -describe Idv::UpcaseVendorEnvVars do - describe '#call' do - before do - allow(Figaro.env).to receive(:profile_proofing_vendor).and_return('equifax') - allow(Figaro.env).to receive(:state_id_proofing_vendor).and_return('aamva') - allow(Figaro.env).to receive(:phone_proofing_vendor).and_return('equifax') - stub_const 'ENV', ENV.to_h.merge( - 'equifax_thing' => 'some value', - 'aamva_thing' => 'other value' - ) - end - - it 'sets UPPER case value equal to existing ENV var' do - expect(ENV['FOO_THING']).to eq nil - - subject.call - - expect(ENV['equifax_thing']).to eq 'some value' - expect(ENV['EQUIFAX_THING']).to eq 'some value' - expect(ENV['aamva_thing']).to eq 'other value' - expect(ENV['AAMVA_THING']).to eq 'other value' - end - end -end diff --git a/spec/services/idv/vendor_result_spec.rb b/spec/services/idv/vendor_result_spec.rb index ca065bb6f64..e19651dccc5 100644 --- a/spec/services/idv/vendor_result_spec.rb +++ b/spec/services/idv/vendor_result_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Idv::VendorResult do let(:success) { true } let(:errors) { { foo: ['is not valid'] } } - let(:reasons) { %w[foo bar baz] } + let(:messages) { %w[foo bar baz] } let(:normalized_applicant) { { last_name: 'Ever', first_name: 'Greatest' } } let(:timed_out) { false } @@ -11,7 +11,7 @@ Idv::VendorResult.new( success: success, errors: errors, - reasons: reasons, + messages: messages, normalized_applicant: normalized_applicant, timed_out: timed_out ) @@ -44,7 +44,7 @@ it 'has simple attributes' do expect(new_from_json.success?).to eq(vendor_result.success?) expect(new_from_json.errors).to eq(vendor_result.errors) - expect(new_from_json.reasons).to eq(vendor_result.reasons) + expect(new_from_json.messages).to eq(vendor_result.messages) end it 'turns applicant into a full object' do diff --git a/spec/services/vendor_validator_result_storage_spec.rb b/spec/services/vendor_validator_result_storage_spec.rb index 7261080a27c..63505b53f0e 100644 --- a/spec/services/vendor_validator_result_storage_spec.rb +++ b/spec/services/vendor_validator_result_storage_spec.rb @@ -35,7 +35,7 @@ expect(result.success?).to eq(original_result.success?) expect(result.errors).to eq(original_result.errors) - expect(result.reasons).to eq(original_result.reasons) + expect(result.messages).to eq(original_result.messages) expect(result.normalized_applicant.as_json). to eq(original_result.normalized_applicant.as_json) end From 13597945806aa2d8e0215caa731e038a59fedd1d Mon Sep 17 00:00:00 2001 From: Andy Brody Date: Fri, 4 May 2018 14:44:54 -0400 Subject: [PATCH 17/19] Allow configuring job queue_adapter with choices. (#2121) * Fix exception notifier in sandbox envs. * Allow configuring job queue_adapter with choices. **Why**: We are currently exploring ways to remedy the unreliability of our ActiveJob queue delivery. Currently we drop jobs whenever worker servers are terminated because Sidekiq does not guarantee reliable or timely delivery. **How**: Allow setting config.active_job.queue_adapter from Figaro.env.queue_adapter_weights, which is parsed as JSON and should contain a hash mapping queue adapter to integer weight. The app will pick among these choices at random. For example, to tell the app to use `:sidekiq` on 90% of startups and `:async` on 10%, set: Figaro.env.queue_adapter_weights = '{"sidekiq": 90, "async": 10}' * Add some more input validation to sample. * Add explicit config example for queue adapter. * Add a test for QueueConfig. --- app/views/exception_notifier/_data.text.erb | 6 +- config/application.rb | 4 +- config/application.yml.example | 4 ++ config/initializers/exception_notification.rb | 4 +- lib/queue_config.rb | 40 +++++++++++++ lib/random_tools.rb | 44 ++++++++++++++ spec/lib/queue_config_spec.rb | 25 ++++++++ spec/lib/random_tools_spec.rb | 59 +++++++++++++++++++ 8 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 lib/queue_config.rb create mode 100644 lib/random_tools.rb create mode 100644 spec/lib/queue_config_spec.rb create mode 100644 spec/lib/random_tools_spec.rb diff --git a/app/views/exception_notifier/_data.text.erb b/app/views/exception_notifier/_data.text.erb index 1be593c38dc..711ad8f71fc 100644 --- a/app/views/exception_notifier/_data.text.erb +++ b/app/views/exception_notifier/_data.text.erb @@ -5,7 +5,7 @@ Queue: <%= job['queue'] %> Retry: <%= job['retry'] %> Error class: <%= job['error_class'] %> Error message: <%= job['error_message'] %> -Created at: <%= Time.at(job['created_at']) %> -Enqueued at: <%= Time.at(job['enqueued_at']) %> -Failed at: <%= Time.at(job['failed_at']) %> +Created at: <%= Time.at(job['created_at']) rescue 'unknown' %> +Enqueued at: <%= Time.at(job['enqueued_at']) rescue 'unknown' %> +Failed at: <%= Time.at(job['failed_at']) rescue 'unknown' %> Retry count: <%= job['retry_count'] %> diff --git a/config/application.rb b/config/application.rb index d326fa1a07d..dcdcb81eed2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -3,11 +3,13 @@ Bundler.require(*Rails.groups) +require_relative '../lib/queue_config.rb' + APP_NAME = 'login.gov'.freeze module Upaya class Application < Rails::Application - config.active_job.queue_adapter = :sidekiq + config.active_job.queue_adapter = Upaya::QueueConfig.choose_queue_adapter config.autoload_paths << Rails.root.join('app', 'mailers', 'concerns') config.time_zone = 'UTC' diff --git a/config/application.yml.example b/config/application.yml.example index 2b90ee3c880..dd093a003be 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -33,6 +33,10 @@ queue_health_check_dead_interval_seconds: '60' # How often to enqueue simple jobs to make sure the background queues are running queue_health_check_frequency_seconds: '30' +# Configuration to probabilistically select the config.active_job.queue_adapter +# Currently known options are: sidekiq, async, inline +queue_adapter_weights: '{"sidekiq": 1}' + # The number of words in the personal key phrase recovery_code_length: '4' diff --git a/config/initializers/exception_notification.rb b/config/initializers/exception_notification.rb index 715252292e0..eaf7359ea2a 100644 --- a/config/initializers/exception_notification.rb +++ b/config/initializers/exception_notification.rb @@ -6,8 +6,8 @@ ExceptionNotification.configure do |config| config.add_notifier( :email, - email_prefix: "[#{APP_NAME} EXCEPTION - #{LoginGov::Hostdata.env}] ", - sender_address: %("Exception Notifier" ), + email_prefix: "[#{LoginGov::Hostdata.domain} EXCEPTION - #{LoginGov::Hostdata.env}] ", + sender_address: %("Exception Notifier" ), exception_recipients: EXCEPTION_RECIPIENTS, error_grouping: true, sections: %w[request backtrace session] diff --git a/lib/queue_config.rb b/lib/queue_config.rb new file mode 100644 index 00000000000..2853281f34a --- /dev/null +++ b/lib/queue_config.rb @@ -0,0 +1,40 @@ +require_relative './random_tools.rb' + +module Upaya + module QueueConfig + # rubocop:disable Metrics/MethodLength + + # Known acceptable values for config.active_job.queue_adapter + KNOWN_QUEUE_ADAPTERS = %i[sidekiq inline async].freeze + + # Select a queue adapter for use, including possible random weights as + # defined by Figaro.env.queue_adapter_weights (a JSON mapping from queue + # adapters to integer weights).. + def self.choose_queue_adapter + adapter_config = Figaro.env.queue_adapter_weights + + # default to Sidekiq if no config present + return :sidekiq unless adapter_config + + options = JSON.parse(adapter_config, symbolize_names: true) + + options.each_key do |adapter| + unless KNOWN_QUEUE_ADAPTERS.include?(adapter) + raise ArgumentError, "Unknown queue adapter: #{adapter.inspect}" + end + end + + result = Upaya::RandomTools.random_weighted_sample(options) + + logger.info("Selected config.active_job.queue_adapter = #{result.inspect}") + + result + end + + def self.logger + @log ||= Rails.logger || Logger.new(STDOUT) + end + + # rubocop:enable Metrics/MethodLength + end +end diff --git a/lib/random_tools.rb b/lib/random_tools.rb new file mode 100644 index 00000000000..2a9c69eb5bb --- /dev/null +++ b/lib/random_tools.rb @@ -0,0 +1,44 @@ +module Upaya + module RandomTools + # rubocop:disable MethodLength, Style/IfUnlessModifier, Metrics/AbcSize + + # Randomly choose an item among choices with integer weights. For example, + # if passed `{'a' => 3, 'b' => 1}`, the expected return value will approach + # 'a' 75% of the time and 'b' 25% of the time. + # + # @param [Hash{Object => Integer}] choices A mapping from each choice to an + # integer weight. The weights will be relative to the sum of all weights. + # + # @return [Object] One of the keys chosen from the choices hash. + # + def self.random_weighted_sample(choices) + if choices.empty? + raise ArgumentError, 'Cannot choose among empty choices hash' + end + + sum = 0 + choices.each_pair do |item, weight| + unless weight.is_a?(Integer) && weight >= 0 + raise ArgumentError, "Choices must have >= 0 integer weights, " \ + "got #{item.inspect} => #{weight.inspect}" + end + sum += weight + end + + if sum.zero? + raise ArgumentError, 'Must have non-zero weight among choices' + end + + target = rand(sum) + + choices.each_pair do |item, weight| + return item if target < weight + target -= weight + end + + raise NotImplementedError, 'This line should not be reached' + end + + # rubocop:enable MethodLength, Style/IfUnlessModifier, Metrics/AbcSize + end +end diff --git a/spec/lib/queue_config_spec.rb b/spec/lib/queue_config_spec.rb new file mode 100644 index 00000000000..78cb1a3b4a1 --- /dev/null +++ b/spec/lib/queue_config_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe Upaya::QueueConfig do + describe '.choose_queue_adapter' do + it 'raises ArgumentError given invalid choice' do + expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"invalid": 1}') + expect { + Upaya::QueueConfig.choose_queue_adapter + }.to raise_error(ArgumentError, /Unknown queue adapter/) + end + + it 'handles sidekiq' do + expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"sidekiq": 1}') + expect(Upaya::QueueConfig.choose_queue_adapter).to eq :sidekiq + end + it 'handles async' do + expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"async": 1, "sidekiq": 0}') + expect(Upaya::QueueConfig.choose_queue_adapter).to eq :async + end + it 'handles inline' do + expect(Figaro.env).to receive(:queue_adapter_weights).and_return('{"inline": 1}') + expect(Upaya::QueueConfig.choose_queue_adapter).to eq :inline + end + end +end diff --git a/spec/lib/random_tools_spec.rb b/spec/lib/random_tools_spec.rb new file mode 100644 index 00000000000..1bd9deb5361 --- /dev/null +++ b/spec/lib/random_tools_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe Upaya::RandomTools do + describe '#random_weighted_sample' do + it 'raises ArgumentError given empty choices' do + expect { + Upaya::RandomTools.random_weighted_sample({}) + }.to raise_error(ArgumentError, /empty choices/) + end + + it 'handles equal weights -- 0' do + expect(Upaya::RandomTools).to receive(:rand).with(2).and_return(0) + input = { A: 1, B: 1 } + expect(Upaya::RandomTools.random_weighted_sample(input)).to eq :A + end + it 'handles equal weights -- 1' do + expect(Upaya::RandomTools).to receive(:rand).with(2).and_return(1) + input = { A: 1, B: 1 } + expect(Upaya::RandomTools.random_weighted_sample(input)).to eq :B + end + + it 'handles complex weights' do + input = { A: 1, B: 1, C: 4, D: 2, E: 2 } + [ + [0, :A], + [1, :B], + [2, :C], + [3, :C], + [4, :C], + [5, :C], + [6, :D], + [7, :D], + [8, :E], + [9, :E], + ].each do |rand_result, expected_return_value| + expect(Upaya::RandomTools).to receive(:rand).with(10).and_return(rand_result) + expect(Upaya::RandomTools.random_weighted_sample(input)).to eq expected_return_value + end + end + + it 'rejects non-integer weights' do + expect { + Upaya::RandomTools.random_weighted_sample(a: 1.5) + }.to raise_error(ArgumentError, /integer/) + end + + it 'rejects negative weights' do + expect { + Upaya::RandomTools.random_weighted_sample(a: 10, b: -1) + }.to raise_error(ArgumentError, />= 0/) + end + + it 'rejects weights sum to zero' do + expect { + Upaya::RandomTools.random_weighted_sample(a: 0) + }.to raise_error(ArgumentError, /non-zero/) + end + end +end From 201a5ad1e0cc29ffaa103f6493f5880b31fce310 Mon Sep 17 00:00:00 2001 From: David Corwin Date: Fri, 4 May 2018 16:56:09 -0700 Subject: [PATCH 18/19] LG-214 Update `remember device` translations (#2130) **WHY** - To be more accurate by specifying `browser` instead of `device` - To be more helpful to our spanish and french speaking friends --- README.md | 4 ++-- config/locales/forms/en.yml | 2 +- config/locales/forms/es.yml | 2 +- config/locales/forms/fr.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9672ef494d1..08804069a41 100644 --- a/README.md +++ b/README.md @@ -265,10 +265,10 @@ login.gov team for credentials and other values. ### Managing translation files -To help us handle extra newlines and make sure we wrap lines consistently, we have a script called `./script/normalize-yaml` that helps format YAML consistently. After importing translations (or making changes to the *.yml files with strings, run this for the IDP app: +To help us handle extra newlines and make sure we wrap lines consistently, we have a script called `./scripts/normalize-yaml` that helps format YAML consistently. After importing translations (or making changes to the *.yml files with strings, run this for the IDP app: ``` $ make normalize_yaml ``` -[mac-test-passphrase-prompt]: mac-test-passphrase-prompt.png "Mac Test Passphrase Prompt" \ No newline at end of file +[mac-test-passphrase-prompt]: mac-test-passphrase-prompt.png "Mac Test Passphrase Prompt" diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index e3ed8257175..a8b1eab00f1 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -17,7 +17,7 @@ en: show_hdr: Create a strong password messages: current_address: You should be be able to receive mail at this address. - remember_device: Remember this device for %{duration} days + remember_device: Remember this browser for %{duration} days passwords: edit: buttons: diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index 2a37fbcacd1..c698cc70954 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -17,7 +17,7 @@ es: show_hdr: Crear una contraseña segura messages: current_address: Debería poder recibir correo en esta dirección. - remember_device: NOT TRANSLATED YET + remember_device: Recuerde este navegador por %{duration} días passwords: edit: buttons: diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index a6fbaa9e070..9cdb6c8ee2f 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -18,7 +18,7 @@ fr: messages: current_address: Vous devriez être en mesure de recevoir du courrier à cette adresse. - remember_device: NOT TRANSLATED YET + remember_device: Enregistrer ce navigateur pendant %{duration} jours passwords: edit: buttons: From d0a161295c326692d625f1f74c605f56a3006b86 Mon Sep 17 00:00:00 2001 From: Steve Urciuoli Date: Fri, 4 May 2018 16:18:41 -0400 Subject: [PATCH 19/19] LG-237 Add Secret Service PIX SP to Production **Why**: USSS Pix has completed integration testing and is going live in prod **How**: Update service_providers.yml file, add cert, and logo. Add new agency USSS in agencies.yml --- app/assets/images/sp-logos/usss_pix.png | Bin 0 -> 46392 bytes certs/sp/usss_prod.crt | 22 ++++++++++++++++++++++ config/agencies.yml | 6 ++++++ config/service_providers.yml | 15 +++++++++++++++ 4 files changed, 43 insertions(+) create mode 100644 app/assets/images/sp-logos/usss_pix.png create mode 100644 certs/sp/usss_prod.crt diff --git a/app/assets/images/sp-logos/usss_pix.png b/app/assets/images/sp-logos/usss_pix.png new file mode 100644 index 0000000000000000000000000000000000000000..26581a23ccd0476c236a5242eaf58607af20a6af GIT binary patch literal 46392 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 w3`a5(BXs)b!VUjE{(T(p