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/idv/doc_auth_vendor_concern.rb b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb index 079a828753b..a9b141969e2 100644 --- a/app/controllers/concerns/idv/doc_auth_vendor_concern.rb +++ b/app/controllers/concerns/idv/doc_auth_vendor_concern.rb @@ -6,17 +6,17 @@ 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 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 + if resolved_authn_context_result.facial_match? || socure_user_set.maxed_users? + 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 @@ -33,5 +33,23 @@ def doc_auth_vendor_enabled?(vendor) false 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/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/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/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/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/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/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/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/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/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/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/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); 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/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/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/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/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/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/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/app/services/idv/socure_user_set.rb b/app/services/idv/socure_user_set.rb index be0e123ae7a..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 @@ -22,12 +41,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/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/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/config/application.yml.default b/config/application.yml.default index 743581627b4..23a5a22d3bc 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -402,7 +402,9 @@ 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 team_ada_email: '' team_all_login_emails: '[]' 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/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 572c450ce86..0f3bb7adf49 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -119,6 +119,17 @@ cron: cron_24h, args: -> { [Time.zone.yesterday] }, }, + # Send the SP IdV Weekly Dropoff Report + 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] }, + }, # Sync opted out phone numbers from AWS phone_number_opt_out_sync_job: { class: 'PhoneNumberOptOutSyncJob', diff --git a/docs/frontend.md b/docs/frontend.md index 219c2c9d3dc..3d2cf17af24 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 @@ -22,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 @@ -155,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 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/lib/identity_config.rb b/lib/identity_config.rb index 4a6557cac45..bd7f8b65868 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -438,7 +438,9 @@ 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) config.add(:team_ada_email, type: :string) config.add(:team_all_login_emails, type: :json) 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/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/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/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 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 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/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/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/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/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 [ 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/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/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, 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/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/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/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/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/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/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..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 @@ -532,6 +557,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 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/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/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 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/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/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/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/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, ) 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 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 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 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/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 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, 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 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. 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 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