From a293b09cf554384b4b2a32a5f904b97aae1313d4 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:07:17 -0500 Subject: [PATCH 01/19] LG-15173: Adding email in select flow links back to select page (#11805) * LG-15173: Adding email in select flow links back to select page changelog: Upcoming Features, Email Selection, Adding email in select flow links back to select page * Fix lint error on trailing comma --- app/controllers/users/emails_controller.rb | 25 ++-- app/views/users/emails/verify.html.erb | 45 ++++--- .../users/emails_controller_spec.rb | 115 +++++++++++++----- .../users/emails/verify.html.erb_spec.rb | 57 +++++++-- 4 files changed, 179 insertions(+), 63 deletions(-) diff --git a/app/controllers/users/emails_controller.rb b/app/controllers/users/emails_controller.rb index 240e1b90283..5ff987542bf 100644 --- a/app/controllers/users/emails_controller.rb +++ b/app/controllers/users/emails_controller.rb @@ -9,9 +9,10 @@ class EmailsController < ApplicationController before_action :check_max_emails_per_account, only: %i[show add] before_action :retain_confirmed_emails, only: %i[delete] before_action :confirm_recently_authenticated_2fa + before_action :validate_session_email, only: [:verify] def show - session[:in_select_email_flow] = true if params[:in_select_email_flow] + session[:in_select_email_flow] = in_select_email_flow_param analytics.add_email_visit(in_select_email_flow: in_select_email_flow?) @add_user_email_form = AddUserEmailForm.new @pending_completions_consent = pending_completions_consent? @@ -42,11 +43,11 @@ def resend analytics.resend_add_email_request(success: true) SendAddEmailConfirmation.new(current_user).call(email_address:, request_id:) flash[:success] = t('notices.resend_confirmation_email.success') - redirect_to add_email_verify_email_url + redirect_to add_email_verify_email_url(in_select_email_flow: in_select_email_flow_param) else analytics.resend_add_email_request(success: false) flash[:error] = t('errors.general') - redirect_to add_email_url + redirect_to add_email_url(in_select_email_flow: in_select_email_flow_param) end end @@ -71,16 +72,22 @@ def pending_completions_consent? end def verify - if session_email.blank? - redirect_to add_email_url - else - render :verify, - locals: { email: session_email, in_select_email_flow: params[:in_select_email_flow] } - end + @email = session_email + @in_select_email_flow = in_select_email_flow_param + @pending_completions_consent = pending_completions_consent? end private + def validate_session_email + return if session_email.present? + redirect_to add_email_url + end + + def in_select_email_flow_param + true if params[:in_select_email_flow].present? + end + def in_select_email_flow? session[:in_select_email_flow] == true end diff --git a/app/views/users/emails/verify.html.erb b/app/views/users/emails/verify.html.erb index 211b59fc6ee..8fc739fcd26 100644 --- a/app/views/users/emails/verify.html.erb +++ b/app/views/users/emails/verify.html.erb @@ -1,17 +1,9 @@ <% self.title = t('titles.verify_email') %> -<% if @resend_confirmation %> - <%= render AlertComponent.new( - type: :success, - class: 'margin-bottom-4', - message: t('notices.resend_confirmation_email.success'), - ) %> -<% end %> - <%= render PageHeadingComponent.new.with_content(t('headings.verify_email')) %>

<%= t('notices.signed_up_and_confirmed.first_paragraph_start') %> - <%= email %> + <%= @email %> <%= t('notices.signed_up_and_confirmed.first_paragraph_end') %>

@@ -20,17 +12,40 @@ <%= t('notices.signed_up_and_confirmed.no_email_sent_explanation_start') %> -<%= button_to(add_email_resend_path, method: :post, class: 'usa-button usa-button--unstyled', form_class: 'display-inline-block padding-left-1') { t('links.resend') } %> -

<%= t('notices.use_diff_email.text_html', link_html: link_to(t('notices.use_diff_email.link'), add_email_path(in_select_email_flow: in_select_email_flow))) %>

+<%= render ButtonComponent.new( + url: add_email_resend_path(in_select_email_flow: @in_select_email_flow), + method: :post, + unstyled: true, + form_class: 'display-inline-block padding-left-1', + ).with_content(t('links.resend')) %> + +

+ <%= t( + 'notices.use_diff_email.text_html', + link_html: link_to( + t('notices.use_diff_email.link'), + add_email_path(in_select_email_flow: @in_select_email_flow), + ), + ) %> +

+

<%= t('devise.registrations.close_window') %>

-<% if FeatureManagement.enable_load_testing_mode? && EmailAddress.find_with_email(email) %> +<% if FeatureManagement.enable_load_testing_mode? && EmailAddress.find_with_email(@email) %> <%= link_to( 'CONFIRM NOW', - sign_up_create_email_confirmation_url(confirmation_token: EmailAddress.find_with_email(email).confirmation_token), - id: 'confirm-now', + sign_up_create_email_confirmation_url(confirmation_token: EmailAddress.find_with_email(@email).confirmation_token), ) %> +
<% end %> -<%= link_to t('idv.messages.return_to_profile', app_name: APP_NAME), account_path %> +<% if @in_select_email_flow %> + <% if @pending_completions_consent %> + <%= link_to t('forms.buttons.back'), sign_up_select_email_path %> + <% else %> + <%= link_to t('forms.buttons.back'), account_connected_accounts_path %> + <% end %> +<% else %> + <%= link_to t('idv.messages.return_to_profile', app_name: APP_NAME), account_path %> +<% end %> diff --git a/spec/controllers/users/emails_controller_spec.rb b/spec/controllers/users/emails_controller_spec.rb index 08c98970eed..be5fa9f993a 100644 --- a/spec/controllers/users/emails_controller_spec.rb +++ b/spec/controllers/users/emails_controller_spec.rb @@ -89,10 +89,62 @@ end describe '#verify' do + subject(:response) { get :verify, params: params } + let(:email) { Faker::Internet.email } + let(:params) { {} } + + before do + stub_sign_in + session[:email] = email + end + + it 'assigns instance variables for view' do + response + + expect(assigns(:email)).to eq(email) + expect(assigns(:in_select_email_flow)).to be_nil + expect(assigns(:pending_completions_consent)).to eq(false) + end + + context 'in email select flow' do + let(:params) { super().merge(in_select_email_flow: true) } + + it 'assigns instance variables for view' do + response + + expect(assigns(:email)).to eq(email) + expect(assigns(:in_select_email_flow)).to eq(true) + expect(assigns(:pending_completions_consent)).to eq(false) + end + end + + context 'with pending completions consent' do + before do + allow(controller).to receive(:needs_completion_screen_reason).and_return(:new_sp) + end + + it 'assigns instance variables for view' do + response + + expect(assigns(:email)).to eq(email) + expect(assigns(:in_select_email_flow)).to be_nil + expect(assigns(:pending_completions_consent)).to eq(true) + end + end + + context 'without session email' do + let(:email) { nil } + + it 'redirects to add email page' do + expect(response).to redirect_to add_email_url + end + end + context 'with malformed payload' do + let(:params) { super().merge(request_id: { foo: 'bar' }) } + it 'does not blow up' do - expect { get :verify, params: { request_id: { foo: 'bar' } } } - .to_not raise_error + expect { response }.to_not raise_error end end end @@ -158,52 +210,53 @@ end describe '#resend' do + subject(:response) { post :resend, params: params } + let(:params) { {} } + let(:email) { create(:email_address, :unconfirmed, user:).email } let(:user) { create(:user) } + before do stub_sign_in(user) stub_analytics + session[:email] = email end - context 'valid email exists in session' do - it 'sends email' do - email = Faker::Internet.email + it 'sends email' do + response - post :add, params: { user: { email: email } } + expect(@analytics).to have_logged_event('Resend Add Email Requested', success: true) + expect(last_email_sent).to have_subject( + t('user_mailer.email_confirmation_instructions.subject'), + ) - expect(@analytics).to have_logged_event( - 'Add Email Requested', - success: true, - user_id: user.uuid, - domain_name: email.split('@').last, - in_select_email_flow: false, - ) + expect(response).to redirect_to(add_email_verify_email_url) + expect(last_email_sent).to have_subject( + t('user_mailer.email_confirmation_instructions.subject'), + ) + expect(ActionMailer::Base.deliveries.count).to eq 1 + end - post :resend + it 'flashes success message' do + response - expect(@analytics).to have_logged_event( - 'Resend Add Email Requested', - { success: true }, - ) - expect(last_email_sent).to have_subject( - t('user_mailer.email_confirmation_instructions.subject'), - ) + expect(flash[:success]).to eq(t('notices.resend_confirmation_email.success')) + end - expect(response).to redirect_to(add_email_verify_email_url) - expect(last_email_sent).to have_subject( - t('user_mailer.email_confirmation_instructions.subject'), - ) - expect(ActionMailer::Base.deliveries.count).to eq 2 + context 'in select email flow' do + let(:params) { super().merge(in_select_email_flow: true) } + + it 'includes select email parameter in redirect url' do + expect(response).to redirect_to add_email_verify_email_url(in_select_email_flow: true) end end context 'no valid email exists in session' do + let(:email) { nil } + it 'shows an error and redirects to add email page' do - post :resend + response - expect(@analytics).to have_logged_event( - 'Resend Add Email Requested', - { success: false }, - ) + expect(@analytics).to have_logged_event('Resend Add Email Requested', success: false) expect(flash[:error]).to eq t('errors.general') expect(response).to redirect_to(add_email_url) expect(ActionMailer::Base.deliveries.count).to eq 0 diff --git a/spec/views/users/emails/verify.html.erb_spec.rb b/spec/views/users/emails/verify.html.erb_spec.rb index f90ae443004..66cd3a1c8d0 100644 --- a/spec/views/users/emails/verify.html.erb_spec.rb +++ b/spec/views/users/emails/verify.html.erb_spec.rb @@ -1,11 +1,14 @@ require 'rails_helper' RSpec.describe 'users/emails/verify.html.erb' do + subject(:rendered) { render } let(:email) { 'foo@bar.com' } + let(:in_select_email_flow) { nil } + let(:pending_completions_consent) { false } before do - allow(view).to receive(:email).and_return(email) - allow(view).to receive(:in_select_email_flow).and_return(nil) - @resend_email_confirmation_form = ResendEmailConfirmationForm.new + assign(:email, email) + assign(:in_select_email_flow, in_select_email_flow) + assign(:pending_completions_consent, pending_completions_consent) end it 'has a localized title' do @@ -15,15 +18,21 @@ end it 'has a localized header' do - render - expect(rendered).to have_selector('h1', text: t('headings.verify_email')) end it 'contains link to resend confirmation page' do - render + expect(rendered).to have_css( + "form[action='#{add_email_resend_path}'] button", + text: t('links.resend'), + ) + end - expect(rendered).to have_button(t('links.resend')) + it 'contains link to return to account page' do + expect(rendered).to have_link( + t('idv.messages.return_to_profile', app_name: APP_NAME), + href: account_path, + ) end context 'when enable_load_testing_mode? is true and email address found' do @@ -38,7 +47,6 @@ expect(rendered).to have_link( 'CONFIRM NOW', href: sign_up_create_email_confirmation_url(confirmation_token: 'some_token'), - id: 'confirm-now', ) end end @@ -66,4 +74,37 @@ expect(rendered).not_to have_link('CONFIRM NOW', href: sign_up_create_email_confirmation_url) end end + + context 'when in email select flow' do + let(:in_select_email_flow) { true } + + it 'maintains email select flow parameter for resend' do + expect(rendered).to have_css( + "form[action='#{add_email_resend_path(in_select_email_flow: true)}'] button", + text: t('links.resend'), + ) + end + + context 'in sign up completions flow' do + let(:pending_completions_consent) { true } + + it 'contains a link to return back to sign up select email selection screen' do + expect(rendered).to have_link( + t('forms.buttons.back'), + href: sign_up_select_email_path, + ) + end + end + + context 'in connected accounts flow' do + let(:pending_completions_consent) { false } + + it 'contains a link to return back to connected accounts screen' do + expect(rendered).to have_link( + t('forms.buttons.back'), + href: account_connected_accounts_path, + ) + end + end + end end From 4eddf187da7b26842931d1b6df0ba89727fbabec Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:24:02 -0500 Subject: [PATCH 02/19] Raise error when analytics test hash_including is exactly equal (#11802) * Raise error when analytics test hash_including is exactly equal changelog: Internal, Automated Testing, Raise error when analytics test hash_including is exactly equal * Exempt shared examples from hash_including check Since some use of shared example can match exactly while others don't * Simplify in_shared_example? to use top-level metadata property --- .../event_disavowal_controller_spec.rb | 42 ++++--- .../by_mail/request_letter_controller_spec.rb | 8 +- .../by_mail/resend_letter_controller_spec.rb | 18 ++- .../idv/cancellations_controller_spec.rb | 30 ++--- .../idv/enter_password_controller_spec.rb | 69 +++++------- .../idv/otp_verification_controller_spec.rb | 12 +- .../idv/personal_key_controller_spec.rb | 50 ++++----- spec/controllers/idv/phone_controller_spec.rb | 106 ++++++++---------- .../idv/session_errors_controller_spec.rb | 24 ++-- .../idv/sessions_controller_spec.rb | 24 ++-- .../openid_connect/logout_controller_spec.rb | 42 +++---- .../redirect/help_center_controller_spec.rb | 7 +- .../marketing_site_controller_spec.rb | 2 +- .../redirect/return_to_sp_controller_spec.rb | 16 ++- spec/controllers/saml_idp_controller_spec.rb | 16 ++- spec/controllers/sign_out_controller_spec.rb | 3 +- .../backup_code_setup_controller_spec.rb | 2 +- .../users/email_language_controller_spec.rb | 2 +- .../users/rules_of_use_controller_spec.rb | 2 +- .../users/sessions_controller_spec.rb | 6 +- spec/features/idv/cancel_spec.rb | 34 +++--- spec/support/fake_analytics_spec.rb | 56 ++++++--- spec/support/have_logged_event_matcher.rb | 28 ++++- 23 files changed, 294 insertions(+), 305 deletions(-) diff --git a/spec/controllers/event_disavowal_controller_spec.rb b/spec/controllers/event_disavowal_controller_spec.rb index f4e4b6180b2..3ca8d2d91ee 100644 --- a/spec/controllers/event_disavowal_controller_spec.rb +++ b/spec/controllers/event_disavowal_controller_spec.rb @@ -46,8 +46,10 @@ expect(@analytics).to have_logged_event( 'Event disavowal token invalid', build_analytics_hash( + user_id: event.user.uuid, success: false, errors: { event: [t('event_disavowals.errors.event_already_disavowed')] }, + error_details: { event: { event_already_disavowed: true } }, ), ) end @@ -60,8 +62,10 @@ expect(@analytics).to have_logged_event( 'Event disavowal token invalid', build_analytics_hash( + user_id: event.user.uuid, success: false, errors: { event: [t('event_disavowals.errors.event_already_disavowed')] }, + error_details: { event: { event_already_disavowed: true } }, ), ) expect(assigns(:forbidden_passwords)).to be_nil @@ -96,6 +100,7 @@ expect(@analytics).to have_logged_event( 'Event disavowal password reset', build_analytics_hash( + user_id: event.user.uuid, success: false, errors: { password: [ @@ -104,6 +109,7 @@ ), ], }, + error_details: { password: { too_short: true } }, ), ) end @@ -119,8 +125,10 @@ expect(@analytics).to have_logged_event( 'Event disavowal password reset', build_analytics_hash( + user_id: event.user.uuid, success: false, errors: { password: ['Password must be at least 12 characters long'] }, + error_details: { password: { too_short: true } }, ), ) expect(assigns(:forbidden_passwords)).to all(be_a(String)) @@ -141,8 +149,10 @@ expect(@analytics).to have_logged_event( 'Event disavowal token invalid', build_analytics_hash( + user_id: event.user.uuid, success: false, errors: { event: [t('event_disavowals.errors.event_already_disavowed')] }, + error_details: { event: { event_already_disavowed: true } }, ), ) end @@ -166,26 +176,28 @@ errors: { user: [t('event_disavowals.errors.no_account')], }, + error_details: { + user: { blank: true }, + }, ), ) end end end - def build_analytics_hash(success: true, errors: nil, user_id: nil) - hash_including( - { - event_created_at: event.created_at, - disavowed_device_last_used_at: event.device&.last_used_at, - success: success, - errors: errors, - event_id: event.id, - event_type: event.event_type, - event_ip: event.ip, - disavowed_device_user_agent: event.device.user_agent, - disavowed_device_last_ip: event.device.last_ip, - user_id: user_id, - }.compact, - ) + def build_analytics_hash(success: true, errors: nil, error_details: nil, user_id: nil) + { + event_created_at: event.created_at, + disavowed_device_last_used_at: event.device&.last_used_at, + success:, + errors:, + error_details:, + event_id: event.id, + event_type: event.event_type, + event_ip: event.ip, + disavowed_device_user_agent: event.device.user_agent, + disavowed_device_last_ip: event.device.last_ip, + user_id:, + }.compact end end diff --git a/spec/controllers/idv/by_mail/request_letter_controller_spec.rb b/spec/controllers/idv/by_mail/request_letter_controller_spec.rb index 6cb398c660d..9e18598d4b0 100644 --- a/spec/controllers/idv/by_mail/request_letter_controller_spec.rb +++ b/spec/controllers/idv/by_mail/request_letter_controller_spec.rb @@ -94,11 +94,9 @@ expect(@analytics).to have_logged_event( 'IdV: USPS address letter requested', - hash_including( - resend: false, - phone_step_attempts: 1, - hours_since_first_letter: 0, - ), + resend: false, + phone_step_attempts: 1, + hours_since_first_letter: 0, ) end diff --git a/spec/controllers/idv/by_mail/resend_letter_controller_spec.rb b/spec/controllers/idv/by_mail/resend_letter_controller_spec.rb index 4884ef2ab84..72d98cc1943 100644 --- a/spec/controllers/idv/by_mail/resend_letter_controller_spec.rb +++ b/spec/controllers/idv/by_mail/resend_letter_controller_spec.rb @@ -66,11 +66,10 @@ expect(@analytics).to have_logged_event( 'IdV: USPS address letter requested', - hash_including( - resend: true, - first_letter_requested_at: user.pending_profile.gpo_verification_pending_at, - hours_since_first_letter: 24, - ), + resend: true, + first_letter_requested_at: user.pending_profile.gpo_verification_pending_at, + hours_since_first_letter: 24, + phone_step_attempts: 0, ) expect(@analytics).to have_logged_event( @@ -90,11 +89,10 @@ expect(@analytics).to have_logged_event( 'IdV: USPS address letter requested', - hash_including( - resend: true, - first_letter_requested_at: user.pending_profile.gpo_verification_pending_at, - hours_since_first_letter: 24, - ), + resend: true, + first_letter_requested_at: user.pending_profile.gpo_verification_pending_at, + hours_since_first_letter: 24, + phone_step_attempts: 0, ) expect(@analytics).to have_logged_event( diff --git a/spec/controllers/idv/cancellations_controller_spec.rb b/spec/controllers/idv/cancellations_controller_spec.rb index 12ef3b256c1..915d79ef909 100644 --- a/spec/controllers/idv/cancellations_controller_spec.rb +++ b/spec/controllers/idv/cancellations_controller_spec.rb @@ -22,9 +22,7 @@ expect(@analytics).to have_logged_event( 'IdV: cancellation visited', - hash_including( - request_came_from: 'no referer', - ), + request_came_from: 'no referer', ) end @@ -37,9 +35,7 @@ expect(@analytics).to have_logged_event( 'IdV: cancellation visited', - hash_including( - request_came_from: 'users/sessions#new', - ), + request_came_from: 'users/sessions#new', ) end @@ -51,10 +47,8 @@ expect(@analytics).to have_logged_event( 'IdV: cancellation visited', - hash_including( - request_came_from: 'no referer', - step: 'first', - ), + request_came_from: 'no referer', + step: 'first', ) end @@ -116,9 +110,7 @@ expect(@analytics).to have_logged_event( 'IdV: cancellation go back', - hash_including( - step: 'first', - ), + step: 'first', ) end @@ -136,12 +128,10 @@ expect(@analytics).to have_logged_event( 'IdV: cancellation go back', - hash_including( - step: 'barcode', - cancelled_enrollment: false, - enrollment_code: enrollment.enrollment_code, - enrollment_id: enrollment.id, - ), + step: 'barcode', + cancelled_enrollment: false, + enrollment_code: enrollment.enrollment_code, + enrollment_id: enrollment.id, ) end end @@ -172,7 +162,7 @@ expect(@analytics).to have_logged_event( 'IdV: cancellation confirmed', - hash_including(step: 'first'), + step: 'first', ) end diff --git a/spec/controllers/idv/enter_password_controller_spec.rb b/spec/controllers/idv/enter_password_controller_spec.rb index 7f88fcc31d0..ed86a3988e1 100644 --- a/spec/controllers/idv/enter_password_controller_spec.rb +++ b/spec/controllers/idv/enter_password_controller_spec.rb @@ -274,13 +274,11 @@ def show expect(@analytics).to have_logged_event( :idv_enter_password_submitted, - hash_including( - success: false, - fraud_review_pending: false, - fraud_rejection: false, - gpo_verification_pending: false, - in_person_verification_pending: false, - ), + success: false, + fraud_review_pending: false, + fraud_rejection: false, + gpo_verification_pending: false, + in_person_verification_pending: false, ) end end @@ -290,14 +288,12 @@ def show expect(@analytics).to have_logged_event( :idv_enter_password_submitted, - hash_including( - success: true, - fraud_review_pending: false, - fraud_rejection: false, - gpo_verification_pending: false, - in_person_verification_pending: false, - proofing_workflow_time_in_seconds: 5.minutes.to_i, - ), + success: true, + fraud_review_pending: false, + fraud_rejection: false, + gpo_verification_pending: false, + in_person_verification_pending: false, + proofing_workflow_time_in_seconds: 5.minutes.to_i, ) expect(@analytics).to have_logged_event( 'IdV: final resolution', @@ -891,16 +887,15 @@ def show put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } expect(@analytics).to have_logged_event( :idv_enter_password_submitted, - hash_including( - { - success: true, - fraud_review_pending: fraud_review_pending?, - fraud_pending_reason: fraud_pending_reason, - fraud_rejection: false, - gpo_verification_pending: false, - in_person_verification_pending: false, - }.compact, - ), + { + success: true, + fraud_review_pending: fraud_review_pending?, + fraud_pending_reason: fraud_pending_reason, + fraud_rejection: false, + gpo_verification_pending: false, + in_person_verification_pending: false, + proofing_workflow_time_in_seconds: kind_of(Numeric), + }.compact, ) expect(@analytics).to have_logged_event( 'IdV: final resolution', @@ -963,13 +958,11 @@ def show expect(@analytics).to have_logged_event( 'IdV: USPS address letter enqueued', - hash_including( - resend: false, - enqueued_at: Time.zone.now, - phone_step_attempts: 1, - first_letter_requested_at: subject.idv_session.profile.gpo_verification_pending_at, - hours_since_first_letter: 0, - ), + resend: false, + enqueued_at: Time.zone.now, + phone_step_attempts: 1, + first_letter_requested_at: subject.idv_session.profile.gpo_verification_pending_at, + hours_since_first_letter: 0, ) end @@ -982,13 +975,11 @@ def show expect(@analytics).to have_logged_event( 'IdV: USPS address letter enqueued', - hash_including( - resend: false, - enqueued_at: Time.zone.now, - phone_step_attempts: RateLimiter.max_attempts(rate_limit_type), - first_letter_requested_at: subject.idv_session.profile.gpo_verification_pending_at, - hours_since_first_letter: 0, - ), + resend: false, + enqueued_at: Time.zone.now, + phone_step_attempts: RateLimiter.max_attempts(rate_limit_type), + first_letter_requested_at: subject.idv_session.profile.gpo_verification_pending_at, + hours_since_first_letter: 0, ) end end diff --git a/spec/controllers/idv/otp_verification_controller_spec.rb b/spec/controllers/idv/otp_verification_controller_spec.rb index f308199a8e8..49c5de05c7d 100644 --- a/spec/controllers/idv/otp_verification_controller_spec.rb +++ b/spec/controllers/idv/otp_verification_controller_spec.rb @@ -158,13 +158,11 @@ expect(@analytics).to have_logged_event( 'IdV: phone confirmation otp submitted', - hash_including( - success: true, - code_expired: false, - code_matches: true, - otp_delivery_preference: :sms, - second_factor_attempts_count: 0, - ), + success: true, + code_expired: false, + code_matches: true, + otp_delivery_preference: :sms, + second_factor_attempts_count: 0, ) end end diff --git a/spec/controllers/idv/personal_key_controller_spec.rb b/spec/controllers/idv/personal_key_controller_spec.rb index 5128d3cdd01..7d95759da4d 100644 --- a/spec/controllers/idv/personal_key_controller_spec.rb +++ b/spec/controllers/idv/personal_key_controller_spec.rb @@ -468,12 +468,10 @@ def assert_personal_key_generated_for_profiles(*profile_pii_pairs) expect(@analytics).to have_logged_event( 'IdV: personal key submitted', - hash_including( - address_verification_method: 'phone', - fraud_review_pending: false, - fraud_rejection: false, - in_person_verification_pending: false, - ), + address_verification_method: 'phone', + fraud_review_pending: false, + fraud_rejection: false, + in_person_verification_pending: false, ) end end @@ -510,12 +508,10 @@ def assert_personal_key_generated_for_profiles(*profile_pii_pairs) expect(@analytics).to have_logged_event( 'IdV: personal key submitted', - hash_including( - address_verification_method: 'gpo', - fraud_review_pending: false, - fraud_rejection: false, - in_person_verification_pending: false, - ), + address_verification_method: 'gpo', + fraud_review_pending: false, + fraud_rejection: false, + in_person_verification_pending: false, ) end end @@ -540,12 +536,10 @@ def assert_personal_key_generated_for_profiles(*profile_pii_pairs) expect(@analytics).to have_logged_event( 'IdV: personal key submitted', - hash_including( - address_verification_method: 'phone', - fraud_review_pending: false, - fraud_rejection: false, - in_person_verification_pending: false, - ), + address_verification_method: 'phone', + fraud_review_pending: false, + fraud_rejection: false, + in_person_verification_pending: false, ) end end @@ -568,12 +562,10 @@ def assert_personal_key_generated_for_profiles(*profile_pii_pairs) expect(@analytics).to have_logged_event( 'IdV: personal key submitted', - hash_including( - address_verification_method: 'phone', - fraud_review_pending: false, - fraud_rejection: false, - in_person_verification_pending: false, - ), + address_verification_method: 'phone', + fraud_review_pending: false, + fraud_rejection: false, + in_person_verification_pending: false, ) end end @@ -595,12 +587,10 @@ def assert_personal_key_generated_for_profiles(*profile_pii_pairs) expect(@analytics).to have_logged_event( 'IdV: personal key submitted', - hash_including( - fraud_review_pending: true, - fraud_rejection: false, - address_verification_method: 'phone', - in_person_verification_pending: false, - ), + fraud_review_pending: true, + fraud_rejection: false, + address_verification_method: 'phone', + in_person_verification_pending: false, ) end end diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index 8295394f36a..e572dd1a4ee 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -289,21 +289,19 @@ expect(@analytics).to have_logged_event( 'IdV: phone confirmation form', - hash_including( - success: false, - errors: { - phone: [improbable_phone_message], - otp_delivery_preference: [improbable_otp_message], - }, - error_details: { - phone: { improbable_phone: true }, - otp_delivery_preference: { inclusion: true }, - }, - carrier: 'Test Mobile Carrier', - phone_type: :mobile, - otp_delivery_preference: '🎷', - types: [], - ), + success: false, + errors: { + phone: [improbable_phone_message], + otp_delivery_preference: [improbable_otp_message], + }, + error_details: { + phone: { improbable_phone: true }, + otp_delivery_preference: { inclusion: true }, + }, + carrier: 'Test Mobile Carrier', + phone_type: :mobile, + otp_delivery_preference: '🎷', + types: [], ) expect(subject.idv_session.vendor_phone_confirmation).to be_falsy @@ -335,15 +333,13 @@ expect(@analytics).to have_logged_event( 'IdV: phone confirmation form', - hash_including( - success: true, - area_code: '703', - country_code: 'US', - carrier: 'Test Mobile Carrier', - phone_type: :mobile, - otp_delivery_preference: 'sms', - types: [:fixed_or_mobile], - ), + success: true, + area_code: '703', + country_code: 'US', + carrier: 'Test Mobile Carrier', + phone_type: :mobile, + otp_delivery_preference: 'sms', + types: [:fixed_or_mobile], ) end @@ -436,21 +432,19 @@ expect(@analytics).to have_logged_event( 'IdV: phone confirmation vendor', - hash_including( - success: true, - new_phone_added: true, - hybrid_handoff_phone_used: false, - phone_fingerprint: Pii::Fingerprinter.fingerprint(proofing_phone.e164), - country_code: proofing_phone.country, - area_code: proofing_phone.area_code, - vendor: { - vendor_name: 'AddressMock', - exception: nil, - timed_out: false, - transaction_id: 'address-mock-transaction-id-123', - reference: '', - }, - ), + success: true, + new_phone_added: true, + hybrid_handoff_phone_used: false, + phone_fingerprint: Pii::Fingerprinter.fingerprint(proofing_phone.e164), + country_code: proofing_phone.country, + area_code: proofing_phone.area_code, + vendor: { + vendor_name: 'AddressMock', + exception: nil, + timed_out: false, + transaction_id: 'address-mock-transaction-id-123', + reference: '', + }, ) end end @@ -515,24 +509,22 @@ expect(@analytics).to have_logged_event( 'IdV: phone confirmation vendor', - hash_including( - success: false, - new_phone_added: true, - hybrid_handoff_phone_used: false, - phone_fingerprint: Pii::Fingerprinter.fingerprint(proofing_phone.e164), - country_code: proofing_phone.country, - area_code: proofing_phone.area_code, - errors: { - phone: ['The phone number could not be verified.'], - }, - vendor: { - vendor_name: 'AddressMock', - exception: nil, - timed_out: false, - transaction_id: 'address-mock-transaction-id-123', - reference: '', - }, - ), + success: false, + new_phone_added: true, + hybrid_handoff_phone_used: false, + phone_fingerprint: Pii::Fingerprinter.fingerprint(proofing_phone.e164), + country_code: proofing_phone.country, + area_code: proofing_phone.area_code, + errors: { + phone: ['The phone number could not be verified.'], + }, + vendor: { + vendor_name: 'AddressMock', + exception: nil, + timed_out: false, + transaction_id: 'address-mock-transaction-id-123', + reference: '', + }, ) end diff --git a/spec/controllers/idv/session_errors_controller_spec.rb b/spec/controllers/idv/session_errors_controller_spec.rb index 5b8b958185c..3bf0665bb44 100644 --- a/spec/controllers/idv/session_errors_controller_spec.rb +++ b/spec/controllers/idv/session_errors_controller_spec.rb @@ -188,10 +188,8 @@ expect(@analytics).to have_logged_event( 'IdV: session error visited', - hash_including( - type: action.to_s, - remaining_submit_attempts: IdentityConfig.store.idv_max_attempts - 1, - ), + type: action.to_s, + remaining_submit_attempts: IdentityConfig.store.idv_max_attempts - 1, ) end @@ -269,10 +267,8 @@ expect(@analytics).to have_logged_event( 'IdV: session error visited', - hash_including( - type: action.to_s, - remaining_submit_attempts: 0, - ), + type: action.to_s, + remaining_submit_attempts: 0, ) end end @@ -311,10 +307,8 @@ expect(@analytics).to have_logged_event( 'IdV: session error visited', - hash_including( - type: 'ssn_failure', - remaining_submit_attempts: 0, - ), + type: 'ssn_failure', + remaining_submit_attempts: 0, ) end end @@ -345,10 +339,8 @@ expect(@analytics).to have_logged_event( 'IdV: session error visited', - hash_including( - type: action.to_s, - remaining_submit_attempts: 0, - ), + type: action.to_s, + remaining_submit_attempts: 0, ) end end diff --git a/spec/controllers/idv/sessions_controller_spec.rb b/spec/controllers/idv/sessions_controller_spec.rb index 6d0a44aa23d..35ab0beb584 100644 --- a/spec/controllers/idv/sessions_controller_spec.rb +++ b/spec/controllers/idv/sessions_controller_spec.rb @@ -44,10 +44,8 @@ expect(@analytics).to have_logged_event( 'IdV: start over', - hash_including( - location: 'get_help', - step: 'first', - ), + location: 'get_help', + step: 'first', ) end @@ -58,13 +56,11 @@ delete :destroy, params: { step: 'barcode', location: '' } expect(@analytics).to have_logged_event( 'IdV: start over', - hash_including( - location: '', - step: 'barcode', - cancelled_enrollment: true, - enrollment_code: user.pending_in_person_enrollment.enrollment_code, - enrollment_id: user.pending_in_person_enrollment.id, - ), + location: '', + step: 'barcode', + cancelled_enrollment: true, + enrollment_code: user.pending_in_person_enrollment.enrollment_code, + enrollment_id: user.pending_in_person_enrollment.id, ) end end @@ -92,10 +88,8 @@ expect(@analytics).to have_logged_event( 'IdV: start over', - hash_including( - location: 'clear_and_start_over', - step: 'gpo_verify', - ), + location: 'clear_and_start_over', + step: 'gpo_verify', ) end end diff --git a/spec/controllers/openid_connect/logout_controller_spec.rb b/spec/controllers/openid_connect/logout_controller_spec.rb index 690e59f66bd..1360f3fd8ed 100644 --- a/spec/controllers/openid_connect/logout_controller_spec.rb +++ b/spec/controllers/openid_connect/logout_controller_spec.rb @@ -165,14 +165,12 @@ ) expect(@analytics).to have_logged_event( 'Logout Initiated', - hash_including( - success: true, - client_id: service_provider.issuer, - client_id_parameter_present: false, - id_token_hint_parameter_present: true, - sp_initiated: true, - oidc: true, - ), + success: true, + client_id: service_provider.issuer, + client_id_parameter_present: false, + id_token_hint_parameter_present: true, + sp_initiated: true, + oidc: true, ) expect(@analytics).to_not have_logged_event( @@ -329,14 +327,12 @@ ) expect(@analytics).to have_logged_event( 'OIDC Logout Page Visited', - hash_including( - success: true, - client_id: service_provider.issuer, - client_id_parameter_present: true, - id_token_hint_parameter_present: false, - sp_initiated: true, - oidc: true, - ), + success: true, + client_id: service_provider.issuer, + client_id_parameter_present: true, + id_token_hint_parameter_present: false, + sp_initiated: true, + oidc: true, ) expect(@analytics).to_not have_logged_event( :sp_integration_errors_present, @@ -468,14 +464,12 @@ ) expect(@analytics).to have_logged_event( 'OIDC Logout Page Visited', - hash_including( - success: true, - client_id: service_provider.issuer, - client_id_parameter_present: true, - id_token_hint_parameter_present: false, - sp_initiated: true, - oidc: true, - ), + success: true, + client_id: service_provider.issuer, + client_id_parameter_present: true, + id_token_hint_parameter_present: false, + sp_initiated: true, + oidc: true, ) expect(@analytics).to_not have_logged_event( diff --git a/spec/controllers/redirect/help_center_controller_spec.rb b/spec/controllers/redirect/help_center_controller_spec.rb index 71c97a08358..ea05256cda5 100644 --- a/spec/controllers/redirect/help_center_controller_spec.rb +++ b/spec/controllers/redirect/help_center_controller_spec.rb @@ -12,7 +12,7 @@ expect(response).to redirect_to redirect_url expect(@analytics).to have_logged_event( 'External Redirect', - hash_including(redirect_url: redirect_url_base), + redirect_url: redirect_url_base, ) end end @@ -45,10 +45,7 @@ it 'redirects to the help center article and logs' do redirect_url = MarketingSite.help_center_article_url(category:, article:) expect(response).to redirect_to redirect_url - expect(@analytics).to have_logged_event( - 'External Redirect', - hash_including(redirect_url: redirect_url), - ) + expect(@analytics).to have_logged_event('External Redirect', redirect_url:) end context 'with optional anchor' do diff --git a/spec/controllers/redirect/marketing_site_controller_spec.rb b/spec/controllers/redirect/marketing_site_controller_spec.rb index 35b32bb8b9f..a7fcd3071ce 100644 --- a/spec/controllers/redirect/marketing_site_controller_spec.rb +++ b/spec/controllers/redirect/marketing_site_controller_spec.rb @@ -14,7 +14,7 @@ response expect(@analytics).to have_logged_event( 'External Redirect', - hash_including(redirect_url: MarketingSite.base_url), + redirect_url: MarketingSite.base_url, ) end end diff --git a/spec/controllers/redirect/return_to_sp_controller_spec.rb b/spec/controllers/redirect/return_to_sp_controller_spec.rb index a341bc5c2dc..8b8d2f0b5be 100644 --- a/spec/controllers/redirect/return_to_sp_controller_spec.rb +++ b/spec/controllers/redirect/return_to_sp_controller_spec.rb @@ -36,7 +36,7 @@ expect(response).to redirect_to(expected_redirect_uri) expect(@analytics).to have_logged_event( 'Return to SP: Cancelled', - hash_including(redirect_url: expected_redirect_uri), + redirect_url: expected_redirect_uri, ) end end @@ -59,7 +59,7 @@ expect(response).to redirect_to(expected_redirect_uri) expect(@analytics).to have_logged_event( 'Return to SP: Cancelled', - hash_including(redirect_url: expected_redirect_uri), + redirect_url: expected_redirect_uri, ) end end @@ -73,7 +73,7 @@ expect(response).to redirect_to('https://sp.gov/return_to_sp') expect(@analytics).to have_logged_event( 'Return to SP: Cancelled', - hash_including(redirect_url: 'https://sp.gov/return_to_sp'), + redirect_url: 'https://sp.gov/return_to_sp', ) end end @@ -99,7 +99,7 @@ expect(response).to redirect_to('https://sp.gov/failure_to_proof') expect(@analytics).to have_logged_event( 'Return to SP: Failed to proof', - hash_including(redirect_url: 'https://sp.gov/failure_to_proof'), + redirect_url: 'https://sp.gov/failure_to_proof', ) end end @@ -110,11 +110,9 @@ expect(@analytics).to have_logged_event( 'Return to SP: Failed to proof', - hash_including( - redirect_url: a_kind_of(String), - step: 'first', - location: 'bottom', - ), + redirect_url: a_kind_of(String), + step: 'first', + location: 'bottom', ) end end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 2557043d324..27875bd504b 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -51,7 +51,9 @@ expect(@analytics).to have_logged_event( 'Logout Initiated', - hash_including(sp_initiated: false, oidc: false, saml_request_valid: true), + sp_initiated: false, + oidc: false, + saml_request_valid: true, ) end @@ -74,7 +76,9 @@ expect(@analytics).to have_logged_event( 'Logout Initiated', - hash_including(sp_initiated: true, oidc: false, saml_request_valid: true), + sp_initiated: true, + oidc: false, + saml_request_valid: true, ) end @@ -85,7 +89,9 @@ expect(@analytics).to have_logged_event( 'Logout Initiated', - hash_including(sp_initiated: true, oidc: false, saml_request_valid: false), + sp_initiated: true, + oidc: false, + saml_request_valid: false, ) expect(@analytics).to have_logged_event( :sp_integration_errors_present, @@ -148,7 +154,9 @@ expect(@analytics).to have_logged_event( 'Logout Initiated', - hash_including(sp_initiated: true, oidc: false, saml_request_valid: false), + sp_initiated: true, + oidc: false, + saml_request_valid: false, ) expect(@analytics).to have_logged_event( :sp_integration_errors_present, diff --git a/spec/controllers/sign_out_controller_spec.rb b/spec/controllers/sign_out_controller_spec.rb index 80885fa4e7b..c53b2c6ce42 100644 --- a/spec/controllers/sign_out_controller_spec.rb +++ b/spec/controllers/sign_out_controller_spec.rb @@ -26,8 +26,7 @@ get :destroy - expect(@analytics) - .to have_logged_event('Logout Initiated', hash_including(method: 'cancel link')) + expect(@analytics).to have_logged_event('Logout Initiated', method: 'cancel link') end end end diff --git a/spec/controllers/users/backup_code_setup_controller_spec.rb b/spec/controllers/users/backup_code_setup_controller_spec.rb index a419fd620b0..53123e9ea69 100644 --- a/spec/controllers/users/backup_code_setup_controller_spec.rb +++ b/spec/controllers/users/backup_code_setup_controller_spec.rb @@ -214,7 +214,7 @@ get :edit expect(@analytics).to have_logged_event( 'Backup Code Regenerate Visited', - hash_including(in_account_creation_flow: false), + in_account_creation_flow: false, ) end end diff --git a/spec/controllers/users/email_language_controller_spec.rb b/spec/controllers/users/email_language_controller_spec.rb index 2ca91d1f109..4e8d45f341e 100644 --- a/spec/controllers/users/email_language_controller_spec.rb +++ b/spec/controllers/users/email_language_controller_spec.rb @@ -63,7 +63,7 @@ expect(@analytics).to have_logged_event( 'Email Language: Updated', - hash_including(success: true), + success: true, ) end end diff --git a/spec/controllers/users/rules_of_use_controller_spec.rb b/spec/controllers/users/rules_of_use_controller_spec.rb index 7dc096083b3..12b998f036c 100644 --- a/spec/controllers/users/rules_of_use_controller_spec.rb +++ b/spec/controllers/users/rules_of_use_controller_spec.rb @@ -123,7 +123,7 @@ expect(@analytics).to have_logged_event( 'Rules of Use Submitted', - hash_including(success: true), + success: true, ) end diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 39d72de7baa..d65559cab99 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -67,10 +67,8 @@ expect(@analytics).to have_logged_event( 'Logout Initiated', - hash_including( - sp_initiated: false, - oidc: false, - ), + sp_initiated: false, + oidc: false, ) expect(controller.current_user).to be nil end diff --git a/spec/features/idv/cancel_spec.rb b/spec/features/idv/cancel_spec.rb index 11c8995e553..e6223d34743 100644 --- a/spec/features/idv/cancel_spec.rb +++ b/spec/features/idv/cancel_spec.rb @@ -43,7 +43,7 @@ expect(page).to have_current_path(original_path) expect(fake_analytics).to have_logged_event( 'IdV: cancellation go back', - hash_including(step: 'agreement'), + step: 'agreement', ) end @@ -68,7 +68,7 @@ expect(page).to have_current_path(idv_welcome_path) expect(fake_analytics).to have_logged_event( 'IdV: start over', - hash_including(step: 'agreement'), + step: 'agreement', ) end @@ -93,7 +93,7 @@ expect(page).to have_current_path(account_path) expect(fake_analytics).to have_logged_event( 'IdV: cancellation confirmed', - hash_including(step: 'agreement'), + step: 'agreement', ) # After visiting /verify, expect to redirect to the first step in the IdV flow. @@ -113,11 +113,9 @@ expect(fake_analytics).to have_logged_event( 'IdV: cancellation visited', - hash_including( - proofing_components: { document_check: 'mock', document_type: 'state_id' }, - request_came_from: 'idv/ssn#show', - step: 'ssn', - ), + proofing_components: { document_check: 'mock', document_type: 'state_id' }, + request_came_from: 'idv/ssn#show', + step: 'ssn', ) expect(page).to have_unique_form_landmark_labels @@ -130,10 +128,8 @@ expect(fake_analytics).to have_logged_event( 'IdV: cancellation go back', - hash_including( - proofing_components: { document_check: 'mock', document_type: 'state_id' }, - step: 'ssn', - ), + proofing_components: { document_check: 'mock', document_type: 'state_id' }, + step: 'ssn', ) click_link t('links.cancel') @@ -141,10 +137,8 @@ expect(fake_analytics).to have_logged_event( 'IdV: start over', - hash_including( - proofing_components: { document_check: 'mock', document_type: 'state_id' }, - step: 'ssn', - ), + proofing_components: { document_check: 'mock', document_type: 'state_id' }, + step: 'ssn', ) complete_doc_auth_steps_before_ssn_step @@ -154,10 +148,8 @@ expect(fake_analytics).to have_logged_event( 'IdV: cancellation confirmed', - hash_including( - step: 'ssn', - proofing_components: { document_check: 'mock', document_type: 'state_id' }, - ), + step: 'ssn', + proofing_components: { document_check: 'mock', document_type: 'state_id' }, ) end end @@ -195,7 +187,7 @@ expect(current_url).to start_with('http://localhost:7654/auth/result?error=access_denied') expect(fake_analytics).to have_logged_event( 'IdV: cancellation confirmed', - hash_including(step: 'agreement'), + step: 'agreement', ) start_idv_from_sp(sp) diff --git a/spec/support/fake_analytics_spec.rb b/spec/support/fake_analytics_spec.rb index aacd56fbdcf..b7f702093b7 100644 --- a/spec/support/fake_analytics_spec.rb +++ b/spec/support/fake_analytics_spec.rb @@ -120,7 +120,7 @@ context 'event name + hash' do let(:track_event) { -> { analytics.track_event :my_event, arg1: 42 } } let(:track_event_with_different_args) { -> { analytics.track_event :my_event, arg1: 43 } } - let(:track_event_with_extra_args) do + let(:track_matching_event_with_more_args) do -> { analytics.track_event :my_event, arg1: 42, arg2: 43 } @@ -175,7 +175,7 @@ end it 'raises if an event that matches but has additional args has been logged' do - track_event_with_extra_args.call + track_matching_event_with_more_args.call expect(&code_under_test) .to raise_error(RSpec::Expectations::ExpectationNotMetError) do |err| @@ -326,28 +326,28 @@ end it 'does not raise if matching + non-matching event logged' do - track_event.call - track_event_with_different_args.call + track_matching_event_with_more_args.call + track_other_event.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) end it 'does not raise if event was logged 1x' do - track_event.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) end it 'does not raise if event was logged 1x' do - track_event.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) end it 'does not raise if event was logged 2x' do - track_event.call - track_event.call + track_matching_event_with_more_args.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) @@ -458,8 +458,30 @@ end end - it 'does not raise if matching + non-matching event logged' do + it 'raises if hash_including match has exact properties' do track_event.call + + expect(&code_under_test) + .to raise_error(RSpec::Expectations::ExpectationNotMetError) do |err| + expect(err.message).to match(/Unexpected use of hash_including/) + end + end + + shared_examples 'a track event call within shared examples' do + it 'does not raise if hash_including match has exact properties in shared examples' do + track_event.call + + expect(&code_under_test) + .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) do |err| + expect(err.message).to match(/Unexpected use of hash_including/) + end + end + end + + it_behaves_like 'a track event call within shared examples' + + it 'does not raise if matching + non-matching event logged' do + track_matching_event_with_more_args.call track_event_with_different_args.call expect(&code_under_test) @@ -467,20 +489,20 @@ end it 'does not raise if event was logged 1x' do - track_event.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) end it 'does not raise if event was logged 1x' do - track_event.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) end it 'does not raise if event was logged 2x' do - track_event.call - track_event.call + track_matching_event_with_more_args.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) @@ -507,14 +529,14 @@ end it 'does not raise if event was logged 1x' do - track_event.call + track_matching_event_with_more_args.call expect(&code_under_test) .not_to raise_error(RSpec::Expectations::ExpectationNotMetError) end it 'raises if event was logged 2x' do - track_event.call - track_event.call + track_matching_event_with_more_args.call + track_matching_event_with_more_args.call expect(&code_under_test) .to raise_error(RSpec::Expectations::ExpectationNotMetError) do |err| @@ -523,7 +545,7 @@ with hash_including(arg1: 42) Events received: - {:my_event=>[{:arg1=>42}, {:arg1=>42}]} + {:my_event=>[{:arg1=>42, :arg2=>43}, {:arg1=>42, :arg2=>43}]} MESSAGE end end diff --git a/spec/support/have_logged_event_matcher.rb b/spec/support/have_logged_event_matcher.rb index 8718d84fc98..eb335b0d49f 100644 --- a/spec/support/have_logged_event_matcher.rb +++ b/spec/support/have_logged_event_matcher.rb @@ -4,6 +4,8 @@ class HaveLoggedEventMatcher include RSpec::Matchers::Composable include RSpec::Matchers::BuiltIn::CountExpectation + attr_reader :hash_including_equals_failure_message + def initialize( expected_event_name: nil, expected_attributes: nil @@ -13,6 +15,7 @@ def initialize( end def failure_message + return hash_including_equals_failure_message if hash_including_equals_failure_message.present? return fake_analytics_missing_failure_message if fake_analytics_missing? matching_events = events[expected_event_name] @@ -47,7 +50,8 @@ def matches?(actual) events[expected_event_name] || [] else (events[expected_event_name] || []).filter do |actual_attributes| - values_match?(expected_attributes, actual_attributes) + values_match?(expected_attributes, actual_attributes) && + !hash_including_equals(expected_attributes, actual_attributes) end end @@ -66,6 +70,28 @@ def fake_analytics_missing? !@actual.is_a?(FakeAnalytics) end + def hash_including_equals(expected_attributes, actual_attributes) + if expected_attributes.instance_of?(RSpec::Mocks::ArgumentMatchers::HashIncludingMatcher) && + expected_attributes.instance_variable_get(:@expected) == actual_attributes && + !in_shared_example? + @hash_including_equals_failure_message = <<~STR + Unexpected use of hash_including when included attributes are exactly equal to actual attributes + + Strict equality is preferred over hash_including except when intentionally testing a hash including more than properties than what's asserted + + Expected: #{expected_attributes.instance_variable_get(:@expected)} + Actual: #{actual_attributes} + STR + true + else + false + end + end + + def in_shared_example? + RSpec.current_example.metadata[:shared_group_inclusion_backtrace].present? + end + def fake_analytics_missing_failure_message <<~STR Matching expected logged events requires analytics to be stubbed. From 4ac6a29101dbf1b2ce6449552bab66731e74bb7f Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:30:01 -0500 Subject: [PATCH 03/19] Add introductory high-level overview for frontend documentation (#11807) * Add introductory high-level overview for frontend documentation changelog: Internal, Documentation, Add introductory high-level overview for frontend documentation * Use nested list to describe and link folder structure * Remove "enhanced" addendum for ViewComponent * Fix JavaScript README links --- docs/frontend.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/frontend.md b/docs/frontend.md index 219c2c9d3dc..cf909d77f5d 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -1,5 +1,45 @@ # Front-end Architecture +While the Login.gov application is architected as a Ruby on Rails project and most of its pages are +fully rendered by the backend server, the front end is set up to use modern best-practices to suit +the needs of interactivity and consistency in the user experience, and to ensure a maintainable and +convenient developer experience. + +As a high-level overview, the front end consists of: + +- [Propshaft](https://github.com/rails/propshaft) is used as an asset pipeline library for Ruby on + Rails, in combination with [cssbundling-rails](https://github.com/rails/cssbundling-rails) and + [jsbundling-rails](https://github.com/rails/jsbundling-rails) for compiling stylesheets and + scripts. +- JavaScript is written as [TypeScript](https://www.typescriptlang.org/), bundled using [Webpack](https://webpack.js.org/) + with syntax transformation applied by [Babel](https://babeljs.io/). + - For highly-interactive pages, we use [React](https://react.dev/) + - For all other JavaScript interaction, we use native [web components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) + (custom elements) +- Stylesheets are written as [Sass](https://sass-lang.com/), and builds upon the [Login.gov Design System](https://github.com/18F/identity-design-system), + which in turn builds upon the [U.S. Web Design System (USWDS)](https://designsystem.digital.gov/). +- HTML reuse is facilitated by the [ViewComponent](https://viewcomponent.org/) gem. + +The general folder structure for front-end assets includes: + +- `app/` + - `assets/` + - `builds/`: Source location for compiled stylesheets used by Propshaft in asset compilation + - `fonts/`: Source font assets + - `images/`: Source image assets + - `stylesheets/`: Source Sass files + - [`components/`][components-readme]: ViewComponent implementations + - `javascript/` + - [`packages/`][packages-readme]: JavaScript workspace NPM packages + - [`packs/`][packs-readme]: JavaScript entrypoints referenced by pages +- `public/` + - `assets/`: Compiled images, fonts, and stylesheets + - `packs/`: Compiled JavaScript + +[components-readme]: ../app/components/README.md +[packages-readme]: ../app/javascript/packages/README.md +[packs-readme]: ../app/javascript/packs/README.md + ## CSS + HTML ### At a Glance From a442f8e372deaa6b66ef92664c19a4b3134e269b Mon Sep 17 00:00:00 2001 From: A Shukla Date: Tue, 28 Jan 2025 08:55:42 -0600 Subject: [PATCH 04/19] LG-15275 enforce limit of allowed Socure users (#11808) * changelog: Upcoming Features, socure, enforce socure max user limit * Fixing styling * reusing socure user set connection if it exists --- .../concerns/idv/doc_auth_vendor_concern.rb | 6 ++- app/services/idv/socure_user_set.rb | 4 +- .../idv/document_capture_controller_spec.rb | 44 ++++++++++++++++--- .../hybrid_mobile/entry_controller_spec.rb | 18 ++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb index 079a828753b..be241cdca16 100644 --- a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb +++ b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb @@ -6,7 +6,7 @@ module DocAuthVendorConcern # @returns[String] String identifying the vendor to use for doc auth. def doc_auth_vendor - if resolved_authn_context_result.facial_match? + if resolved_authn_context_result.facial_match? || socure_user_set.maxed_users? if doc_auth_vendor_enabled?(Idp::Constants::Vendors::LEXIS_NEXIS) bucket = :lexis_nexis elsif doc_auth_vendor_enabled?(Idp::Constants::Vendors::MOCK) @@ -33,5 +33,9 @@ def doc_auth_vendor_enabled?(vendor) false end end + + def socure_user_set + @socure_user_set ||= SocureUserSet.new + end end end diff --git a/app/services/idv/socure_user_set.rb b/app/services/idv/socure_user_set.rb index be0e123ae7a..4d4d41cf830 100644 --- a/app/services/idv/socure_user_set.rb +++ b/app/services/idv/socure_user_set.rb @@ -22,12 +22,12 @@ def count end end - private - def maxed_users? count >= IdentityConfig.store.doc_auth_socure_max_allowed_users end + private + def key 'idv:socure:users' end diff --git a/spec/controllers/idv/document_capture_controller_spec.rb b/spec/controllers/idv/document_capture_controller_spec.rb index 9966a2969b1..65a228e1287 100644 --- a/spec/controllers/idv/document_capture_controller_spec.rb +++ b/spec/controllers/idv/document_capture_controller_spec.rb @@ -36,6 +36,12 @@ ) end + around do |ex| + REDIS_POOL.with { |client| client.flushdb } + ex.run + REDIS_POOL.with { |client| client.flushdb } + end + before do stub_sign_in(user) stub_up_to(:hybrid_handoff, idv_session: subject.idv_session) @@ -191,14 +197,42 @@ end end - context 'socure is the default vendor but facial match is required' do + context 'socure is the default vendor' do let(:idv_vendor) { Idp::Constants::Vendors::SOCURE } - let(:facial_match_required) { true } - it 'does not redirect to Socure controller' do - get :show + describe 'facial match is required' do + let(:facial_match_required) { true } + + it 'does not redirect to Socure controller' do + get :show + + expect(response).to_not redirect_to idv_socure_document_capture_url + end + end - expect(response).to_not redirect_to idv_socure_document_capture_url + describe 'facial match not required but socure user limit reached' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_socure_max_allowed_users).and_return(1) + Idv::SocureUserSet.new.add_user!(user_uuid: '001') + end + + it 'does not redirect to Socure controller' do + get :show + + expect(response).to_not redirect_to idv_socure_document_capture_url + end + end + + describe 'facial match not required and socure user limit not reached' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_socure_max_allowed_users).and_return(2) + Idv::SocureUserSet.new.add_user!(user_uuid: '001') + end + it 'does redirect to Socure controller' do + get :show + + expect(response).to redirect_to idv_socure_document_capture_url + end end end diff --git a/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb index 386b0f12cb5..d45b2deb57c 100644 --- a/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb @@ -14,6 +14,12 @@ let(:session_uuid) { document_capture_session.uuid } let(:idv_vendor) { Idp::Constants::Vendors::MOCK } + around do |ex| + REDIS_POOL.with { |client| client.flushdb } + ex.run + REDIS_POOL.with { |client| client.flushdb } + end + before do allow(IdentityConfig.store).to receive(:doc_auth_vendor).and_return(idv_vendor) allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return(idv_vendor) @@ -58,6 +64,7 @@ let(:idv_vendor) { Idp::Constants::Vendors::MOCK } let(:vendor_switching_enabled) { true } let(:lexis_nexis_percent) { 100 } + let(:socure_user_limit) { 10 } let(:acr_values) do [ Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, @@ -79,6 +86,8 @@ .and_return(vendor_switching_enabled) allow(IdentityConfig.store).to receive(:doc_auth_vendor_lexis_nexis_percent) .and_return(lexis_nexis_percent) + allow(IdentityConfig.store).to receive(:doc_auth_socure_max_allowed_users) + .and_return(socure_user_limit) get :show, params: { 'document-capture-session': session_uuid } end @@ -90,6 +99,15 @@ end end + context 'doc auth vendor is socure with socure user limit reached' do + let(:idv_vendor) { Idp::Constants::Vendors::SOCURE } + let(:socure_user_limit) { 0 } + + it 'redirects to the lexis nexis first step' do + expect(response).to redirect_to idv_hybrid_mobile_document_capture_url + end + end + context 'facial match is required' do let(:acr_values) do [ From ccb7d6dccaf82fd00ed4a4b0ac21338f1e89f84b Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:33:04 -0500 Subject: [PATCH 05/19] LG-14052: Convert mismatched WebAuthn platform attachment (#11788) changelog: User-Facing Improvements, Multi-Factor Authentication, Convert Security Key to Face or Touch Unlock when detected as platform authenticator --- .../users/webauthn_setup_controller.rb | 14 +- app/forms/webauthn_setup_form.rb | 39 +++- app/models/webauthn_configuration.rb | 6 + app/services/analytics_events.rb | 23 ++- .../users/webauthn_setup_controller_spec.rb | 116 ++++++++++-- spec/forms/webauthn_setup_form_spec.rb | 173 +++++++++++++++--- spec/models/webauthn_configuration_spec.rb | 18 ++ 7 files changed, 335 insertions(+), 54 deletions(-) diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 43fbc570331..ec1719c102a 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -134,7 +134,7 @@ def process_valid_webauthn(form) success: true, ) handle_remember_device_preference(params[:remember_device]) - if form.platform_authenticator? + if form.setup_as_platform_authenticator? handle_valid_verification_for_confirmation_context( auth_method: TwoFactorAuthenticatable::AuthMethod::WEBAUTHN_PLATFORM, ) @@ -144,7 +144,7 @@ def process_valid_webauthn(form) analytics, threatmetrix_attrs, ) - flash[:success] = t('notices.webauthn_platform_configured') + flash[:success] = t('notices.webauthn_platform_configured') if !form.transports_mismatch? else handle_valid_verification_for_confirmation_context( auth_method: TwoFactorAuthenticatable::AuthMethod::WEBAUTHN, @@ -155,9 +155,15 @@ def process_valid_webauthn(form) analytics, threatmetrix_attrs, ) - flash[:success] = t('notices.webauthn_configured') + flash[:success] = t('notices.webauthn_configured') if !form.transports_mismatch? + end + + if form.transports_mismatch? + user_session[:webauthn_mismatch_id] = form.webauthn_configuration.id + redirect_to webauthn_setup_mismatch_path + else + redirect_to next_setup_path || after_mfa_setup_path end - redirect_to next_setup_path || after_mfa_setup_path end def analytics_properties diff --git a/app/forms/webauthn_setup_form.rb b/app/forms/webauthn_setup_form.rb index 727e6f60c39..ea95f712bc5 100644 --- a/app/forms/webauthn_setup_form.rb +++ b/app/forms/webauthn_setup_form.rb @@ -12,7 +12,7 @@ class WebauthnSetupForm validate :name_is_unique validate :validate_attestation_response - attr_reader :attestation_response + attr_reader :attestation_response, :webauthn_configuration def initialize(user:, user_session:, device_name:) @user = user @@ -48,6 +48,18 @@ def platform_authenticator? !!@platform_authenticator end + def setup_as_platform_authenticator? + if transports.present? + platform_authenticator_transports? + else + platform_authenticator? + end + end + + def transports_mismatch? + transports.present? && platform_authenticator_transports? != platform_authenticator? + end + def generic_error_message if platform_authenticator? I18n.t('errors.webauthn_platform_setup.general_error') @@ -134,11 +146,11 @@ def create_webauthn_configuration credential = attestation_response.credential public_key = Base64.strict_encode64(credential.public_key) id = Base64.strict_encode64(credential.id) - user.webauthn_configurations.create( + @webauthn_configuration = user.webauthn_configurations.create( credential_public_key: public_key, credential_id: id, name: name, - platform_authenticator: platform_authenticator, + platform_authenticator: setup_as_platform_authenticator?, transports: transports.presence, authenticator_data_flags: authenticator_data_flags, aaguid: aaguid, @@ -166,20 +178,29 @@ def aaguid nil end + def platform_authenticator_transports? + (transports & WebauthnConfiguration::PLATFORM_AUTHENTICATOR_TRANSPORTS.to_a).present? + end + + def multi_factor_auth_method + if setup_as_platform_authenticator? + 'webauthn_platform' + else + 'webauthn' + end + end + def extra_analytics_attributes - auth_method = if platform_authenticator? - 'webauthn_platform' - else - 'webauthn' - end { mfa_method_counts: mfa_user.enabled_two_factor_configuration_counts_hash, enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, - multi_factor_auth_method: auth_method, + multi_factor_auth_method:, pii_like_keypaths: [[:mfa_method_counts, :phone]], authenticator_data_flags: authenticator_data_flags, unknown_transports: invalid_transports.presence, aaguid: aaguid, + transports: transports, + transports_mismatch: transports_mismatch?, } end end diff --git a/app/models/webauthn_configuration.rb b/app/models/webauthn_configuration.rb index 5973745835f..7d0acdb2a3e 100644 --- a/app/models/webauthn_configuration.rb +++ b/app/models/webauthn_configuration.rb @@ -7,6 +7,12 @@ class WebauthnConfiguration < ApplicationRecord validates :credential_public_key, presence: true validate :valid_transports + # https://w3c.github.io/webauthn/#enum-transport + PLATFORM_AUTHENTICATOR_TRANSPORTS = %w[ + hybrid + internal + ].to_set.freeze + # https://w3c.github.io/webauthn/#enum-transport VALID_TRANSPORTS = %w[ usb diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 8b84d05100a..b52f97caf0c 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -5834,6 +5834,10 @@ def multi_factor_auth_phone_setup( # @param [String, nil] aaguid AAGUID value of WebAuthn device # @param [String[], nil] unknown_transports Array of unrecognized WebAuthn transports, intended to # be used in case of future specification changes. + # @param [String[], nil] transports WebAuthn transports associated with registration. + # @param [Boolean, nil] transports_mismatch Whether the WebAuthn transports associated with + # registration contradict the authenticator attachment for user setup. For example, a user can + # set up a platform authenticator through the Security Key setup flow. # @param [:authentication, :account_creation, nil] webauthn_platform_recommended A/B test for # recommended Face or Touch Unlock setup, if applicable. def multi_factor_auth_setup( @@ -5859,6 +5863,8 @@ def multi_factor_auth_setup( attempts: nil, aaguid: nil, unknown_transports: nil, + transports: nil, + transports_mismatch: nil, webauthn_platform_recommended: nil, **extra ) @@ -5886,6 +5892,8 @@ def multi_factor_auth_setup( attempts:, aaguid:, unknown_transports:, + transports:, + transports_mismatch:, webauthn_platform_recommended:, **extra, ) @@ -7753,9 +7761,12 @@ def webauthn_setup_mismatch_visited( ) end - # @param [Boolean] platform_authenticator - # @param [Boolean] success - # @param [Hash, nil] errors + # @param [Boolean] platform_authenticator Whether submission is for setting up a platform + # authenticator. This aligns to what the user experienced in setting up the authenticator. + # However, if `transports_mismatch` is true, the authentication method is created as the + # opposite of this value. + # @param [Boolean] success Whether the submission was successful + # @param [Hash, nil] errors Errors resulting from form validation, or nil if successful. # @param [Boolean] in_account_creation_flow Whether user is going through account creation flow # Tracks whether or not Webauthn setup was successful def webauthn_setup_submitted( @@ -7767,10 +7778,10 @@ def webauthn_setup_submitted( ) track_event( :webauthn_setup_submitted, - platform_authenticator: platform_authenticator, - success: success, + platform_authenticator:, + success:, + errors:, in_account_creation_flow:, - errors: errors, **extra, ) end diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index 7f9886240bc..afa5fc4a37d 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -34,7 +34,7 @@ end end - describe 'when signed in and not account creation' do + context 'when signed in and not account creation' do let(:user) { create(:user, :fully_registered, :with_authentication_app) } before do @@ -42,7 +42,7 @@ stub_sign_in(user) end - describe 'GET new' do + describe '#new' do it 'tracks page visit' do stub_sign_in stub_analytics @@ -77,7 +77,9 @@ end end - describe 'patch confirm' do + describe '#confirm' do + subject(:response) { patch :confirm, params: params } + let(:params) do { attestation_object: attestation_object, @@ -103,6 +105,16 @@ controller.user_session[:webauthn_challenge] = webauthn_challenge end + it 'should flash a success message after successfully creating' do + response + + expect(flash[:success]).to eq(t('notices.webauthn_configured')) + end + + it 'redirects to next setup path' do + expect(response).to redirect_to(account_url) + end + it 'tracks the submission' do Funnel::Registration::AddMfa.call(user.id, 'phone', @analytics, threatmetrix_attrs) @@ -130,6 +142,8 @@ ed: false, }, attempts: 1, + transports: ['usb'], + transports_mismatch: false, ) expect(@analytics).to have_logged_event( :webauthn_setup_submitted, @@ -139,6 +153,74 @@ ) end + context 'with transports mismatch' do + let(:params) { super().merge(transports: 'internal') } + + it 'handles as successful setup for platform authenticator' do + expect(controller).to receive(:handle_valid_verification_for_confirmation_context).with( + auth_method: TwoFactorAuthenticatable::AuthMethod::WEBAUTHN_PLATFORM, + ) + + response + end + + it 'does not flash success message' do + response + + expect(flash[:success]).to be_nil + end + + it 'redirects to mismatch confirmation' do + expect(response).to redirect_to(webauthn_setup_mismatch_url) + end + + it 'sets session value for mismatched configuration id' do + response + + expect(controller.user_session[:webauthn_mismatch_id]) + .to eq(user.webauthn_configurations.last.id) + end + end + + context 'with platform authenticator set up' do + let(:params) { super().merge(platform_authenticator: true, transports: 'internal') } + + it 'should flash a success message after successfully creating' do + response + + expect(flash[:success]).to eq(t('notices.webauthn_platform_configured')) + end + + context 'with transports mismatch' do + let(:params) { super().merge(transports: 'usb') } + + it 'handles as successful setup for cross-platform authenticator' do + expect(controller).to receive(:handle_valid_verification_for_confirmation_context).with( + auth_method: TwoFactorAuthenticatable::AuthMethod::WEBAUTHN, + ) + + response + end + + it 'does not flash success message' do + response + + expect(flash[:success]).to be_nil + end + + it 'redirects to mismatch confirmation' do + expect(response).to redirect_to(webauthn_setup_mismatch_url) + end + + it 'sets session value for mismatched configuration id' do + response + + expect(controller.user_session[:webauthn_mismatch_id]) + .to eq(user.webauthn_configurations.last.id) + end + end + end + context 'with setup from sms recommendation' do before do controller.user_session[:webauthn_platform_recommended] = :authentication @@ -283,6 +365,8 @@ multi_factor_auth_method: 'webauthn', success: true, attempts: 1, + transports: ['usb'], + transports_mismatch: false, ) expect(@analytics).to have_logged_event( :webauthn_setup_submitted, @@ -344,6 +428,8 @@ multi_factor_auth_method: 'webauthn_platform', success: true, attempts: 1, + transports: ['internal', 'hybrid'], + transports_mismatch: false, ) end @@ -380,18 +466,18 @@ expect(@analytics).to have_logged_event( 'Multi-Factor Authentication Setup', - { - enabled_mfa_methods_count: 0, - errors: { - attestation_object: [I18n.t('errors.webauthn_platform_setup.general_error')], - }, - error_details: { attestation_object: { invalid: true } }, - in_account_creation_flow: false, - mfa_method_counts: {}, - multi_factor_auth_method: 'webauthn_platform', - success: false, - attempts: 1, + enabled_mfa_methods_count: 0, + errors: { + attestation_object: [I18n.t('errors.webauthn_platform_setup.general_error')], }, + error_details: { attestation_object: { invalid: true } }, + in_account_creation_flow: false, + mfa_method_counts: {}, + multi_factor_auth_method: 'webauthn_platform', + success: false, + attempts: 1, + transports: ['internal', 'hybrid'], + transports_mismatch: false, ) end end @@ -448,6 +534,8 @@ ed: false, }, attempts: 2, + transports: ['usb'], + transports_mismatch: false, ) expect(@analytics).to have_logged_event( :webauthn_setup_submitted, diff --git a/spec/forms/webauthn_setup_form_spec.rb b/spec/forms/webauthn_setup_form_spec.rb index 21b02f6a56c..1e78a2a96bd 100644 --- a/spec/forms/webauthn_setup_form_spec.rb +++ b/spec/forms/webauthn_setup_form_spec.rb @@ -19,13 +19,31 @@ protocol:, } end - let(:subject) { WebauthnSetupForm.new(user:, user_session:, device_name:) } + subject(:form) { WebauthnSetupForm.new(user:, user_session:, device_name:) } before do allow(IdentityConfig.store).to receive(:domain_name).and_return(domain_name) end + describe '#webauthn_configuration' do + subject(:webauthn_configuration) { form.webauthn_configuration } + + it { is_expected.to be_nil } + + context 'after successful submission' do + before do + form.submit(params) + end + + it 'returns the created configuration' do + expect(webauthn_configuration).to eq(user.reload.webauthn_configurations.take) + end + end + end + describe '#submit' do + subject(:result) { form.submit(params) } + context 'when the input is valid' do context 'security key' do it 'returns FormResponse with success: true and creates a webauthn configuration' do @@ -46,9 +64,11 @@ pii_like_keypaths: [[:mfa_method_counts, :phone]], } - expect(subject.submit(params).to_h).to eq( + expect(result.to_h).to eq( success: true, errors: nil, + transports: ['usb'], + transports_mismatch: false, **extra_attributes, ) @@ -63,11 +83,10 @@ expect(PushNotification::HttpPush).to receive(:deliver) .with(PushNotification::RecoveryInformationChangedEvent.new(user: user)) - subject.submit(params) + result end it 'does not contains uuid' do - result = subject.submit(params) expect(result.extra[:aaguid]).to eq nil end end @@ -79,7 +98,6 @@ end it 'creates a platform authenticator' do - result = subject.submit(params) expect(result.extra[:multi_factor_auth_method]).to eq 'webauthn_platform' user.reload @@ -94,8 +112,6 @@ let(:params) { super().merge(authenticator_data_value: '65') } it 'includes data flags with bs set as false ' do - result = subject.submit(params) - expect(result.to_h[:authenticator_data_flags]).to eq( up: true, uv: false, @@ -111,8 +127,6 @@ let(:params) { super().merge(authenticator_data_value: 'bad_error') } it 'should not include authenticator data flag' do - result = subject.submit(params) - expect(result.to_h[:authenticator_data_flags]).to be_nil end end @@ -121,14 +135,11 @@ let(:params) { super().merge(authenticator_data_value: nil) } it 'should not include authenticator data flag' do - result = subject.submit(params) - expect(result.to_h[:authenticator_data_flags]).to be_nil end end it 'contains uuid' do - result = subject.submit(params) expect(result.extra[:aaguid]).to eq aaguid end end @@ -137,7 +148,7 @@ let(:params) { super().merge(transports: 'wrong') } it 'creates a webauthn configuration without transports' do - subject.submit(params) + result user.reload @@ -145,8 +156,6 @@ end it 'includes unknown transports in extra analytics' do - result = subject.submit(params) - expect(result.to_h).to eq( success: true, errors: nil, @@ -164,6 +173,8 @@ pii_like_keypaths: [[:mfa_method_counts, :phone]], unknown_transports: ['wrong'], aaguid: nil, + transports: [], + transports_mismatch: false, ) end end @@ -190,7 +201,7 @@ pii_like_keypaths: [[:mfa_method_counts, :phone]], } - expect(subject.submit(params).to_h).to eq( + expect(result.to_h).to eq( success: false, errors: { attestation_object: [ @@ -201,6 +212,8 @@ ], }, error_details: { attestation_object: { invalid: true } }, + transports: ['usb'], + transports_mismatch: false, **extra_attributes, ) end @@ -210,7 +223,7 @@ let(:params) { super().except(:transports) } it 'creates a webauthn configuration without transports' do - subject.submit(params) + result user.reload @@ -242,7 +255,7 @@ pii_like_keypaths: [[:mfa_method_counts, :phone]], } - expect(subject.submit(params).to_h).to eq( + expect(result.to_h).to eq( success: false, errors: { attestation_object: [ @@ -253,11 +266,129 @@ ], }, error_details: { attestation_object: { invalid: true } }, + transports: ['usb'], + transports_mismatch: false, **extra_attributes, ) end end + + context 'with transports mismatch' do + let(:params) { super().merge(transports: 'internal') } + + it 'returns setup as mismatched type' do + expect(result.to_h).to eq( + success: true, + errors: nil, + enabled_mfa_methods_count: 1, + mfa_method_counts: { webauthn_platform: 1 }, + multi_factor_auth_method: 'webauthn_platform', + authenticator_data_flags: { + up: true, + uv: false, + be: true, + bs: true, + at: false, + ed: true, + }, + unknown_transports: nil, + aaguid: nil, + transports: ['internal'], + transports_mismatch: true, + pii_like_keypaths: [[:mfa_method_counts, :phone]], + ) + end + end + end + + describe '#setup_as_platform_authenticator?' do + subject(:setup_as_platform_authenticator?) { form.setup_as_platform_authenticator? } + + it { is_expected.to eq(false) } + + context 'after successful submission' do + before do + form.submit(params) + end + + it { is_expected.to eq(false) } + + context 'without transports' do + let(:params) { super().merge(transports: nil) } + + it { is_expected.to eq(false) } + end + + context 'with platform authenticator transports' do + let(:params) { super().merge(transports: 'internal') } + + it { is_expected.to eq(true) } + end + + context 'when setup as platform authenticator' do + let(:params) { super().merge(platform_authenticator: true, transports: 'internal') } + + it { is_expected.to eq(true) } + + context 'without transports' do + let(:params) { super().merge(transports: nil) } + + it { is_expected.to eq(true) } + end + + context 'without platform authenticator transports' do + let(:params) { super().merge(transports: 'usb') } + + it { is_expected.to eq(false) } + end + end + end end + + describe '#transports_mismatch?' do + subject(:transports_mismatch?) { form.transports_mismatch? } + + it { is_expected.to eq(false) } + + context 'after successful submission' do + before do + form.submit(params) + end + + it { is_expected.to eq(false) } + + context 'without transports' do + let(:params) { super().merge(transports: nil) } + + it { is_expected.to eq(false) } + end + + context 'with platform authenticator transports' do + let(:params) { super().merge(transports: 'internal') } + + it { is_expected.to eq(true) } + end + + context 'when setup as platform authenticator' do + let(:params) { super().merge(platform_authenticator: true, transports: 'internal') } + + it { is_expected.to eq(false) } + + context 'without transports' do + let(:params) { super().merge(transports: nil) } + + it { is_expected.to eq(false) } + end + + context 'without platform authenticator transports' do + let(:params) { super().merge(transports: 'usb') } + + it { is_expected.to eq(true) } + end + end + end + end + describe '.name_is_unique' do context 'webauthn' do let(:user) do @@ -266,7 +397,7 @@ user end it 'checks for unique device on a webauthn device' do - result = subject.submit(params) + result = form.submit(params) expect(result.extra[:multi_factor_auth_method]).to eq 'webauthn' expect(result.errors[:name]).to eq( [I18n.t( @@ -295,7 +426,7 @@ end it 'adds a new platform device with the same existing name and appends a (1)' do - result = subject.submit(params) + result = form.submit(params) expect(result.extra[:multi_factor_auth_method]).to eq 'webauthn_platform' expect(user.webauthn_configurations.platform_authenticators.count).to eq(2) expect( @@ -321,7 +452,7 @@ end it 'adds a second new platform device with the same existing name and appends a (2)' do - result = subject.submit(params) + result = form.submit(params) expect(result.success?).to eq(true) expect(user.webauthn_configurations.platform_authenticators.count).to eq(3) diff --git a/spec/models/webauthn_configuration_spec.rb b/spec/models/webauthn_configuration_spec.rb index e1c84e1cad4..33d8cfcc037 100644 --- a/spec/models/webauthn_configuration_spec.rb +++ b/spec/models/webauthn_configuration_spec.rb @@ -10,6 +10,24 @@ let(:subject) { create(:webauthn_configuration) } + describe '.PLATFORM_AUTHENTICATOR_TRANSPORTS' do + subject(:transports) { WebauthnConfiguration::PLATFORM_AUTHENTICATOR_TRANSPORTS } + + it 'is a frozen array of strings' do + expect(transports).to all be_a(String) + expect(transports.frozen?).to eq(true) + end + end + + describe '.VALID_TRANSPORTS' do + subject(:transports) { WebauthnConfiguration::VALID_TRANSPORTS } + + it 'is a frozen array of strings' do + expect(transports).to all be_a(String) + expect(transports.frozen?).to eq(true) + end + end + describe '#selection_presenters' do context 'for a roaming authenticator' do it 'returns a WebauthnSelectionPresenter in an array' do From 4f747aa5d310203380935eacd0b612a7af31a73e Mon Sep 17 00:00:00 2001 From: Gina <125507397+gina-yamada@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:20:26 -0700 Subject: [PATCH 06/19] LG-15335 Update IdV: in person proofing location submitted event to include selected_location for hybrid flow (#11806) * setSubmitEventMetadata selected_location for hybri * fix linter err * changelog: Bug Fixes, In-person proofing, Add selected_location to IdV in person proofing location submitted analytic event when flow path is hybrid --- ...on-full-address-entry-post-office-search-step.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx index fca72bf0a9c..d39f5388275 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-full-address-entry-post-office-search-step.tsx @@ -16,7 +16,7 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ const { inPersonURL, locationsURL, usStatesTerritories } = useContext(InPersonContext); const [inProgress, setInProgress] = useState(false); const [autoSubmit, setAutoSubmit] = useState(false); - const { trackEvent } = useContext(AnalyticsContext); + const { trackEvent, setSubmitEventMetadata } = useContext(AnalyticsContext); const [locationResults, setLocationResults] = useState( null, ); @@ -36,12 +36,16 @@ function InPersonLocationFullAddressEntryPostOfficeSearchStep({ // useCallBack here prevents unnecessary rerenders due to changing function identity const handleLocationSelect = useCallback( async (e: any, id: number) => { - if (flowPath !== 'hybrid') { - e.preventDefault(); - } const selectedLocation = locationResults![id]!; const { streetAddress, formattedCityStateZip } = selectedLocation; const selectedLocationAddress = `${streetAddress}, ${formattedCityStateZip}`; + + if (flowPath !== 'hybrid') { + e.preventDefault(); + } else { + setSubmitEventMetadata({ selected_location: selectedLocationAddress }); + } + onChange({ selectedLocationAddress }); if (autoSubmit) { setDisabledAddressSearch(true); From 7bc45ac2bef1023e7f771d29586902d5de19aaad Mon Sep 17 00:00:00 2001 From: Doug Price Date: Tue, 28 Jan 2025 12:43:16 -0500 Subject: [PATCH 07/19] LG-15375: Ensure Socure user can opt in to IPP (#11804) We found a bug where if a user was bucketed into Socure for doc auth, when they tried to opt-in to IPP from the How To Verify page, they would be redirected to Socure. This fixes it. changelog: Bug Fixes, Identity Verification, Allow users designated for Socure to opt-in to In Person Proofing --- .../concerns/idv/document_capture_concern.rb | 3 +- .../idv/how_to_verify_controller.rb | 2 +- .../idv/how_to_verify_controller_spec.rb | 2 +- .../idv/doc_auth/how_to_verify_spec.rb | 52 ++++++++++++++++--- .../doc_auth/socure_document_capture_spec.rb | 2 + 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index 32807cfea33..d6731425af0 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -76,8 +76,9 @@ def redirect_to_correct_vendor(vendor, in_hybrid_mobile:) when Idp::Constants::Vendors::LEXIS_NEXIS, Idp::Constants::Vendors::MOCK in_hybrid_mobile ? idv_hybrid_mobile_document_capture_path : idv_document_capture_path + else + return end - redirect_to correct_path end diff --git a/app/controllers/idv/how_to_verify_controller.rb b/app/controllers/idv/how_to_verify_controller.rb index 5b65ae67cfb..0da694a0942 100644 --- a/app/controllers/idv/how_to_verify_controller.rb +++ b/app/controllers/idv/how_to_verify_controller.rb @@ -41,7 +41,7 @@ def update idv_session.opted_in_to_in_person_proofing = true idv_session.flow_path = 'standard' idv_session.skip_doc_auth_from_how_to_verify = true - redirect_to idv_document_capture_url + redirect_to idv_document_capture_url(step: :how_to_verify) end else render :show, locals: { error: result.first_error_message } diff --git a/spec/controllers/idv/how_to_verify_controller_spec.rb b/spec/controllers/idv/how_to_verify_controller_spec.rb index 8233aa8992f..d5a450e99be 100644 --- a/spec/controllers/idv/how_to_verify_controller_spec.rb +++ b/spec/controllers/idv/how_to_verify_controller_spec.rb @@ -237,7 +237,7 @@ put :update, params: params expect(subject.idv_session.skip_doc_auth_from_how_to_verify).to be true - expect(response).to redirect_to(idv_document_capture_url) + expect(response).to redirect_to(idv_document_capture_url(step: :how_to_verify)) end it 'sends analytics_submitted event when remote proofing is selected' do diff --git a/spec/features/idv/doc_auth/how_to_verify_spec.rb b/spec/features/idv/doc_auth/how_to_verify_spec.rb index c4a5522add6..0f6dc2de6b3 100644 --- a/spec/features/idv/doc_auth/how_to_verify_spec.rb +++ b/spec/features/idv/doc_auth/how_to_verify_spec.rb @@ -87,6 +87,8 @@ context 'when both ipp and opt-in ipp are enabled' do context 'and when sp has opted into ipp' do + include InPersonHelper + let(:in_person_proofing_opt_in_enabled) { true } it 'displays expected content and navigates to choice' do @@ -99,19 +101,57 @@ # go back and choose in person option page.go_back click_on t('forms.buttons.continue_ipp') - expect(page).to have_current_path(idv_document_capture_path) + expect(page).to have_current_path(idv_document_capture_path(step: :how_to_verify)) end context 'when selfie is enabled' do - include InPersonHelper + let(:facial_match_required) { true } + + it 'goes to direct IPP if selected and can come back' do + expect(page).to have_current_path(idv_how_to_verify_path) + expect(page).to have_content(t('doc_auth.headings.how_to_verify')) + click_on t('forms.buttons.continue_ipp') + expect(page).to have_current_path(idv_document_capture_path(step: :how_to_verify)) + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.find_a_post_office'), + ) + expect(page).to have_content(t('headings.verify')) + click_on t('forms.buttons.back') + expect(page).to have_current_path(idv_how_to_verify_path) + end + + context 'when the user is bucketed for Socure doc_auth' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_vendor).and_return('socure') + allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return('socure') + end + + it 'goes to direct IPP if selected and can come back' do + expect(page).to have_current_path(idv_how_to_verify_path) + expect(page).to have_content(t('doc_auth.headings.how_to_verify')) + click_on t('forms.buttons.continue_ipp') + expect(page).to have_current_path(idv_document_capture_path(step: :how_to_verify)) + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.find_a_post_office'), + ) + expect(page).to have_content(t('headings.verify')) + click_on t('forms.buttons.back') + expect(page).to have_current_path(idv_how_to_verify_path) + end + end + end - let(:facial_match_required) { false } + context 'when the user is bucketed for Socure doc_auth' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_vendor).and_return('socure') + allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return('socure') + end it 'goes to direct IPP if selected and can come back' do expect(page).to have_current_path(idv_how_to_verify_path) expect(page).to have_content(t('doc_auth.headings.how_to_verify')) click_on t('forms.buttons.continue_ipp') - expect(page).to have_current_path(idv_document_capture_path) + expect(page).to have_current_path(idv_document_capture_path(step: :how_to_verify)) expect_in_person_step_indicator_current_step( t('step_indicator.flows.idv.find_a_post_office'), ) @@ -207,7 +247,7 @@ end it 'should not be bounced back to How to Verify with opt in disabled midstream' do - expect(page).to have_current_path(idv_document_capture_path) + expect(page).to have_current_path(idv_document_capture_path(step: :how_to_verify)) allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } page.go_back expect(page).to have_current_path(idv_document_capture_path) @@ -236,7 +276,7 @@ end it 'should be bounced back to How to Verify' do - expect(page).to have_current_path(idv_document_capture_path) + expect(page).to have_current_path(idv_document_capture_path(step: :how_to_verify)) page.go_back expect(page).to have_current_path(idv_how_to_verify_url) end diff --git a/spec/features/idv/doc_auth/socure_document_capture_spec.rb b/spec/features/idv/doc_auth/socure_document_capture_spec.rb index 247b31b3a77..98e623fa94c 100644 --- a/spec/features/idv/doc_auth/socure_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/socure_document_capture_spec.rb @@ -532,6 +532,8 @@ def expect_rate_limited_header(expected_to_be_present) end context 'when selfie is enabled' do + let(:facial_match_required) { true } + it 'redirects back to agreement page' do expect(page).to have_current_path(idv_agreement_path) end From 5d1ad2668c863939e0ed37128e1a9abce6e52d72 Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:45:25 -0500 Subject: [PATCH 08/19] Add build tooling sections to frontend documentation (#11812) changelog: Internal, Documentation, Add build tooling sections to frontend documentation --- docs/frontend.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/docs/frontend.md b/docs/frontend.md index cf909d77f5d..3d2cf17af24 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -62,6 +62,36 @@ margins or borders. - [U.S. Web Design System utilities](https://designsystem.digital.gov/utilities/) +### CSS Build Tooling + +Stylesheets are compiled from Sass source files using our [`@18f/identity-build-sass` NPM package](../app/javascript/packages/build-sass/README.md) +`build-sass` command-line utility. + +`@18f/identity-build-sass` is a wrapper of the official Sass [`sass-embedded` NPM package](https://www.npmjs.com/package/sass-embedded), +with a few additional features: + +- Minifies stylesheets in production environments. +- Downgrades stylesheets to use browser vendor prefixes where necessary. +- Adds load paths for the [Login.gov Design System](https://github.com/18f/identity-design-system) + and [U.S. Web Design System](https://designsystem.digital.gov/). + +Sass source files are automatically compiled from: + +- `app/assets/stylesheets/`: Application-wide stylesheets to be included in every page. +- `app/components/`: Stylesheets which are loaded automatically whenever the corresponding + ViewComponent component implementation is used in a page. + +Compiled files are output to the `app/assets/builds/` directory, which is where [Propshaft](https://github.com/rails/propshaft) +looks for assets referenced by asset URL helpers like `stylesheet_path` and others. + +In deployed environments, we rely on Propshaft to append a [fingerprint](https://en.wikipedia.org/wiki/Fingerprint_(computing)) +suffix to invalidate caches for previous versions of the stylesheet. + +The [`cssbundling-rails` gem](https://github.com/rails/cssbundling-rails) is a dependency of the +project, which enhances `rake assets:precompile` to invoke `yarn build:css` as part of +[assets precompilation](https://guides.rubyonrails.org/asset_pipeline.html#precompiling-assets) +during application deployment. + ## JavaScript ### At a Glance @@ -195,6 +225,54 @@ how [`Idv::AnalyticsEventEnhancer`][analytics_events_enhancer.rb] is implemented [data_attributes]: https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes [analytics_events_enhancer.rb]: https://github.com/18F/identity-idp/blob/main/app/services/idv/analytics_events_enhancer.rb +### JavaScript Build Tooling + +JavaScript is bundled using [Webpack](https://webpack.js.org/) with syntax transformation applied by +[Babel](https://babeljs.io/). + +Webpack is configured to look for entrypoints in: + +- `app/javascript/packs/`: JavaScript bundles that are loaded in Ruby on Rails view files using the + `javascript_packs_tag_once` script helper. +- `app/components/`: JavaScript bundles which are loaded automatically whenever the corresponding + ViewComponent component implementation is used in a page. + +Compiled files are output to the `public/packs/` directory, along with a manifest file. The manifest +file is used by the Ruby on Rails backend to determine all of the assets associated with a given +pack to be included when the script is referenced: + +- The exact file name of the compiled script, which may include a [fingerprint](https://en.wikipedia.org/wiki/Fingerprint_(computing)) + suffix to invalidate caches for previous versions of the script in deployed environments. +- A list of images and other assets referenced in a script using [`@18f/identity-assets` package](../app/javascript/packages/assets/README.md) + utilities. +- A reference to additional JavaScript file for each locale containing translations, if the script + uses [`@18f/identity-i18n` package](../app/javascript/packages/i18n/README.md) + translation functions. +- SHA256 checksums of compiled scripts, used for [subresource integrity attributes](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity). + +As an example, see [the manifest file for current production JavaScript assets](https://secure.login.gov/packs/manifest.json). + +The manifest is generated using [WebpackManifestPlugin](https://github.com/shellscape/webpack-manifest-plugin), +enhanced with custom Webpack plugins: + +- [`RailsAssetsWebpackPlugin`][rails-assets-webpack-plugin-readme]: Detects calls to `getAssetPath` + to enhance the manifest to include the set of assets referenced by a script bundle. +- [`RailsI18nWebpackPlugin`][rails-i18n-webpack-plugin-readme]: Detects calls to `t` translation + function to generate new script files for every supported locale containing translations data for + keys referenced by a script bundle, enhancing the manifest to add those new script files as + additional assets of the script. + +The JavaScript manifest is parsed by the [`AssetSources` class](../lib/asset_sources.rb) when the +[`render_javascript_pack_once_tags` script helper method](../app/helpers/script_helper.rb) is called +in the application's view layout. + +The [`jsbundling-rails` gem](https://github.com/rails/jsbundling-rails) is a dependency of the +project, which enhances `rake assets:precompile` to invoke `yarn build` as part of [assets precompilation](https://guides.rubyonrails.org/asset_pipeline.html#precompiling-assets) +during application deployment. + +[rails-assets-webpack-plugin-readme]: ../app/javascript/packages/assets/README.md +[rails-i18n-webpack-plugin-readme]: ../app/javascript/packages/rails-i18n-webpack-plugin/README.md + ## Image Assets When possible, use SVG format for images, as these render at higher quality and with a smaller file From bdd92766b79a89ce3aa536254bedc3d20698a3da Mon Sep 17 00:00:00 2001 From: Amir Reavis-Bey Date: Tue, 28 Jan 2025 14:10:09 -0500 Subject: [PATCH 09/19] bypass ID expiration by configuration in test mode (#11811) * temporarily disable expiraiton check for ID in testing mode * [skip changelog] * happy linting * add spec * happy linting * updating date check logic to use parse_legacy to accomodate Date objects * comment update * add add'l test for 2020-01-01 fails in non-test mode --------- Co-authored-by: ashukla --- app/forms/idv/doc_pii_form.rb | 4 ++ spec/forms/idv/doc_pii_form_spec.rb | 59 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/app/forms/idv/doc_pii_form.rb b/app/forms/idv/doc_pii_form.rb index cc3355bb9d9..07acb09c7fd 100644 --- a/app/forms/idv/doc_pii_form.rb +++ b/app/forms/idv/doc_pii_form.rb @@ -109,6 +109,10 @@ def dob_valid? end def state_id_expired? + # temporary fix, tracked for removal in LG-15600 + return if IdentityConfig.store.socure_docv_verification_data_test_mode && + DateParser.parse_legacy(state_id_expiration) == Date.parse('2020-01-01') + if state_id_expiration && DateParser.parse_legacy(state_id_expiration).past? errors.add(:state_id_expiration, generic_error, type: :state_id_expiration) end diff --git a/spec/forms/idv/doc_pii_form_spec.rb b/spec/forms/idv/doc_pii_form_spec.rb index f4ceee1be8a..4fa18c4e8e6 100644 --- a/spec/forms/idv/doc_pii_form_spec.rb +++ b/spec/forms/idv/doc_pii_form_spec.rb @@ -282,6 +282,65 @@ id_expiration_status: 'present', ) end + + context 'expiration date is 2020-01-01' do # test 2020-01-01 fails outside socure test mode + let(:pii) { state_id_expired_error_pii.merge(state_id_expiration: '2020-01-01') } + it 'returns a single state ID expiration error' do + result = subject.submit + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(false) + expect(result.errors[:state_id_expiration]).to eq [ + t('doc_auth.errors.general.no_liveness'), + ] + expect(result.extra).to eq( + attention_with_barcode: false, + pii_like_keypaths: pii_like_keypaths, + id_issued_status: 'present', + id_expiration_status: 'present', + ) + end + end + + context 'when in socure_test_mode' do + before do + allow(IdentityConfig.store).to receive(:socure_docv_verification_data_test_mode) + .and_return(true) + end + + it 'returns a single state ID expiration error' do + result = subject.submit + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(false) + expect(result.errors[:state_id_expiration]).to eq [ + t('doc_auth.errors.general.no_liveness'), + ] + expect(result.extra).to eq( + attention_with_barcode: false, + pii_like_keypaths: pii_like_keypaths, + id_issued_status: 'present', + id_expiration_status: 'present', + ) + end + + context 'expiration date is 2020-01-01' do + let(:pii) { state_id_expired_error_pii.merge(state_id_expiration: '2020-01-01') } + it 'returns a successful form response' do + result = subject.submit + + expect(result).to be_kind_of(FormResponse) + expect(result.success?).to eq(true) + expect(result.errors).to be_empty + expect(result.extra).to eq( + attention_with_barcode: false, + pii_like_keypaths: pii_like_keypaths, + id_issued_status: 'present', + id_expiration_status: 'present', + ) + end + end + end end context 'when there is a non-string zipcode' do From 500fd4eee6556fca2f7fe246276db534825dd604 Mon Sep 17 00:00:00 2001 From: Aaron Date: Tue, 28 Jan 2025 13:23:50 -0600 Subject: [PATCH 10/19] update end of day bug (#11809) * update end of day bug * add end of day regression test * Update spec/lib/reporting/identity_verification_report_spec.rb perfect! Co-authored-by: Zach Margolis --------- Co-authored-by: Zach Margolis --- lib/reporting/identity_verification_report.rb | 2 +- .../reporting/identity_verification_report_spec.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/reporting/identity_verification_report.rb b/lib/reporting/identity_verification_report.rb index 089a9d8f27a..683b06ec370 100644 --- a/lib/reporting/identity_verification_report.rb +++ b/lib/reporting/identity_verification_report.rb @@ -304,7 +304,7 @@ def fraud_review_passed def verified_user_count @verified_user_count ||= Reports::BaseReport.transaction_with_timeout do - Profile.where(active: true).where('verified_at <= ?', time_range.end).count + Profile.where(active: true).where('verified_at <= ?', time_range.end.end_of_day).count end end diff --git a/spec/lib/reporting/identity_verification_report_spec.rb b/spec/lib/reporting/identity_verification_report_spec.rb index 2f5f3e3ae68..e01aed04219 100644 --- a/spec/lib/reporting/identity_verification_report_spec.rb +++ b/spec/lib/reporting/identity_verification_report_spec.rb @@ -612,5 +612,19 @@ subject.cloudwatch_client end end + + describe '#verified_user_count' do + let!(:profile) do + create( + :profile, + :active, + created_at: time_range.end.end_of_day, + verified_at: time_range.end.end_of_day, + ) + end + it 'counts users through end of day' do + expect(report.verified_user_count).to eq(1) + end + end end end From c7de1230f1813a4c2790d636d8531e824bf91519 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 28 Jan 2025 15:11:07 -0500 Subject: [PATCH 11/19] LG-15394 Add tooling for an SP proofing events by UUID report (#11787) We have an agency partner who requested a specialized report the involves a table with columns representing proofing events and rows representing users for the partner where the values in each cell correspond to whether that user encountered that event in the given time period. We have been manually servicing this request for a little while. This commit adds tooling to generate this report automatically so it does not require manual processing. [skip changelog] --- .../reports/sp_proofing_events_by_uuid.rb | 62 +++++ config/application.yml.default | 1 + config/initializers/job_configurations.rb | 6 + lib/identity_config.rb | 1 + lib/reporting/sp_proofing_events_by_uuid.rb | 226 ++++++++++++++++++ .../sp_proofing_events_by_uuid_spec.rb | 113 +++++++++ .../sp_proofing_events_by_uuid_spec.rb | 162 +++++++++++++ 7 files changed, 571 insertions(+) create mode 100644 app/jobs/reports/sp_proofing_events_by_uuid.rb create mode 100644 lib/reporting/sp_proofing_events_by_uuid.rb create mode 100644 spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb create mode 100644 spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb diff --git a/app/jobs/reports/sp_proofing_events_by_uuid.rb b/app/jobs/reports/sp_proofing_events_by_uuid.rb new file mode 100644 index 00000000000..03a91bb63cf --- /dev/null +++ b/app/jobs/reports/sp_proofing_events_by_uuid.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'reporting/sp_proofing_events_by_uuid' + +module Reports + class SpProofingEventsByUuid < BaseReport + attr_accessor :report_date + + def perform(report_date) + return unless IdentityConfig.store.s3_reports_enabled + + self.report_date = report_date + + IdentityConfig.store.sp_proofing_events_by_uuid_report_configs.each do |report_config| + send_report(report_config) + end + end + + def send_report(report_config) + return unless IdentityConfig.store.s3_reports_enabled + issuers = report_config['issuers'] + agency_abbreviation = report_config['agency_abbreviation'] + emails = report_config['emails'] + + agency_report_nane = "#{agency_abbreviation.downcase}_proofing_events_by_uuid" + agency_report_title = "#{agency_abbreviation} Proofing Events By UUID" + + report_maker = build_report_maker( + issuers:, + agency_abbreviation:, + time_range: report_date.to_date.weeks_ago(1).all_week(:sunday), + ) + + csv = report_maker.to_csv + + save_report(agency_report_nane, csv, extension: 'csv') + + if emails.blank? + Rails.logger.warn "No email addresses received - #{agency_report_title} NOT SENT" + return false + end + + email_message = <<~HTML.html_safe # rubocop:disable Rails/OutputSafety +

#{agency_report_title}

+ HTML + + emails.each do |email| + ReportMailer.tables_report( + email: email, + subject: "#{agency_report_title} - #{report_date.to_date}", + reports: report_maker.as_emailable_reports, + message: email_message, + attachment_format: :csv, + ).deliver_now + end + end + + def build_report_maker(issuers:, agency_abbreviation:, time_range:) + Reporting::SpProofingEventsByUuid.new(issuers:, agency_abbreviation:, time_range:) + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 743581627b4..fc77926b7ba 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -403,6 +403,7 @@ socure_reason_code_base_url: '' socure_reason_code_timeout_in_seconds: 5 sp_handoff_bounce_max_seconds: 2 sp_issuer_user_counts_report_configs: '[]' +sp_proofing_events_by_uuid_report_configs: '[]' state_tracking_enabled: true team_ada_email: '' team_all_login_emails: '[]' diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 572c450ce86..d8f87610a8b 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -119,6 +119,12 @@ cron: cron_24h, args: -> { [Time.zone.yesterday] }, }, + # Send the SP IdV Weekly Dropoff Report + sp_idv_weekly_dropoff: { + class: 'Reports::SpProofingEventsByUuid', + cron: cron_every_monday_2am, + args: -> { [Time.zone.today] }, + }, # Sync opted out phone numbers from AWS phone_number_opt_out_sync_job: { class: 'PhoneNumberOptOutSyncJob', diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 4a6557cac45..b09a7ff326a 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -439,6 +439,7 @@ def self.store config.add(:socure_reason_code_timeout_in_seconds, type: :integer) config.add(:sp_handoff_bounce_max_seconds, type: :integer) config.add(:sp_issuer_user_counts_report_configs, type: :json) + config.add(:sp_proofing_events_by_uuid_report_configs, type: :json) config.add(:state_tracking_enabled, type: :boolean) config.add(:team_ada_email, type: :string) config.add(:team_all_login_emails, type: :json) diff --git a/lib/reporting/sp_proofing_events_by_uuid.rb b/lib/reporting/sp_proofing_events_by_uuid.rb new file mode 100644 index 00000000000..d9cc5eca312 --- /dev/null +++ b/lib/reporting/sp_proofing_events_by_uuid.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require 'reporting/cloudwatch_client' +require 'reporting/cloudwatch_query_quoting' + +module Reporting + class SpProofingEventsByUuid + attr_reader :issuers, :agency_abbreviation, :time_range + + def initialize( + issuers:, + agency_abbreviation:, + time_range:, + verbose: false, + progress: false, + cloudwatch_client: nil + ) + @issuers = issuers + @agency_abbreviation = agency_abbreviation + @time_range = time_range + @verbose = verbose + @progress = progress + @cloudwatch_client = cloudwatch_client + end + + def verbose? + @verbose + end + + def progress? + @progress + end + + def query(after_row:) + base_query = <<~QUERY + filter properties.service_provider in #{issuers.inspect} or + (name = "IdV: enter verify by mail code submitted" and properties.event_properties.initiating_service_provider in #{issuers.inspect}) + | filter name in [ + "IdV: doc auth welcome visited", + "IdV: doc auth document_capture visited", + "Frontend: IdV: front image added", + "Frontend: IdV: back image added", + "idv_selfie_image_added", + "IdV: doc auth image upload vendor submitted", + "IdV: doc auth ssn submitted", + "IdV: doc auth verify proofing results", + "IdV: phone confirmation form", + "IdV: phone confirmation vendor", + "IdV: final resolution", + "IdV: enter verify by mail code submitted", + "Fraud: Profile review passed", + "Fraud: Profile review rejected", + "User registration: agency handoff visited", + "SP redirect initiated" + ] + + | fields coalesce(name = "Fraud: Profile review passed" and properties.event_properties.success, 0) * properties.event_properties.profile_age_in_seconds as fraud_review_profile_age_in_seconds, + coalesce(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed, 0) * properties.event_properties.profile_age_in_seconds as gpo_profile_age_in_seconds, + fraud_review_profile_age_in_seconds + gpo_profile_age_in_seconds as profile_age_in_seconds + + | stats sum(name = "IdV: doc auth welcome visited") > 0 as workflow_started, + sum(name = "IdV: doc auth document_capture visited") > 0 as doc_auth_started, + sum(name = "Frontend: IdV: front image added") > 0 and sum(name = "Frontend: IdV: back image added") > 0 as document_captured, + sum(name = "idv_selfie_image_added") > 0 as selfie_captured, + sum(name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.success) > 0 as doc_auth_passed, + sum(name = "IdV: doc auth ssn submitted") > 0 as ssn_submitted, + sum(name = "IdV: doc auth verify proofing results") > 0 as personal_info_submitted, + sum(name = "IdV: doc auth verify proofing results" and properties.event_properties.success) > 0 as personal_info_verified, + sum(name = "IdV: phone confirmation form") > 0 as phone_submitted, + sum(name = "IdV: phone confirmation vendor" and properties.event_properties.success) > 0 as phone_verified, + sum(name = "IdV: final resolution") > 0 as online_workflow_completed, + sum(name = "IdV: final resolution" and !properties.event_properties.gpo_verification_pending and !properties.event_properties.in_person_verification_pending and !coalesce(properties.event_properties.fraud_pending_reason, 0)) > 0 as verified_in_band, + sum(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed) > 0 as verified_by_mail, + sum(name = "Fraud: Profile review passed" and properties.event_properties.success) > 0 as verified_fraud_review, + max(profile_age_in_seconds) as out_of_band_verification_pending_seconds, + sum(name = "User registration: agency handoff visited" and properties.event_properties.ial2) > 0 as agency_handoff, + sum(name = "SP redirect initiated" and properties.event_properties.ial == 2) > 0 as sp_redirect, + toMillis(min(@timestamp)) as first_event + by properties.user_id as login_uuid + | filter workflow_started > 0 or verified_by_mail > 0 or verified_fraud_review > 0 + | limit 10000 + | sort first_event asc + QUERY + return base_query if after_row.nil? + + base_query + " | filter first_event > #{after_row['first_event']}" + end + + def as_csv + csv = [] + csv << ['Date Range', "#{time_range.begin.to_date} - #{time_range.end.to_date}"] + csv << csv_header + data.each do |result_row| + csv << result_row + end + csv.compact + end + + def to_csv + CSV.generate do |csv| + as_csv.each do |row| + csv << row + end + end + end + + def as_emailable_reports + [ + EmailableReport.new( + title: "#{agency_abbreviation} Proofing Events By UUID", + table: as_csv, + filename: "#{agency_abbreviation.downcase}_proofing_events_by_uuid", + ), + ] + end + + def csv_header + [ + 'UUID', + 'Workflow Started', + 'Documnet Capture Started', + 'Document Captured', + 'Selfie Captured', + 'Document Authentication Passed', + 'SSN Submitted', + 'Personal Information Submitted', + 'Personal Information Verified', + 'Phone Submitted', + 'Phone Verified', + 'Verification Workflow Complete', + 'Identity Verified for In-Band Users', + 'Identity Verified for Verify-By-Mail Users', + 'Identity Verified for Fraud Review Users', + 'Out-of-Band Verification Pending Seconds', + 'Agency Handoff Visited', + 'Agency Handoff Submitted', + ] + end + + def data + return @data if defined? @data + + login_uuid_data ||= fetch_results.map do |result_row| + process_result_row(result_row) + end + login_uuid_to_agency_uuid_map = build_uuid_map(login_uuid_data.map(&:first)) + + @data = login_uuid_data.map do |row| + login_uuid, *row_data = row + agency_uuid = login_uuid_to_agency_uuid_map[login_uuid] + next if agency_uuid.nil? + [agency_uuid, *row_data] + end.compact + end + + def process_result_row(result_row) + [ + result_row['login_uuid'], + result_row['workflow_started'] == '1', + result_row['doc_auth_started'] == '1', + result_row['document_captured'] == '1', + result_row['selfie_captured'] == '1', + result_row['doc_auth_passed'] == '1', + result_row['ssn_submitted'] == '1', + result_row['personal_info_submitted'] == '1', + result_row['personal_info_verified'] == '1', + result_row['phone_submitted'] == '1', + result_row['phone_verified'] == '1', + result_row['online_workflow_completed'] == '1', + result_row['verified_in_band'] == '1', + result_row['verified_by_mail'] == '1', + result_row['verified_fraud_review'] == '1', + result_row['out_of_band_verification_pending_seconds'].to_i, + result_row['agency_handoff'] == '1', + result_row['sp_redirect'] == '1', + ] + end + + # rubocop:disable Rails/FindEach + # Use of `find` instead of `find_each` here is safe since we are already batching the UUIDs + # that go into the query + def build_uuid_map(uuids) + uuid_map = Hash.new + + uuids.each_slice(1000) do |uuid_slice| + AgencyIdentity.joins(:user).where( + agency:, + users: { uuid: uuid_slice }, + ).each do |agency_identity| + uuid_map[agency_identity.user.uuid] = agency_identity.uuid + end + end + + uuid_map + end + # rubocop:enable Rails/FindEach + + def agency + @agency ||= begin + record = Agency.find_by(abbreviation: agency_abbreviation) + raise "Unable to find agency with abbreviation: #{agency_abbreviation}" if record.nil? + record + end + end + + def fetch_results(after_row: nil) + results = cloudwatch_client.fetch( + query: query(after_row:), + from: time_range.begin.beginning_of_day, + to: time_range.end.end_of_day, + ) + return results if results.count < 10000 + results + fetch_results(after_row: results.last) + end + + def cloudwatch_client + @cloudwatch_client ||= Reporting::CloudwatchClient.new( + num_threads: 1, + ensure_complete_logs: false, + slice_interval: 100.years, + progress: progress?, + logger: verbose? ? Logger.new(STDERR) : nil, + ) + end + end +end diff --git a/spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb b/spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb new file mode 100644 index 00000000000..a2fe2bff220 --- /dev/null +++ b/spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe Reports::SpProofingEventsByUuid do + let(:report_date) { Date.new(2024, 12, 9) } + let(:agency_abbreviation) { 'ABC' } + let(:report_emails) { ['test@example.com'] } + let(:issuers) { ['super:cool:test:issuer'] } + let(:sp_proofing_events_by_uuid_report_configs) do + [ + { + 'issuers' => issuers, + 'agency_abbreviation' => 'ABC', + 'emails' => report_emails, + }, + ] + end + + before do + allow(IdentityConfig.store).to receive(:s3_reports_enabled).and_return(true) + allow(IdentityConfig.store).to receive( + :sp_proofing_events_by_uuid_report_configs, + ).and_return( + sp_proofing_events_by_uuid_report_configs, + ) + end + + describe '#perform' do + it 'gets a CSV from the report maker, saves it to S3, and sends email to team' do + report = [ + ['UUID', 'Welcome Visited', 'Welcome Submitted'], + ['123abc', true, true], + ['456def', true, false], + ] + csv_report = CSV.generate do |csv| + report.each { |row| csv << row } + end + emailable_reports = [ + Reporting::EmailableReport.new( + title: 'DOL Proofing Events By UUID - 2024-12-01', + table: report, + filename: 'dol_proofing_events_by_uuid', + ), + ] + + report_maker = double( + Reporting::SpProofingEventsByUuid, + to_csv: csv_report, + as_emailable_reports: emailable_reports, + ) + + allow(subject).to receive(:build_report_maker).with( + issuers: issuers, + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 7), + ).and_return(report_maker) + expect(subject).to receive(:save_report).with( + 'abc_proofing_events_by_uuid', + csv_report, + extension: 'csv', + ) + + expect(ReportMailer).to receive(:tables_report).once.with( + email: 'test@example.com', + subject: 'ABC Proofing Events By UUID - 2024-12-09', + reports: emailable_reports, + message: anything, + attachment_format: :csv, + ).and_call_original + + subject.perform(report_date) + end + + context 'with no emails configured' do + let(:report_emails) { [] } + + it 'does not send the report in email' do + report_maker = double( + Reporting::SpProofingEventsByUuid, + to_csv: 'I am a CSV, see', + identity_verification_emailable_report: 'I am a report', + ) + allow(subject).to receive(:build_report_maker).with( + issuers: issuers, + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 7), + ).and_return(report_maker) + expect(subject).to receive(:save_report).with( + 'abc_proofing_events_by_uuid', + 'I am a CSV, see', + extension: 'csv', + ) + + expect(ReportMailer).to_not receive(:tables_report) + + subject.perform(report_date) + end + end + end + + describe '#build_report_maker' do + it 'is a identity verification report maker with the correct attributes' do + report_maker = subject.build_report_maker( + issuers: ['super:cool:test:issuer'], + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 7), + ) + + expect(report_maker.issuers).to eq(['super:cool:test:issuer']) + expect(report_maker.agency_abbreviation).to eq('ABC') + expect(report_maker.time_range).to eq(Date.new(2024, 12, 1)..Date.new(2024, 12, 7)) + end + end +end diff --git a/spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb b/spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb new file mode 100644 index 00000000000..674a8f83dc6 --- /dev/null +++ b/spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb @@ -0,0 +1,162 @@ +require 'rails_helper' +require 'reporting/sp_proofing_events_by_uuid' + +RSpec.describe Reporting::SpProofingEventsByUuid do + let(:issuer) { 'super:cool:test:issuer' } + let(:agency_abbreviation) { 'DOL' } + let(:agency) { Agency.find_by(abbreviation: agency_abbreviation) } + + let(:time_range) { Date.new(2024, 12, 1).all_week(:sunday) } + + let(:deleted_user_uuid) { 'deleted_user_test' } + let(:non_agency_user_uuid) { 'non_agency_user_test' } + let(:agency_user_login_uuid) { 'agency_user_login_uuid_test' } + let(:agency_user_agency_uuid) { 'agency_user_agency_uuid_test' } + + let(:cloudwatch_logs) do + [ + { + 'login_uuid' => deleted_user_uuid, + 'workflow_started' => '1', + 'first_event' => '1.735275676123E12', + }, + { + 'login_uuid' => non_agency_user_uuid, + 'workflow_started' => '1', + 'first_event' => '1.735275676456E12', + }, + { + 'login_uuid' => agency_user_login_uuid, + 'workflow_started' => '1', + 'first_event' => '1.735275676789E12', + }, + ] + end + + let(:expect_csv_result) do + [ + ['Date Range', '2024-12-01 - 2024-12-07'], + [ + 'UUID', + 'Workflow Started', + 'Documnet Capture Started', + 'Document Captured', + 'Selfie Captured', + 'Document Authentication Passed', + 'SSN Submitted', + 'Personal Information Submitted', + 'Personal Information Verified', + 'Phone Submitted', + 'Phone Verified', + 'Verification Workflow Complete', + 'Identity Verified for In-Band Users', + 'Identity Verified for Verify-By-Mail Users', + 'Identity Verified for Fraud Review Users', + 'Out-of-Band Verification Pending Seconds', + 'Agency Handoff Visited', + 'Agency Handoff Submitted', + ], + [ + agency_user_agency_uuid, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + 0, + false, + false, + ], + ] + end + + before do + create(:user, uuid: non_agency_user_uuid) + agency_user = create(:user, uuid: agency_user_login_uuid) + create(:agency_identity, user: agency_user, agency:, uuid: agency_user_agency_uuid) + + stub_cloudwatch_logs(cloudwatch_logs) + end + + subject(:report) do + Reporting::SpProofingEventsByUuid.new( + issuers: Array(issuer), agency_abbreviation:, time_range:, + ) + end + + describe '#as_csv' do + it 'renders a CSV report with converted UUIDs' do + aggregate_failures do + expect_csv_result.zip(report.as_csv).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe '#to_csv' do + it 'generates a csv' do + csv = CSV.parse(report.to_csv, headers: false) + + stringified_csv = expect_csv_result.map { |row| row.map(&:to_s) } + + aggregate_failures do + csv.map(&:to_a).zip(stringified_csv).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe '#as_emailable_reports' do + it 'returns an array with an emailable report' do + expect(report.as_emailable_reports).to eq( + [ + Reporting::EmailableReport.new( + title: 'DOL Proofing Events By UUID', + table: expect_csv_result, + filename: 'dol_proofing_events_by_uuid', + ), + ], + ) + end + end + + describe '#data' do + it 'fetches additional results if 10k results are returned' do + cloudwatch_client = double(Reporting::CloudwatchClient) + expect(cloudwatch_client).to receive(:fetch).ordered do |args| + expect(args[:query]).to_not include('| filter first_event') + [ + { + 'login_uuid' => agency_user_login_uuid, + 'workflow_started' => '1', + 'first_event' => '1.123456E12', + }, + ] * 10000 + end + expect(cloudwatch_client).to receive(:fetch).ordered do |args| + expect(args[:query]).to include('| filter first_event > 1.123456E12') + [ + { + 'login_uuid' => agency_user_login_uuid, + 'workflow_started' => '1', + 'first_event' => '1.123456E12', + }, + ] + end + allow(report).to receive(:cloudwatch_client).and_return(cloudwatch_client) + + expect(report.data.count).to eq(10_001) + end + end +end From 32b1f7f888621090ab8506ff8237491d8892fd99 Mon Sep 17 00:00:00 2001 From: Jessica Dembe Date: Tue, 28 Jan 2025 15:56:06 -0500 Subject: [PATCH 12/19] LG-15248: Account Management no change available (#11701) * add requested attribute to accounts_show_presenter * add logic for decorated_sp_session * check for requested_attributes and ial2_requested * add tests for requested attribes and ial2 requested * changelog: User-Facing Improvements, account management, no change available if partner shares all emails * add logic to show/hide change button * clean up controller and presenter * clean up requested_attributes in the rest of controllers and specs * change logic for all emails requested as well as `nil` to `false` in specs * remove `ial2_requested` * lintfix * fix logic to show desired behavior when is false * remove debugger * fix logic * add supporting test for view * match `all_emails_requested` to be a boolean value * add logic for if the partner does not have `email` as a consented attribute * logic change to `all_emails_requested?` * refine check for no email (WIP), refine method name * fix all broken tests * change service provider -> partner * more logic correction * rename and fix logic * Update app/controllers/accounts/connected_accounts_controller.rb Co-authored-by: Andrew Duthie <1779930+aduth@users.noreply.github.com> * change `all_emails requested` -> `change_email_available?` * remove `change_email_available` * move logic around * logic in presenter and view * change to `change_option_available`, fix test * edit test block * change name to be more intuitive * add tests to view * fix account show presenter tests * method name cleanup * more test fixes * more changes * fix eager loading problem Co-authored-by: Andrew Duthie * lintfix * add test * test 1 for connected apps * check against `identities` and pass value * place logic in model, remove logic from presenter and controller, add check to presenter * no longer doing logic in controller, so return connected apps to previous state * remove `requested_attributes` * fix logic in model and view * method name change * remove unneeded keyword * fix tests * Repurpose ServiceProviderIdentity#hide_change_email? as verified_single_email_attribute? * Revert changes to ApplicationController, AccountShowPresenter spec * Handle nil verified_attributes * Update tests for connected accounts view * Exempt eager loading error validation for connected accounts * Update stubbed identity in connected accounts feature spec to include email --------- Co-authored-by: Andrew Duthie <1779930+aduth@users.noreply.github.com> Co-authored-by: Andrew Duthie --- app/models/service_provider_identity.rb | 6 +++ app/views/accounts/_connected_app.html.erb | 30 +++++++------ config/environments/test.rb | 10 +++++ spec/features/account_connected_apps_spec.rb | 2 + spec/models/service_provider_identity_spec.rb | 42 +++++++++++++++++++ .../connected_accounts/show.html.erb_spec.rb | 37 +++++++++++++++- 6 files changed, 113 insertions(+), 14 deletions(-) diff --git a/app/models/service_provider_identity.rb b/app/models/service_provider_identity.rb index 5ad3eaf6ede..3da98d4eb9f 100644 --- a/app/models/service_provider_identity.rb +++ b/app/models/service_provider_identity.rb @@ -65,6 +65,12 @@ def happened_at last_authenticated_at.in_time_zone('UTC') end + def verified_single_email_attribute? + verified_attributes.present? && + verified_attributes.include?('email') && + !verified_attributes.include?('all_emails') + end + def email_address_for_sharing if IdentityConfig.store.feature_select_email_to_share_enabled && email_address return email_address diff --git a/app/views/accounts/_connected_app.html.erb b/app/views/accounts/_connected_app.html.erb index 63b229fea39..615fbae0409 100644 --- a/app/views/accounts/_connected_app.html.erb +++ b/app/views/accounts/_connected_app.html.erb @@ -12,18 +12,24 @@ <% if IdentityConfig.store.feature_select_email_to_share_enabled %> - <%= t( - 'account.connected_apps.associated_attributes_html', - timestamp_html: render(TimeComponent.new(time: identity.created_at)), - ) %> -
- - <%= identity.email_address&.email || t('account.connected_apps.email_not_selected') %> - - <%= link_to( - t('help_text.requested_attributes.change_email_link'), - edit_connected_account_selected_email_path(identity_id: identity.id), - ) %> + <% if identity.verified_single_email_attribute? %> + <%= t( + 'account.connected_apps.associated_attributes_html', + timestamp_html: render(TimeComponent.new(time: identity.created_at)), + ) %> + + <%= identity.email_address&.email || t('account.connected_apps.email_not_selected') %> + + <%= link_to( + t('help_text.requested_attributes.change_email_link'), + edit_connected_account_selected_email_path(identity_id: identity.id), + ) %> + <% else %> + <%= t( + 'account.connected_apps.associated_html', + timestamp_html: render(TimeComponent.new(time: identity.created_at)), + ) %> + <% end %> <% else %> <%= t( 'account.connected_apps.associated_html', diff --git a/config/environments/test.rb b/config/environments/test.rb index 2abaa4644e9..b85d3bc7c1d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -52,6 +52,16 @@ ].each do |association| Bullet.add_safelist(type: :n_plus_one_query, class_name: 'User', association: association) end + + # Eager loading of email addresses is used on the Connected Accounts page, since most accounts + # will share an email address that can be changed by the user. An unoptimized query error is + # raised by bullet if the email address is not used, but it can't be known at the time of the + # query whether the email addresses will be used for all connected accounts. + Bullet.add_safelist( + type: :unused_eager_loading, + class_name: 'ServiceProviderIdentity', + association: :email_address, + ) end config.active_support.test_order = :random diff --git a/spec/features/account_connected_apps_spec.rb b/spec/features/account_connected_apps_spec.rb index 9ed731123a6..c37ad9cd25f 100644 --- a/spec/features/account_connected_apps_spec.rb +++ b/spec/features/account_connected_apps_spec.rb @@ -18,6 +18,7 @@ user: user, created_at: Time.zone.now - 80.days, service_provider: 'http://localhost:3000', + verified_attributes: ['email'], ) end let(:identity_without_link) do @@ -27,6 +28,7 @@ user: user, created_at: Time.zone.now - 50.days, service_provider: 'https://rp2.serviceprovider.com/auth/saml/metadata', + verified_attributes: ['email'], ) end let(:identity_timestamp) do diff --git a/spec/models/service_provider_identity_spec.rb b/spec/models/service_provider_identity_spec.rb index 54015505d9d..f188c84368f 100644 --- a/spec/models/service_provider_identity_spec.rb +++ b/spec/models/service_provider_identity_spec.rb @@ -2,10 +2,12 @@ RSpec.describe ServiceProviderIdentity do let(:user) { create(:user, :fully_registered) } + let(:verified_attributes) { [] } let(:identity) do ServiceProviderIdentity.create( user_id: user.id, service_provider: 'externalapp', + verified_attributes:, ) end subject { identity } @@ -182,6 +184,46 @@ end end + describe '#verified_single_email_attribute?' do + subject(:verified_single_email_attribute?) { identity.verified_single_email_attribute? } + + context 'with attributes nil' do + let(:verified_attributes) { nil } + + it { is_expected.to be false } + end + + context 'with no attributes verified' do + let(:verified_attributes) { [] } + + it { is_expected.to be false } + end + + context 'with a non-email attribute verified' do + let(:verified_attributes) { ['openid'] } + + it { is_expected.to be false } + end + + context 'with all_emails attribute verified' do + let(:verified_attributes) { ['all_emails'] } + + it { is_expected.to be false } + end + + context 'with email attribute verified' do + let(:verified_attributes) { ['email'] } + + it { is_expected.to be true } + + context 'with all_emails attribute verified' do + let(:verified_attributes) { ['email', 'all_emails'] } + + it { is_expected.to be false } + end + end + end + describe '#email_address_for_sharing' do let!(:last_login_email_address) do create( diff --git a/spec/views/accounts/connected_accounts/show.html.erb_spec.rb b/spec/views/accounts/connected_accounts/show.html.erb_spec.rb index 9a18c8cbb00..eed3b16bb81 100644 --- a/spec/views/accounts/connected_accounts/show.html.erb_spec.rb +++ b/spec/views/accounts/connected_accounts/show.html.erb_spec.rb @@ -31,7 +31,7 @@ end context 'with a connected app' do - let!(:identity) { create(:service_provider_identity, user:) } + let!(:identity) { create(:service_provider_identity, user:, verified_attributes: ['email']) } it 'lists applications with link to revoke' do render @@ -68,10 +68,43 @@ end end + context 'when the partner requests all_emails' do + before { identity.update(verified_attributes: ['all_emails']) } + + it 'does not show the change link' do + render + + expect(rendered).not_to have_content(t('account.connected_apps.email_not_selected')) + expect(rendered).not_to have_link( + t('help_text.requested_attributes.change_email_link'), + href: edit_connected_account_selected_email_path(identity_id: identity.id), + ) + end + end + + context 'when the partner does not request email' do + before { identity.update(verified_attributes: ['ssn']) } + + it 'hides the change link' do + render + + expect(rendered).not_to have_content(t('account.connected_apps.email_not_selected')) + expect(rendered).to_not have_link( + t('help_text.requested_attributes.change_email_link'), + href: edit_connected_account_selected_email_path(identity_id: identity.id), + ) + end + end + context 'with connected app having linked email' do let(:email_address) { user.confirmed_email_addresses.take } let!(:identity) do - create(:service_provider_identity, user:, email_address_id: email_address.id) + create( + :service_provider_identity, + user:, + email_address_id: email_address.id, + verified_attributes: ['email'], + ) end it 'renders associated email with option to change' do From b3941b13049692bfdc672facea64d8b4c3ff7b5c Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:58:26 -0500 Subject: [PATCH 13/19] Extract mixin for common MFA deletion behaviors (#11796) changelog: Internal, Code Quality, Extract mixin for common MFA deletion behaviors --- .../two_factor_authentication/auth_app_controller.rb | 6 ++---- .../two_factor_authentication/piv_cac_controller.rb | 10 ++-------- .../two_factor_authentication/webauthn_controller.rb | 6 ++---- app/controllers/concerns/mfa_deletion_concern.rb | 2 +- app/controllers/users/auth_app_controller.rb | 6 ++---- app/controllers/users/backup_code_setup_controller.rb | 5 ++--- app/controllers/users/edit_phone_controller.rb | 6 ++---- app/controllers/users/piv_cac_controller.rb | 10 ++-------- app/controllers/users/webauthn_controller.rb | 6 ++---- spec/controllers/concerns/mfa_deletion_concern_spec.rb | 10 ++++++++++ 10 files changed, 27 insertions(+), 40 deletions(-) diff --git a/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb index 4fb8f491c94..0d71428bc8e 100644 --- a/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb +++ b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb @@ -6,6 +6,7 @@ module TwoFactorAuthentication class AuthAppController < ApplicationController include CsrfTokenConcern include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :render_unauthorized, unless: :recently_authenticated_2fa? @@ -37,10 +38,7 @@ def destroy analytics.auth_app_delete_submitted(**result) if result.success? - create_user_event(:authenticator_disabled) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :authenticator_disabled) render json: { success: true } else render json: { success: false, error: result.first_error_message }, status: :bad_request diff --git a/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb b/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb index ca0425b1cf7..1079576606a 100644 --- a/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb +++ b/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb @@ -7,6 +7,7 @@ class PivCacController < ApplicationController include CsrfTokenConcern include ReauthenticationRequiredConcern include PivCacConcern + include MfaDeletionConcern before_action :render_unauthorized, unless: :recently_authenticated_2fa? @@ -38,9 +39,7 @@ def destroy analytics.piv_cac_delete_submitted(**result) if result.success? - create_user_event(:piv_cac_disabled) - revoke_remember_device(current_user) - deliver_push_notification + handle_successful_mfa_deletion(event_type: :piv_cac_disabled) clear_piv_cac_information render json: { success: true } else @@ -50,11 +49,6 @@ def destroy private - def deliver_push_notification - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - end - def render_unauthorized render json: { error: 'Unauthorized' }, status: :unauthorized end diff --git a/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb b/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb index d34aef733e6..b5a291d949a 100644 --- a/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb +++ b/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb @@ -6,6 +6,7 @@ module TwoFactorAuthentication class WebauthnController < ApplicationController include CsrfTokenConcern include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :render_unauthorized, unless: :recently_authenticated_2fa? @@ -37,10 +38,7 @@ def destroy analytics.webauthn_delete_submitted(**result) if result.success? - create_user_event(:webauthn_key_removed) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :webauthn_key_removed) render json: { success: true } else render json: { success: false, error: result.first_error_message }, status: :bad_request diff --git a/app/controllers/concerns/mfa_deletion_concern.rb b/app/controllers/concerns/mfa_deletion_concern.rb index 0f4c647aa2e..0b533882da1 100644 --- a/app/controllers/concerns/mfa_deletion_concern.rb +++ b/app/controllers/concerns/mfa_deletion_concern.rb @@ -4,7 +4,7 @@ module MfaDeletionConcern include RememberDeviceConcern def handle_successful_mfa_deletion(event_type:) - create_user_event(event_type) + create_user_event(event_type) if event_type revoke_remember_device(current_user) event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) PushNotification::HttpPush.deliver(event) diff --git a/app/controllers/users/auth_app_controller.rb b/app/controllers/users/auth_app_controller.rb index 34b07987fe5..32a6058dfd3 100644 --- a/app/controllers/users/auth_app_controller.rb +++ b/app/controllers/users/auth_app_controller.rb @@ -3,6 +3,7 @@ module Users class AuthAppController < ApplicationController include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -32,10 +33,7 @@ def destroy if result.success? flash[:success] = t('two_factor_authentication.auth_app.deleted') - create_user_event(:authenticator_disabled) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :authenticator_disabled) redirect_to account_path else flash[:error] = result.first_error_message diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index dfc9619b8de..4d9ece3644a 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -4,6 +4,7 @@ module Users class BackupCodeSetupController < ApplicationController include TwoFactorAuthenticatableMethods include MfaSetupConcern + include MfaDeletionConcern include SecureHeadersConcern include ReauthenticationRequiredConcern @@ -58,10 +59,8 @@ def refreshed def delete current_user.backup_code_configurations.destroy_all - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: nil) flash[:success] = t('notices.backup_codes_deleted') - revoke_remember_device(current_user) if in_multi_mfa_selection_flow? redirect_to authentication_methods_setup_path else diff --git a/app/controllers/users/edit_phone_controller.rb b/app/controllers/users/edit_phone_controller.rb index 9642cece9e4..9f829207829 100644 --- a/app/controllers/users/edit_phone_controller.rb +++ b/app/controllers/users/edit_phone_controller.rb @@ -4,6 +4,7 @@ module Users class EditPhoneController < ApplicationController include RememberDeviceConcern include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_user_can_edit_phone @@ -29,9 +30,7 @@ def update def destroy track_deletion_analytics_event phone_configuration.destroy! - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - revoke_remember_device(current_user) + handle_successful_mfa_deletion(event_type: :phone_removed) flash[:success] = t('two_factor_authentication.phone.delete.success') redirect_to account_url end @@ -55,7 +54,6 @@ def track_deletion_analytics_event success: true, phone_configuration_id: phone_configuration.id, ) - create_user_event(:phone_removed) end def phone_configuration diff --git a/app/controllers/users/piv_cac_controller.rb b/app/controllers/users/piv_cac_controller.rb index ec87ff792ad..ff157c17e7c 100644 --- a/app/controllers/users/piv_cac_controller.rb +++ b/app/controllers/users/piv_cac_controller.rb @@ -4,6 +4,7 @@ module Users class PivCacController < ApplicationController include ReauthenticationRequiredConcern include PivCacConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -33,9 +34,7 @@ def destroy analytics.piv_cac_delete_submitted(**result) if result.success? - create_user_event(:piv_cac_disabled) - revoke_remember_device(current_user) - deliver_push_notification + handle_successful_mfa_deletion(event_type: :piv_cac_disabled) clear_piv_cac_information flash[:success] = presenter.delete_success_alert_text @@ -48,11 +47,6 @@ def destroy private - def deliver_push_notification - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - end - def form @form ||= form_class.new(user: current_user, configuration_id: params[:id]) end diff --git a/app/controllers/users/webauthn_controller.rb b/app/controllers/users/webauthn_controller.rb index c7dee0cf2a8..60cb69f8e47 100644 --- a/app/controllers/users/webauthn_controller.rb +++ b/app/controllers/users/webauthn_controller.rb @@ -3,6 +3,7 @@ module Users class WebauthnController < ApplicationController include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -33,10 +34,7 @@ def destroy if result.success? flash[:success] = presenter.delete_success_alert_text - create_user_event(:webauthn_key_removed) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :webauthn_key_removed) redirect_to account_path else flash[:error] = result.first_error_message diff --git a/spec/controllers/concerns/mfa_deletion_concern_spec.rb b/spec/controllers/concerns/mfa_deletion_concern_spec.rb index 0193cbffaae..45bd0ccf3f5 100644 --- a/spec/controllers/concerns/mfa_deletion_concern_spec.rb +++ b/spec/controllers/concerns/mfa_deletion_concern_spec.rb @@ -38,5 +38,15 @@ result end + + context 'with nil event_type argument' do + let(:event_type) { nil } + + it 'does not create user event' do + expect(controller).not_to receive(:create_user_event) + + result + end + end end end From b55429fc68dfc716e563f5f22a440cc951bc1e96 Mon Sep 17 00:00:00 2001 From: John Maxwell Date: Tue, 28 Jan 2025 19:10:56 -0500 Subject: [PATCH 14/19] LG-15309 simple log pii validation to analytics (#11813) Log Pii validation event to analytics in the Socure flow changelog: Upcoming Features, Socure, Log Pii validation --------- Co-authored-by: Amir Reavis-Bey --- app/jobs/socure_docv_results_job.rb | 34 +++++++++++++++++-- app/models/document_capture_session.rb | 5 ++- .../socure/responses/docv_result_response.rb | 10 +----- .../doc_auth/socure_document_capture_spec.rb | 25 ++++++++++++++ .../hybrid_socure_mobile_spec.rb | 8 +++++ spec/jobs/socure_docv_results_job_spec.rb | 29 +++++++++++++++- .../responses/docv_result_response_spec.rb | 28 --------------- 7 files changed, 97 insertions(+), 42 deletions(-) delete mode 100644 spec/services/doc_auth/socure/responses/docv_result_response_spec.rb diff --git a/app/jobs/socure_docv_results_job.rb b/app/jobs/socure_docv_results_job.rb index 38c79c0ec05..21a46b73dfc 100644 --- a/app/jobs/socure_docv_results_job.rb +++ b/app/jobs/socure_docv_results_job.rb @@ -15,14 +15,31 @@ def perform(document_capture_session_uuid:, async: true, docv_transaction_token_ document_capture_session timer = JobHelpers::Timer.new - response = timer.time('vendor_request') do + docv_result_response = timer.time('vendor_request') do socure_document_verification_result end log_verification_request( - docv_result_response: response, + docv_result_response:, vendor_request_time_in_ms: timer.results['vendor_request'], ) - document_capture_session.store_result_from_response(response) + + if docv_result_response.success? + doc_pii_response = Idv::DocPiiForm.new(pii: docv_result_response.pii_from_doc.to_h).submit + log_pii_validation(doc_pii_response:) + + unless doc_pii_response&.success? + document_capture_session.store_failed_auth_data( + doc_auth_success: true, + selfie_status: docv_result_response.selfie_status, + errors: { pii_validation: 'failed' }, + front_image_fingerprint: nil, + back_image_fingerprint: nil, + selfie_image_fingerprint: nil, + ) + return + end + end + document_capture_session.store_result_from_response(docv_result_response) end private @@ -50,6 +67,17 @@ def log_verification_request(docv_result_response:, vendor_request_time_in_ms:) ) end + def log_pii_validation(doc_pii_response:) + analytics.idv_doc_auth_submitted_pii_validation( + **doc_pii_response.to_h.merge( + submit_attempts: rate_limiter&.attempts, + remaining_submit_attempts: rate_limiter&.remaining_count, + flow_path: nil, + liveness_checking_required: nil, + ), + ) + end + def socure_document_verification_result DocAuth::Socure::Requests::DocvResultRequest.new( document_capture_session_uuid:, diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 47b618a6bb6..2ce93dea6ca 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -33,7 +33,8 @@ def store_result_from_response(doc_auth_response) end def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, - selfie_image_fingerprint:, doc_auth_success:, selfie_status:) + selfie_image_fingerprint:, doc_auth_success:, selfie_status:, + errors: nil) session_result = load_result || DocumentCaptureSessionResult.new( id: generate_result_id, ) @@ -46,6 +47,8 @@ def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, session_result.add_failed_back_image!(back_image_fingerprint) session_result.add_failed_selfie_image!(selfie_image_fingerprint) if selfie_status == :fail + session_result.errors = errors + EncryptedRedisStructStorage.store( session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.in_seconds, diff --git a/app/services/doc_auth/socure/responses/docv_result_response.rb b/app/services/doc_auth/socure/responses/docv_result_response.rb index bc89bab0d22..017b65f0b89 100644 --- a/app/services/doc_auth/socure/responses/docv_result_response.rb +++ b/app/services/doc_auth/socure/responses/docv_result_response.rb @@ -42,7 +42,7 @@ def initialize(http_response:, @pii_from_doc = read_pii super( - success: successful_result? && pii_valid?, + success: successful_result?, errors: error_messages, pii_from_doc:, extra: extra_attributes, @@ -98,8 +98,6 @@ def successful_result? def error_messages if !successful_result? { socure: { reason_codes: get_data(DATA_PATHS[:reason_codes]) } } - elsif !pii_valid? - { pii_validation: 'failed' } else {} end @@ -178,12 +176,6 @@ def parse_date(date_string) Rails.logger.info(message) nil end - - def pii_valid? - return @pii_valid if !@pii_valid.nil? - - @pii_valid = Idv::DocPiiForm.new(pii: pii_from_doc.to_h).submit.success? - end end end end diff --git a/spec/features/idv/doc_auth/socure_document_capture_spec.rb b/spec/features/idv/doc_auth/socure_document_capture_spec.rb index 98e623fa94c..e591091fa0a 100644 --- a/spec/features/idv/doc_auth/socure_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/socure_document_capture_spec.rb @@ -29,6 +29,7 @@ socure_docv_webhook_repeat_endpoints.each { |endpoint| stub_request(:post, endpoint) } allow(IdentityConfig.store).to receive(:ruby_workers_idv_enabled).and_return(false) allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(SocureDocvResultsJob).to receive(:analytics).and_return(fake_analytics) @docv_transaction_token = stub_docv_document_request allow(IdentityConfig.store).to receive(:socure_docv_verification_data_test_mode) .and_return(socure_docv_verification_data_test_mode) @@ -153,6 +154,9 @@ visit idv_socure_document_capture_path expect(page).to have_current_path(idv_session_errors_rate_limited_path) + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) end end end @@ -263,6 +267,9 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) end end @@ -285,6 +292,9 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) end end end @@ -333,6 +343,9 @@ expect(page).to have_current_path(idv_ssn_url) expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) fill_out_ssn_form_ok click_idv_continue @@ -357,6 +370,9 @@ expect(page).to have_current_path(idv_ssn_url) expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) fill_out_ssn_form_ok click_idv_continue @@ -394,6 +410,12 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) fill_out_ssn_form_ok click_idv_continue @@ -427,6 +449,9 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).not_to have_logged_event( + :idv_doc_auth_submitted_pii_validation, + ) end end diff --git a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb index f5729f69374..866794851cb 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb @@ -36,6 +36,7 @@ .and_return(socure_docv_verification_data_test_mode) @docv_transaction_token = stub_docv_document_request stub_analytics + allow_any_instance_of(SocureDocvResultsJob).to receive(:analytics).and_return(@analytics) end context 'happy path', allow_browser_log: true do @@ -112,6 +113,10 @@ expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) expect(page).to have_current_path(idv_ssn_path) expect(@analytics).to have_logged_event(:idv_socure_document_request_submitted) + expect(@analytics).to have_logged_event(:idv_socure_verification_data_requested) + expect(@analytics).to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) fill_out_ssn_form_ok click_idv_continue @@ -549,6 +554,9 @@ expect(page).to have_text(t('doc_auth.headers.general.network_error')) expect(page).to have_text(t('doc_auth.errors.general.new_network_error')) expect(@analytics).to have_logged_event(:idv_socure_document_request_submitted) + expect(@analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) end perform_in_browser(:desktop) do diff --git a/spec/jobs/socure_docv_results_job_spec.rb b/spec/jobs/socure_docv_results_job_spec.rb index 99aadd2ffdc..a50cf8421b8 100644 --- a/spec/jobs/socure_docv_results_job_spec.rb +++ b/spec/jobs/socure_docv_results_job_spec.rb @@ -112,7 +112,34 @@ expect(document_capture_session_result.selfie_status).to eq(:not_processed) end - it 'expect fake analytics to have logged idv_socure_verification_data_requested' do + context 'Pii validation fails' do + before do + allow_any_instance_of(Idv::DocPiiForm).to receive(:zipcode).and_return(:invalid_junk) + end + + it 'stores a failed result' do + perform + + document_capture_session.reload + document_capture_session_result = document_capture_session.load_result + expect(document_capture_session_result.success).to eq(false) + expect(document_capture_session_result.doc_auth_success).to eq(true) + expect(document_capture_session_result.errors).to eq({ pii_validation: 'failed' }) + end + end + + it 'logs an idv_doc_auth_submitted_pii_validation event' do + perform + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + hash_including( + :submit_attempts, + :remaining_submit_attempts, + ), + ) + end + + it 'logs an idv_socure_verification_data_requested event' do perform expect(fake_analytics).to have_logged_event( :idv_socure_verification_data_requested, diff --git a/spec/services/doc_auth/socure/responses/docv_result_response_spec.rb b/spec/services/doc_auth/socure/responses/docv_result_response_spec.rb deleted file mode 100644 index dc8525994d4..00000000000 --- a/spec/services/doc_auth/socure/responses/docv_result_response_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'rails_helper' - -RSpec.describe DocAuth::Socure::Responses::DocvResultResponse do - subject(:docv_response) do - http_response = Struct.new(:body).new(SocureDocvFixtures.pass_json) - described_class.new(http_response:) - end - - context 'Socure says OK and the PII is valid' do - it 'succeeds' do - expect(docv_response.success?).to be(true) - end - end - - context 'Socure says OK but the PII is invalid' do - before do - allow_any_instance_of(Idv::DocPiiForm).to receive(:zipcode).and_return(:invalid_junk) - end - - it 'fails' do - expect(docv_response.success?).to be(false) - end - - it 'with a pii failure error' do - expect(docv_response.errors).to eq({ pii_validation: 'failed' }) - end - end -end From 374c199e7a7a87a1e0d8f6495aad704e9a77ba45 Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Wed, 29 Jan 2025 07:59:34 -0600 Subject: [PATCH 15/19] Create MFA Report script (#11740) changelog: Internal, Reporting, Create MFA Report script --- lib/data_pull.rb | 41 +++++++++++++++++++ .../create_mfa_configurations_report.rb | 1 + spec/lib/data_pull_spec.rb | 27 ++++++++++++ .../create_mfa_configurations_report_spec.rb | 3 ++ 4 files changed, 72 insertions(+) diff --git a/lib/data_pull.rb b/lib/data_pull.rb index 1a2749dfda6..17da72ee2cc 100644 --- a/lib/data_pull.rb +++ b/lib/data_pull.rb @@ -39,6 +39,8 @@ def banner * #{basename} ig-request uuid1 uuid2 --requesting-issuer=ABC:DEF:GHI + * #{basename} mfa-report uuid1 uuid2 + * #{basename} profile-summary uuid1 uuid2 * #{basename} uuid-convert partner-uuid1 partner-uuid2 @@ -59,6 +61,7 @@ def subtask(name) 'email-lookup' => EmailLookup, 'events-summary' => EventsSummary, 'ig-request' => InspectorGeneralRequest, + 'mfa-report' => MfaReport, 'profile-summary' => ProfileSummary, 'uuid-convert' => UuidConvert, 'uuid-export' => UuidExport, @@ -156,6 +159,44 @@ def run(args:, config:) end end + class MfaReport + def run(args:, config:) + require 'data_requests/deployed' + uuids = args + + users, missing_uuids = uuids.map do |uuid| + DataRequests::Deployed::LookupUserByUuid.new(uuid).call || uuid + end.partition { |u| u.is_a?(User) } + + output = users.map do |user| + output = DataRequests::Deployed::CreateMfaConfigurationsReport.new(user).call + output[:uuid] = user.uuid + + output + end + + if config.include_missing? + output += missing_uuids.map do |uuid| + { + uuid: uuid, + phone_configurations: [], + auth_app_configurations: [], + webauthn_configurations: [], + piv_cac_configurations: [], + backup_code_configurations: [], + not_found: true, + } + end + end + + ScriptBase::Result.new( + subtask: 'mfa-report', + uuids: uuids, + json: output, + ) + end + end + class InspectorGeneralRequest def run(args:, config:) require 'data_requests/deployed' diff --git a/lib/data_requests/deployed/create_mfa_configurations_report.rb b/lib/data_requests/deployed/create_mfa_configurations_report.rb index 3a52f2e743c..d5c827d40ca 100644 --- a/lib/data_requests/deployed/create_mfa_configurations_report.rb +++ b/lib/data_requests/deployed/create_mfa_configurations_report.rb @@ -63,6 +63,7 @@ def webauthn_configurations_report user.webauthn_configurations.map do |webauthn_configuration| { name: webauthn_configuration.name, + platform_authenticator: webauthn_configuration.platform_authenticator, created_at: webauthn_configuration.created_at, } end diff --git a/spec/lib/data_pull_spec.rb b/spec/lib/data_pull_spec.rb index d772f9abfaf..fcbea784da4 100644 --- a/spec/lib/data_pull_spec.rb +++ b/spec/lib/data_pull_spec.rb @@ -293,6 +293,33 @@ end end + describe DataPull::MfaReport do + subject(:subtask) { DataPull::MfaReport.new } + + describe '#run' do + let(:user) { create(:user) } + let(:args) { [user.uuid] } + let(:config) { ScriptBase::Config.new } + + subject(:result) { subtask.run(args:, config:) } + + it 'runs the MFA report, has a JSON-only response', aggregate_failures: true do + expect(result.table).to be_nil + expect(result.json.first.keys).to contain_exactly( + :uuid, + :phone_configurations, + :auth_app_configurations, + :webauthn_configurations, + :piv_cac_configurations, + :backup_code_configurations, + ) + + expect(result.subtask).to eq('mfa-report') + expect(result.uuids).to eq([user.uuid]) + end + end + end + describe DataPull::InspectorGeneralRequest do subject(:subtask) { DataPull::InspectorGeneralRequest.new } diff --git a/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb b/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb index 6477d692196..77b69927273 100644 --- a/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb +++ b/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb @@ -41,6 +41,9 @@ webauthn_data = result[:webauthn_configurations] expect(webauthn_data.first[:name]).to eq(webauthn_configuration.name) + expect(webauthn_data.first[:platform_authenticator]).to eq( + webauthn_configuration.platform_authenticator, + ) expect(webauthn_data.first[:created_at]).to be_within(1.second).of( webauthn_configuration.created_at, ) From c3b09f101873deb94fed1a1e71e75c148484b92a Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 29 Jan 2025 09:15:39 -0500 Subject: [PATCH 16/19] LG-15395 Add specialized SP IdV Dropoff Report (#11803) We have a partner who requested a specialized IdV drop-off report for their service providers. This report needs to run every Monday and collect weekly data from a specified date up through the previous week. We have been running this report manually for a few weeks now. This commit moves the code the generates this report into the IdP so it can be run automatically. [skip changelog] --- .../reports/sp_idv_weekly_dropoff_report.rb | 61 +++ config/application.yml.default | 1 + config/initializers/job_configurations.rb | 7 +- lib/identity_config.rb | 1 + lib/reporting/sp_idv_weekly_dropoff_report.rb | 478 ++++++++++++++++++ .../sp_idv_weekly_dropoff_report_spec.rb | 96 ++++ .../sp_idv_weekly_dropoff_report_spec.rb | 218 ++++++++ 7 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 app/jobs/reports/sp_idv_weekly_dropoff_report.rb create mode 100644 lib/reporting/sp_idv_weekly_dropoff_report.rb create mode 100644 spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb create mode 100644 spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb diff --git a/app/jobs/reports/sp_idv_weekly_dropoff_report.rb b/app/jobs/reports/sp_idv_weekly_dropoff_report.rb new file mode 100644 index 00000000000..018883799f2 --- /dev/null +++ b/app/jobs/reports/sp_idv_weekly_dropoff_report.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'reporting/sp_idv_weekly_dropoff_report' + +module Reports + class SpIdvWeeklyDropoffReport < BaseReport + attr_accessor :report_date + + def perform(report_date) + return unless IdentityConfig.store.s3_reports_enabled + + self.report_date = report_date + + IdentityConfig.store.sp_idv_weekly_dropoff_report_configs.each do |report_config| + send_report(report_config) + end + end + + def send_report(report_config) + report_start_date = Date.parse(report_config['report_start_date']) + report_end_date = report_date.end_of_week(:sunday).weeks_ago(1).to_date + issuers = report_config['issuers'] + agency_abbreviation = report_config['agency_abbreviation'] + emails = report_config['emails'] + + agency_report_name = "#{agency_abbreviation.downcase}_idv_dropoff_report" + agency_report_title = "#{agency_abbreviation} IdV Dropoff Report" + + report_maker = build_report_maker( + issuers:, + agency_abbreviation:, + time_range: report_start_date..report_end_date, + ) + + save_report(agency_report_name, report_maker.to_csv, extension: 'csv') + + if emails.blank? + Rails.logger.warn "No email addresses received - #{agency_report_title} NOT SENT" + return false + end + + message = <<~HTML.html_safe # rubocop:disable Rails/OutputSafety, +

#{agency_report_title}

+ HTML + + emails.each do |email| + ReportMailer.tables_report( + email: email, + subject: "#{agency_report_title} - #{report_date.to_date}", + reports: report_maker.as_emailable_reports, + message: message, + attachment_format: :csv, + ).deliver_now + end + end + + def build_report_maker(issuers:, agency_abbreviation:, time_range:) + Reporting::SpProofingEventsByUuid.new(issuers:, agency_abbreviation:, time_range:) + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index fc77926b7ba..23a5a22d3bc 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -402,6 +402,7 @@ socure_reason_code_api_key: '' socure_reason_code_base_url: '' socure_reason_code_timeout_in_seconds: 5 sp_handoff_bounce_max_seconds: 2 +sp_idv_weekly_dropoff_report_configs: '[]' sp_issuer_user_counts_report_configs: '[]' sp_proofing_events_by_uuid_report_configs: '[]' state_tracking_enabled: true diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index d8f87610a8b..0f3bb7adf49 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -120,7 +120,12 @@ args: -> { [Time.zone.yesterday] }, }, # Send the SP IdV Weekly Dropoff Report - sp_idv_weekly_dropoff: { + sp_idv_weekly_dropoff_report: { + class: 'Reports::SpIdvWeeklyDropoffReport', + cron: cron_every_monday_2am, + args: -> { [Time.zone.today] }, + }, + sp_proofing_events_by_uuid_report: { class: 'Reports::SpProofingEventsByUuid', cron: cron_every_monday_2am, args: -> { [Time.zone.today] }, diff --git a/lib/identity_config.rb b/lib/identity_config.rb index b09a7ff326a..bd7f8b65868 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -438,6 +438,7 @@ def self.store config.add(:socure_reason_code_base_url, type: :string) config.add(:socure_reason_code_timeout_in_seconds, type: :integer) config.add(:sp_handoff_bounce_max_seconds, type: :integer) + config.add(:sp_idv_weekly_dropoff_report_configs, type: :json) config.add(:sp_issuer_user_counts_report_configs, type: :json) config.add(:sp_proofing_events_by_uuid_report_configs, type: :json) config.add(:state_tracking_enabled, type: :boolean) diff --git a/lib/reporting/sp_idv_weekly_dropoff_report.rb b/lib/reporting/sp_idv_weekly_dropoff_report.rb new file mode 100644 index 00000000000..cc7dcadb4bf --- /dev/null +++ b/lib/reporting/sp_idv_weekly_dropoff_report.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +require 'csv' +begin + require 'reporting/cloudwatch_client' + require 'reporting/cloudwatch_query_quoting' + require 'reporting/command_line_options' +rescue LoadError => e + warn 'could not load paths, try running with "bundle exec rails runner"' + raise e +end + +module Reporting + class SpIdvWeeklyDropoffReport + attr_reader :issuers, :agency_abbreviation, :time_range + + WeeklyDropoffValues = Struct.new( + :start_date, + :end_date, + :ial2_verified_user_count, + :non_ial2_verified_user_count, + :document_authentication_failure_pct, + :selfie_check_failure_pct, + :aamva_check_failure_pct, + :fraud_review_rejected_user_count, + :gpo_passed_count, + :fraud_review_passed_count, + :ipp_passed_count, + :ial2_getting_started_dropoff, + :non_ial2_getting_started_dropoff, + :ial2_document_capture_started_dropoff, + :non_ial2_document_capture_started_dropoff, + :ial2_document_captured_dropoff, + :non_ial2_document_captured_dropoff, + :ial2_selfie_captured_dropoff, + :non_ial2_selfie_captured_dropoff, + :ial2_document_authentication_passed_dropoff, + :non_ial2_document_authentication_passed_dropoff, + :ial2_ssn_dropoff, + :non_ial2_ssn_dropoff, + :ial2_verify_info_submitted_dropoff, + :non_ial2_verify_info_submitted_dropoff, + :ial2_verify_info_passed_dropoff, + :non_ial2_verify_info_passed_dropoff, + :ial2_phone_submitted_dropoff, + :non_ial2_phone_submitted_dropoff, + :ial2_phone_passed_dropoff, + :non_ial2_phone_passed_dropoff, + :ial2_enter_password_dropoff, + :non_ial2_enter_password_dropoff, + :ial2_inline_dropoff, + :non_ial2_inline_dropoff, + :ial2_verify_by_mail_dropoff, + :non_ial2_verify_by_mail_dropoff, + :ial2_fraud_review_dropoff, + :non_ial2_fraud_review_dropoff, + :ial2_personal_key_dropoff, + :non_ial2_personal_key_dropoff, + :ial2_agency_handoff_dropoff, + :non_ial2_agency_handoff_dropoff, + keyword_init: true, + ) do + def formatted_date_range + "#{start_date.to_date} - #{end_date.to_date}" + end + end + + def initialize( + issuers:, + agency_abbreviation:, + time_range:, + verbose: false, + progress: false, + cloudwatch_client: nil + ) + @issuers = issuers + @agency_abbreviation = agency_abbreviation + @time_range = time_range + @verbose = verbose + @progress = progress + @cloudwatch_client = cloudwatch_client + end + + def verbose? + @verbose + end + + def progress? + @progress + end + + # rubocop:disable Layout/LineLength + def as_csv + [ + ['', *data.map(&:formatted_date_range)], + ['Overview'], + ['# of verified users'], + [' - IAL2', *data.map(&:ial2_verified_user_count)], + [' - Non-IAL2', *data.map(&:non_ial2_verified_user_count)], + ['# of contact center cases'], + ['Fraud Checks'], + ['% of users that failed document authentication check', *data.map(&:document_authentication_failure_pct)], + ['% of users that failed facial match check (Only for IAL2)', *data.map(&:selfie_check_failure_pct)], + ['% of users that failed AAMVA attribute match check', *data.map(&:aamva_check_failure_pct)], + ['# of users that failed LG-99 fraud review', *data.map(&:fraud_review_rejected_user_count)], + ['User Experience'], + ['# of verified users via verify-by-mail process (Only for non-IAL2)', *data.map(&:gpo_passed_count)], + ['# of verified users via fraud redress process', *data.map(&:fraud_review_passed_count)], + ['# of verified users via in-person proofing (Not currently enabled)', *data.map(&:ipp_passed_count)], + ['Funnel Analysis'], + ['% drop-off at Workflow Started'], + [' - IAL2', *data.map(&:ial2_getting_started_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_getting_started_dropoff)], + ['% drop-off at Document Capture Started'], + [' - IAL2', *data.map(&:ial2_document_capture_started_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_document_capture_started_dropoff)], + ['% drop-off at Document Captured'], + [' - IAL2', *data.map(&:ial2_document_captured_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_document_captured_dropoff)], + ['% drop-off at Selfie Captured'], + [' - IAL2', *data.map(&:ial2_selfie_captured_dropoff)], + ['% drop-off at Document Authentication Passed'], + [' - IAL2 (with Facial Match)', *data.map(&:ial2_document_authentication_passed_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_document_authentication_passed_dropoff)], + ['% drop-off at SSN Submitted'], + [' - IAL2', *data.map(&:ial2_ssn_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_ssn_dropoff)], + ['% drop-off at Personal Information Submitted'], + [' - IAL2', *data.map(&:ial2_verify_info_submitted_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_verify_info_submitted_dropoff)], + ['% drop-off at Personal Information Verified'], + [' - IAL2', *data.map(&:ial2_verify_info_passed_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_verify_info_passed_dropoff)], + ['% drop-off at Phone Submitted'], + [' - IAL2', *data.map(&:ial2_phone_submitted_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_phone_submitted_dropoff)], + ['% drop-off at Phone Verified'], + [' - IAL2', *data.map(&:ial2_phone_passed_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_phone_passed_dropoff)], + ['% drop-off at Online Wofklow Completed'], + [' - IAL2', *data.map(&:ial2_enter_password_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_enter_password_dropoff)], + ['% drop-off at Verified for In-Band Users'], + [' - IAL2', *data.map(&:ial2_inline_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_inline_dropoff)], + ['% drop-off at Verified for Verify-by-mail Users'], + [' - Non-IAL2', *data.map(&:non_ial2_verify_by_mail_dropoff)], + ['% drop-off at Verified for Fraud Review Users'], + [' - IAL2', *data.map(&:ial2_fraud_review_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_fraud_review_dropoff)], + ['% drop-off at Personal Key Saved'], + [' - IAL2', *data.map(&:ial2_personal_key_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_personal_key_dropoff)], + ['% drop-off at Agency Handoff Submitted'], + [' - IAL2', *data.map(&:ial2_agency_handoff_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_agency_handoff_dropoff)], + ] + end + # rubocop:enable Layout/LineLength + + def to_csv + CSV.generate do |csv| + as_csv.each do |row| + csv << row + end + end + end + + def as_emailable_reports + [ + EmailableReport.new( + title: "#{agency_abbreviation} IdV Dropoff Report", + table: as_csv, + filename: "#{agency_abbreviation.downcase}_idv_dropoff_report", + ), + ] + end + + def out_of_band_query(inline_event_end_date) + inline_event_end_date_ms = inline_event_end_date.to_i * 1000 + <<~QUERY + filter (name = "IdV: final resolution" and properties.service_provider in #{issuers.inspect}) or + name = "IdV: enter verify by mail code submitted" or + name = "GetUspsProofingResultsJob: Enrollment status updated" or + name = "Fraud: Profile review passed" + + | filter (name = "IdV: final resolution" and @timestamp < #{inline_event_end_date_ms} or name != "IdV: final resolution" + + | fields name = "IdV: final resolution" and ( + !properties.event_properties.gpo_verification_pending and + !properties.event_properties.in_person_verification_pending and + !ispresent(properties.event_properties.fraud_pending_reason) + ) + as @verified_inline + | fields name = "IdV: final resolution" and ( + properties.event_properties.gpo_verification_pending and + !properties.event_properties.in_person_verification_pending and + !ispresent(properties.event_properties.fraud_pending_reason) + ) + as @gpo_pending + | fields name = "IdV: final resolution" and ( + properties.event_properties.in_person_verification_pending and + !ispresent(properties.event_properties.fraud_pending_reason) + ) + as @ipp_pending + | fields name = "IdV: final resolution" and ( + ispresent(properties.event_properties.fraud_pending_reason) + ) + as @fraud_pending + + | fields coalesce(name = "IdV: final resolution" and properties.sp_request.facial_match, 0) as is_ial2 + + | stats sum(@verified_inline) > 0 as verified_inline, + sum(@gpo_pending) > 0 and !verified_inline as gpo_pending, + sum(@ipp_pending) > 0 and !gpo_pending and !verified_inline as ipp_pending, + sum(@fraud_pending) > 0 and !ipp_pending and !gpo_pending and !verified_inline as fraud_pending, + sum(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed) > 0 as gpo_passed, + sum(name = "GetUspsProofingResultsJob: Enrollment status updated" and properties.event_properties.passed and properties.event_properties.tmx_status not in ["threatmetrix_review", "threatmetrix_reject"]) > 0 as ipp_passed, + sum(name = "Fraud: Profile review passed") > 0 as fraud_review_passed, + max(is_ial2) + 1 as ial + by properties.user_id + + | filter verified_inline or gpo_pending or ipp_pending or fraud_pending + + | stats 1 - sum(gpo_passed and gpo_pending) / sum(gpo_pending) as verify_by_mail_dropoff, + 1 - sum(ipp_passed and ipp_pending) / sum(ipp_pending) as in_person_dropoff, + 1 - sum(fraud_review_passed and fraud_pending) / sum(fraud_pending) as fraud_review_dropoff + by ial + QUERY + end + + def sp_session_events_query + <<~QUERY + filter (name in [ + "IdV: doc auth welcome visited", + "IdV: doc auth welcome submitted", + "IdV: doc auth document_capture visited", + "Frontend: IdV: front image clicked", + "Frontend: IdV: back image clicked", + "Frontend: IdV: front image added", + "Frontend: IdV: back image added", + "idv_selfie_image_added", + "IdV: doc auth image upload vendor submitted", + "IdV: doc auth ssn visited", + "IdV: doc auth ssn submitted", + "IdV: doc auth verify visited", + "IdV: doc auth verify proofing results", + "IdV: phone of record visited", + "IdV: phone confirmation vendor", + "idv_enter_password_visited", + "IdV: personal key visited", + "IdV: personal key submitted", + "IdV: final resolution", + "User registration: agency handoff visited", + "User registration: complete", + "Fraud: Profile review passed", + "Fraud: Profile review rejected" + ] and properties.service_provider in #{issuers.inspect}) or + (name = "IdV: enter verify by mail code submitted" and properties.event_properties.initiating_service_provider in #{issuers.inspect}) or + (name = "GetUspsProofingResultsJob: Enrollment status updated" and properties.event_properties.issuer in #{issuers.inspect}) + + | fields properties.event_properties.selfie_check_required as selfie_check_required, + name in ["Frontend: IdV: front image clicked", "Frontend: IdV: back image clicked"] as @document_capture_clicked, + name in ["Frontend: IdV: front image added", "Frontend: IdV: back image added"] as @document_captured, + name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.success as @document_authentication_passed + + | fields coalesce(name = "IdV: doc auth welcome visited" and properties.sp_request.facial_match, 0) as is_ial2 + + | stats sum(name = "IdV: doc auth welcome visited") > 0 as getting_started_visited, + sum(name = "IdV: doc auth welcome submitted") > 0 as getting_started_submitted, + sum(name = "IdV: doc auth document_capture visited") > 0 as document_capture_visited, + sum(@document_capture_clicked) > 0 as document_capture_clicked, + sum(@document_captured) > 0 as document_captured, + sum(name = "idv_selfie_image_added") > 0 or sum(selfie_check_required) == 0 as selfie_captured_or_not_required, + sum(name = "IdV: doc auth image upload vendor submitted") > 0 as document_authentication_submitted, + sum(@document_authentication_passed) > 0 as document_authentication_passed, + sum(name = "IdV: doc auth image upload vendor submitted" and ispresent(properties.event_properties.doc_auth_success) and !properties.event_properties.doc_auth_success) > 0 as doc_auth_failure, + sum(name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.liveness_checking_required) > 0 as doc_auth_selfie_check_required, + sum(name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.selfie_status == "fail") > 0 as doc_auth_selfie_check_failure, + sum(name = "IdV: doc auth ssn visited") > 0 as ssn_visited, + sum(name = "IdV: doc auth ssn submitted") > 0 as ssn_submitted, + sum(name = "IdV: doc auth verify visited") > 0 as verify_info_visited, + sum(name = "IdV: doc auth verify proofing results") > 0 as verify_info_submitted, + sum(name = "IdV: doc auth verify proofing results" and properties.event_properties.success) > 0 as verify_info_passed, + sum(name = "IdV: doc auth verify proofing results" and ispresent(properties.event_properties.proofing_results.context.stages.state_id.success) and !properties.event_properties.proofing_results.context.stages.state_id.success and !ispresent(properties.event_properties.proofing_results.context.stages.state_id.exception)) as aamva_failure, + sum(name = "IdV: phone of record visited") > 0 as phone_visited, + sum(name = "IdV: phone confirmation vendor") > 0 as phone_submitted, + sum(name = "IdV: phone confirmation vendor" and properties.event_properties.success) > 0 as phone_passed, + sum(name = "idv_enter_password_visited") > 0 as enter_password_visited, + sum(name = "IdV: final resolution") > 0 as enter_password_submitted, + sum(name = "IdV: personal key visited") > 0 as personal_key_visited, + sum(name = "IdV: personal key submitted") > 0 as personal_key_submitted, + sum(name = "User registration: agency handoff visited" and properties.event_properties.ial2) > 0 as agnecy_handoff_visited, + sum(name = "User registration: complete" and properties.event_properties.ial2) > 0 as agency_handoff_submitted, + sum(name = "IdV: final resolution" and !properties.event_properties.gpo_verification_pending and !properties.event_properties.in_person_verification_pending and !ispresent(properties.event_properties.fraud_pending_reason)) > 0 as verified_inline, + sum(name = "Fraud: Profile review passed") > 0 as fraud_review_passed, + sum(name = "Fraud: Profile review rejected") > 0 as fraud_review_rejected, + sum(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed) > 0 as gpo_passed, + sum(name = "GetUspsProofingResultsJob: Enrollment status updated" and properties.event_properties.passed and properties.event_properties.tmx_status not in ["threatmetrix_review", "threatmetrix_reject"]) > 0 as ipp_passed, + max(is_ial2) + 1 as ial + by properties.user_id + + | stats 1 - sum(getting_started_submitted) / sum(getting_started_visited) as getting_started_dropoff, + 1 - sum(document_capture_clicked) / sum(document_capture_visited) as document_capture_started_dropoff, + 1 - sum(document_captured) / sum(document_capture_visited) as document_captured_dropoff, + 1 - sum(selfie_captured_or_not_required) / sum(document_capture_visited) as selfie_captured_dropoff, + 1 - sum(document_authentication_passed) / sum(document_capture_visited) as document_authentication_passed_dropoff, + 1 - sum(ssn_submitted) / sum(ssn_visited) as ssn_dropoff, + 1 - sum(verify_info_submitted) / sum(verify_info_visited) as verify_info_submitted_dropoff, + 1 - sum(verify_info_passed) / sum(verify_info_visited) as verify_info_passed_dropoff, + 1 - sum(phone_submitted) / sum(phone_visited) as phone_submitted_dropoff, + 1 - sum(phone_passed) / sum(phone_visited) as phone_passed_dropoff, + 1 - sum(enter_password_submitted) / sum(enter_password_visited) as enter_password_dropoff, + 1 - sum(personal_key_submitted) / sum(personal_key_visited) as personal_key_dropoff, + 1 - sum(agency_handoff_submitted) / sum(agnecy_handoff_visited) as agency_handoff_dropoff, + sum(doc_auth_failure and !ssn_submitted) as document_authentication_failure_numerator, + sum(document_authentication_submitted) as document_authentication_failure_denominator, + sum(doc_auth_selfie_check_failure and !doc_auth_failure and !ssn_submitted) as selfie_check_failure_numerator, + sum(doc_auth_selfie_check_required) as selfie_check_failure_denominator, + sum(aamva_failure and !verify_info_passed) as aamva_check_failure_numerator, + sum(verify_info_submitted) as aamva_check_failure_denominator, + sum(verified_inline) as verified_inline_count, + sum(fraud_review_passed) as fraud_review_passed_count, + sum(fraud_review_rejected) as fraud_review_rejected_count, + sum(gpo_passed) as gpo_passed_count, + sum(ipp_passed) as ipp_passed_count + by ial + QUERY + end + + def data + @data ||= time_range_weekly_ranges.map do |week_time_range| + get_results_for_week(week_time_range) + end + end + + def get_results_for_week(week_time_range) + sp_session_events_result_by_ial = fetch_results( + query: sp_session_events_query, + query_time_range: week_time_range, + ).index_by { |result_row| result_row['ial'] } + + out_of_band_query_start = week_time_range.begin + out_of_band_query_end = [week_time_range.end + 4.weeks, Time.zone.now.to_date].min + out_of_band_inline_end_date = week_time_range.end.end_of_day + out_of_band_results_by_ial = fetch_results( + query: out_of_band_query(out_of_band_inline_end_date), + query_time_range: (out_of_band_query_start..out_of_band_query_end), + ).index_by { |result_row| result_row['ial'] } + + compute_weekly_dropoff_values( + sp_session_events_result_by_ial, + out_of_band_results_by_ial, + week_time_range, + ) + end + + # rubocop:disable Layout/LineLength + def compute_weekly_dropoff_values( + sp_session_events_result_by_ial, out_of_band_results_by_ial, week_time_range + ) + WeeklyDropoffValues.new( + start_date: week_time_range.begin.to_s, + end_date: week_time_range.end.to_s, + ial2_verified_user_count: [ + sp_session_events_result_by_ial.dig('2', 'verified_inline_count').to_i, + sp_session_events_result_by_ial.dig('2', 'fraud_review_passed_count').to_i, + sp_session_events_result_by_ial.dig('2', 'gpo_passed_count').to_i, + sp_session_events_result_by_ial.dig('2', 'ipp_passed_count').to_i, + ].sum.to_s, + non_ial2_verified_user_count: [ + sp_session_events_result_by_ial.dig('1', 'verified_inline_count').to_i, + sp_session_events_result_by_ial.dig('1', 'fraud_review_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'gpo_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'ipp_passed_count').to_i, + ].sum.to_s, + document_authentication_failure_pct: compute_percentage( + sp_session_events_result_by_ial.dig('2', 'document_authentication_failure_numerator').to_i + sp_session_events_result_by_ial.dig('1', 'document_authentication_failure_numerator').to_i, + sp_session_events_result_by_ial.dig('2', 'document_authentication_failure_denominator').to_i + sp_session_events_result_by_ial.dig('1', 'document_authentication_failure_denominator').to_i, + ), + selfie_check_failure_pct: compute_percentage( + sp_session_events_result_by_ial.dig('2', 'selfie_check_failure_numerator').to_i + sp_session_events_result_by_ial.dig('1', 'selfie_check_failure_numerator').to_i, + sp_session_events_result_by_ial.dig('2', 'selfie_check_failure_denominator').to_i + sp_session_events_result_by_ial.dig('1', 'selfie_check_failure_denominator').to_i, + ), + aamva_check_failure_pct: compute_percentage( + sp_session_events_result_by_ial.dig('2', 'aamva_check_failure_numerator').to_i + sp_session_events_result_by_ial.dig('1', 'aamva_check_failure_numerator').to_i, + sp_session_events_result_by_ial.dig('2', 'aamva_check_failure_denominator').to_i + sp_session_events_result_by_ial.dig('1', 'aamva_check_failure_denominator').to_i, + ), + fraud_review_rejected_user_count: [ + sp_session_events_result_by_ial.dig('2', 'fraud_review_rejected_count').to_i, + sp_session_events_result_by_ial.dig('1', 'fraud_review_rejected_count').to_i, + ].sum.to_s, + gpo_passed_count: [ + sp_session_events_result_by_ial.dig('2', 'gpo_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'gpo_passed_count').to_i, + ].sum.to_s, + fraud_review_passed_count: [ + sp_session_events_result_by_ial.dig('2', 'fraud_review_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'fraud_review_passed_count').to_i, + ].sum.to_s, + ipp_passed_count: [ + sp_session_events_result_by_ial.dig('2', 'ipp_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'ipp_passed_count').to_i, + ].sum.to_s, + ial2_getting_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'getting_started_dropoff').to_f), + non_ial2_getting_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'getting_started_dropoff').to_f), + ial2_document_capture_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'document_capture_started_dropoff').to_f), + non_ial2_document_capture_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'document_capture_started_dropoff').to_f), + ial2_document_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'document_captured_dropoff').to_f), + non_ial2_document_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'document_captured_dropoff').to_f), + ial2_selfie_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'selfie_captured_dropoff').to_f), + non_ial2_selfie_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'selfie_captured_dropoff').to_f), + ial2_document_authentication_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'document_authentication_passed_dropoff').to_f), + non_ial2_document_authentication_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'document_authentication_passed_dropoff').to_f), + ial2_ssn_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'ssn_dropoff').to_f), + non_ial2_ssn_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'ssn_dropoff').to_f), + ial2_verify_info_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'verify_info_submitted_dropoff').to_f), + non_ial2_verify_info_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'verify_info_submitted_dropoff').to_f), + ial2_verify_info_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'verify_info_passed_dropoff').to_f), + non_ial2_verify_info_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'verify_info_passed_dropoff').to_f), + ial2_phone_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'phone_submitted_dropoff').to_f), + non_ial2_phone_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'phone_submitted_dropoff').to_f), + ial2_phone_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'phone_passed_dropoff').to_f), + non_ial2_phone_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'phone_passed_dropoff').to_f), + ial2_enter_password_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'enter_password_dropoff').to_f), + non_ial2_enter_password_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'enter_password_dropoff').to_f), + ial2_inline_dropoff: format_percentage(0.0), + non_ial2_inline_dropoff: format_percentage(0.0), + ial2_verify_by_mail_dropoff: format_percentage(out_of_band_results_by_ial.dig('2', 'verify_by_mail_dropoff').to_f), + non_ial2_verify_by_mail_dropoff: format_percentage(out_of_band_results_by_ial.dig('1', 'verify_by_mail_dropoff').to_f), + ial2_fraud_review_dropoff: format_percentage(out_of_band_results_by_ial.dig('2', 'fraud_review_dropoff').to_f), + non_ial2_fraud_review_dropoff: format_percentage(out_of_band_results_by_ial.dig('1', 'fraud_review_dropoff').to_f), + ial2_personal_key_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'personal_key_dropoff').to_f), + non_ial2_personal_key_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'personal_key_dropoff').to_f), + ial2_agency_handoff_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'agency_handoff_dropoff').to_f), + non_ial2_agency_handoff_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'agency_handoff_dropoff').to_f), + ) + end + # rubocop:enable Layout/LineLength + + def compute_percentage(numerator, denominator) + return format_percentage(0.0) if denominator == 0 + + format_percentage(numerator.to_f / denominator.to_f) + end + + def format_percentage(value) + return '0.0%' if value.blank? + (value * 100).round(2).to_s + '%' + end + + def time_range_weekly_ranges + start_date = time_range.begin.beginning_of_week(:sunday) + end_date = time_range.end.end_of_week(:sunday) + (start_date..end_date).step(7).map do |week_start| + week_start.all_week(:sunday) + end + end + + def fetch_results(query:, query_time_range:) + cloudwatch_client.fetch( + query:, + from: query_time_range.begin.beginning_of_day, + to: query_time_range.end.end_of_day, + ) + end + + def cloudwatch_client + @cloudwatch_client ||= Reporting::CloudwatchClient.new( + num_threads: 1, + ensure_complete_logs: false, + slice_interval: 100.years, + progress: progress?, + logger: verbose? ? Logger.new(STDERR) : nil, + ) + end + end +end diff --git a/spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb b/spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb new file mode 100644 index 00000000000..a6bf58587e1 --- /dev/null +++ b/spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Reports::SpIdvWeeklyDropoffReport do + let(:report_date) { Date.new(2024, 12, 16).in_time_zone('UTC') } + let(:agency_abbreviation) { 'ABC' } + let(:report_emails) { ['test@example.com'] } + let(:sp_idv_weekly_dropoff_report_configs) do + [ + { + 'issuers' => ['super:cool:test:issuer'], + 'report_start_date' => '2024-12-01', + 'agency_abbreviation' => 'ABC', + 'emails' => report_emails, + }, + ] + end + + before do + allow(IdentityConfig.store).to receive(:s3_reports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:sp_idv_weekly_dropoff_report_configs).and_return( + sp_idv_weekly_dropoff_report_configs, + ) + end + + describe '#perform' do + it 'gets a CSV from the report maker, saves it to S3, and sends email to team' do + allow(IdentityConfig.store).to receive(:team_ada_email).and_return('ada@example.com') + + report = [ + ['Label'], + ['Useful dropoff info', '80%', '90%'], + ['Other dropoff info', '70%', '60%'], + ] + csv_report = CSV.generate do |csv| + report.each { |row| csv << row } + end + emailable_reports = [ + Reporting::EmailableReport.new( + title: 'ABC IdV Dropoff Report - 2024-12-16', + table: report, + filename: 'abc_idv_dropoff_report', + ), + ] + + report_maker = double( + Reporting::SpIdvWeeklyDropoffReport, + to_csv: csv_report, + as_emailable_reports: emailable_reports, + ) + + allow(subject).to receive(:build_report_maker).with( + issuers: ['super:cool:test:issuer'], + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 14), + ).and_return(report_maker) + + expect(subject).to receive(:save_report).with( + 'abc_idv_dropoff_report', + csv_report, + extension: 'csv', + ) + + expect(ReportMailer).to receive(:tables_report).once.with( + email: 'test@example.com', + subject: 'ABC IdV Dropoff Report - 2024-12-16', + reports: emailable_reports, + message: anything, + attachment_format: :csv, + ).and_call_original + + subject.perform(report_date) + end + + context 'with no emails configured' do + let(:report_emails) { [] } + + it 'does not send the report in email' do + report_maker = double( + Reporting::SpIdvWeeklyDropoffReport, + to_csv: 'I am a CSV, see', + identity_verification_emailable_report: 'I am a report', + ) + allow(subject).to receive(:build_report_maker).and_return(report_maker) + expect(subject).to receive(:save_report).with( + 'abc_idv_dropoff_report', + 'I am a CSV, see', + extension: 'csv', + ) + + expect(ReportMailer).to_not receive(:tables_report) + + subject.perform(report_date) + end + end + end +end diff --git a/spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb b/spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb new file mode 100644 index 00000000000..945ab7f4e45 --- /dev/null +++ b/spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb @@ -0,0 +1,218 @@ +require 'rails_helper' +require 'reporting/sp_idv_weekly_dropoff_report' + +RSpec.describe Reporting::SpIdvWeeklyDropoffReport do + let(:issuers) { ['super:cool:test:issuer'] } + let(:agency_abbreviation) { 'ABC' } + let(:time_range) { Date.new(2024, 12, 1)..Date.new(2024, 12, 14) } + + let(:cloudwatch_results) do + [ + [ + { + 'ial' => '1', + 'getting_started_dropoff' => '0.01', + 'document_capture_started_dropoff' => '0.02', + 'document_captured_dropoff' => '0.03', + 'selfie_captured_dropoff' => '0', + 'document_authentication_passed_dropoff' => '0.04', + 'ssn_dropoff' => '0.05', + 'verify_info_submitted_dropoff' => '0.06', + 'verify_info_passed_dropoff' => '0.07', + 'phone_submitted_dropoff' => '0.08', + 'phone_passed_dropoff' => '0.09', + 'enter_password_dropoff' => '0.10', + 'personal_key_dropoff' => '0.11', + 'agency_handoff_dropoff' => '0.12', + 'document_authentication_failure_numerator' => '100', + 'document_authentication_failure_denominator' => '200', + 'selfie_check_failure_numerator' => '0', + 'selfie_check_failure_denominator' => '0', + 'aamva_check_failure_numerator' => '300', + 'aamva_check_failure_denominator' => '400', + 'verified_inline_count' => '500', + 'fraud_review_passed_count' => '600', + 'fraud_review_rejected_count' => '700', + }, + ], + [ + { + 'ial' => '1', + 'verify_by_mail_dropoff' => '0.01', + 'fraud_review_dropoff' => '0.02', + }, + ], + [ + { + 'ial' => '1', + 'getting_started_dropoff' => '0.13', + 'document_capture_started_dropoff' => '0.14', + 'document_captured_dropoff' => '0.15', + 'selfie_captured_dropoff' => '0', + 'document_authentication_passed_dropoff' => '0.16', + 'ssn_dropoff' => '0.17', + 'verify_info_submitted_dropoff' => '0.18', + 'verify_info_passed_dropoff' => '0.19', + 'phone_submitted_dropoff' => '0.20', + 'phone_passed_dropoff' => '0.21', + 'enter_password_dropoff' => '0.22', + 'personal_key_dropoff' => '0.23', + 'agency_handoff_dropoff' => '0.24', + 'document_authentication_failure_numerator' => '800', + 'document_authentication_failure_denominator' => '900', + 'selfie_check_failure_numerator' => '0', + 'selfie_check_failure_denominator' => '0', + 'aamva_check_failure_numerator' => '1000', + 'aamva_check_failure_denominator' => '1100', + 'verified_inline_count' => '1200', + 'fraud_review_passed_count' => '1300', + 'fraud_review_rejected_count' => '1400', + 'gpo_passed_count' => '1500', + }, + { + 'ial' => '2', + 'getting_started_dropoff' => '0.25', + 'document_capture_started_dropoff' => '0.26', + 'document_captured_dropoff' => '0.27', + 'selfie_captured_dropoff' => '0.29', + 'document_authentication_passed_dropoff' => '0.30', + 'ssn_dropoff' => '0.31', + 'verify_info_submitted_dropoff' => '0.32', + 'verify_info_passed_dropoff' => '0.33', + 'phone_submitted_dropoff' => '0.34', + 'phone_passed_dropoff' => '0.35', + 'enter_password_dropoff' => '0.36', + 'personal_key_dropoff' => '0.37', + 'agency_handoff_dropoff' => '0.38', + 'document_authentication_failure_numerator' => '1600', + 'document_authentication_failure_denominator' => '1700', + 'selfie_check_failure_numerator' => '1800', + 'selfie_check_failure_denominator' => '1900', + 'aamva_check_failure_numerator' => '2000', + 'aamva_check_failure_denominator' => '2100', + 'verified_inline_count' => '2200', + 'fraud_review_passed_count' => '2300', + 'fraud_review_rejected_count' => '2400', + 'gpo_passed_count' => '0', + }, + ], + [ + { + 'ial' => '1', + 'verify_by_mail_dropoff' => '0.03', + 'fraud_review_dropoff' => '0.04', + }, + { + 'ial' => '2', + 'verify_by_mail_dropoff' => '0', + 'fraud_review_dropoff' => '0.5', + }, + ], + ] + end + + let(:expected_result) do + [ + ['', '2024-12-01 - 2024-12-07', '2024-12-08 - 2024-12-14'], + ['Overview'], + ['# of verified users'], + [' - IAL2', '0', '4500'], + [' - Non-IAL2', '1100', '4000'], + ['# of contact center cases'], + ['Fraud Checks'], + ['% of users that failed document authentication check', '50.0%', '92.31%'], + ['% of users that failed facial match check (Only for IAL2)', '0.0%', '94.74%'], + ['% of users that failed AAMVA attribute match check', '75.0%', '93.75%'], + ['# of users that failed LG-99 fraud review', '700', '3800'], + ['User Experience'], + ['# of verified users via verify-by-mail process (Only for non-IAL2)', '0', '1500'], + ['# of verified users via fraud redress process', '600', '3600'], + ['# of verified users via in-person proofing (Not currently enabled)', '0', '0'], + ['Funnel Analysis'], + ['% drop-off at Workflow Started'], + [' - IAL2', '0.0%', '25.0%'], + [' - Non-IAL2', '1.0%', '13.0%'], + ['% drop-off at Document Capture Started'], + [' - IAL2', '0.0%', '26.0%'], + [' - Non-IAL2', '2.0%', '14.0%'], + ['% drop-off at Document Captured'], + [' - IAL2', '0.0%', '27.0%'], + [' - Non-IAL2', '3.0%', '15.0%'], + ['% drop-off at Selfie Captured'], + [' - IAL2', '0.0%', '29.0%'], + ['% drop-off at Document Authentication Passed'], + [' - IAL2 (with Facial Match)', '0.0%', '30.0%'], + [' - Non-IAL2', '4.0%', '16.0%'], + ['% drop-off at SSN Submitted'], + [' - IAL2', '0.0%', '31.0%'], + [' - Non-IAL2', '5.0%', '17.0%'], + ['% drop-off at Personal Information Submitted'], + [' - IAL2', '0.0%', '32.0%'], + [' - Non-IAL2', '6.0%', '18.0%'], + ['% drop-off at Personal Information Verified'], + [' - IAL2', '0.0%', '33.0%'], + [' - Non-IAL2', '7.0%', '19.0%'], + ['% drop-off at Phone Submitted'], + [' - IAL2', '0.0%', '34.0%'], + [' - Non-IAL2', '8.0%', '20.0%'], + ['% drop-off at Phone Verified'], + [' - IAL2', '0.0%', '35.0%'], + [' - Non-IAL2', '9.0%', '21.0%'], + ['% drop-off at Online Wofklow Completed'], + [' - IAL2', '0.0%', '36.0%'], + [' - Non-IAL2', '10.0%', '22.0%'], + ['% drop-off at Verified for In-Band Users'], + [' - IAL2', '0.0%', '0.0%'], + [' - Non-IAL2', '0.0%', '0.0%'], + ['% drop-off at Verified for Verify-by-mail Users'], + [' - Non-IAL2', '1.0%', '3.0%'], + ['% drop-off at Verified for Fraud Review Users'], + [' - IAL2', '0.0%', '50.0%'], + [' - Non-IAL2', '2.0%', '4.0%'], + ['% drop-off at Personal Key Saved'], + [' - IAL2', '0.0%', '37.0%'], + [' - Non-IAL2', '11.0%', '23.0%'], + ['% drop-off at Agency Handoff Submitted'], + [' - IAL2', '0.0%', '38.0%'], + [' - Non-IAL2', '12.0%', '24.0%'], + ] + end + + before do + stub_multiple_cloudwatch_logs(*cloudwatch_results) + end + + subject(:report) { described_class.new(issuers:, agency_abbreviation:, time_range:) } + + describe '#as_csv' do + it 'queries cloudwatch and formats a report' do + expect(report.as_csv).to eq(expected_result) + end + end + + describe '#to_csv' do + it 'returns a CSV report' do + csv = CSV.parse(report.to_csv, headers: false) + + aggregate_failures do + csv.map(&:to_a).zip(expected_result).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe 'as_emailable_report' do + it 'returns an array with an emailable report' do + expect(report.as_emailable_reports).to eq( + [ + Reporting::EmailableReport.new( + title: 'ABC IdV Dropoff Report', + table: expected_result, + filename: 'abc_idv_dropoff_report', + ), + ], + ) + end + end +end From 6fa00572f17298890598e85980937bc069213d2b Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Wed, 29 Jan 2025 11:26:47 -0500 Subject: [PATCH 17/19] LG-15179 Timed out hybrid user should be able to retry docv (#11792) * set socure_docv_wait_polling timestamp to nil in show on hybrid mobile * add changelog changelog: Internal, IdV Doc Auth, Allow timed out user to retry docv * have spec ensure socure_docv_wait_polling_started_at is nil --- .../idv/hybrid_mobile/socure/document_capture_controller.rb | 2 ++ .../hybrid_mobile/socure/document_capture_controller_spec.rb | 4 ++++ .../idv/socure/document_capture_controller_spec.rb | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb index f9fd639c4db..d3311aceac7 100644 --- a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb @@ -19,6 +19,8 @@ class DocumentCaptureController < ApplicationController before_action :fetch_test_verification_data, only: [:update] def show + session[:socure_docv_wait_polling_started_at] = nil + Funnel::DocAuth::RegisterStep.new(document_capture_user.id, sp_session[:issuer]) .call('hybrid_mobile_socure_document_capture', :view, true) diff --git a/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb index 8b629416441..faa294e55fd 100644 --- a/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb @@ -148,6 +148,10 @@ ) end + it 'sets any docv timeouts to nil' do + expect(session[:socure_docv_wait_polling_started_at]).to eq nil + end + it 'logs correct info' do expect(@analytics).to have_logged_event( :idv_socure_document_request_submitted, diff --git a/spec/controllers/idv/socure/document_capture_controller_spec.rb b/spec/controllers/idv/socure/document_capture_controller_spec.rb index 453c7a7c3ed..042a53d75f2 100644 --- a/spec/controllers/idv/socure/document_capture_controller_spec.rb +++ b/spec/controllers/idv/socure/document_capture_controller_spec.rb @@ -180,6 +180,10 @@ ) end + it 'sets any docv timeouts to nil' do + expect(subject.idv_session.socure_docv_wait_polling_started_at).to eq nil + end + it 'logs correct info' do expect(@analytics).to have_logged_event( :idv_socure_document_request_submitted, From 2c6ae7c9275d60be132512591f621e629de9642f Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 29 Jan 2025 15:45:05 -0500 Subject: [PATCH 18/19] LG-15457 Read the sex attribute from the TrueID authentication result (#11819) We have received clarification on the following from our vendor: 1. The `Fields_Sex` attribute is no longer available in the `ID_AUTH_FIELDS` group 2. The `Sex` attribute available in the `AUTHENTICATION_RESULT` group can be used to reliably determine the sex displayed on the document 3. The `Sex` attribute in the `AUTHENTICATION_RESULT` group takes on the values of `Male` or `Female`. This commit updates the `DocPiiReader` and the TrueID response fixtures to reflect this. [skip changelog] --- app/services/doc_auth/lexis_nexis/doc_pii_reader.rb | 9 +++++---- .../true_id/true_id_response_attention_barcode.json | 5 ----- ..._response_attention_barcode_with_face_match_fail.json | 5 ----- .../true_id/true_id_response_failed_to_ocr_dob.json | 7 +------ .../true_id/true_id_response_failure_no_liveness.json | 5 ----- .../true_id_response_failure_no_liveness_low_dpi.json | 5 ----- .../true_id_response_failure_with_all_failures.json | 5 ----- .../true_id_response_failure_with_face_match_fail.json | 7 +------ .../true_id_response_failure_with_face_match_pass.json | 5 ----- .../true_id/true_id_response_failure_with_liveness.json | 5 ----- .../lexis_nexis/true_id/true_id_response_success.json | 9 --------- .../lexis_nexis/responses/true_id_response_spec.rb | 2 +- 12 files changed, 8 insertions(+), 61 deletions(-) diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb index 79a46b11d64..f9360503542 100644 --- a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -22,6 +22,7 @@ module DocPiiReader # @return [Pii::StateId, nil] def read_pii(true_id_product) id_auth_field_data = true_id_product&.dig(:IDAUTH_FIELD_DATA) + authentication_result_field_data = true_id_product&.dig(:AUTHENTICATION_RESULT) return nil unless id_auth_field_data.present? state_id_type_slug = id_auth_field_data['Fields_DocumentClassName'] @@ -42,7 +43,7 @@ def read_pii(true_id_product) month: id_auth_field_data['Fields_DOB_Month'], day: id_auth_field_data['Fields_DOB_Day'], ), - sex: parse_sex_value(id_auth_field_data['Fields_Sex']), + sex: parse_sex_value(authentication_result_field_data&.[]('Sex')), height: parse_height_value(id_auth_field_data['Fields_Height']), weight: nil, eye_color: nil, @@ -87,10 +88,10 @@ def parse_sex_value(sex_attribute) # This code will return `nil` for those cases with the intent that they will not be verified # against the DLDV where they will not be recognized # - case sex_attribute&.upcase - when 'M' + case sex_attribute + when 'Male' 'male' - when 'F' + when 'Female' 'female' end end diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json index b65a8b12672..4ec00edb41f 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json @@ -694,11 +694,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "12345"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_Height", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json index b2a6d6421d2..fc51458f48b 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json @@ -714,11 +714,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "12345"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_Height", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json index 2e151deb30e..fedcb07d700 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json @@ -409,12 +409,7 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "12345"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, - { + { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", "Values": [{"Value": "6820051160"}] diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json index d47c7b52a28..8ef023d6c7c 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json @@ -674,11 +674,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json index 040084be529..b86089bc0d7 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json index 3d3282e16f2..2caf549db4e 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json index af9221a5f71..6da199b0bf9 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", @@ -737,4 +732,4 @@ } } ] -} \ No newline at end of file +} diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json index d82cd53bb5a..9c32330a468 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json index 9ae6ac15f2c..13aeae5800a 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json index 6b171049751..1439c76561e 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json @@ -697,15 +697,6 @@ } ] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [ - { - "Value": "M" - } - ] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index 4a2e5bb4dec..9c64557a9c1 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -133,7 +133,7 @@ city: 'ANYTOWN', state: 'MD', dob: '1986-07-01', - sex: nil, + sex: 'male', height: 69, weight: nil, eye_color: nil, From 1eb100ee30646e2c00e951e20260d0e47b75eb9a Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Wed, 29 Jan 2025 20:29:02 -0500 Subject: [PATCH 19/19] LG-15274 add socure user to redis set (#11816) * resolve merge conflict * add spec to check the change of socure user count * add changelog changelog: Internal, Doc Auth, Add a socure user to the socure user redis set * add test for every method in this concern * resolving PR comments by adding second limit check * Moving functionality to a lua script to help with synchronicity * remove implicit nil in choose_socure_bucket * fixup * remove unneded return * add test for facial_match required -> LN disabled * Update spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb * happy linting --------- Co-authored-by: Amir Reavis-Bey Co-authored-by: ashukla --- .../concerns/idv/doc_auth_vendor_concern.rb | 28 +++- app/services/idv/socure_user_set.rb | 25 +++- .../idv/doc_auth_vendor_concern_spec.rb | 134 ++++++++++++++++++ 3 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb diff --git a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb index be241cdca16..a9b141969e2 100644 --- a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb +++ b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb @@ -7,16 +7,16 @@ module DocAuthVendorConcern # @returns[String] String identifying the vendor to use for doc auth. def doc_auth_vendor if resolved_authn_context_result.facial_match? || socure_user_set.maxed_users? - if doc_auth_vendor_enabled?(Idp::Constants::Vendors::LEXIS_NEXIS) - bucket = :lexis_nexis - elsif doc_auth_vendor_enabled?(Idp::Constants::Vendors::MOCK) - bucket = :mock - else - return nil - end + bucket = choose_non_socure_bucket else bucket = ab_test_bucket(:DOC_AUTH_VENDOR) end + + if bucket == :socure + if !add_user_to_socure_set + bucket = choose_non_socure_bucket # force to lexis_nexis if max user reached + end + end DocAuthRouter.doc_auth_vendor_for_bucket(bucket) end @@ -34,8 +34,22 @@ def doc_auth_vendor_enabled?(vendor) end end + private + + def choose_non_socure_bucket + if doc_auth_vendor_enabled?(Idp::Constants::Vendors::LEXIS_NEXIS) + :lexis_nexis + elsif doc_auth_vendor_enabled?(Idp::Constants::Vendors::MOCK) + :mock + end + end + def socure_user_set @socure_user_set ||= SocureUserSet.new end + + def add_user_to_socure_set + socure_user_set.add_user!(user_uuid: current_user.uuid) + end end end diff --git a/app/services/idv/socure_user_set.rb b/app/services/idv/socure_user_set.rb index 4d4d41cf830..63372e9de10 100644 --- a/app/services/idv/socure_user_set.rb +++ b/app/services/idv/socure_user_set.rb @@ -4,15 +4,34 @@ module Idv class SocureUserSet attr_reader :redis_pool + ADD_USER_SCRIPT = <<~LUA_EOF + local key = KEYS[1] + local user_uuid = ARGV[1] + local max_allowed_users = tonumber(ARGV[2]) + + local number_of_socure_users = redis.call('SCARD', key) + if number_of_socure_users >= max_allowed_users then + return false + end + redis.call('SADD', key, user_uuid) + return true + LUA_EOF + + ADD_USER_SCRIPT_SHA1 = Digest::SHA1.hexdigest(ADD_USER_SCRIPT).freeze + def initialize(redis_pool: REDIS_POOL) @redis_pool = redis_pool end def add_user!(user_uuid:) - return if maxed_users? - + script_args = [user_uuid.to_s, IdentityConfig.store.doc_auth_socure_max_allowed_users.to_i] redis_pool.with do |client| - client.sadd(key, user_uuid) + begin + client.evalsha(ADD_USER_SCRIPT_SHA1, [key], script_args) + rescue Redis::CommandError => error + raise error unless error.message.start_with?('NOSCRIPT') + client.eval(ADD_USER_SCRIPT, [key], script_args) + end end end diff --git a/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb b/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb new file mode 100644 index 00000000000..b94ca72d120 --- /dev/null +++ b/spec/controllers/concerns/idv/doc_auth_vendor_concern_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe Idv::DocAuthVendorConcern, :controller do + let(:user) { create(:user) } + let(:socure_user_set) { Idv::SocureUserSet.new } + let(:bucket) { :mock } + + controller ApplicationController do + include Idv::DocAuthVendorConcern + end + + around do |ex| + REDIS_POOL.with { |client| client.flushdb } + ex.run + REDIS_POOL.with { |client| client.flushdb } + end + + describe '#doc_auth_vendor' do + before do + allow(controller).to receive(:current_user).and_return(user) + allow(controller).to receive(:ab_test_bucket) + .with(:DOC_AUTH_VENDOR) + .and_return(bucket) + end + + context 'bucket is LexisNexis' do + let(:bucket) { :lexis_nexis } + + it 'returns lexis nexis as the vendor' do + expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::LEXIS_NEXIS) + end + end + + context 'bucket is Mock' do + let(:bucket) { :mock } + + it 'returns mock as the vendor' do + expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + end + end + + context 'bucket is Socure' do + let(:bucket) { :socure } + + it 'returns socure as the vendor' do + expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::SOCURE) + end + + it 'adds a user to the socure redis set' do + expect { controller.doc_auth_vendor }.to change { socure_user_set.count }.by(1) + end + end + + context 'facial match is required' do + let(:bucket) { :socure } + let(:acr_values) do + [ + Saml::Idp::Constants::IAL2_BIO_REQUIRED_AUTHN_CONTEXT_CLASSREF, + Saml::Idp::Constants::DEFAULT_AAL_AUTHN_CONTEXT_CLASSREF, + ].join(' ') + end + + before do + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_switching_enabled).and_return(true) + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_lexis_nexis_percent).and_return(50) + allow(controller).to receive(:resolved_authn_context_result).and_return(true) + resolved_authn_context = AuthnContextResolver.new( + user: user, + service_provider: nil, + vtr: nil, + acr_values: acr_values, + ).result + allow(controller).to receive(:resolved_authn_context_result) + .and_return(resolved_authn_context) + end + + it 'returns Lexis Nexis as the vendor' do + expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::LEXIS_NEXIS) + end + + context 'Lexis Nexis is disabled' do + before do + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_lexis_nexis_percent).and_return(0) + end + + it 'returns mock vendor' do + expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + end + end + + context 'Lexis Nexis is disabled' do + before do + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_lexis_nexis_percent).and_return(0) + end + + it 'returns mock vendor' do + expect(controller.doc_auth_vendor).to eq(Idp::Constants::Vendors::MOCK) + end + end + end + end + + describe '#doc_auth_vendor_enabled?' do + let(:vendor) { Idp::Constants::Vendors::LEXIS_NEXIS } + + context 'doc_auth_vendor_switching is false' do + before do + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_switching_enabled).and_return(false) + end + + it 'returns false' do + expect(controller.doc_auth_vendor_enabled?(vendor)).to eq false + end + end + + context 'Lexis Nexis is disabled' do + before do + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_switching_enabled).and_return(true) + allow(IdentityConfig.store) + .to receive(:doc_auth_vendor_lexis_nexis_percent).and_return(0) + end + + it 'returns false' do + expect(controller.doc_auth_vendor_enabled?(vendor)).to eq false + end + end + end +end