diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 772d9957464..a4b29d577d4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,7 +6,7 @@ variables: GITLAB_CI: 'true' ECR_REGISTRY: '${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com' - IDP_CI_SHA: 'sha256:db00ce3b6c4b75e449d72e19329afdde94937547a75bfbfb6ef774ed3f5d77b6' + IDP_CI_SHA: 'sha256:cea459aea56802327075b873cc73a8859ecffa359a9311b359ea49b19b1ba934' default: image: '${ECR_REGISTRY}/idp/ci@${IDP_CI_SHA}' @@ -185,7 +185,6 @@ js_tests: - *yarn_install - yarn test - pinpoint-check: stage: test cache: diff --git a/.nvmrc b/.nvmrc index 8351c19397f..b6a7d89c68e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14 +16 diff --git a/.rubocop.yml b/.rubocop.yml index 396f4ee21ba..6d2350a1c74 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -309,6 +309,9 @@ Layout/SpaceInLambdaLiteral: Enabled: true EnforcedStyle: require_no_space +Layout/SpaceInsideArrayLiteralBrackets: + Enabled: true + Layout/SpaceInsideBlockBraces: Enabled: true diff --git a/README.md b/README.md index a4dd53179e9..a4637098202 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ We recommend using [Homebrew](https://brew.sh/), [rbenv](https://github.com/rben - Ruby ~> 3.0.4 - [PostgreSQL](http://www.postgresql.org/download/) - [Redis 5+](http://redis.io/) -- [Node.js v14](https://nodejs.org) --- (to install Node.js v.14 using brew: `brew install node@14`) +- [Node.js v16](https://nodejs.org) - [Yarn](https://yarnpkg.com/en/) - [chromedriver](https://formulae.brew.sh/cask/chromedriver) diff --git a/app/assets/images/alert/pending.svg b/app/assets/images/alert/pending.svg deleted file mode 100644 index 826e9539de1..00000000000 --- a/app/assets/images/alert/pending.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/images/email/README.md b/app/assets/images/email/README.md index 9796d3c69ac..ff8a477bb95 100644 --- a/app/assets/images/email/README.md +++ b/app/assets/images/email/README.md @@ -1,3 +1,7 @@ # Email Images -This folder contains images for exclusive use by mailer templates. This includes email-specific imagery, and also variants of existing assets. For example, since [SVG images are not well-supported](https://www.caniemail.com/features/image-svg/) in all email clients, this folder may include rasterized versions of common SVG images. +This folder contains images for exclusive use by mailer templates. This includes email-specific imagery, and also variants of existing assets. + +For example, since [SVG images are not well-supported](https://www.caniemail.com/features/image-svg/) in all email clients, this folder may include rasterized versions of common SVG images. + +These images should not be used in application views, since vector images (SVG) are typically preferred due to improved render quality and smaller file size. diff --git a/app/assets/images/email/letter-warning.png b/app/assets/images/email/letter-warning.png new file mode 100644 index 00000000000..cc400b46725 Binary files /dev/null and b/app/assets/images/email/letter-warning.png differ diff --git a/app/assets/stylesheets/components/_block-submit-button.scss b/app/assets/stylesheets/components/_block-submit-button.scss deleted file mode 100644 index abc4f3f5174..00000000000 --- a/app/assets/stylesheets/components/_block-submit-button.scss +++ /dev/null @@ -1,17 +0,0 @@ -.block-submit-button { - cursor: pointer; - - &.usa-button--unstyled { - display: block; - line-height: 1.5; - - &:hover, - &:active { - text-decoration: none; - } - - &:focus { - outline: none; - } - } -} diff --git a/app/assets/stylesheets/components/_step-indicator.scss b/app/assets/stylesheets/components/_step-indicator.scss index 1e5a1efb012..c56e753d614 100644 --- a/app/assets/stylesheets/components/_step-indicator.scss +++ b/app/assets/stylesheets/components/_step-indicator.scss @@ -1,6 +1,5 @@ $step-indicator-current-step-border-width: 3px; $step-indicator-line-height: 4px; -$step-indicator-pending-color: #a8b6c6; lg-step-indicator { display: block; @@ -106,15 +105,11 @@ lg-step-indicator { border: $step-indicator-current-step-border-width solid color('success'); } -.step-indicator__step--complete:not(.step-indicator__step--pending)::before { +.step-indicator__step--complete::before { background-color: color('white'); background-image: url('alert/success.svg'); } -.step-indicator__step--pending::before { - background-image: url('alert/pending.svg'); -} - .step-indicator__step:not(:last-child)::after { background-color: color('base-lighter'); content: ''; @@ -126,11 +121,7 @@ lg-step-indicator { width: calc(100% - 1rem - #{$step-indicator-line-height * 2}); } -.step-indicator__step--pending:not(:last-child)::after { - background-color: $step-indicator-pending-color; -} - -.step-indicator__step--complete:not(.step-indicator__step--pending):not(:last-child)::after { +.step-indicator__step--complete:not(:last-child)::after { background-color: color('success'); } diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 41e20125440..aa9c598c28c 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -1,7 +1,6 @@ @import 'account-header'; @import 'banner'; @import 'block-link'; -@import 'block-submit-button'; @import 'btn'; @import 'card'; @import 'container'; diff --git a/app/components/phone_input_component.rb b/app/components/phone_input_component.rb index 31e5ad76cdf..0a512043747 100644 --- a/app/components/phone_input_component.rb +++ b/app/components/phone_input_component.rb @@ -36,9 +36,10 @@ def translated_country_code_names end def international_phone_codes + translated_international_codes = PhoneNumberCapabilities.translated_international_codes supported_country_codes. map do |code_key| - code_data = PhoneNumberCapabilities.translated_international_codes[code_key] + code_data = translated_international_codes[code_key] [ international_phone_code_label(code_data), diff --git a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb index 3ad3ab920c7..82a3952a382 100644 --- a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb +++ b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb @@ -29,6 +29,7 @@ def reset_attempt_count_if_user_no_longer_locked_out def handle_too_many_otp_sends analytics.idv_phone_confirmation_otp_rate_limit_sends + irs_attempts_api_tracker.idv_phone_otp_sent_rate_limited handle_max_attempts('otp_requests') end diff --git a/app/controllers/concerns/idv/step_indicator_concern.rb b/app/controllers/concerns/idv/step_indicator_concern.rb new file mode 100644 index 00000000000..366f5f0be34 --- /dev/null +++ b/app/controllers/concerns/idv/step_indicator_concern.rb @@ -0,0 +1,50 @@ +module Idv + module StepIndicatorConcern + extend ActiveSupport::Concern + + include IdvSession + + included do + helper_method :step_indicator_steps + end + + def step_indicator_steps + if in_person_proofing? + if gpo_address_verification? + Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS_GPO + else + Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS + end + elsif gpo_address_verification? + Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS_GPO + else + Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS + end + end + + private + + def in_person_proofing? + proofing_components_as_hash['document_check'] == Idp::Constants::Vendors::USPS + end + + def gpo_address_verification? + # Proofing component values are (currently) never reset between proofing attempts, hence why + # this refers to the session address verification mechanism and not the proofing component. + !!current_user.pending_profile || idv_session.address_verification_mechanism == 'gpo' + end + + def proofing_components_as_hash + # A proofing component record exists as a zero-or-one-to-one relation with a user, and values + # are set during identity verification. These values are recorded to the profile at creation, + # including for a pending profile. + @proofing_components_as_hash ||= begin + if current_user.pending_profile + current_user.pending_profile.proofing_components + else + ProofingComponent.find_by(user: current_user).as_json + end + end.to_h + end + end +end diff --git a/app/controllers/concerns/verify_sp_attributes_concern.rb b/app/controllers/concerns/verify_sp_attributes_concern.rb index a474059904e..65db253fbd0 100644 --- a/app/controllers/concerns/verify_sp_attributes_concern.rb +++ b/app/controllers/concerns/verify_sp_attributes_concern.rb @@ -3,13 +3,14 @@ def needs_completion_screen_reason return nil if sp_session[:issuer].blank? return nil if sp_session[:request_url].blank? + sp_session_identity = find_sp_session_identity if sp_session_identity.nil? :new_sp - elsif !requested_attributes_verified? + elsif !requested_attributes_verified?(sp_session_identity) :new_attributes - elsif consent_has_expired? + elsif consent_has_expired?(sp_session_identity) :consent_expired - elsif consent_was_revoked? + elsif consent_was_revoked?(sp_session_identity) :consent_revoked end end @@ -26,7 +27,7 @@ def update_verified_attributes ) end - def consent_has_expired? + def consent_has_expired?(sp_session_identity) return false unless sp_session_identity return false if sp_session_identity.deleted_at.present? last_estimated_consent = sp_session_identity.last_consented_at || sp_session_identity.created_at @@ -35,7 +36,7 @@ def consent_has_expired? verified_after_consent?(last_estimated_consent) end - def consent_was_revoked? + def consent_was_revoked?(sp_session_identity) return false unless sp_session_identity sp_session_identity.deleted_at.present? end @@ -48,14 +49,13 @@ def verified_after_consent?(last_estimated_consent) verification_timestamp.present? && last_estimated_consent < verification_timestamp end - def sp_session_identity - @sp_session_identity = - current_user&.identities&.find_by(service_provider: sp_session[:issuer]) + def find_sp_session_identity + current_user&.identities&.find_by(service_provider: sp_session[:issuer]) end - def requested_attributes_verified? - @sp_session_identity && ( - Array(sp_session[:requested_attributes]) - @sp_session_identity.verified_attributes.to_a + def requested_attributes_verified?(sp_session_identity) + sp_session_identity && ( + Array(sp_session[:requested_attributes]) - sp_session_identity.verified_attributes.to_a ).empty? end end diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index 7014a09dac3..1a36d8e6343 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -5,7 +5,9 @@ class FrontendLogController < ApplicationController before_action :check_user_authenticated before_action :validate_parameter_types + # rubocop:disable Layout/LineLength EVENT_MAP = { + 'IdV: verify in person troubleshooting option clicked' => :idv_verify_in_person_troubleshooting_option_clicked, 'IdV: forgot password visited' => :idv_forgot_password, 'IdV: password confirm visited' => :idv_review_info_visited, 'IdV: password confirm submitted' => proc do |analytics| @@ -22,6 +24,7 @@ class FrontendLogController < ApplicationController }.transform_values do |method| method.is_a?(Proc) ? method : AnalyticsEvents.instance_method(method) end.freeze + # rubocop:enable Layout/LineLength def create event = log_params[:event] diff --git a/app/controllers/idv/capture_doc_controller.rb b/app/controllers/idv/capture_doc_controller.rb index 7b54b09b487..0fd1efca22d 100644 --- a/app/controllers/idv/capture_doc_controller.rb +++ b/app/controllers/idv/capture_doc_controller.rb @@ -1,5 +1,9 @@ module Idv class CaptureDocController < ApplicationController + # rubocop:disable Rails/LexicallyScopedActionFilter + # index comes from the flow_state_matchine.rb + before_action :track_index_loads, only: [:index] + # rubocop:enable Rails/LexicallyScopedActionFilter before_action :ensure_user_id_in_session include Flow::FlowStateMachine @@ -20,6 +24,10 @@ def return_to_sp private + def track_index_loads + irs_attempts_api_tracker.idv_phone_upload_link_used + end + def ensure_user_id_in_session return if session[:doc_capture_user_id] && token.blank? && diff --git a/app/controllers/idv/come_back_later_controller.rb b/app/controllers/idv/come_back_later_controller.rb index f1daaceac71..f51840f782b 100644 --- a/app/controllers/idv/come_back_later_controller.rb +++ b/app/controllers/idv/come_back_later_controller.rb @@ -1,5 +1,7 @@ module Idv class ComeBackLaterController < ApplicationController + include StepIndicatorConcern + before_action :confirm_two_factor_authenticated before_action :confirm_user_needs_gpo_confirmation diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index 01d07a7d06a..0e1e80bf5c5 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -1,6 +1,7 @@ module Idv class GpoController < ApplicationController include IdvSession + include StepIndicatorConcern before_action :confirm_two_factor_authenticated before_action :confirm_idv_needed @@ -10,6 +11,7 @@ class GpoController < ApplicationController def index @presenter = GpoPresenter.new(current_user, url_options) + @step_indicator_current_step = step_indicator_current_step analytics.idv_gpo_address_visited( letter_already_sent: @presenter.letter_already_sent?, ) @@ -35,6 +37,14 @@ def gpo_mail_service private + def step_indicator_current_step + if resend_requested? + :get_a_letter + else + :verify_phone_or_address + end + end + def update_tracking analytics.idv_gpo_address_letter_requested(resend: resend_requested?) irs_attempts_api_tracker.idv_letter_requested(success: true, resend: resend_requested?) diff --git a/app/controllers/idv/gpo_verify_controller.rb b/app/controllers/idv/gpo_verify_controller.rb index eed5efd069f..37d5bb8559a 100644 --- a/app/controllers/idv/gpo_verify_controller.rb +++ b/app/controllers/idv/gpo_verify_controller.rb @@ -1,6 +1,7 @@ module Idv class GpoVerifyController < ApplicationController include IdvSession + include StepIndicatorConcern before_action :confirm_two_factor_authenticated before_action :confirm_verification_needed @@ -27,10 +28,15 @@ def create @gpo_verify_form = build_gpo_verify_form if throttle.throttled_else_increment? + irs_attempts_api_tracker.idv_gpo_verification_throttled render_throttled else result = @gpo_verify_form.submit analytics.idv_gpo_verification_submitted(**result.to_h) + irs_attempts_api_tracker.idv_gpo_verification_submitted( + success: result.success?, + failure_reason: result.errors.presence, + ) if result.success? if result.extra[:pending_in_person_enrollment] diff --git a/app/controllers/idv/in_person/ready_to_verify_controller.rb b/app/controllers/idv/in_person/ready_to_verify_controller.rb index b6a60279008..f50c866c93f 100644 --- a/app/controllers/idv/in_person/ready_to_verify_controller.rb +++ b/app/controllers/idv/in_person/ready_to_verify_controller.rb @@ -2,6 +2,7 @@ module Idv module InPerson class ReadyToVerifyController < ApplicationController include RenderConditionConcern + include StepIndicatorConcern check_or_render_not_found -> { IdentityConfig.store.in_person_proofing_enabled } diff --git a/app/controllers/idv/in_person_controller.rb b/app/controllers/idv/in_person_controller.rb index e17a605cb34..4af568d3b63 100644 --- a/app/controllers/idv/in_person_controller.rb +++ b/app/controllers/idv/in_person_controller.rb @@ -9,8 +9,10 @@ class InPersonController < ApplicationController include IdvSession include Flow::FlowStateMachine + include Idv::ThreatMetrixConcern before_action :redirect_if_flow_completed + before_action :override_csp_for_threat_metrix FLOW_STATE_MACHINE_SETTINGS = { step_url: :idv_in_person_step_url, diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index 005b35153a2..19ab4f28c58 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -1,6 +1,7 @@ module Idv class OtpDeliveryMethodController < ApplicationController include IdvSession + include StepIndicatorConcern include PhoneOtpRateLimitable include PhoneOtpSendable @@ -17,6 +18,7 @@ def new def create result = otp_delivery_selection_form.submit(otp_delivery_selection_params) analytics.idv_phone_otp_delivery_selection_submitted(**result.to_h) + return render_new_with_error_message unless result.success? send_phone_confirmation_otp_and_handle_result end @@ -49,6 +51,13 @@ def send_phone_confirmation_otp_and_handle_result save_delivery_preference result = send_phone_confirmation_otp analytics.idv_phone_confirmation_otp_sent(**result.to_h) + + irs_attempts_api_tracker.idv_phone_confirmation_otp_sent( + phone_number: @idv_phone, + success: result.success?, + otp_delivery_method: params[:otp_delivery_preference], + failure_reason: result.success? ? {} : otp_sent_tracker_error(result), + ) if result.success? redirect_to idv_otp_verification_url else @@ -58,6 +67,7 @@ def send_phone_confirmation_otp_and_handle_result def handle_send_phone_confirmation_otp_failure(result) if send_phone_confirmation_otp_rate_limited? + irs_attempts_api_tracker.idv_phone_confirmation_otp_sent_rate_limited handle_too_many_otp_sends else invalid_phone_number(result.extra[:telephony_response].error) @@ -74,6 +84,14 @@ def save_delivery_preference ) end + def otp_sent_tracker_error(result) + if send_phone_confirmation_otp_rate_limited? + { rate_limited: true } + else + { telephony_error: result.extra[:telephony_response]&.error&.friendly_message } + end + end + def otp_delivery_selection_form @otp_delivery_selection_form ||= Idv::OtpDeliveryMethodForm.new end diff --git a/app/controllers/idv/otp_verification_controller.rb b/app/controllers/idv/otp_verification_controller.rb index 2ad46468497..d2c76cb5979 100644 --- a/app/controllers/idv/otp_verification_controller.rb +++ b/app/controllers/idv/otp_verification_controller.rb @@ -1,6 +1,7 @@ module Idv class OtpVerificationController < ApplicationController include IdvSession + include StepIndicatorConcern include PhoneOtpRateLimitable # confirm_two_factor_authenticated before action is in PhoneOtpRateLimitable @@ -67,6 +68,7 @@ def phone_confirmation_otp_verification_form @phone_confirmation_otp_verification_form ||= PhoneConfirmationOtpVerificationForm.new( user: current_user, user_phone_confirmation_session: idv_session.user_phone_confirmation_session, + irs_attempts_api_tracker: irs_attempts_api_tracker, ) end end diff --git a/app/controllers/idv/personal_key_controller.rb b/app/controllers/idv/personal_key_controller.rb index 4da94db1bc6..20204bc3218 100644 --- a/app/controllers/idv/personal_key_controller.rb +++ b/app/controllers/idv/personal_key_controller.rb @@ -1,6 +1,7 @@ module Idv class PersonalKeyController < ApplicationController include IdvSession + include StepIndicatorConcern include SecureHeadersConcern before_action :apply_secure_headers_override @@ -9,7 +10,6 @@ class PersonalKeyController < ApplicationController before_action :confirm_profile_has_been_created def show - @step_indicator_steps = step_indicator_steps analytics.idv_personal_key_visited add_proofing_component @@ -26,14 +26,6 @@ def update private - def step_indicator_steps - steps = Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS - return steps if idv_session.address_verification_mechanism != 'gpo' - steps.map do |step| - step[:name] == :verify_phone_or_address ? step.merge(status: :pending) : step - end - end - def next_step if pending_profile? && idv_session.address_verification_mechanism == 'gpo' idv_come_back_later_url @@ -75,6 +67,7 @@ def personal_key def generate_personal_key cacher = Pii::Cacher.new(current_user, user_session) + irs_attempts_api_tracker.idv_personal_key_generated(success: true) idv_session.profile.encrypt_recovery_pii(cacher.fetch) end diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index 9a844613d79..875b565576c 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -1,6 +1,7 @@ module Idv class PhoneController < ApplicationController include IdvStepConcern + include StepIndicatorConcern attr_reader :idv_form @@ -30,6 +31,11 @@ def new def create result = idv_form.submit(step_params) analytics.idv_phone_confirmation_form_submitted(**result.to_h) + irs_attempts_api_tracker.idv_phone_submitted( + phone_number: step_params[:phone], + success: result.success?, + failure_reason: result.errors, + ) flash[:error] = result.first_error_message if !result.success? return render :new, locals: { gpo_letter_available: gpo_letter_available } if !result.success? submit_proofing_attempt diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index c13e55e9f24..93680105fc2 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -3,6 +3,7 @@ class ReviewController < ApplicationController before_action :personal_key_confirmed include IdvStepConcern + include StepIndicatorConcern include PhoneConfirmation before_action :confirm_idv_steps_complete @@ -28,13 +29,14 @@ def confirm_current_password return if valid_password? analytics.idv_review_complete(success: false) + irs_attempts_api_tracker.idv_password_entered(success: false) + flash[:error] = t('idv.errors.incorrect_password') redirect_to idv_review_url end def new @applicant = idv_session.applicant - @step_indicator_steps = step_indicator_steps analytics.idv_review_info_visited gpo_mail_service = Idv::GpoMail.new(current_user) @@ -47,6 +49,7 @@ def new end def create + irs_attempts_api_tracker.idv_password_entered(success: true) init_profile user_session[:need_personal_key_confirmation] = true redirect_to next_step @@ -64,14 +67,6 @@ def redirect_to_idv_app_if_enabled redirect_to idv_app_path end - def step_indicator_steps - steps = Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS - return steps if idv_session.address_verification_mechanism != 'gpo' - steps.map do |step| - step[:name] == :verify_phone_or_address ? step.merge(status: :pending) : step - end - end - def flash_message_content if idv_session.address_verification_mechanism != 'gpo' phone_of_record_msg = ActionController::Base.helpers.content_tag( diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index bc44343b241..a4eb321d3d2 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -1,6 +1,7 @@ class IdvController < ApplicationController include IdvSession include AccountReactivationConcern + include InheritedProofingConcern before_action :confirm_two_factor_authenticated before_action :profile_needs_reactivation?, only: [:index] @@ -36,6 +37,7 @@ def sp_over_quota_limit? def verify_identity analytics.idv_intro_visit + return redirect_to idv_inherited_proofing_url if va_inherited_proofing? redirect_to idv_doc_auth_url end diff --git a/app/forms/idv/address_form.rb b/app/forms/idv/address_form.rb index 381bb878aab..71aa2d16a22 100644 --- a/app/forms/idv/address_form.rb +++ b/app/forms/idv/address_form.rb @@ -24,7 +24,7 @@ def submit(params) errors: errors, extra: { address_edited: @address_edited, - pii_like_keypaths: [[:errors, :zipcode ], [:error_details, :zipcode]], + pii_like_keypaths: [[:errors, :zipcode], [:error_details, :zipcode]], }, ) end diff --git a/app/forms/idv/phone_confirmation_otp_verification_form.rb b/app/forms/idv/phone_confirmation_otp_verification_form.rb index 19016f09e6f..6caa41867e7 100644 --- a/app/forms/idv/phone_confirmation_otp_verification_form.rb +++ b/app/forms/idv/phone_confirmation_otp_verification_form.rb @@ -1,10 +1,11 @@ module Idv class PhoneConfirmationOtpVerificationForm - attr_reader :user, :user_phone_confirmation_session, :code + attr_reader :user, :user_phone_confirmation_session, :irs_attempts_api_tracker, :code - def initialize(user:, user_phone_confirmation_session:) + def initialize(user:, user_phone_confirmation_session:, irs_attempts_api_tracker:) @user = user @user_phone_confirmation_session = user_phone_confirmation_session + @irs_attempts_api_tracker = irs_attempts_api_tracker end def submit(code:) @@ -32,11 +33,18 @@ def clear_second_factor_attempts def increment_second_factor_attempts user.second_factor_attempts_count += 1 attributes = {} - attributes[:second_factor_locked_at] = Time.zone.now if user.max_login_attempts? + if user.max_login_attempts? + attributes[:second_factor_locked_at] = Time.zone.now + irs_attempts_api_tracker.idv_phone_otp_submitted_rate_limited(phone: user_phone) + end UpdateUser.new(user: user, attributes: attributes).call end + def user_phone + user_phone_confirmation_session.phone + end + def extra_analytics_attributes { code_expired: user_phone_confirmation_session.expired?, diff --git a/app/forms/idv/ssn_format_form.rb b/app/forms/idv/ssn_format_form.rb index 628cca898c5..6354b763d83 100644 --- a/app/forms/idv/ssn_format_form.rb +++ b/app/forms/idv/ssn_format_form.rb @@ -21,7 +21,7 @@ def submit(params) FormResponse.new( success: valid?, errors: errors, - extra: { pii_like_keypaths: [[:errors, :ssn ], [:error_details, :ssn]] }, + extra: { pii_like_keypaths: [[:errors, :ssn], [:error_details, :ssn]] }, ) end diff --git a/app/javascript/packages/components/block-link.tsx b/app/javascript/packages/components/block-link.tsx index de51b5a9266..51ea6911124 100644 --- a/app/javascript/packages/components/block-link.tsx +++ b/app/javascript/packages/components/block-link.tsx @@ -1,18 +1,13 @@ import Link, { LinkProps } from './link'; import BlockLinkArrow from './block-link-arrow'; -export interface BlockLinkProps extends LinkProps { - /** - * Link destination. - */ - href: string; -} +export interface BlockLinkProps extends LinkProps {} -function BlockLink({ href, children, className, ...linkProps }: BlockLinkProps) { +function BlockLink({ children, className, ...linkProps }: BlockLinkProps) { const classes = ['block-link', className].filter(Boolean).join(' '); return ( - + {children} diff --git a/app/javascript/packages/components/block-submit-button.spec.tsx b/app/javascript/packages/components/block-submit-button.spec.tsx deleted file mode 100644 index 3feab6c1be3..00000000000 --- a/app/javascript/packages/components/block-submit-button.spec.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render } from '@testing-library/react'; -import BlockSubmitButton from './block-submit-button'; - -describe('BlockSubmitButton', () => { - const buttonLabel = 'Click here to submit'; - - it('renders a button with an expected class name, type, and arrow content', () => { - const { getByRole } = render({buttonLabel}); - - const button = getByRole('button') as HTMLInputElement; - - expect(button.classList.contains('block-submit-button')).to.be.true(); - expect(button.querySelector('.block-link__arrow')).to.exist(); - expect(button.textContent).to.equal(buttonLabel); - expect(button.type).to.equal('submit'); - }); - - context('with custom css class', () => { - it('renders a link with passed class name', () => { - const { getByRole } = render( - {buttonLabel}, - ); - - const button = getByRole('button'); - - expect(button.classList.contains('block-submit-button')).to.be.true(); - expect(button.classList.contains('my-custom-class')).to.be.true(); - }); - }); -}); diff --git a/app/javascript/packages/components/block-submit-button.tsx b/app/javascript/packages/components/block-submit-button.tsx deleted file mode 100644 index 7616499d49f..00000000000 --- a/app/javascript/packages/components/block-submit-button.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { ReactNode } from 'react'; -import BlockLinkArrow from './block-link-arrow'; - -interface BlockSubmitButtonProps extends React.ComponentPropsWithoutRef<'button'> { - /** - * Link text. - */ - children?: ReactNode; - - /** - * Additional class names to apply. - */ - className?: string; -} - -function BlockSubmitButton({ children, className, ...linkProps }: BlockSubmitButtonProps) { - const classes = [ - 'block-submit-button', - 'usa-button--unstyled', - 'usa-link', - 'block-link', - 'width-full', - className, - ] - .filter(Boolean) - .join(' '); - - return ( - - ); -} - -export default BlockSubmitButton; diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 5c6b215a486..4b69ae82205 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -1,7 +1,6 @@ export { default as Accordion } from './accordion'; export { default as Alert } from './alert'; export { default as BlockLink } from './block-link'; -export { default as BlockSubmitButton } from './block-submit-button'; export { default as Button } from './button'; export { default as FullScreen } from './full-screen'; export { default as Icon } from './icon'; diff --git a/app/javascript/packages/components/troubleshooting-options.tsx b/app/javascript/packages/components/troubleshooting-options.tsx index 80ad8c42141..8f27f15f2e9 100644 --- a/app/javascript/packages/components/troubleshooting-options.tsx +++ b/app/javascript/packages/components/troubleshooting-options.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react'; -import { BlockLink, BlockSubmitButton } from '@18f/identity-components'; +import { BlockLink } from '@18f/identity-components'; import { useI18n } from '@18f/identity-react-i18n'; import { BlockLinkProps } from './block-link'; export type TroubleshootingOption = Omit & { - url?: string; + url: string; text: ReactNode; @@ -48,13 +48,9 @@ function TroubleshootingOptions({ diff --git a/app/javascript/packages/device/index.js b/app/javascript/packages/device/index.js index 53de2ecd1ac..bdd835e797e 100644 --- a/app/javascript/packages/device/index.js +++ b/app/javascript/packages/device/index.js @@ -1,3 +1,16 @@ +/** + * Returns true if the device is an iPad, or false otherwise. + * + * iPadOS devices no longer list the correct user agent. As a proxy, we check for the incorrect + * one (Macintosh) then test the number of touchpoints, which for iPads will be 5. + * + * @return {boolean} + */ +export function isIPad() { + const { userAgent, maxTouchPoints } = window.navigator; + return /ipad/i.test(userAgent) || (/macintosh/i.test(userAgent) && maxTouchPoints === 5); +} + /** * Returns true if the device is likely a mobile device, or false otherwise. This is a rough * approximation, using device user agent sniffing. @@ -5,7 +18,7 @@ * @return {boolean} */ export function isLikelyMobile() { - return /ip(hone|ad|od)|android/i.test(window.navigator.userAgent); + return isIPad() || /iphone|android/i.test(window.navigator.userAgent); } /** diff --git a/spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx similarity index 77% rename from spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx rename to app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx index 53b84bec331..c3c2e55a847 100644 --- a/spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx +++ b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.spec.tsx @@ -1,19 +1,26 @@ +import sinon from 'sinon'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentType } from 'react'; import { MarketingSiteContextProvider, ServiceProviderContextProvider, } from '@18f/identity-document-capture'; -import { FlowContext } from '@18f/identity-verify-flow'; -import DocumentCaptureTroubleshootingOptions from '@18f/identity-document-capture/components/document-capture-troubleshooting-options'; +import { FlowContext, FlowContextValue } from '@18f/identity-verify-flow'; +import AnalyticsContext from '../context/analytics'; +import DocumentCaptureTroubleshootingOptions from './document-capture-troubleshooting-options'; +import type { ServiceProviderContext } from '../context/service-provider'; describe('DocumentCaptureTroubleshootingOptions', () => { const helpCenterRedirectURL = 'https://example.com/redirect/'; const inPersonURL = 'https://example.com/some/idv/ipp/url'; - const serviceProviderContext = { + const serviceProviderContext: ServiceProviderContext = { name: 'Example SP', failureToProofURL: 'http://example.test/url/to/failure-to-proof', + isLivenessRequired: false, + getFailureToProofURL: () => '', }; - const wrappers = { + const wrappers: Record = { MarketingSiteContext: ({ children }) => ( {children} @@ -33,7 +40,7 @@ describe('DocumentCaptureTroubleshootingOptions', () => { wrapper: wrappers.MarketingSiteContext, }); - const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link')); + const links = getAllByRole('link') as HTMLAnchorElement[]; expect(links).to.have.lengthOf(2); expect(links[0].textContent).to.equal( @@ -58,7 +65,7 @@ describe('DocumentCaptureTroubleshootingOptions', () => { wrapper: wrappers.helpCenterAndServiceProviderContext, }); - const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link')); + const links = getAllByRole('link') as HTMLAnchorElement[]; expect(links).to.have.lengthOf(3); expect(links[0].textContent).to.equal( @@ -93,7 +100,7 @@ describe('DocumentCaptureTroubleshootingOptions', () => { }, ); - const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link')); + const links = getAllByRole('link') as HTMLAnchorElement[]; expect(links[0].href).to.equal( 'https://example.com/redirect/?category=verify-your-identity&article=how-to-add-images-of-your-state-issued-id&location=custom', @@ -136,29 +143,48 @@ describe('DocumentCaptureTroubleshootingOptions', () => { }); context('hasErrors and inPersonURL', () => { - const wrapper = ({ children }) => ( - {children} + const wrapper: ComponentType = ({ children }) => ( + + {children} + ); it('has link to IPP flow', () => { - const { getByText, getAllByRole } = render( + const { getByText, getByRole } = render( , { wrapper }, ); expect(getByText('components.troubleshooting_options.new_feature')).to.exist(); - const buttons = getAllByRole('button'); - const ippButton = buttons.find( - ({ textContent }) => textContent === 'idv.troubleshooting.options.verify_in_person', + const link = getByRole('link', { name: 'idv.troubleshooting.options.verify_in_person' }); + + expect(link).to.exist(); + }); + + it('logs an event when clicking the troubleshooting option', async () => { + const trackEvent = sinon.stub(); + const { getByRole } = render( + + + , + { wrapper }, + ); + + const link = getByRole('link', { name: 'idv.troubleshooting.options.verify_in_person' }); + await userEvent.click(link); + + expect(trackEvent).to.have.been.calledWith( + 'IdV: verify in person troubleshooting option clicked', ); - expect(ippButton).to.exist(); }); }); context('hasErrors and inPersonURL but showInPersonOption is false', () => { - const wrapper = ({ children }) => ( - {children} + const wrapper: ComponentType = ({ children }) => ( + + {children} + ); it('does not have link to IPP flow', () => { @@ -191,7 +217,7 @@ describe('DocumentCaptureTroubleshootingOptions', () => { }, ); - const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link')); + const links = getAllByRole('link') as HTMLAnchorElement[]; expect(links).to.have.lengthOf(1); expect(links[0].getAttribute('href')).to.equal( diff --git a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx index 3fa24c0946b..b272fb95e48 100644 --- a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx @@ -5,6 +5,7 @@ import { useI18n } from '@18f/identity-react-i18n'; import type { TroubleshootingOption } from '@18f/identity-components/troubleshooting-options'; import ServiceProviderContext from '../context/service-provider'; import MarketingSiteContext from '../context/marketing-site'; +import AnalyticsContext from '../context/analytics'; interface DocumentCaptureTroubleshootingOptionsProps { /** @@ -43,6 +44,7 @@ function DocumentCaptureTroubleshootingOptions({ const { t } = useI18n(); const { inPersonURL } = useContext(FlowContext); const { getHelpCenterURL } = useContext(MarketingSiteContext); + const { trackEvent } = useContext(AnalyticsContext); const { name: spName, getFailureToProofURL } = useContext(ServiceProviderContext); return ( @@ -81,7 +83,13 @@ function DocumentCaptureTroubleshootingOptions({ trackEvent('IdV: verify in person troubleshooting option clicked'), + }, + ]} /> )} diff --git a/app/javascript/packages/document-capture/context/device.js b/app/javascript/packages/document-capture/context/device.js index 0787ec3fbff..2ff9e599a6b 100644 --- a/app/javascript/packages/document-capture/context/device.js +++ b/app/javascript/packages/document-capture/context/device.js @@ -1,12 +1,12 @@ import { createContext } from 'react'; /** - * @typedef DeviceContext + * @typedef DeviceContextValue * * @prop {boolean} isMobile Device is a mobile device. */ -const DeviceContext = createContext(/** @type {DeviceContext} */ ({ isMobile: false })); +const DeviceContext = createContext(/** @type {DeviceContextValue} */ ({ isMobile: false })); DeviceContext.displayName = 'DeviceContext'; diff --git a/app/javascript/packages/document-capture/context/index.js b/app/javascript/packages/document-capture/context/index.ts similarity index 93% rename from app/javascript/packages/document-capture/context/index.js rename to app/javascript/packages/document-capture/context/index.ts index 22b60166c26..66c62141ecf 100644 --- a/app/javascript/packages/document-capture/context/index.js +++ b/app/javascript/packages/document-capture/context/index.ts @@ -15,3 +15,5 @@ export { default as FailedCaptureAttemptsContext, Provider as FailedCaptureAttemptsContextProvider, } from './failed-capture-attempts'; + +export type { DeviceContextValue } from './device'; diff --git a/app/javascript/packages/eslint-plugin/CHANGELOG.md b/app/javascript/packages/eslint-plugin/CHANGELOG.md index a36354271bb..1aac94968ab 100644 --- a/app/javascript/packages/eslint-plugin/CHANGELOG.md +++ b/app/javascript/packages/eslint-plugin/CHANGELOG.md @@ -46,6 +46,7 @@ - `no-useless-constructor` - React: The following rules are no longer enforced: - `react/no-array-index-key` +- `prefer-const` is only enforced on destructuring assignment if all variables should be `const` ([`destructuring: 'all'` option](https://eslint.org/docs/latest/rules/prefer-const#destructuring)). ## v2.0.0 (2022-03-14) diff --git a/app/javascript/packages/eslint-plugin/configs/recommended.js b/app/javascript/packages/eslint-plugin/configs/recommended.js index c3e20c28a6e..c125bc6c393 100644 --- a/app/javascript/packages/eslint-plugin/configs/recommended.js +++ b/app/javascript/packages/eslint-plugin/configs/recommended.js @@ -55,6 +55,7 @@ const config = { 'object-curly-spacing': 'off', 'operator-linebreak': 'off', 'padded-blocks': 'off', + 'prefer-const': ['error', { destructuring: 'all', ignoreReadBeforeAssign: true }], 'quote-props': 'off', 'require-await': 'error', 'rest-spread-spacing': 'off', diff --git a/app/javascript/packages/normalize-yaml/index.js b/app/javascript/packages/normalize-yaml/index.js index c09ba7d585c..b70b20d3189 100644 --- a/app/javascript/packages/normalize-yaml/index.js +++ b/app/javascript/packages/normalize-yaml/index.js @@ -13,7 +13,7 @@ import { getVisitors } from './visitors/index.js'; /** * @param {string} content Original content. - * @param {NormalizeOptions=} options Normalize options. + * @param {NormalizeOptions} options Normalize options. * * @return {string} Normalized content. */ diff --git a/app/javascript/packages/secret-session-storage/index.ts b/app/javascript/packages/secret-session-storage/index.ts index 0f58e3f2bb1..5c15e279d7d 100644 --- a/app/javascript/packages/secret-session-storage/index.ts +++ b/app/javascript/packages/secret-session-storage/index.ts @@ -6,7 +6,8 @@ type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string /** * Convert an ArrayBuffer to an equivalent string. */ -export const ab2s = (buffer: Uint8Array) => String.fromCharCode.apply(null, new Uint8Array(buffer)); +export const ab2s = (buffer: ArrayBuffer) => + String.fromCharCode.apply(null, new Uint8Array(buffer)); /** * Convert a string to an equivalent ArrayBuffer. diff --git a/app/javascript/packages/step-indicator/step-indicator-step.spec.tsx b/app/javascript/packages/step-indicator/step-indicator-step.spec.tsx index 6a0a3df6775..10838a751fa 100644 --- a/app/javascript/packages/step-indicator/step-indicator-step.spec.tsx +++ b/app/javascript/packages/step-indicator/step-indicator-step.spec.tsx @@ -14,7 +14,6 @@ describe('StepIndicatorStep', () => { expect(status).to.be.ok(); expect(step.classList.contains('step-indicator__step--current')).to.be.true(); expect(step.classList.contains('step-indicator__step--complete')).to.be.false(); - expect(step.classList.contains('step-indicator__step--pending')).to.be.false(); expect(status.classList.contains('step-indicator__step-subtitle')).to.be.false(); expect(status.classList.contains('usa-sr-only')).to.be.true(); }); @@ -32,30 +31,11 @@ describe('StepIndicatorStep', () => { expect(status).to.be.ok(); expect(step.classList.contains('step-indicator__step--current')).to.be.false(); expect(step.classList.contains('step-indicator__step--complete')).to.be.true(); - expect(step.classList.contains('step-indicator__step--pending')).to.be.false(); expect(status.classList.contains('step-indicator__step-subtitle')).to.be.false(); expect(status.classList.contains('usa-sr-only')).to.be.true(); }); }); - context('pending step', () => { - it('renders step', () => { - const { getByText } = render(); - - const title = getByText('Step'); - const status = getByText('step_indicator.status.pending'); - const step = title.closest('.step-indicator__step')!; - - expect(title).to.be.ok(); - expect(status).to.be.ok(); - expect(step.classList.contains('step-indicator__step--current')).to.be.false(); - expect(step.classList.contains('step-indicator__step--complete')).to.be.false(); - expect(step.classList.contains('step-indicator__step--pending')).to.be.true(); - expect(status.classList.contains('step-indicator__step-subtitle')).to.be.true(); - expect(status.classList.contains('usa-sr-only')).to.be.false(); - }); - }); - context('incomplete step', () => { it('renders step', () => { const { getByText } = render( @@ -70,7 +50,6 @@ describe('StepIndicatorStep', () => { expect(status).to.be.ok(); expect(step.classList.contains('step-indicator__step--current')).to.be.false(); expect(step.classList.contains('step-indicator__step--complete')).to.be.false(); - expect(step.classList.contains('step-indicator__step--pending')).to.be.false(); expect(status.classList.contains('step-indicator__step-subtitle')).to.be.false(); expect(status.classList.contains('usa-sr-only')).to.be.true(); }); diff --git a/app/javascript/packages/step-indicator/step-indicator-step.tsx b/app/javascript/packages/step-indicator/step-indicator-step.tsx index b892787db88..95d93add49b 100644 --- a/app/javascript/packages/step-indicator/step-indicator-step.tsx +++ b/app/javascript/packages/step-indicator/step-indicator-step.tsx @@ -26,7 +26,6 @@ function StepIndicatorStep({ title, status }: StepIndicatorStepProps) { 'step-indicator__step', status === CURRENT && 'step-indicator__step--current', status === COMPLETE && 'step-indicator__step--complete', - status === PENDING && 'step-indicator__step--pending', ] .filter(Boolean) .join(' '); diff --git a/app/javascript/packages/verify-flow/context/flow-context.tsx b/app/javascript/packages/verify-flow/context/flow-context.tsx index b4b6509543d..17d24ecbd8c 100644 --- a/app/javascript/packages/verify-flow/context/flow-context.tsx +++ b/app/javascript/packages/verify-flow/context/flow-context.tsx @@ -24,7 +24,7 @@ export interface FlowContextValue { /** * Handle flow completion with a given destination URL. */ - onComplete({ completionURL: string }): void; + onComplete: ({ completionURL }: { completionURL: string }) => void; } const FlowContext = createContext({ diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx index 2fefb1935c6..232d71e64ed 100644 --- a/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx @@ -38,15 +38,18 @@ describe('VerifyFlowStepIndicator', () => { }); context('with gpo as address verification method', () => { - it('renders address verification as pending', () => { - const { getByText } = render( + it('revises the flow path to omit address verification and add letter step', () => { + const { queryByText } = render( , ); - const previous = getByText('step_indicator.flows.idv.verify_phone_or_address'); - expect(previous.closest('.step-indicator__step--pending')).to.exist(); + const verifyAddress = queryByText('step_indicator.flows.idv.verify_phone_or_address'); + const getALetter = queryByText('step_indicator.flows.idv.get_a_letter'); + + expect(verifyAddress).to.not.exist(); + expect(getALetter).to.exist(); }); }); diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx index d2b957a563f..668a92914d5 100644 --- a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx @@ -16,7 +16,8 @@ type VerifyFlowStepIndicatorStep = | 'verify_phone_or_address' | 'secure_account' | 'find_a_post_office' - | 'go_to_the_post_office'; + | 'go_to_the_post_office' + | 'get_a_letter'; interface VerifyFlowConfig { /** @@ -94,35 +95,35 @@ export function getStepStatus(index, currentStepIndex): StepStatus { } /** - * Given contextual details of the current flow path, returns explicit statuses which should be used - * at particular steps. + * Given contextual details of the current flow path, returns the relevant flow configuration. * * @param details Flow details * - * @return Step status overrides. + * @return Flow step configuration. */ -function getStatusOverrides({ +function getFlowStepsConfig({ + path, addressVerificationMethod, }: { + path: VerifyFlowPath; addressVerificationMethod: AddressVerificationMethod; -}) { - const statuses: Partial> = {}; +}): VerifyFlowConfig { + let { steps, mapping } = FLOW_STEP_PATHS[path]; if (addressVerificationMethod === 'gpo') { - statuses.verify_phone_or_address = StepStatus.PENDING; + steps = steps.filter((step) => step !== 'verify_phone_or_address').concat('get_a_letter'); } - return statuses; + return { steps, mapping }; } function VerifyFlowStepIndicator({ currentStep, path = VerifyFlowPath.DEFAULT, }: VerifyFlowStepIndicatorProps) { - const { steps, mapping } = FLOW_STEP_PATHS[path]; - const currentStepIndex = steps.indexOf(mapping[currentStep]); const { addressVerificationMethod } = useContext(AddressVerificationMethodContext); - const statusOverrides = getStatusOverrides({ addressVerificationMethod }); + const { steps, mapping } = getFlowStepsConfig({ path, addressVerificationMethod }); + const currentStepIndex = steps.indexOf(mapping[currentStep]); // i18n-tasks-use t('step_indicator.flows.idv.getting_started') // i18n-tasks-use t('step_indicator.flows.idv.verify_id') @@ -131,6 +132,7 @@ function VerifyFlowStepIndicator({ // i18n-tasks-use t('step_indicator.flows.idv.secure_account') // i18n-tasks-use t('step_indicator.flows.idv.find_a_post_office') // i18n-tasks-use t('step_indicator.flows.idv.go_to_the_post_office') + // i18n-tasks-use t('step_indicator.flows.idv.get_a_letter') return ( @@ -138,7 +140,7 @@ function VerifyFlowStepIndicator({ ))} diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.tsx similarity index 72% rename from app/javascript/packs/document-capture.jsx rename to app/javascript/packs/document-capture.tsx index 9a0d2b2a6ea..9572cd89663 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.tsx @@ -14,47 +14,28 @@ import { import { isCameraCapableMobile } from '@18f/identity-device'; import { FlowContext } from '@18f/identity-verify-flow'; import { trackEvent as baseTrackEvent } from '@18f/identity-analytics'; - -/** @typedef {import('@18f/identity-document-capture').FlowPath} FlowPath */ -/** @typedef {import('@18f/identity-i18n').I18n} I18n */ - -/** - * @typedef LoginGov - * - * @prop {Record} assets - */ - -/** - * @typedef LoginGovGlobals - * - * @prop {LoginGov} LoginGov - */ - -/** - * @typedef {typeof window & LoginGovGlobals} DocumentCaptureGlobal - */ +import type { FlowPath, DeviceContextValue } from '@18f/identity-document-capture'; /** - * @typedef AppRootData - * - * @prop {string} helpCenterRedirectUrl - * @prop {string} appName - * @prop {string} maxCaptureAttemptsBeforeTips - * @prop {string} maxAttemptsBeforeNativeCamera - * @prop {FlowPath} flowPath - * @prop {string} cancelUrl - * @prop {string=} idvInPersonUrl - * @prop {string} securityAndPrivacyHowItWorksUrl - * * @see AppContext * @see MarketingSiteContextProvider * @see FailedCaptureAttemptsContext * @see UploadContext */ +interface AppRootData { + helpCenterRedirectUrl: string; + appName: string; + maxCaptureAttemptsBeforeTips: string; + maxAttemptsBeforeNativeCamera: string; + flowPath: FlowPath; + cancelUrl: string; + idvInPersonUrl?: string; + securityAndPrivacyHowItWorksUrl: string; +} -const appRoot = /** @type {HTMLDivElement} */ (document.getElementById('document-capture-form')); +const appRoot = document.getElementById('document-capture-form')!; const isMockClient = appRoot.hasAttribute('data-mock-client'); -const keepAliveEndpoint = /** @type {string} */ (appRoot.getAttribute('data-keep-alive-endpoint')); +const keepAliveEndpoint = appRoot.getAttribute('data-keep-alive-endpoint')!; const glareThreshold = Number(appRoot.getAttribute('data-glare-threshold')) ?? undefined; const sharpnessThreshold = Number(appRoot.getAttribute('data-sharpness-threshold')) ?? undefined; @@ -65,10 +46,7 @@ function getServiceProvider() { return { name, failureToProofURL, isLivenessRequired }; } -/** - * @return {Record<'front'|'back'|'selfie', string>} - */ -function getBackgroundUploadURLs() { +function getBackgroundUploadURLs(): Record<'front' | 'back' | 'selfie', string> { return ['front', 'back', 'selfie'].reduce((result, key) => { const url = appRoot.getAttribute(`data-${key}-image-upload-url`); if (url) { @@ -76,34 +54,27 @@ function getBackgroundUploadURLs() { } return result; - }, /** @type {Record<'front'|'back'|'selfie', string>} */ ({})); + }, {} as Record<'front' | 'back' | 'selfie', string>); } -/** - * @return {string?} - */ -function getMetaContent(name) { - const meta = /** @type {HTMLMetaElement?} */ (document.querySelector(`meta[name="${name}"]`)); +function getMetaContent(name): string | null { + const meta = document.querySelector(`meta[name="${name}"]`); return meta?.content ?? null; } -/** @type {import('@18f/identity-document-capture/context/device').DeviceContext} */ -const device = { - isMobile: isCameraCapableMobile(), -}; +const device: DeviceContextValue = { isMobile: isCameraCapableMobile() }; -/** @type {import('@18f/identity-analytics').trackEvent} */ -function trackEvent(event, payload) { +const trackEvent: typeof baseTrackEvent = (event, payload) => { const { flowPath } = appRoot.dataset; return baseTrackEvent(event, { ...payload, flow_path: flowPath }); -} +}; (async () => { const backgroundUploadURLs = getBackgroundUploadURLs(); const isAsyncForm = Object.keys(backgroundUploadURLs).length > 0; const csrf = getMetaContent('csrf-token'); - const formData = { + const formData: Record = { document_capture_session_uuid: appRoot.getAttribute('data-document-capture-session-uuid'), locale: document.documentElement.lang, }; @@ -127,7 +98,7 @@ function trackEvent(event, payload) { const keepAlive = () => window.fetch(keepAliveEndpoint, { method: 'POST', - headers: /** @type {string[][]} */ ([csrf && ['X-CSRF-Token', csrf]].filter(Boolean)), + headers: [csrf && ['X-CSRF-Token', csrf]].filter(Boolean) as [string, string][], }); const { @@ -139,7 +110,7 @@ function trackEvent(event, payload) { cancelUrl: cancelURL, idvInPersonUrl: inPersonURL, securityAndPrivacyHowItWorksUrl: securityAndPrivacyHowItWorksURL, - } = /** @type {AppRootData} */ (appRoot.dataset); + } = appRoot.dataset as DOMStringMap & AppRootData; const App = composeComponents( [AppContext.Provider, { value: { appName } }], diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb index 988ad3ed92d..a43c6a0bf12 100644 --- a/app/jobs/get_usps_proofing_results_job.rb +++ b/app/jobs/get_usps_proofing_results_job.rb @@ -18,49 +18,85 @@ class GetUspsProofingResultsJob < ApplicationJob discard_on GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError + def enrollment_analytics_attributes(enrollment, complete:) + { + enrollment_code: enrollment.enrollment_code, + enrollment_id: enrollment.id, + minutes_since_last_status_check: enrollment.minutes_since_last_status_check, + minutes_since_last_status_update: enrollment.minutes_since_last_status_update, + minutes_to_completion: complete ? enrollment.minutes_since_established : nil, + } + end + def perform(_now) return true unless IdentityConfig.store.in_person_proofing_enabled - proofer = UspsInPersonProofing::Proofer.new - + @enrollment_outcomes = { + enrollments_checked: 0, + enrollments_errored: 0, + enrollments_expired: 0, + enrollments_failed: 0, + enrollments_in_progress: 0, + enrollments_passed: 0, + } reprocess_delay_minutes = IdentityConfig.store. get_usps_proofing_results_job_reprocess_delay_minutes - InPersonEnrollment.needs_usps_status_check( + enrollments = InPersonEnrollment.needs_usps_status_check( ...reprocess_delay_minutes.minutes.ago, - ).each do |enrollment| - # Record and commit attempt to check enrollment status to database - enrollment.update(status_check_attempted_at: Time.zone.now) + ) + started_at = Time.zone.now + analytics.idv_in_person_usps_proofing_results_job_started( + enrollments_count: enrollments.count, + reprocess_delay_minutes: reprocess_delay_minutes, + ) + + check_enrollments(enrollments) + + analytics.idv_in_person_usps_proofing_results_job_completed( + **enrollment_outcomes, + duration_seconds: (Time.zone.now - started_at).seconds.round(2), + ) + + true + end + + private + + attr_accessor :enrollment_outcomes + + DEFAULT_EMAIL_DELAY_IN_HOURS = 1 + + def check_enrollments(enrollments) + proofer = UspsInPersonProofing::Proofer.new + + enrollments.each do |enrollment| + # Add a unique ID for enrollments that don't have one enrollment.update(unique_id: enrollment.usps_unique_id) if enrollment.unique_id.blank? + + status_check_attempted_at = Time.zone.now + enrollment_outcomes[:enrollments_checked] += 1 response = nil + errored = true begin response = proofer.request_proofing_results( enrollment.unique_id, enrollment.enrollment_code ) + errored = false rescue Faraday::BadRequestError => err handle_bad_request_error(err, enrollment) - next rescue StandardError => err handle_standard_error(err, enrollment) - next end - unless response.is_a?(Hash) - handle_response_is_not_a_hash(enrollment) - next - end + process_enrollment_response(enrollment, response) unless errored - update_enrollment_status(enrollment, response) + # Record the attempt to update the enrollment + enrollment.update(status_check_attempted_at: status_check_attempted_at) end - - true end - private - - DEFAULT_EMAIL_DELAY_IN_HOURS = 1 - def analytics(user: AnonymousUser.new) Analytics.new(user: user, request: nil, session: {}, sp: nil) end @@ -69,96 +105,118 @@ def handle_bad_request_error(err, enrollment) case err.response&.[](:body)&.[]('responseMessage') when IPP_INCOMPLETE_ERROR_MESSAGE # Customer has not been to post office for IPP + enrollment_outcomes[:enrollments_in_progress] += 1 when IPP_EXPIRED_ERROR_MESSAGE - # Customer's IPP enrollment has expired - enrollment.update(status: :expired) + handle_expired_status_update(enrollment) else analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( + **enrollment_analytics_attributes(enrollment, complete: false), reason: 'Request exception', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, exception_class: err.class.to_s, exception_message: err.message, ) + enrollment_outcomes[:enrollments_errored] += 1 end end def handle_standard_error(err, enrollment) + enrollment_outcomes[:enrollments_errored] += 1 analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( + **enrollment_analytics_attributes(enrollment, complete: false), reason: 'Request exception', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, exception_class: err.class.to_s, exception_message: err.message, ) end def handle_response_is_not_a_hash(enrollment) + enrollment_outcomes[:enrollments_errored] += 1 analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( + **enrollment_analytics_attributes(enrollment, complete: false), reason: 'Bad response structure', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, ) end def handle_unsupported_status(enrollment, status) - analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_failure( + enrollment_outcomes[:enrollments_errored] += 1 + analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( + **enrollment_analytics_attributes(enrollment, complete: false), reason: 'Unsupported status', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, status: status, ) end - def handle_unsupported_id_type(enrollment, primary_id_type) - analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_failure( + def handle_unsupported_id_type(enrollment, response) + enrollment_outcomes[:enrollments_failed] += 1 + analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_updated( + **enrollment_analytics_attributes(enrollment, complete: true), + fraud_suspected: response['fraudSuspected'], + passed: false, + primary_id_type: response['primaryIdType'], reason: 'Unsupported ID type', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, - primary_id_type: primary_id_type, ) + enrollment.update(status: :failed) + end + + def handle_expired_status_update(enrollment) + enrollment_outcomes[:enrollments_expired] += 1 + analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_updated( + **enrollment_analytics_attributes(enrollment, complete: true), + fraud_suspected: nil, + passed: false, + reason: 'Enrollment has expired', + ) + enrollment.update(status: :expired) end def handle_failed_status(enrollment, response) - analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_failure( - reason: 'Failed status', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, + enrollment_outcomes[:enrollments_failed] += 1 + analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_updated( + **enrollment_analytics_attributes(enrollment, complete: true), failure_reason: response['failureReason'], fraud_suspected: response['fraudSuspected'], + passed: false, primary_id_type: response['primaryIdType'], proofing_state: response['proofingState'], + reason: 'Failed status', secondary_id_type: response['secondaryIdType'], transaction_end_date_time: response['transactionEndDateTime'], transaction_start_date_time: response['transactionStartDateTime'], ) + + enrollment.update(status: :failed) + send_failed_email(enrollment.user, enrollment) end - def handle_successful_status_update(enrollment) - analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_success( + def handle_successful_status_update(enrollment, response) + enrollment_outcomes[:enrollments_passed] += 1 + analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_updated( + **enrollment_analytics_attributes(enrollment, complete: true), + fraud_suspected: response['fraudSuspected'], + passed: true, reason: 'Successful status update', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, ) + enrollment.profile.activate + enrollment.update(status: :passed) + send_verified_email(enrollment.user, enrollment) end - def update_enrollment_status(enrollment, response) + def process_enrollment_response(enrollment, response) + unless response.is_a?(Hash) + handle_response_is_not_a_hash(enrollment) + return + end + case response['status'] when IPP_STATUS_PASSED if SUPPORTED_ID_TYPES.include?(response['primaryIdType']) - enrollment.profile.activate - enrollment.update(status: :passed) - handle_successful_status_update(enrollment) - send_verified_email(enrollment.user, enrollment) + handle_successful_status_update(enrollment, response) else # Unsupported ID type - enrollment.update(status: :failed) - handle_unsupported_id_type(enrollment, response['primaryIdType']) + handle_unsupported_id_type(enrollment, response) end when IPP_STATUS_FAILED - enrollment.update(status: :failed) handle_failed_status(enrollment, response) - send_failed_email(enrollment.user, enrollment) else handle_unsupported_status(enrollment, response['status']) end diff --git a/app/jobs/reports/combined_invoice_supplement_report.rb b/app/jobs/reports/combined_invoice_supplement_report.rb index 4c8a1b4a2d7..3cedf95f830 100644 --- a/app/jobs/reports/combined_invoice_supplement_report.rb +++ b/app/jobs/reports/combined_invoice_supplement_report.rb @@ -48,7 +48,7 @@ def perform(_date) def combine_by_iaa_month(by_iaa_results:, by_issuer_results:) by_iaa_and_year_month = by_iaa_results.group_by do |result| - [ result[:key], result[:year_month] ] + [result[:key], result[:year_month]] end by_issuer_iaa_issuer_year_months = by_issuer_results. diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index 50cd47406e3..c3e3cc450ea 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -20,6 +20,7 @@ def perform( user_id: nil, threatmetrix_session_id: nil, request_ip: nil, + issuer: nil, dob_year_only: nil # rubocop:disable Lint:UnusedMethodArgument ) timer = JobHelpers::Timer.new @@ -40,6 +41,7 @@ def perform( user: user, threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, + issuer: issuer, ) callback_log_data = proof_lexisnexis_then_aamva( @@ -102,9 +104,11 @@ def proof_lexisnexis_ddp_with_threatmetrix_if_needed( applicant_pii:, user:, threatmetrix_session_id:, - request_ip: + request_ip:, + issuer: ) return unless IdentityConfig.store.lexisnexis_threatmetrix_enabled + return unless issuer_allows_threatmetrix?(issuer) # The API call will fail without a session ID, so do not attempt to make # it to avoid leaking data when not required. @@ -244,4 +248,9 @@ def add_threatmetrix_proofing_component(user_id, threatmetrix_result) update(threatmetrix: true, threatmetrix_review_status: threatmetrix_result.review_status) end + + def issuer_allows_threatmetrix?(issuer) + return IdentityConfig.store.no_sp_device_profiling_enabled if issuer.blank? + ServiceProvider.find_by(issuer: issuer)&.device_profiling_enabled + end end diff --git a/app/jobs/risc_delivery_job.rb b/app/jobs/risc_delivery_job.rb index 16e6ba5fe25..feffaceea2e 100644 --- a/app/jobs/risc_delivery_job.rb +++ b/app/jobs/risc_delivery_job.rb @@ -18,7 +18,7 @@ class RiscDeliveryJob < ApplicationJob attempts: 10 def self.warning_error_classes - NETWORK_ERRORS + [ RedisRateLimiter::LimitError ] + NETWORK_ERRORS + [RedisRateLimiter::LimitError] end def perform( diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 5bed758dfb2..6e0ef65b0a7 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -49,6 +49,8 @@ def reset_password_instructions(user, email, token:) with_user_locale(user) do @locale = locale_url_param @token = token + @pending_profile_requires_verification = user.decorate.pending_profile_requires_verification? + @hide_title = @pending_profile_requires_verification mail(to: email, subject: t('user_mailer.reset_password_instructions.subject')) end end diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index d7841fc2ca5..f897f6be30a 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -20,6 +20,7 @@ class InPersonEnrollment < ApplicationRecord validate :profile_belongs_to_user before_save(:on_status_updated, if: :will_save_change_to_status?) + before_create(:set_unique_id, unless: :unique_id) # Find enrollments that need a status check via the USPS API def self.needs_usps_status_check(check_interval) @@ -39,6 +40,21 @@ def needs_usps_status_check?(check_interval) ) end + def minutes_since_established + return unless enrollment_established_at.present? + (Time.zone.now - enrollment_established_at).seconds.in_minutes.round(2) + end + + def minutes_since_last_status_check + return unless status_check_attempted_at.present? + (Time.zone.now - status_check_attempted_at).seconds.in_minutes.round(2) + end + + def minutes_since_last_status_update + return unless status_updated_at.present? + (Time.zone.now - status_updated_at).seconds.in_minutes.round(2) + end + # (deprecated) Returns the value to use for the USPS enrollment ID def usps_unique_id user.uuid.delete('-').slice(0, 18) @@ -55,6 +71,10 @@ def on_status_updated self.status_updated_at = Time.zone.now end + def set_unique_id + self.unique_id = self.class.generate_unique_id + end + def profile_belongs_to_user return unless profile.present? diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 0e03f4986bd..953a7ad58ce 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -495,6 +495,11 @@ def idv_come_back_later_visit track_event('IdV: come back later visited') end + # The user clicked the troubleshooting option to start in-person proofing + def idv_verify_in_person_troubleshooting_option_clicked + track_event('IdV: verify in person troubleshooting option clicked') + end + # The user visited the "ready to verify" page for the in person proofing flow def idv_in_person_ready_to_verify_visit track_event('IdV: in person ready to verify visited') @@ -2381,69 +2386,121 @@ def user_registration_email_confirmation( ) end - # Tracks exceptions that are raised when running GetUspsProofingResultsJob - # @param [String] reason why was the exception raised? - # @param [String] enrollment_id + # Tracks if USPS in-person proofing enrollment request fails + # @param [String] context + # @param [String] reason + # @param [Integer] enrollment_id # @param [String] exception_class # @param [String] exception_message - def idv_in_person_usps_proofing_results_job_exception( - reason:, enrollment_id:, exception_class: nil, exception_message: nil, **extra + def idv_in_person_usps_request_enroll_exception( + context:, + reason:, + enrollment_id:, + exception_class:, + exception_message:, + **extra ) track_event( - 'GetUspsProofingResultsJob: Exception raised', - reason: reason, + 'USPS IPPaaS enrollment failed', + context: context, enrollment_id: enrollment_id, exception_class: exception_class, exception_message: exception_message, + reason: reason, **extra, ) end - # Tracks individual enrollments that fail during GetUspsProofingResultsJob - # @param [String] reason why did this enrollment fail? - # @param [String] enrollment_id - def idv_in_person_usps_proofing_results_job_enrollment_failure(reason:, enrollment_id:, **extra) + # GetUspsProofingResultsJob is beginning. Includes some metadata about what the job will do + # @param [Integer] enrollments_count number of enrollments eligible for status check + # @param [Integer] reprocess_delay_minutes minimum delay since last status check + def idv_in_person_usps_proofing_results_job_started( + enrollments_count:, + reprocess_delay_minutes:, + **extra + ) track_event( - 'GetUspsProofingResultsJob: Enrollment failed proofing', - reason: reason, - enrollment_id: enrollment_id, + 'GetUspsProofingResultsJob: Job started', + enrollments_count: enrollments_count, + reprocess_delay_minutes: reprocess_delay_minutes, + **extra, + ) + end + + # GetUspsProofingResultsJob has completed. Includes counts of various outcomes encountered + # @param [Float] duration_seconds number of minutes the job was running + # @param [Integer] enrollments_checked number of enrollments eligible for status check + # @param [Integer] enrollments_errored number of enrollments for which we encountered an error + # @param [Integer] enrollments_expired number of enrollments which expired + # @param [Integer] enrollments_failed number of enrollments which failed identity proofing + # @param [Integer] enrollments_in_progress number of enrollments which did not have any change + # @param [Integer] enrollments_passed number of enrollments which passed identity proofing + def idv_in_person_usps_proofing_results_job_completed( + duration_seconds:, + enrollments_checked:, + enrollments_errored:, + enrollments_expired:, + enrollments_failed:, + enrollments_in_progress:, + enrollments_passed:, + **extra + ) + track_event( + 'GetUspsProofingResultsJob: Job completed', + duration_seconds: duration_seconds, + enrollments_checked: enrollments_checked, + enrollments_errored: enrollments_errored, + enrollments_expired: enrollments_expired, + enrollments_failed: enrollments_failed, + enrollments_in_progress: enrollments_in_progress, + enrollments_passed: enrollments_passed, **extra, ) end - # Tracks if USPS in-person proofing enrollment request fails - # @param [String] context - # @param [String] reason - # @param [Integer] enrollment_id + # Tracks exceptions that are raised when running GetUspsProofingResultsJob + # @param [String] reason why was the exception raised? + # @param [String] enrollment_id # @param [String] exception_class # @param [String] exception_message - def idv_in_person_usps_request_enroll_exception( - context:, + def idv_in_person_usps_proofing_results_job_exception( reason:, enrollment_id:, - exception_class:, - exception_message:, + exception_class: nil, + exception_message: nil, **extra ) track_event( - 'USPS IPPaaS enrollment failed', - context: context, + 'GetUspsProofingResultsJob: Exception raised', + reason: reason, enrollment_id: enrollment_id, exception_class: exception_class, exception_message: exception_message, - reason: reason, **extra, ) end - # Tracks individual enrollments that succeed during GetUspsProofingResultsJob - # @param [String] reason why did this enrollment pass? + # Tracks individual enrollments that are updated during GetUspsProofingResultsJob + # @param [String] enrollment_code # @param [String] enrollment_id - def idv_in_person_usps_proofing_results_job_enrollment_success(reason:, enrollment_id:, **extra) + # @param [Boolean] fraud_suspected + # @param [Boolean] passed did this enrollment pass or fail? + # @param [String] reason why did this enrollment pass or fail? + def idv_in_person_usps_proofing_results_job_enrollment_updated( + enrollment_code:, + enrollment_id:, + fraud_suspected:, + passed:, + reason:, + **extra + ) track_event( - 'GetUspsProofingResultsJob: Enrollment passed proofing', - reason: reason, + 'GetUspsProofingResultsJob: Enrollment status updated', + enrollment_code: enrollment_code, enrollment_id: enrollment_id, + fraud_suspected: fraud_suspected, + passed: passed, + reason: reason, **extra, ) end diff --git a/app/services/db/proofing_cost/add_user_proofing_cost.rb b/app/services/db/proofing_cost/add_user_proofing_cost.rb index fa7d00d35f3..e818ac6b535 100644 --- a/app/services/db/proofing_cost/add_user_proofing_cost.rb +++ b/app/services/db/proofing_cost/add_user_proofing_cost.rb @@ -13,6 +13,7 @@ class ProofingCostTypeError < StandardError; end lexis_nexis_address gpo_letter phone_otp + threatmetrix ].freeze def self.call(user_id, token) diff --git a/app/services/db/sp_cost/add_sp_cost.rb b/app/services/db/sp_cost/add_sp_cost.rb index 6c4ef015e18..fe798e4e130 100644 --- a/app/services/db/sp_cost/add_sp_cost.rb +++ b/app/services/db/sp_cost/add_sp_cost.rb @@ -12,6 +12,7 @@ class SpCostTypeError < StandardError; end lexis_nexis_resolution lexis_nexis_address gpo_letter + threatmetrix ].freeze def self.call(service_provider, ial, token, transaction_id: nil, user: nil) diff --git a/app/services/idv/agent.rb b/app/services/idv/agent.rb index 87fc65ab2a9..e0356c89ffe 100644 --- a/app/services/idv/agent.rb +++ b/app/services/idv/agent.rb @@ -10,7 +10,8 @@ def proof_resolution( trace_id:, user_id:, threatmetrix_session_id:, - request_ip: + request_ip:, + issuer: ) document_capture_session.create_proofing_session @@ -27,6 +28,7 @@ def proof_resolution( user_id: user_id, threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, + issuer: issuer, } if IdentityConfig.store.ruby_workers_idv_enabled diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb index 9d85c05d4cf..984de98abef 100644 --- a/app/services/idv/flows/doc_auth_flow.rb +++ b/app/services/idv/flows/doc_auth_flow.rb @@ -22,6 +22,14 @@ class DocAuthFlow < Flow::BaseFlow { name: :secure_account }, ].freeze + STEP_INDICATOR_STEPS_GPO = [ + { name: :getting_started }, + { name: :verify_id }, + { name: :verify_info }, + { name: :secure_account }, + { name: :get_a_letter }, + ].freeze + OPTIONAL_SHOW_STEPS = { verify_wait: Idv::Steps::VerifyWaitStepShow, }.freeze diff --git a/app/services/idv/flows/in_person_flow.rb b/app/services/idv/flows/in_person_flow.rb index 50ee75e7a3c..e5e33831da0 100644 --- a/app/services/idv/flows/in_person_flow.rb +++ b/app/services/idv/flows/in_person_flow.rb @@ -24,10 +24,6 @@ class InPersonFlow < Flow::BaseFlow redo_ssn: Idv::Actions::RedoSsnAction, }.freeze - # WILLFIX: (LG-6308) move this to the barcode page when - # we finish setting up IPP step indicators - # i18n-tasks-use t('step_indicator.flows.idv.go_to_the_post_office') - STEP_INDICATOR_STEPS = [ { name: :find_a_post_office }, { name: :verify_info }, @@ -36,6 +32,14 @@ class InPersonFlow < Flow::BaseFlow { name: :go_to_the_post_office }, ].freeze + STEP_INDICATOR_STEPS_GPO = [ + { name: :find_a_post_office }, + { name: :verify_info }, + { name: :secure_account }, + { name: :get_a_letter }, + { name: :go_to_the_post_office }, + ].freeze + def initialize(controller, session, name) @idv_session = self.class.session_idv(session) super(controller, STEPS, ACTIONS, session[name]) diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index 04733adbebe..d3111e912b5 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -130,6 +130,11 @@ def verify_step_document_capture_session_uuid_key :idv_verify_step_document_capture_session_uuid end + def service_provider_device_profiling_enabled? + return IdentityConfig.store.no_sp_device_profiling_enabled if sp_session[:issuer].blank? + ServiceProvider.find_by(issuer: sp_session[:issuer])&.device_profiling_enabled + end + def track_document_state(state) return unless IdentityConfig.store.state_tracking_enabled && state doc_auth_log = DocAuthLog.find_by(user_id: user_id) diff --git a/app/services/idv/steps/ipp/ssn_step.rb b/app/services/idv/steps/ipp/ssn_step.rb index 75f30b74f24..0d1b211261b 100644 --- a/app/services/idv/steps/ipp/ssn_step.rb +++ b/app/services/idv/steps/ipp/ssn_step.rb @@ -18,19 +18,26 @@ def call def extra_view_variables { updating_ssn: updating_ssn, + threatmetrix_session_id: generate_threatmetrix_session_id, } end private - def ssn - flow_params[:ssn] - end - def form_submit Idv::SsnFormatForm.new(current_user).submit(permit(:ssn)) end + def generate_threatmetrix_session_id + return unless service_provider_device_profiling_enabled? + flow_session[:threatmetrix_session_id] = SecureRandom.uuid if !updating_ssn + flow_session[:threatmetrix_session_id] + end + + def ssn + flow_params[:ssn] + end + def updating_ssn flow_session.dig(:pii_from_user, :ssn).present? end diff --git a/app/services/idv/steps/ssn_step.rb b/app/services/idv/steps/ssn_step.rb index e2518ea0525..e959254601b 100644 --- a/app/services/idv/steps/ssn_step.rb +++ b/app/services/idv/steps/ssn_step.rb @@ -7,6 +7,7 @@ def call return invalid_state_response if invalid_state? flow_session[:pii_from_doc][:ssn] = ssn + @flow.irs_attempts_api_tracker.idv_ssn_submitted( success: true, ssn: ssn, @@ -28,6 +29,17 @@ def form_submit Idv::SsnFormatForm.new(current_user).submit(permit(:ssn)) end + def invalid_state_response + mark_step_incomplete(:document_capture) + FormResponse.new(success: false) + end + + def generate_threatmetrix_session_id + return unless service_provider_device_profiling_enabled? + flow_session[:threatmetrix_session_id] = SecureRandom.uuid if !updating_ssn + flow_session[:threatmetrix_session_id] + end + def ssn flow_params[:ssn] end @@ -39,17 +51,6 @@ def invalid_state? def updating_ssn flow_session.dig(:pii_from_doc, :ssn).present? end - - def invalid_state_response - mark_step_incomplete(:document_capture) - FormResponse.new(success: false) - end - - def generate_threatmetrix_session_id - return unless IdentityConfig.store.proofing_device_profiling_collecting_enabled - flow_session[:threatmetrix_session_id] = SecureRandom.uuid if !updating_ssn - flow_session[:threatmetrix_session_id] - end end end end diff --git a/app/services/idv/steps/upload_step.rb b/app/services/idv/steps/upload_step.rb index b473984701d..9caac138b77 100644 --- a/app/services/idv/steps/upload_step.rb +++ b/app/services/idv/steps/upload_step.rb @@ -6,6 +6,8 @@ class UploadStep < DocAuthBaseStep def call @flow.irs_attempts_api_tracker.document_upload_method_selected(upload_method: params[:type]) + # See the simple_form_for in + # app/views/idv/doc_auth/upload.html.erb if params[:type] == 'desktop' handle_desktop_selection else @@ -65,7 +67,9 @@ def bypass_send_link_steps end def mobile_device? - BrowserCache.parse(request.user_agent).mobile? + # See app/javascript/packs/document-capture-welcome.js + # And app/services/idv/steps/agreement_step.rb + !!flow_session[:skip_upload_step] end def form_response(destination:) diff --git a/app/services/idv/steps/verify_base_step.rb b/app/services/idv/steps/verify_base_step.rb index 90cbfb54591..b393cc1b704 100644 --- a/app/services/idv/steps/verify_base_step.rb +++ b/app/services/idv/steps/verify_base_step.rb @@ -49,10 +49,12 @@ def add_proofing_costs(results) if stage == :resolution # transaction_id comes from ConversationId add_cost(:lexis_nexis_resolution, transaction_id: hash[:transaction_id]) - tmx_id = hash[:threatmetrix_request_id] - add_cost(:threatmetrix, transaction_id: tmx_id) if tmx_id elsif stage == :state_id process_aamva(hash[:transaction_id]) + elsif stage == :threatmetrix + # transaction_id comes from request_id + tmx_id = hash[:transaction_id] + add_cost(:threatmetrix, transaction_id: tmx_id) if tmx_id end end end @@ -181,6 +183,7 @@ def enqueue_job user_id: user_id, threatmetrix_session_id: flow_session[:threatmetrix_session_id], request_ip: request.remote_ip, + issuer: sp_session[:issuer], ) end @@ -224,6 +227,20 @@ def async_state_done(current_async_state) pii_like_keypaths: [[:errors, :ssn]], }, ) + pii_from_doc = pii || {} + @flow.irs_attempts_api_tracker.idv_verification_submitted( + success: form_response.success?, + document_state: pii_from_doc[:state], + document_number: pii_from_doc[:state_id_number], + document_issued: pii_from_doc[:state_id_issued], + document_expiration: pii_from_doc[:state_id_expiration], + first_name: pii_from_doc[:first_name], + last_name: pii_from_doc[:last_name], + date_of_birth: pii_from_doc[:dob], + address: pii_from_doc[:address1], + ssn: pii_from_doc[:ssn], + failure_reason: form_response.errors&.presence, + ) if form_response.success? response = check_ssn diff --git a/app/services/irs_attempts_api/tracker_events.rb b/app/services/irs_attempts_api/tracker_events.rb index 42ce12971f8..b2cf10f53cd 100644 --- a/app/services/irs_attempts_api/tracker_events.rb +++ b/app/services/irs_attempts_api/tracker_events.rb @@ -130,19 +130,26 @@ def idv_document_upload_submitted( ) end - # Tracks when a user submits OTP code sent to their phone + # Tracks when the user submits their idv phone number # @param [String] phone_number # param [Boolean] success # @param [Hash>] failure_reason - def idv_phone_otp_submitted(phone_number:, success:, failure_reason: nil) + def idv_phone_submitted(phone_number:, success:, failure_reason: nil) track_event( - :idv_phone_otp_submitted, + :idv_phone_submitted, phone_number: phone_number, success: success, failure_reason: failure_reason, ) end + # Tracks Idv phone OTP sent rate limits + def idv_phone_otp_sent_rate_limited + track_event( + :idv_phone_otp_sent_rate_limited, + ) + end + # The user has exceeded the rate limit during idv document upload def idv_document_upload_rate_limited track_event( @@ -150,6 +157,33 @@ def idv_document_upload_rate_limited ) end + # Tracks when the user submits a password for identity proofing + # @param [Boolean] success + def idv_password_entered(success:) + track_event( + :idv_password_entered, + success: success, + ) + end + + # param [Boolean] Success + # param [Hash>] failure_reason displays GPO submission failed + # GPO verification submitted from Letter sent to verify address + def idv_gpo_verification_submitted(success:, failure_reason:) + track_event( + :idv_gpo_verification_submitted, + success: success, + failure_reason: failure_reason, + ) + end + + # GPO verification submission throttled, user entered in too many invalid gpo letter codes + def idv_gpo_verification_throttled + track_event( + :idv_gpo_verification_throttled, + ) + end + # @param [Boolean] success # @param [String] resend # The Address validation letter has been requested by user @@ -161,9 +195,63 @@ def idv_letter_requested(success:, resend:) ) end + # @param [Boolean] success + # Personal Key got generated for user + def idv_personal_key_generated(success:) + track_event( + :idv_personal_key_generated, + success: success, + ) + end + # @param [Boolean] success # @param [String] phone_number - # The phone upload link was sent during the IDV process + # @param [String] otp_delivery_method + # @param [Hash>] failure_reason + # Track when OTP is sent and what method chosen during idv flow. + def idv_phone_confirmation_otp_sent(success:, phone_number:, + otp_delivery_method:, failure_reason:) + track_event( + :idv_phone_confirmation_otp_sent, + success: success, + phone_number: phone_number, + otp_delivery_method: otp_delivery_method, + failure_reason: failure_reason, + ) + end + + # Track when OTP phone sent is rate limited during idv flow + def idv_phone_confirmation_otp_sent_rate_limited + track_event( + :idv_phone_confirmation_otp_sent_rate_limited, + ) + end + + # Tracks when a user submits OTP code sent to their phone + # @param [String] phone_number + # param [Boolean] success + # @param [Hash>] failure_reason + def idv_phone_otp_submitted(phone_number:, success:, failure_reason: nil) + track_event( + :idv_phone_otp_submitted, + phone_number: phone_number, + success: success, + failure_reason: failure_reason, + ) + end + + # The user reached the rate limit for Idv phone OTP submitted + # @param [String] phone + def idv_phone_otp_submitted_rate_limited(phone:) + track_event( + :idv_phone_otp_submitted_rate_limited, + phone: phone, + ) + end + + # @param [Boolean] success + # @param [String] phone_number + # The phone number that the link was sent to during the IDV process # @param [Hash>] failure_reason def idv_phone_upload_link_sent( success:, @@ -178,6 +266,13 @@ def idv_phone_upload_link_sent( ) end + # The user has used a phone_upload_link to upload docs on their mobile device + def idv_phone_upload_link_used + track_event( + :idv_phone_upload_link_used, + ) + end + # @param [Boolean] success # @param [String] ssn # User entered in SSN number during Identity verification @@ -189,6 +284,46 @@ def idv_ssn_submitted(success:, ssn:) ) end + # @param [Boolean] success + # @param [String] document_state + # @param [String] document_number + # @param [String] document_issued + # @param [String] document_expiration + # @param [String] first_name + # @param [String] last_name + # @param [String] date_of_birth + # @param [String] address + # @param [Hash>] failure_reason + # The verification was submitted during the IDV process + def idv_verification_submitted( + success:, + document_state: nil, + document_number: nil, + document_issued: nil, + document_expiration: nil, + first_name: nil, + last_name: nil, + date_of_birth: nil, + address: nil, + ssn: nil, + failure_reason: nil + ) + track_event( + :idv_verification_submitted, + success: success, + document_state: document_state, + document_number: document_number, + document_issued: document_issued, + document_expiration: document_expiration, + first_name: first_name, + last_name: last_name, + date_of_birth: date_of_birth, + address: address, + ssn: ssn, + failure_reason: failure_reason, + ) + end + # @param [Boolean] success True if the email and password matched # A user has initiated a logout event def logout_initiated(success:) diff --git a/app/services/phone_number_capabilities.rb b/app/services/phone_number_capabilities.rb index 6dd35b65099..cd63da50ab7 100644 --- a/app/services/phone_number_capabilities.rb +++ b/app/services/phone_number_capabilities.rb @@ -10,12 +10,17 @@ def self.load_config attr_reader :phone, :phone_confirmed def self.translated_international_codes - translated_international_codes_data = {} - INTERNATIONAL_CODES.each do |k, value| - translated_international_codes_data[k] = - value.merge('name' => I18n.t("countries.#{k.downcase}")) + @translated_intl_codes_data = nil if Rails.env.development? + return @translated_intl_codes_data[I18n.locale] if @translated_intl_codes_data + + @translated_intl_codes_data = Hash.new { |h, k| h[k] = {} } + I18n.available_locales.each do |locale| + INTERNATIONAL_CODES.each do |k, value| + @translated_intl_codes_data[locale][k] = + value.merge('name' => I18n.t("countries.#{k.downcase}", locale: locale)) + end end - translated_international_codes_data + @translated_intl_codes_data[I18n.locale] end def initialize(phone, phone_confirmed:) diff --git a/app/services/proofing/lexis_nexis/ddp/response_redacter.rb b/app/services/proofing/lexis_nexis/ddp/response_redacter.rb index fa584f1e399..4940af000f3 100644 --- a/app/services/proofing/lexis_nexis/ddp/response_redacter.rb +++ b/app/services/proofing/lexis_nexis/ddp/response_redacter.rb @@ -10,6 +10,12 @@ class ResponseRedacter account_email_result account_email_score account_email_worst_score + account_address_assert_history + account_address_country + account_address_first_seen + account_address_last_event + account_address_last_update + account_address_score account_address_state account_lex_id account_lex_id_first_seen @@ -37,6 +43,13 @@ class ResponseRedacter bb_fraud_rating bb_fraud_score champion_request_duration + device_first_seen + device_id_confidence + device_last_event + device_last_update + device_match_result + device_score + device_worst_score digital_id digital_id_confidence digital_id_confidence_rating @@ -50,6 +63,10 @@ class ResponseRedacter digital_id_trust_score_summary_reason_code emailage.emailriskscore.billriskcountry emailage.emailriskscore.correlationid + emailage.emailriskscore.domain_creation_days + emailage.emailriskscore.domainage + emailage.emailriskscore.domaincategory + emailage.emailriskscore.domaincountrymatch emailage.emailriskscore.domainexists emailage.emailriskscore.domainrelevantinfo emailage.emailriskscore.domainrelevantinfoid @@ -65,10 +82,16 @@ class ResponseRedacter emailage.emailriskscore.eascore emailage.emailriskscore.eastatusid emailage.emailriskscore.emailexists + emailage.emailriskscore.emailtobilladdressconfidence + emailage.emailriskscore.emailtoipconfidence emailage.emailriskscore.first_seen_days emailage.emailriskscore.firstverificationdate emailage.emailriskscore.fraudrisk + emailage.emailriskscore.ip_risklevel + emailage.emailriskscore.ip_riskreason + emailage.emailriskscore.iptobilladdressconfidence emailage.emailriskscore.namematch + emailage.emailriskscore.overalldigitalidentityscore emailage.emailriskscore.phone_status emailage.emailriskscore.responsestatus.errorcode emailage.emailriskscore.responsestatus.status @@ -92,13 +115,42 @@ class ResponseRedacter fraudpoint.synthetic_identity_index fraudpoint.transaction_status fraudpoint.vulnerable_victim_index - integration_hub_results + fuzzy_device_first_seen + fuzzy_device_id_confidence + fuzzy_device_last_event + fuzzy_device_last_update + fuzzy_device_match_result + fuzzy_device_result + fuzzy_device_score + fuzzy_device_worst_score + http_connection_type + http_referer_domain + input_ip_assert_history + input_ip_connection_type + input_ip_first_seen + input_ip_last_event + input_ip_last_update + input_ip_score + input_ip_worst_score + national_id_first_seen + national_id_last_event + national_id_last_update + national_id_score + national_id_type + national_id_worst_score org_id policy policy_details_api policy_engine_version policy_score + page_time_on primary_industry + private_browsing + profile_api_timedelta + profile_connection_type + profiling_datetime + profiling_delta + proxy_score reason_code request_duration request_id @@ -110,6 +162,9 @@ class ResponseRedacter session_id session_id_query_count summary_risk_score + time_zone + time_zone_dst_offset + timezone_name tmx_reason_code tmx_risk_rating tmx_summary_reason_code @@ -121,11 +176,15 @@ class ResponseRedacter tps_type tps_vendor tps_was_timeout + true_ip_score + true_ip_worst_score unknown_session ] - # @param [Hash] body + # @param [Hash, nil] parsed JSON response body def self.redact(hash) + return { error: 'TMx response body was empty' } if hash.nil? + return { error: 'TMx response body was malformed' } unless hash.is_a? Hash filtered_response_h = hash.slice(*ALLOWED_RESPONSE_FIELDS) unfiltered_keys = hash.keys - filtered_response_h.keys unfiltered_keys.each do |key| diff --git a/app/services/proofing/lexis_nexis/ddp/verification_request.rb b/app/services/proofing/lexis_nexis/ddp/verification_request.rb index 787f31ddc42..a63a7bddd57 100644 --- a/app/services/proofing/lexis_nexis/ddp/verification_request.rb +++ b/app/services/proofing/lexis_nexis/ddp/verification_request.rb @@ -21,6 +21,7 @@ def build_request_body account_last_name: applicant[:last_name], account_telephone: '', # applicant[:phone], decision was made not to send phone account_drivers_license_number: applicant[:state_id_number].gsub(/\W/, ''), + account_drivers_license_type: 'us_dl', account_drivers_license_issuer: applicant[:state_id_jurisdiction].to_s.strip, event_type: 'ACCOUNT_CREATION', policy: IdentityConfig.store.lexisnexis_threatmetrix_policy, diff --git a/app/services/proofing/result.rb b/app/services/proofing/result.rb index 4f199c38a3a..4c2d85faada 100644 --- a/app/services/proofing/result.rb +++ b/app/services/proofing/result.rb @@ -8,13 +8,15 @@ def initialize( context: {}, exception: nil, transaction_id: nil, - reference: nil + reference: nil, + response_body: nil ) @errors = errors @context = context @exception = exception @transaction_id = transaction_id @reference = reference + @response_body = response_body end # rubocop:disable Style/OptionalArguments diff --git a/app/services/service_provider_seeder.rb b/app/services/service_provider_seeder.rb index e4f525678c6..1e21e70bd82 100644 --- a/app/services/service_provider_seeder.rb +++ b/app/services/service_provider_seeder.rb @@ -29,7 +29,6 @@ def run 'agency', 'certs', 'restrict_to_deploy_env', - 'uuid_priority', 'protocol', 'native', ).merge(certs: cert_pems)) diff --git a/app/services/usps_in_person_proofing/enrollment_helper.rb b/app/services/usps_in_person_proofing/enrollment_helper.rb index a93d093e356..f37a5d57387 100644 --- a/app/services/usps_in_person_proofing/enrollment_helper.rb +++ b/app/services/usps_in_person_proofing/enrollment_helper.rb @@ -39,10 +39,14 @@ def establishing_in_person_enrollment_for_user(user) end def create_usps_enrollment(enrollment, pii) + # Use the enrollment's unique_id value if it exists, otherwise use the deprecated + # #usps_unique_id value in order to remain backwards-compatible. LG-7024 will remove this + unique_id = enrollment.unique_id || enrollment.usps_unique_id address = [pii['address1'], pii['address2']].select(&:present?).join(' ') + applicant = UspsInPersonProofing::Applicant.new( { - unique_id: enrollment.usps_unique_id, + unique_id: unique_id, first_name: pii['first_name'], last_name: pii['last_name'], address: address, diff --git a/app/views/idv/come_back_later/show.html.erb b/app/views/idv/come_back_later/show.html.erb index 30f8ca3aa74..d30ac255012 100644 --- a/app/views/idv/come_back_later/show.html.erb +++ b/app/views/idv/come_back_later/show.html.erb @@ -1,9 +1,7 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS.map do |step| - step[:name] == :secure_account ? step.merge(status: :complete) : step - end, - current_step: :verify_phone_or_address, + steps: step_indicator_steps, + current_step: :get_a_letter, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', } %> diff --git a/app/views/idv/gpo/index.html.erb b/app/views/idv/gpo/index.html.erb index 38a077dcdfb..40adc2b4189 100644 --- a/app/views/idv/gpo/index.html.erb +++ b/app/views/idv/gpo/index.html.erb @@ -2,8 +2,8 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS, - current_step: :verify_phone_or_address, + steps: step_indicator_steps, + current_step: @step_indicator_current_step, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', } %> diff --git a/app/views/idv/gpo_verify/index.html.erb b/app/views/idv/gpo_verify/index.html.erb index be009c21be5..3a0db712445 100644 --- a/app/views/idv/gpo_verify/index.html.erb +++ b/app/views/idv/gpo_verify/index.html.erb @@ -1,9 +1,7 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS.map do |step| - step[:name] == :secure_account ? step.merge(status: :complete) : step - end, - current_step: :verify_phone_or_address, + steps: step_indicator_steps, + current_step: :get_a_letter, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', } %> diff --git a/app/views/idv/in_person/ready_to_verify/show.html.erb b/app/views/idv/in_person/ready_to_verify/show.html.erb index 71ff6114e88..594d16d84b2 100644 --- a/app/views/idv/in_person/ready_to_verify/show.html.erb +++ b/app/views/idv/in_person/ready_to_verify/show.html.erb @@ -2,7 +2,7 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS, + steps: step_indicator_steps, current_step: :go_to_the_post_office, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', diff --git a/app/views/idv/in_person/ssn.html.erb b/app/views/idv/in_person/ssn.html.erb index 5bb75490e02..558e4ce0128 100644 --- a/app/views/idv/in_person/ssn.html.erb +++ b/app/views/idv/in_person/ssn.html.erb @@ -1 +1 @@ -<%= render 'idv/shared/ssn', flow_session: flow_session, success_alert_enabled: false, updating_ssn: updating_ssn %> +<%= render 'idv/shared/ssn', flow_session: flow_session, success_alert_enabled: false, updating_ssn: updating_ssn, threatmetrix_session_id: threatmetrix_session_id %> diff --git a/app/views/idv/otp_delivery_method/new.html.erb b/app/views/idv/otp_delivery_method/new.html.erb index 681fde0512d..3b7b3fc5356 100644 --- a/app/views/idv/otp_delivery_method/new.html.erb +++ b/app/views/idv/otp_delivery_method/new.html.erb @@ -1,6 +1,6 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS, + steps: step_indicator_steps, current_step: :verify_phone_or_address, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', diff --git a/app/views/idv/otp_verification/show.html.erb b/app/views/idv/otp_verification/show.html.erb index 498f4e670f7..2fb2fdc57bc 100644 --- a/app/views/idv/otp_verification/show.html.erb +++ b/app/views/idv/otp_verification/show.html.erb @@ -1,6 +1,6 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS, + steps: step_indicator_steps, current_step: :verify_phone_or_address, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', diff --git a/app/views/idv/personal_key/show.html.erb b/app/views/idv/personal_key/show.html.erb index 3cf94aa1791..afb3c740477 100644 --- a/app/views/idv/personal_key/show.html.erb +++ b/app/views/idv/personal_key/show.html.erb @@ -1,6 +1,6 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: @step_indicator_steps, + steps: step_indicator_steps, current_step: :secure_account, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', diff --git a/app/views/idv/phone/new.html.erb b/app/views/idv/phone/new.html.erb index dfc825161b1..b5f074b1a7b 100644 --- a/app/views/idv/phone/new.html.erb +++ b/app/views/idv/phone/new.html.erb @@ -1,6 +1,6 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS, + steps: step_indicator_steps, current_step: :verify_phone_or_address, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', diff --git a/app/views/idv/review/new.html.erb b/app/views/idv/review/new.html.erb index 26266a254ef..4acc313a176 100644 --- a/app/views/idv/review/new.html.erb +++ b/app/views/idv/review/new.html.erb @@ -2,7 +2,7 @@ <% content_for(:pre_flash_content) do %> <%= render 'shared/step_indicator', { - steps: @step_indicator_steps, + steps: step_indicator_steps, current_step: :secure_account, locale_scope: 'idv', class: 'margin-x-neg-2 margin-top-neg-4 tablet:margin-x-neg-6 tablet:margin-top-neg-4', diff --git a/app/views/idv/shared/_ssn.html.erb b/app/views/idv/shared/_ssn.html.erb index a936994cd99..45036b92334 100644 --- a/app/views/idv/shared/_ssn.html.erb +++ b/app/views/idv/shared/_ssn.html.erb @@ -30,10 +30,10 @@ locals: <% if IdentityConfig.store.proofing_device_profiling_collecting_enabled %> <% unless IdentityConfig.store.lexisnexis_threatmetrix_org_id.empty? %> - <% if flow_session[:threatmetrix_session_id].present? %> - <%= javascript_include_tag "https://h.online-metrix.net/fp/tags.js?org_id=#{IdentityConfig.store.lexisnexis_threatmetrix_org_id}&session_id=#{flow_session[:threatmetrix_session_id]}", nonce: true %> + <% if threatmetrix_session_id.present? %> + <%= javascript_include_tag "https://h.online-metrix.net/fp/tags.js?org_id=#{IdentityConfig.store.lexisnexis_threatmetrix_org_id}&session_id=#{threatmetrix_session_id}", nonce: true %> <% end %> diff --git a/app/views/shared/_step_indicator.html.erb b/app/views/shared/_step_indicator.html.erb index 57257457882..bfb925bffaf 100644 --- a/app/views/shared/_step_indicator.html.erb +++ b/app/views/shared/_step_indicator.html.erb @@ -27,7 +27,7 @@ locals: status: step[:status] || if current_step == step[:name] :current - elsif current_step_index > index + elsif current_step_index.to_i > index :complete end, ) { step[:title] } %> diff --git a/app/views/shared/_step_indicator_step.html.erb b/app/views/shared/_step_indicator_step.html.erb index 94b3b433a3b..9dbfd877d51 100644 --- a/app/views/shared/_step_indicator_step.html.erb +++ b/app/views/shared/_step_indicator_step.html.erb @@ -7,8 +7,7 @@ locals: status = local_assigns[:status] || :not_complete classes = ['step-indicator__step'] classes << 'step-indicator__step--current' if local_assigns[:status] == :current - classes << 'step-indicator__step--complete' if local_assigns[:status] == :complete - classes << 'step-indicator__step--pending' if local_assigns[:status] == :pending %> + classes << 'step-indicator__step--complete' if local_assigns[:status] == :complete %> <%# Using `aria-current="step"` would be the preferred method to indicate the current step, but at the diff --git a/app/views/user_mailer/reset_password_instructions.html.erb b/app/views/user_mailer/reset_password_instructions.html.erb index 4e89bc661e6..c109b8090b7 100644 --- a/app/views/user_mailer/reset_password_instructions.html.erb +++ b/app/views/user_mailer/reset_password_instructions.html.erb @@ -1,3 +1,21 @@ +<% if @pending_profile_requires_verification %> + + + + + + +
+ <%= image_tag('email/letter-warning.png', width: 140, height: 140, alt: '') %> + +

<%= t('user_mailer.reset_password_instructions.gpo_letter_header') %>

+

<%= t('user_mailer.reset_password_instructions.gpo_letter_description') %>

+
+
+

+ <%= @header || message.subject %> +

+ <% end %>

<%= t( 'user_mailer.reset_password_instructions.header', diff --git a/app/views/users/backup_code_setup/create.html.erb b/app/views/users/backup_code_setup/create.html.erb index 9e83cb6e0b6..8387b717009 100644 --- a/app/views/users/backup_code_setup/create.html.erb +++ b/app/views/users/backup_code_setup/create.html.erb @@ -15,7 +15,7 @@

- <% [ @codes.first(@codes.length / 2), @codes.last(@codes.length / 2)].each do |section| %> + <% [@codes.first(@codes.length / 2), @codes.last(@codes.length / 2)].each do |section| %>
<% section.each do |code| %>
diff --git a/config/application.yml.default b/config/application.yml.default index f04b902a6b1..756ed2a4e79 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -178,6 +178,7 @@ max_piv_cac_per_account: 2 min_password_score: 3 mx_timeout: 3 new_sign_up_cancellation_url_enabled: true +no_sp_device_profiling_enabled: false otp_delivery_blocklist_maxretry: 10 otp_valid_for: 10 otps_per_ip_limit: 25 diff --git a/config/locales/inherited_proofing/es.yml b/config/locales/inherited_proofing/es.yml index d43d06f3c65..0690e109889 100644 --- a/config/locales/inherited_proofing/es.yml +++ b/config/locales/inherited_proofing/es.yml @@ -2,15 +2,19 @@ es: inherited_proofing: buttons: - continue: replaceme + continue: Continuar headings: - welcome: replaceme + welcome: Empiece con la verificación de su identidad info: - no_sp_name: replaceme - privacy_html: replaceme %{app_name} %{link} - welcome_html: replaceme %{sp_name} + no_sp_name: La agencia a la que está intentando acceder + privacy_html: '%{app_name} es un sitio web gubernamental seguro que cumple con + las normas más estrictas de protección de datos. Solo utilizamos sus + datos para verificar su identidad. %{link} sobre nuestras medidas de + privacidad y seguridad.' + welcome_html: '%{sp_name} necesita asegurarse de que sea usted y no alguien que + se haga pasar por usted.' instructions: - bullet1: replaceme - learn_more: replaceme - privacy: replaceme - welcome: replaceme + bullet1: Un número de teléfono con un plan tarifario vinculado a su nombre. + learn_more: Obtenga más información + privacy: Nuestras normas de privacidad y seguridad + welcome: 'Deberá contar con lo siguiente para verificar su identidad:' diff --git a/config/locales/inherited_proofing/fr.yml b/config/locales/inherited_proofing/fr.yml index b149f6518a4..a172530673d 100644 --- a/config/locales/inherited_proofing/fr.yml +++ b/config/locales/inherited_proofing/fr.yml @@ -2,15 +2,19 @@ fr: inherited_proofing: buttons: - continue: replaceme + continue: Continuer headings: - welcome: replaceme + welcome: Commencez à vérifier votre identité info: - no_sp_name: replaceme - privacy_html: replaceme %{app_name} %{link} - welcome_html: replaceme %{sp_name} + no_sp_name: L’agence à laquelle vous essayez d’accéder + privacy_html: '%{app_name} est un site gouvernemental sécurisé qui respecte les + normes les plus strictes en matière de protection des données. Nous + n’utilisons vos données uniquement pour vérifier votre identité. %{link} + sur nos mesures de confidentialité et de sécurité.' + welcome_html: '%{sp_name} doit s’assurer que vous êtes bien vous, et non + quelqu’un qui se fait passer pour vous.' instructions: - bullet1: replaceme - learn_more: replaceme - privacy: replaceme - welcome: replaceme + bullet1: Un numéro de téléphone associé à un forfait téléphonique à votre nom. + learn_more: En savoir plus + privacy: Nos normes de confidentialité et de sécurité + welcome: 'Pour vérifier votre identité, vous aurez besoin de:' diff --git a/config/locales/step_indicator/en.yml b/config/locales/step_indicator/en.yml index 632d68d2bab..4867d45caac 100644 --- a/config/locales/step_indicator/en.yml +++ b/config/locales/step_indicator/en.yml @@ -5,6 +5,7 @@ en: flows: idv: find_a_post_office: Find a Post Office + get_a_letter: Get a letter in the mail getting_started: Getting started go_to_the_post_office: Go to the Post Office secure_account: Secure your account diff --git a/config/locales/step_indicator/es.yml b/config/locales/step_indicator/es.yml index ba1363dce93..d878afd0bad 100644 --- a/config/locales/step_indicator/es.yml +++ b/config/locales/step_indicator/es.yml @@ -5,6 +5,7 @@ es: flows: idv: find_a_post_office: Encontrar una oficina de correos + get_a_letter: Reciba una carta por correo getting_started: Inicio go_to_the_post_office: Ir a la oficina de correos secure_account: Proteje tu cuenta diff --git a/config/locales/step_indicator/fr.yml b/config/locales/step_indicator/fr.yml index fa93938adeb..3323146f8dc 100644 --- a/config/locales/step_indicator/fr.yml +++ b/config/locales/step_indicator/fr.yml @@ -5,6 +5,7 @@ fr: flows: idv: find_a_post_office: Trouver un bureau de poste + get_a_letter: Recevoir une lettre par la poste getting_started: Démarrer go_to_the_post_office: Se rendre au bureau de poste secure_account: Sécurisez votre compte diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index b4806b30a6a..69c0fd2346a 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -220,6 +220,10 @@ en: subject: Unusual activity — reset your %{app_name} password reset_password_instructions: footer: This link expires in %{expires} hours. + gpo_letter_description: If you reset your password, the confirmation code in + your letter will no longer work and you’ll have to verify your identity + again. + gpo_letter_header: Your letter is on the way header: To finish resetting your password, please click the link below or copy and paste the entire link into your browser. link_text: Reset your password diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index d0e93460cf8..b143a0fed07 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -232,6 +232,10 @@ es: subject: Actividad inusual — restablezca su contraseña de %{app_name} reset_password_instructions: footer: Este enlace expira en %{expires} horas. + gpo_letter_description: Si restablece su contraseña, el código de confirmación + que figura en su carta dejará de funcionar y tendrá que volver a + verificar su identidad. + gpo_letter_header: Su carta está en camino header: Para terminar de restablecer su contraseña, haga clic en el enlace de abajo o copie y pegue el enlace completo en su navegador. link_text: Restablezca su contraseña diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 234f4c356c4..598a18098bd 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -244,6 +244,10 @@ fr: subject: Activité inhabituelle — réinitialisez votre mot de passe %{app_name} reset_password_instructions: footer: Ce lien expire dans %{expires} heures. + gpo_letter_description: Si vous réinitialisez votre mot de passe, le code de + confirmation contenu dans votre lettre ne correspondra plus et vous + devrez de vérifier à nouveau votre identité. + gpo_letter_header: Votre lettre est en route header: Pour terminer la réinitialisation de votre mot de passe, veuillez cliquer sur le lien ci-dessous ou copier et coller le lien complet dans votre navigateur. diff --git a/config/service_providers.localdev.yml b/config/service_providers.localdev.yml index 5e22299273b..97d28f88df3 100644 --- a/config/service_providers.localdev.yml +++ b/config/service_providers.localdev.yml @@ -33,7 +33,6 @@ test: - 'saml_test_sp2' agency: 'Test Government Agency' agency_id: 1 - uuid_priority: 10 friendly_name: 'Your friendly Government Agency' logo: 'generic.svg' return_to_sp_url: 'http://localhost:3000' @@ -175,7 +174,6 @@ test: friendly_name: 'Example iOS App' agency: '18F' agency_id: 1 - uuid_priority: 20 logo: 'generic.svg' ial: 2 push_notification_url: http://localhost/push_notifications @@ -190,7 +188,6 @@ test: friendly_name: 'Example app that disallows prompt=login' agency: '18F' agency_id: 1 - uuid_priority: 20 logo: 'generic.svg' ial: 1 allow_prompt_login: false @@ -204,7 +201,6 @@ test: friendly_name: 'Example iOS App' agency: '18F' agency_id: 1 - uuid_priority: 20 logo: 'generic.svg' allow_prompt_login: true @@ -321,7 +317,6 @@ test: - 'saml_test_sp' agency: 'Test Government Agency' agency_id: 1 - uuid_priority: 10 friendly_name: 'Your friendly Government Agency (inactive)' logo: 'generic.svg' return_to_sp_url: 'http://localhost:3000' @@ -342,7 +337,6 @@ test: friendly_name: 'Example iOS App (inactive)' agency: '18F' agency_id: 1 - uuid_priority: 20 logo: 'generic.svg' ial: 2 push_notification_url: http://localhost/push_notifications @@ -399,7 +393,6 @@ development: - 'sp_rails_demo' agency: '18F' agency_id: 1 - uuid_priority: 10 friendly_name: '18F Test Service Provider' logo: 'generic.svg' return_to_sp_url: 'http://localhost:3003' @@ -411,7 +404,6 @@ development: friendly_name: 'Dashboard' agency: 'GSA' agency_id: 2 - uuid_priority: 30 logo: '18f.svg' certs: - 'identity_dashboard_cert' @@ -427,7 +419,6 @@ development: friendly_name: 'Example iOS App' agency: '18F' agency_id: 1 - uuid_priority: 20 logo: 'generic.svg' 'urn:gov:gsa:openidconnect:sp:sinatra': diff --git a/db/primary_migrate/20220902162411_add_device_profiling_enabled_to_service_providers.rb b/db/primary_migrate/20220902162411_add_device_profiling_enabled_to_service_providers.rb new file mode 100644 index 00000000000..2064ab7ad87 --- /dev/null +++ b/db/primary_migrate/20220902162411_add_device_profiling_enabled_to_service_providers.rb @@ -0,0 +1,10 @@ +class AddDeviceProfilingEnabledToServiceProviders < ActiveRecord::Migration[7.0] + def up + add_column :service_providers, :device_profiling_enabled, :boolean + change_column_default :service_providers, :device_profiling_enabled, false + end + + def down + remove_column :service_providers, :device_profiling_enabled + end +end diff --git a/db/primary_migrate/20220906112214_add_threatmetrix_to_proofing_cost.rb b/db/primary_migrate/20220906112214_add_threatmetrix_to_proofing_cost.rb new file mode 100644 index 00000000000..82a1a92cc9b --- /dev/null +++ b/db/primary_migrate/20220906112214_add_threatmetrix_to_proofing_cost.rb @@ -0,0 +1,12 @@ +class AddThreatmetrixToProofingCost < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def up + add_column :proofing_costs, :threatmetrix_count, :integer + change_column_default :proofing_costs, :threatmetrix_count, 0 + end + + def down + remove_column :proofing_costs, :threatmetrix_count + end +end diff --git a/db/schema.rb b/db/schema.rb index 0a150f2319e..900978dc5c4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_08_08_140030) do +ActiveRecord::Schema[7.0].define(version: 2022_09_06_112214) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -476,6 +476,7 @@ t.datetime "updated_at", precision: nil, null: false t.integer "acuant_result_count", default: 0 t.integer "acuant_selfie_count", default: 0 + t.integer "threatmetrix_count", default: 0 t.index ["user_id"], name: "index_proofing_costs_on_user_id", unique: true end @@ -553,6 +554,7 @@ t.boolean "email_nameid_format_allowed", default: false t.boolean "use_legacy_name_id_behavior", default: false t.boolean "irs_attempts_api_enabled" + t.boolean "device_profiling_enabled", default: false t.index ["issuer"], name: "index_service_providers_on_issuer", unique: true end diff --git a/lib/analytics_events_documenter.rb b/lib/analytics_events_documenter.rb index 5655a831600..c9ec4ef5657 100644 --- a/lib/analytics_events_documenter.rb +++ b/lib/analytics_events_documenter.rb @@ -70,7 +70,7 @@ def self.run(argv) output.puts JSON.pretty_generate(documenter.as_json) end - [ output.string.presence, exit_status ] + [output.string.presence, exit_status] end def initialize(database_path:, class_name:, require_extra_params:) diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 73794d075aa..6966ba7ae9c 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -257,6 +257,7 @@ def self.build_store(config_map) config.add(:max_piv_cac_per_account, type: :integer) config.add(:min_password_score, type: :integer) config.add(:mx_timeout, type: :integer) + config.add(:no_sp_device_profiling_enabled, type: :boolean) config.add(:nonessential_email_banlist, type: :json) config.add(:otp_delivery_blocklist_findtime, type: :integer) config.add(:otp_delivery_blocklist_maxretry, type: :integer) diff --git a/package.json b/package.json index 4a5324f5629..40b141c1d7c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "engines": { - "node": ">=14" + "node": ">=16" }, "workspaces": [ "app/javascript/packages/*" @@ -45,7 +45,6 @@ "zxcvbn": "4.4.2" }, "devDependencies": { - "@peculiar/webcrypto": "^1.1.6", "@testing-library/dom": "^7.29.0", "@testing-library/react": "^11.2.2", "@testing-library/react-hooks": "^3.7.0", @@ -60,7 +59,7 @@ "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.8", "@typescript-eslint/eslint-plugin": "^5.12.0", - "@typescript-eslint/parser": "^5.12.0", + "@typescript-eslint/parser": "^5.36.2", "chai": "^4.2.0", "chai-as-promised": "^7.1.1", "clipboard-polyfill": "^3.0.3", @@ -83,7 +82,7 @@ "sinon-chai": "^3.5.0", "stylelint": "^14.1.0", "svgo": "^2.8.0", - "typescript": "^4.5.5", + "typescript": "^4.8.2", "webpack-dev-server": "^4.7.2" }, "resolutions": { diff --git a/scripts/changelog_check.rb b/scripts/changelog_check.rb index df7cc6f5f88..812901306d5 100755 --- a/scripts/changelog_check.rb +++ b/scripts/changelog_check.rb @@ -134,7 +134,7 @@ def generate_changelog(git_log) changelog_entry = ChangelogEntry.new( category: category, - subcategory: change[:subcategory].capitalize, + subcategory: change[:subcategory], pr_number: pr_number&.named_captures&.fetch('pr'), change: change[:change].sub(/./, &:upcase), ) @@ -150,13 +150,14 @@ def generate_changelog(git_log) # Entries with the same category and change are grouped into one changelog line so that we can # support multi-PR changes. def format_changelog(changelog_entries) - changelog_entries = changelog_entries.group_by { |entry| [entry.category, entry.change] } + changelog_entries = changelog_entries. + sort_by(&:subcategory). + group_by { |entry| [entry.category, entry.change] } changelog = '' CATEGORIES.each do |category| category_changes = changelog_entries. - filter { |(changelog_category, _change), _changes| changelog_category == category }. - sort_by { |(_category, change), _changes| change } + filter { |(changelog_category, _change), _changes| changelog_category == category } next if category_changes.empty? changelog.concat("## #{category}\n") diff --git a/spec/components/phone_input_component_spec.rb b/spec/components/phone_input_component_spec.rb index 6bcefc03640..cba11cff60b 100644 --- a/spec/components/phone_input_component_spec.rb +++ b/spec/components/phone_input_component_spec.rb @@ -136,10 +136,11 @@ context 'with delivery unsupported country' do before do - stub_const( - 'PhoneNumberCapabilities::INTERNATIONAL_CODES', - PhoneNumberCapabilities::INTERNATIONAL_CODES.merge( - 'US' => PhoneNumberCapabilities::INTERNATIONAL_CODES['US'].merge('supports_sms' => false), + allow(PhoneNumberCapabilities).to receive(:translated_international_codes).and_return( + PhoneNumberCapabilities.translated_international_codes.merge( + 'US' => PhoneNumberCapabilities.translated_international_codes['US'].merge( + 'supports_sms' => false, + ), ), ) end @@ -159,10 +160,9 @@ context 'with delivery unsupported unconfirmed country' do before do - stub_const( - 'PhoneNumberCapabilities::INTERNATIONAL_CODES', - PhoneNumberCapabilities::INTERNATIONAL_CODES.merge( - 'US' => PhoneNumberCapabilities::INTERNATIONAL_CODES['US'].merge( + allow(PhoneNumberCapabilities).to receive(:translated_international_codes).and_return( + PhoneNumberCapabilities.translated_international_codes.merge( + 'US' => PhoneNumberCapabilities.translated_international_codes['US'].merge( 'supports_sms_unconfirmed' => false, ), ), diff --git a/spec/controllers/api/irs_attempts_api_controller_spec.rb b/spec/controllers/api/irs_attempts_api_controller_spec.rb index 1aa97ecc635..177f9ffa9ed 100644 --- a/spec/controllers/api/irs_attempts_api_controller_spec.rb +++ b/spec/controllers/api/irs_attempts_api_controller_spec.rb @@ -22,7 +22,9 @@ event_type: :test_event, session_id: 'test-session-id', occurred_at: time, - event_metadata: {}, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, ) jti = event.jti jwe = event.to_jwe diff --git a/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb b/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb new file mode 100644 index 00000000000..9ca3ffa218f --- /dev/null +++ b/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe Idv::PhoneOtpRateLimitable, type: :controller do + controller ApplicationController do + include Idv::PhoneOtpRateLimitable + + def handle_max_attempts(_arg = nil) + true + end + end + + describe '#handle_too_many_otp_sends' do + before do + stub_analytics + stub_attempts_tracker + allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) + end + + it 'calls analytics tracking event' do + subject.handle_too_many_otp_sends + + expect(@analytics).to have_received(:track_event).with( + 'Idv: Phone OTP sends rate limited', + ) + end + + it 'calls irs tracking event idv_phone_otp_sent_rate_limited' do + subject.handle_too_many_otp_sends + + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :idv_phone_otp_sent_rate_limited, + ) + end + end +end diff --git a/spec/controllers/concerns/idv/step_indicator_concern_spec.rb b/spec/controllers/concerns/idv/step_indicator_concern_spec.rb new file mode 100644 index 00000000000..cdfb9f763b9 --- /dev/null +++ b/spec/controllers/concerns/idv/step_indicator_concern_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe Idv::StepIndicatorConcern, type: :controller do + controller ApplicationController do + include Idv::StepIndicatorConcern + end + + let(:profile) { nil } + let(:user) { create(:user, profiles: [profile].compact) } + + before { stub_sign_in(user) } + + describe '#step_indicator_steps' do + subject(:steps) { controller.step_indicator_steps } + + it 'returns doc auth steps' do + expect(steps).to eq Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS + end + + context 'with pending profile' do + let(:profile) { create(:profile, deactivation_reason: :gpo_verification_pending) } + + it 'returns doc auth gpo steps' do + expect(steps).to eq Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS_GPO + end + end + + context 'with gpo address verification method' do + before do + idv_session = instance_double(Idv::Session) + allow(idv_session).to receive(:method_missing). + with(:address_verification_mechanism). + and_return('gpo') + allow(controller).to receive(:idv_session).and_return(idv_session) + end + + it 'returns doc auth gpo steps' do + expect(steps).to eq Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS_GPO + end + end + + context 'with in person proofing component' do + context 'with proofing component via pending profile' do + let(:profile) do + create( + :profile, + deactivation_reason: :gpo_verification_pending, + proofing_components: { 'document_check' => Idp::Constants::Vendors::USPS }, + ) + end + + it 'returns in person gpo steps' do + expect(steps).to eq Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS_GPO + end + end + + context 'with proofing component via current idv session' do + before do + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + end + + it 'returns in person steps' do + expect(steps).to eq Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS + end + + context 'with gpo address verification method' do + before do + idv_session = instance_double(Idv::Session) + allow(idv_session).to receive(:method_missing). + with(:address_verification_mechanism). + and_return('gpo') + allow(controller).to receive(:idv_session).and_return(idv_session) + end + + it 'returns in person gpo steps' do + expect(steps).to eq Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS_GPO + end + end + end + end + end +end diff --git a/spec/controllers/concerns/verify_sp_attributes_concern_spec.rb b/spec/controllers/concerns/verify_sp_attributes_concern_spec.rb index 85db9e38c4a..d6be336fdfa 100644 --- a/spec/controllers/concerns/verify_sp_attributes_concern_spec.rb +++ b/spec/controllers/concerns/verify_sp_attributes_concern_spec.rb @@ -14,7 +14,7 @@ allow(controller).to receive(:sp_session_identity).and_return(sp_session_identity) end - subject(:consent_has_expired?) { controller.consent_has_expired? } + subject(:consent_has_expired?) { controller.consent_has_expired?(sp_session_identity) } context 'when there is no sp_session_identity' do let(:sp_session_identity) { nil } @@ -113,7 +113,7 @@ allow(controller).to receive(:sp_session_identity).and_return(sp_session_identity) end - subject(:consent_was_revoked?) { controller.consent_was_revoked? } + subject(:consent_was_revoked?) { controller.consent_was_revoked?(sp_session_identity) } context 'when there is no sp_session_identity' do let(:sp_session_identity) { nil } diff --git a/spec/controllers/idv/capture_doc_controller_spec.rb b/spec/controllers/idv/capture_doc_controller_spec.rb index 4ee21685bad..aad42e9ca2a 100644 --- a/spec/controllers/idv/capture_doc_controller_spec.rb +++ b/spec/controllers/idv/capture_doc_controller_spec.rb @@ -19,7 +19,9 @@ before do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:idv_phone_upload_link_used) allow(Identity::Hostdata::EC2).to receive(:load). and_return(OpenStruct.new(region: 'us-west-2', domain: 'example.com')) end @@ -33,6 +35,8 @@ it 'redirects to the root url' do get :index + expect(@irs_attempts_api_tracker).to have_received(:idv_phone_upload_link_used) + expect(response).to redirect_to root_url end end @@ -41,6 +45,8 @@ it 'redirects to the root url' do get :index, params: { 'document-capture-session': 'foo' } + expect(@irs_attempts_api_tracker).to have_received(:idv_phone_upload_link_used) + expect(response).to redirect_to root_url end end @@ -51,6 +57,8 @@ get :index, params: { 'document-capture-session': session_uuid } end + expect(@irs_attempts_api_tracker).to have_received(:idv_phone_upload_link_used) + expect(response).to redirect_to root_url end end @@ -59,6 +67,8 @@ it 'redirects to the first step' do get :index, params: { 'document-capture-session': session_uuid } + expect(@irs_attempts_api_tracker).to have_received(:idv_phone_upload_link_used) + expect(response).to redirect_to idv_capture_doc_step_url(step: :document_capture) end end @@ -68,6 +78,8 @@ mock_session(user.id) get :index + expect(@irs_attempts_api_tracker).to have_received(:idv_phone_upload_link_used) + expect(response).to redirect_to idv_capture_doc_step_url(step: :document_capture) end end @@ -94,6 +106,8 @@ mock_next_step(:document_capture) get :show, params: { step: 'document_capture' } + + expect(@irs_attempts_api_tracker).not_to have_received(:idv_phone_upload_link_used) end it 'renders the capture_complete template' do @@ -108,20 +122,26 @@ mock_next_step(:capture_complete) get :show, params: { step: 'capture_complete' } + + expect(@irs_attempts_api_tracker).not_to have_received(:idv_phone_upload_link_used) end it 'renders a 404 with a non existent step' do get :show, params: { step: 'foo' } + expect(@irs_attempts_api_tracker).not_to have_received(:idv_phone_upload_link_used) + expect(response).to_not be_not_found end - it 'tracks analytics' do + it 'tracks expected events' do mock_next_step(:capture_complete) result = { step: 'capture_complete', flow_path: 'hybrid', step_count: 1 } get :show, params: { step: 'capture_complete' } + expect(@irs_attempts_api_tracker).not_to have_received(:idv_phone_upload_link_used) + expect(@analytics).to have_received(:track_event).with( 'IdV: ' + "#{Analytics::DOC_AUTH} capture_complete visited".downcase, result ) @@ -133,6 +153,8 @@ get :show, params: { step: 'capture_complete' } get :show, params: { step: 'capture_complete' } + expect(@irs_attempts_api_tracker).not_to have_received(:idv_phone_upload_link_used) + expect(@analytics).to have_received(:track_event).ordered.with( 'IdV: ' + "#{Analytics::DOC_AUTH} capture_complete visited".downcase, hash_including(step: 'capture_complete', step_count: 1), diff --git a/spec/controllers/idv/doc_auth_controller_spec.rb b/spec/controllers/idv/doc_auth_controller_spec.rb index 79a0da47c19..4040e1caa96 100644 --- a/spec/controllers/idv/doc_auth_controller_spec.rb +++ b/spec/controllers/idv/doc_auth_controller_spec.rb @@ -12,6 +12,7 @@ :confirm_two_factor_authenticated, :fsm_initialize, :ensure_correct_step, + :override_csp_for_threat_metrix, ) end diff --git a/spec/controllers/idv/gpo_controller_spec.rb b/spec/controllers/idv/gpo_controller_spec.rb index c3771ccf1f7..534f09977b2 100644 --- a/spec/controllers/idv/gpo_controller_spec.rb +++ b/spec/controllers/idv/gpo_controller_spec.rb @@ -52,6 +52,12 @@ expect(response).to be_ok end + it 'assigns the current step indicator step as "verify phone or address"' do + get :index + + expect(assigns(:step_indicator_current_step)).to eq(:verify_phone_or_address) + end + context 'with letter already sent' do before do allow_any_instance_of(Idv::GpoPresenter).to receive(:letter_already_sent?).and_return(true) @@ -66,6 +72,18 @@ ) end end + + context 'resending a letter' do + before do + allow(controller).to receive(:resend_requested?).and_return(true) + end + + it 'assigns the current step indicator step as "get a letter"' do + get :index + + expect(assigns(:step_indicator_current_step)).to eq(:get_a_letter) + end + end end describe '#create' do diff --git a/spec/controllers/idv/gpo_verify_controller_spec.rb b/spec/controllers/idv/gpo_verify_controller_spec.rb index 5a320954df7..65d49284b39 100644 --- a/spec/controllers/idv/gpo_verify_controller_spec.rb +++ b/spec/controllers/idv/gpo_verify_controller_spec.rb @@ -18,6 +18,7 @@ before do stub_analytics + stub_attempts_tracker stub_sign_in(user) decorated_user = stub_decorated_user_with_pending_profile(user) create( @@ -68,7 +69,6 @@ end it 'renders throttled page' do - stub_analytics expect(@analytics).to receive(:track_event).with( 'IdV: GPO verification visited', ).once @@ -108,6 +108,8 @@ enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) + expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). + with(success: true, failure_reason: nil) action @@ -143,6 +145,8 @@ enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) + expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). + with(success: true, failure_reason: nil) action @@ -170,6 +174,9 @@ error_details: { otp: [:confirmation_code_incorrect] }, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) + failure_reason = { otp: ['Incorrect code. Did you type it in correctly?'] } + expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). + with(success: false, failure_reason: failure_reason) action @@ -204,6 +211,8 @@ throttle_type: :verify_gpo_key, ).once + expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_throttled).once + (max_attempts + 1).times do |i| post( :create, diff --git a/spec/controllers/idv/in_person_controller_spec.rb b/spec/controllers/idv/in_person_controller_spec.rb index 7c39809098c..b7848936cfa 100644 --- a/spec/controllers/idv/in_person_controller_spec.rb +++ b/spec/controllers/idv/in_person_controller_spec.rb @@ -22,6 +22,7 @@ :confirm_two_factor_authenticated, :fsm_initialize, :ensure_correct_step, + :override_csp_for_threat_metrix, ) end end diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index a46fcc0e1b9..59055427148 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -207,6 +207,7 @@ before do stub_analytics + stub_attempts_tracker allow(Telephony).to receive(:send_confirmation_otp).and_return(telephony_response) end @@ -225,6 +226,13 @@ 'Vendor Phone Validation failed', telephony_error_analytics_hash ) + expect(@irs_attempts_api_tracker).to receive(:idv_phone_confirmation_otp_sent).with( + phone_number: '+1 (225) 555-5000', + success: false, + otp_delivery_method: 'sms', + failure_reason: { telephony_error: I18n.t('telephony.error.friendly_message.generic') }, + ) + post :create, params: params end end diff --git a/spec/controllers/idv/personal_key_controller_spec.rb b/spec/controllers/idv/personal_key_controller_spec.rb index 3ee2ef4a9a1..83a666bf588 100644 --- a/spec/controllers/idv/personal_key_controller_spec.rb +++ b/spec/controllers/idv/personal_key_controller_spec.rb @@ -6,11 +6,6 @@ def stub_idv_session stub_sign_in(user) - idv_session = Idv::Session.new( - user_session: subject.user_session, - current_user: user, - service_provider: nil, - ) idv_session.applicant = applicant idv_session.resolution_successful = true profile_maker = Idv::ProfileMaker.new( @@ -29,6 +24,13 @@ def stub_idv_session let(:user) { create(:user, :signed_up, password: password) } let(:applicant) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE } let(:profile) { subject.idv_session.profile } + let(:idv_session) do + Idv::Session.new( + user_session: subject.user_session, + current_user: user, + service_provider: nil, + ) + end describe 'before_actions' do it 'includes before_actions from AccountStateChecker' do @@ -81,6 +83,7 @@ def index describe '#show' do before do stub_idv_session + stub_attempts_tracker end it 'sets code instance variable' do @@ -109,24 +112,17 @@ def index expect(flash[:allow_confirmations_continue]).to eq true end - it 'sets flash.now[:success]' do + it 'logs when user generates personal key' do + idv_session.personal_key = nil + expect(@irs_attempts_api_tracker).to receive(:idv_personal_key_generated).with( + success: true, + ) get :show - expect(flash[:success]).to eq t('idv.messages.confirm') end - context 'user selected gpo verification' do - before do - subject.idv_session.address_verification_mechanism = 'gpo' - end - - it 'assigns step indicator steps with pending status' do - get :show - - expect(flash.now[:success]).to eq t('idv.messages.mail_sent') - expect(assigns(:step_indicator_steps)).to include( - hash_including(name: :verify_phone_or_address, status: :pending), - ) - end + it 'sets flash.now[:success]' do + get :show + expect(flash[:success]).to eq t('idv.messages.confirm') end end diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index 53d0d1e0e6f..dbae6fed8da 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -135,7 +135,9 @@ user = build(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) stub_verify_steps_one_and_two(user) stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) end it 'renders #new' do @@ -178,15 +180,30 @@ ) expect(subject.idv_session.vendor_phone_confirmation).to be_falsy end + + it 'tracks irs event idv_phone_submitted' do + put :create, params: { idv_phone_form: { phone: '703' } } + + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :idv_phone_submitted, + success: false, + phone_number: '703', + failure_reason: { + phone: [t('errors.messages.must_have_us_country_code')], + }, + ) + end end context 'when form is valid' do before do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) end - it 'tracks event with valid phone' do + it 'tracks events with valid phone' do user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) stub_verify_steps_one_and_two(user) @@ -206,6 +223,12 @@ expect(@analytics).to have_received(:track_event).with( 'IdV: phone confirmation form', result ) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :idv_phone_submitted, + success: true, + phone_number: good_phone, + failure_reason: {}, + ) end context 'when same as user phone' do diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index d6f18a029c8..94cee69b93b 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -144,11 +144,13 @@ def show before(:each) do stub_sign_in(user) + stub_attempts_tracker routes.draw do post 'show' => 'idv/review#show' end allow(subject).to receive(:confirm_idv_steps_complete).and_return(true) allow(subject).to receive(:idv_session).and_return(idv_session) + allow(@irs_attempts_api_tracker).to receive(:track_event) end context 'user does not provide password' do @@ -161,12 +163,21 @@ def show end context 'user provides wrong password' do - it 'redirects to new' do + before do post :show, params: { user: { password: 'wrong' } } + end + it 'redirects to new' do expect(flash[:error]).to eq t('idv.errors.incorrect_password') expect(response).to redirect_to idv_review_path end + + it 'tracks irs password entered event (idv_password_entered)' do + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :idv_password_entered, + success: false, + ) + end end context 'user provides correct password' do @@ -209,14 +220,6 @@ def show ) end - it 'shows steps' do - get :new - - expect(subject.view_assigns['step_indicator_steps']).not_to include( - hash_including(name: :verify_phone_or_address, status: :pending), - ) - end - context 'idv app password confirm step is enabled' do before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). @@ -231,20 +234,6 @@ def show end end - context 'user chooses address verification' do - before do - idv_session.address_verification_mechanism = 'gpo' - end - - it 'shows revises steps to show pending address verification' do - get :new - - expect(subject.view_assigns['step_indicator_steps']).to include( - hash_including(name: :verify_phone_or_address, status: :pending), - ) - end - end - context 'user has not requested too much mail' do before do idv_session.address_verification_mechanism = 'gpo' @@ -304,6 +293,8 @@ def show context 'user has completed all steps' do before do idv_session + stub_attempts_tracker + allow(@irs_attempts_api_tracker).to receive(:track_event) end it 'redirects to personal key path' do @@ -324,6 +315,15 @@ def show expect(response).to redirect_to idv_personal_key_path end + it 'tracks irs password entered event (idv_password_entered)' do + put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } + + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :idv_password_entered, + success: true, + ) + end + it 'creates Profile with applicant attributes' do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } diff --git a/spec/controllers/idv_controller_spec.rb b/spec/controllers/idv_controller_spec.rb index c4c8646f3dc..6529c857d5c 100644 --- a/spec/controllers/idv_controller_spec.rb +++ b/spec/controllers/idv_controller_spec.rb @@ -55,6 +55,18 @@ expect(response).to redirect_to idv_doc_auth_path end + context 'with a VA inherited proofing session' do + before do + stub_sign_in + allow(controller).to receive(:va_inherited_proofing?).and_return(true) + end + + it 'redirects to inherited proofing' do + get :index + expect(response).to redirect_to idv_inherited_proofing_path + end + end + context 'sp has reached quota limit' do let(:issuer) { 'foo' } diff --git a/spec/factories/in_person_enrollments.rb b/spec/factories/in_person_enrollments.rb index bb78cab7ac9..36e5da3ee1d 100644 --- a/spec/factories/in_person_enrollments.rb +++ b/spec/factories/in_person_enrollments.rb @@ -2,19 +2,34 @@ factory :in_person_enrollment do user { association :user, :signed_up } profile { association :profile, user: user } - unique_id { Faker::Number.hexadecimal(digits: 18) } + current_address_matches_id { true } + selected_location_details { { name: 'BALTIMORE' } } trait :establishing do - after :build do |enrollment| - enrollment.status = :establishing - end + status { :establishing } end trait :pending do - after :build do |enrollment| - enrollment.status = :pending - enrollment.enrollment_code = Faker::Number.number(digits: 16) - end + status { :pending } + enrollment_code { Faker::Number.number(digits: 16) } + enrollment_established_at { Time.zone.now } + status_updated_at { Time.zone.now } + end + + trait :expired do + status { :expired } + enrollment_code { Faker::Number.number(digits: 16) } + enrollment_established_at { Time.zone.now } + status_check_attempted_at { Time.zone.now } + status_updated_at { Time.zone.now } + end + + trait :failed do + status { :failed } + enrollment_code { Faker::Number.number(digits: 16) } + enrollment_established_at { Time.zone.now } + status_check_attempted_at { Time.zone.now } + status_updated_at { Time.zone.now } end end end diff --git a/spec/features/idv/doc_auth/email_sent_step_spec.rb b/spec/features/idv/doc_auth/email_sent_step_spec.rb index 8ebba47251d..99b07e8231a 100644 --- a/spec/features/idv/doc_auth/email_sent_step_spec.rb +++ b/spec/features/idv/doc_auth/email_sent_step_spec.rb @@ -5,6 +5,7 @@ include DocAuthHelper before do + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true) sign_in_and_2fa_user complete_doc_auth_steps_before_email_sent_step end diff --git a/spec/features/idv/doc_auth/upload_step_spec.rb b/spec/features/idv/doc_auth/upload_step_spec.rb index 05b9676ef7d..bf4c4e50b9f 100644 --- a/spec/features/idv/doc_auth/upload_step_spec.rb +++ b/spec/features/idv/doc_auth/upload_step_spec.rb @@ -9,6 +9,7 @@ before do sign_in_and_2fa_user + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(true) complete_doc_auth_steps_before_upload_step allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker). @@ -55,6 +56,10 @@ end context 'on a desktop device' do + before do + allow_any_instance_of(Idv::Steps::UploadStep).to receive(:mobile_device?).and_return(false) + end + it 'is on the correct page' do expect(page).to have_current_path(idv_doc_auth_upload_step) expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) diff --git a/spec/features/idv/doc_auth/verify_step_spec.rb b/spec/features/idv/doc_auth/verify_step_spec.rb index 1760d3aa540..3c02da3fc28 100644 --- a/spec/features/idv/doc_auth/verify_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_step_spec.rb @@ -7,8 +7,11 @@ let(:skip_step_completion) { false } let(:max_attempts) { Throttle.max_attempts(:idv_resolution) } let(:fake_analytics) { FakeAnalytics.new } + let(:fake_attempts_tracker) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } before do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(ApplicationController).to receive(:irs_attempts_api_tracker). + and_return(fake_attempts_tracker) end it 'displays the expected content' do @@ -28,6 +31,19 @@ end it 'proceeds to the next page upon confirmation' do + expect(fake_attempts_tracker).to receive(:idv_verification_submitted).with( + success: true, + failure_reason: nil, + document_state: 'MT', + document_number: '1111111111111', + document_issued: '2019-12-31', + document_expiration: '2099-12-31', + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + date_of_birth: '1938-10-06', + address: '1 FAKE RD', + ssn: '900-66-1234', + ) sign_in_and_2fa_user complete_doc_auth_steps_before_verify_step click_idv_continue @@ -101,6 +117,19 @@ end it 'does not proceed to the next page if resolution fails' do + expect(fake_attempts_tracker).to receive(:idv_verification_submitted).with( + success: false, + failure_reason: { ssn: ['Unverified SSN.'] }, + document_state: 'MT', + document_number: '1111111111111', + document_issued: '2019-12-31', + document_expiration: '2099-12-31', + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + date_of_birth: '1938-10-06', + address: '1 FAKE RD', + ssn: '123-45-6666', + ) sign_in_and_2fa_user complete_doc_auth_steps_before_ssn_step fill_out_ssn_form_with_ssn_that_fails_resolution @@ -120,6 +149,19 @@ end it 'does not proceed to the next page if resolution raises an exception' do + expect(fake_attempts_tracker).to receive(:idv_verification_submitted).with( + success: false, + failure_reason: nil, + document_state: 'MT', + document_number: '1111111111111', + document_issued: '2019-12-31', + document_expiration: '2099-12-31', + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + date_of_birth: '1938-10-06', + address: '1 FAKE RD', + ssn: '000-00-0000', + ) sign_in_and_2fa_user complete_doc_auth_steps_before_ssn_step fill_out_ssn_form_with_ssn_that_raises_exception @@ -180,6 +222,7 @@ threatmetrix_session_id: nil, user_id: user.id, request_ip: kind_of(String), + issuer: anything, ). and_call_original @@ -207,6 +250,7 @@ threatmetrix_session_id: nil, user_id: user.id, request_ip: kind_of(String), + issuer: anything, ). and_call_original @@ -232,6 +276,7 @@ threatmetrix_session_id: nil, user_id: user.id, request_ip: kind_of(String), + issuer: anything, ). and_call_original diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 003be1fe652..70984f75de4 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -27,18 +27,22 @@ complete_prepare_step(user) # state ID page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) expect(page).to have_content(t('in_person_proofing.headings.state_id')) complete_state_id_step(user) # address page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) expect(page).to have_content(t('in_person_proofing.headings.address')) complete_address_step(user) # ssn page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) expect(page).to have_content(t('doc_auth.headings.ssn')) complete_ssn_step(user) # verify page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.verify_info')) expect(page).to have_content(t('headings.verify')) expect(page).to have_text(InPersonHelper::GOOD_FIRST_NAME) expect(page).to have_text(InPersonHelper::GOOD_LAST_NAME) @@ -71,14 +75,29 @@ complete_verify_step(user) # phone page + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) expect(page).to have_content(t('idv.titles.session.phone')) - complete_phone_step(user) + fill_out_phone_form_ok(MfaContext.new(user).phone_configurations.first.phone) + click_idv_continue + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + choose_idv_otp_delivery_method_sms + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) + fill_in_code_with_last_phone_otp + click_submit_default # password confirm page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) expect(page).to have_content(t('idv.titles.session.review', app_name: APP_NAME)) complete_review_step(user) # personal key page + expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) expect(page).to have_content(t('titles.idv.personal_key')) deadline = nil freeze_time do @@ -89,6 +108,9 @@ end # ready to verify page + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.go_to_the_post_office'), + ) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa enrollment_code = JSON.parse( UspsInPersonProofing::Mock::Fixtures.request_enroll_response, @@ -177,7 +199,7 @@ mock_doc_auth_attention_with_barcode attach_and_submit_images - click_button t('idv.troubleshooting.options.verify_in_person') + click_link t('idv.troubleshooting.options.verify_in_person') bethesda_location = page.find_all('.location-collection-item')[1] bethesda_location.click_button(t('in_person_proofing.body.location.location_button')) @@ -213,10 +235,16 @@ begin_in_person_proofing complete_all_in_person_proofing_steps click_on t('idv.troubleshooting.options.verify_by_mail') + expect_in_person_step_indicator_current_step( + t('step_indicator.flows.idv.verify_phone_or_address'), + ) click_on t('idv.buttons.mail.send') + expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) complete_review_step + expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) acknowledge_and_confirm_personal_key + expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.get_a_letter')) expect(page).to have_content(t('idv.titles.come_back_later')) expect(page).to have_current_path(idv_come_back_later_path) @@ -224,9 +252,13 @@ expect(page).to have_current_path(account_path) expect(page).not_to have_content(t('headings.account.verified_account')) click_on t('account.index.verification.reactivate_button') + expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.get_a_letter')) click_button t('forms.verify_profile.submit') expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + expect_in_person_gpo_step_indicator_current_step( + t('step_indicator.flows.idv.go_to_the_post_office'), + ) expect(page).not_to have_content(t('account.index.verification.success')) end diff --git a/spec/features/idv/steps/confirmation_step_spec.rb b/spec/features/idv/steps/confirmation_step_spec.rb index b3f719acb6e..fef0f1b2441 100644 --- a/spec/features/idv/steps/confirmation_step_spec.rb +++ b/spec/features/idv/steps/confirmation_step_spec.rb @@ -25,7 +25,7 @@ '.step-indicator__step--complete', text: t('step_indicator.flows.idv.verify_phone_or_address'), ) - expect(page).not_to have_css('.step-indicator__step--pending') + expect(page).not_to have_content(t('step_indicator.flows.idv.get_a_letter')) end it 'allows the user to refresh and still displays the personal key' do @@ -70,10 +70,8 @@ it 'shows status content for gpo verification progress' do expect(page).to have_content(t('idv.messages.mail_sent')) expect_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) - expect(page).to have_css( - '.step-indicator__step--pending', - text: t('step_indicator.flows.idv.verify_phone_or_address'), - ) + expect(page).to have_content(t('step_indicator.flows.idv.get_a_letter')) + expect(page).not_to have_content(t('step_indicator.flows.idv.verify_phone_or_address')) end it_behaves_like 'personal key page', :gpo diff --git a/spec/features/idv/steps/gpo_otp_verification_step_spec.rb b/spec/features/idv/steps/gpo_otp_verification_step_spec.rb index 5d1905d32c6..50ee9ecc7f9 100644 --- a/spec/features/idv/steps/gpo_otp_verification_step_spec.rb +++ b/spec/features/idv/steps/gpo_otp_verification_step_spec.rb @@ -3,13 +3,95 @@ feature 'idv gpo otp verification step', :js do include IdvStepHelper - it_behaves_like 'gpo otp verification step' - it_behaves_like 'gpo otp verification step', :oidc - it_behaves_like 'gpo otp verification step', :saml - - context 'with GPO proofing disabled it still lets users with a letter verify' do - it_behaves_like 'gpo otp verification step' - it_behaves_like 'gpo otp verification step', :oidc - it_behaves_like 'gpo otp verification step', :saml + let(:otp) { 'ABC123' } + let(:profile) do + create( + :profile, + deactivation_reason: :gpo_verification_pending, + pii: { ssn: '123-45-6789', dob: '1970-01-01' }, + ) + end + let(:gpo_confirmation_code) do + create( + :gpo_confirmation_code, + profile: profile, + otp_fingerprint: Pii::Fingerprinter.fingerprint(otp), + ) + end + let(:user) { profile.user } + + it 'prompts for confirmation code at sign in' do + sign_in_live_with_2fa(user) + + expect(current_path).to eq idv_gpo_verify_path + expect(page).to have_content t('idv.messages.gpo.resend') + + gpo_confirmation_code + fill_in t('forms.verify_profile.name'), with: otp + click_button t('forms.verify_profile.submit') + + expect(user.events.account_verified.size).to eq 1 + expect(page).to_not have_content(t('account.index.verification.reactivate_button')) + end + + it 'renders an error for an expired GPO OTP' do + sign_in_live_with_2fa(user) + + gpo_confirmation_code.update(code_sent_at: 11.days.ago) + fill_in t('forms.verify_profile.name'), with: otp + click_button t('forms.verify_profile.submit') + + expect(current_path).to eq idv_gpo_verify_path + expect(page).to have_content t('errors.messages.gpo_otp_expired') + + user.reload + + expect(user.events.account_verified.size).to eq 0 + expect(user.active_profile).to be_nil + end + + it 'allows a user to resend a letter' do + allow(Base32::Crockford).to receive(:encode).and_return(otp) + + sign_in_live_with_2fa(user) + + expect(GpoConfirmation.count).to eq(0) + expect(GpoConfirmationCode.count).to eq(0) + + click_on t('idv.messages.gpo.resend') + + expect_step_indicator_current_step(t('step_indicator.flows.idv.get_a_letter')) + + click_on t('idv.buttons.mail.send') + + expect(GpoConfirmation.count).to eq(1) + expect(GpoConfirmationCode.count).to eq(1) + expect(current_path).to eq idv_come_back_later_path + + confirmation_code = GpoConfirmationCode.first + otp_fingerprint = Pii::Fingerprinter.fingerprint(otp) + + expect(confirmation_code.otp_fingerprint).to eq(otp_fingerprint) + expect(confirmation_code.profile).to eq(profile) + end + + context 'with gpo feature disabled' do + before do + allow(IdentityConfig.store).to receive(:enable_gpo_verification?).and_return(true) + end + + it 'allows a user to verify their account for an existing pending profile' do + sign_in_live_with_2fa(user) + + expect(current_path).to eq idv_gpo_verify_path + expect(page).to have_content t('idv.messages.gpo.resend') + + gpo_confirmation_code + fill_in t('forms.verify_profile.name'), with: otp + click_button t('forms.verify_profile.submit') + + expect(user.events.account_verified.size).to eq 1 + expect(page).to_not have_content(t('account.index.verification.reactivate_button')) + end end end diff --git a/spec/features/reports/proofing_costs_report_spec.rb b/spec/features/reports/proofing_costs_report_spec.rb index 1e920612e36..14978fd2129 100644 --- a/spec/features/reports/proofing_costs_report_spec.rb +++ b/spec/features/reports/proofing_costs_report_spec.rb @@ -28,6 +28,7 @@ 'gpo_letter_count_average' => 0.0, 'lexis_nexis_address_count_average' => 0.0, 'phone_otp_count_average' => 0.0, + 'threatmetrix_count_average' => 0.0, } end diff --git a/spec/features/users/password_reset_with_pending_profile_spec.rb b/spec/features/users/password_reset_with_pending_profile_spec.rb new file mode 100644 index 00000000000..835b03fc46e --- /dev/null +++ b/spec/features/users/password_reset_with_pending_profile_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +feature 'reset password with pending profile' do + include PersonalKeyHelper + + let(:user) { create(:user, :signed_up) } + + scenario 'password reset email includes warning for pending profile' do + profile = create( + :profile, + deactivation_reason: :gpo_verification_pending, + pii: { ssn: '666-66-1234', dob: '1920-01-01', phone: '+1 703-555-9999' }, + user: user, + ) + create(:gpo_confirmation_code, profile: profile) + + trigger_reset_password_and_click_email_link(user.email) + + html_body = ActionMailer::Base.deliveries.last.html_part.body.decoded + expect(html_body).to include( + t('user_mailer.reset_password_instructions.gpo_letter_description'), + ) + end + + scenario 'password reset email does not include warning without pending profile' do + trigger_reset_password_and_click_email_link(user.email) + + html_body = ActionMailer::Base.deliveries.last.html_part.body.decoded + expect(html_body).to_not include( + t('user_mailer.reset_password_instructions.gpo_letter_description'), + ) + end +end diff --git a/spec/features/users/verify_profile_spec.rb b/spec/features/users/verify_profile_spec.rb index 2ef5246038a..263093cbd09 100644 --- a/spec/features/users/verify_profile_spec.rb +++ b/spec/features/users/verify_profile_spec.rb @@ -18,14 +18,10 @@ end context 'GPO letter' do - it 'shows step indicator progress with current verify step, completed secure account' do + it 'shows step indicator progress with current step' do sign_in_live_with_2fa(user) - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_phone_or_address')) - expect(page).to have_css( - '.step-indicator__step--complete', - text: t('step_indicator.flows.idv.secure_account'), - ) + expect_step_indicator_current_step(t('step_indicator.flows.idv.get_a_letter')) end scenario 'valid OTP' do diff --git a/spec/fixtures/proofing/lexis_nexis/ddp/request.json b/spec/fixtures/proofing/lexis_nexis/ddp/request.json index 5821658ef76..b8bc0424fdf 100644 --- a/spec/fixtures/proofing/lexis_nexis/ddp/request.json +++ b/spec/fixtures/proofing/lexis_nexis/ddp/request.json @@ -13,6 +13,7 @@ "account_last_name": "McTesterson", "account_telephone": "", "account_drivers_license_number": "12345678", + "account_drivers_license_type": "us_dl", "account_drivers_license_issuer" : "LA", "event_type": "ACCOUNT_CREATION", "policy": "test-policy", diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_full_fail_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/address_failure_response.json similarity index 73% rename from spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_full_fail_response.json rename to spec/fixtures/proofing/lexis_nexis/instant_verify/address_failure_response.json index 05474c48377..8db2423f193 100644 --- a/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_full_fail_response.json +++ b/spec/fixtures/proofing/lexis_nexis/instant_verify/address_failure_response.json @@ -3,7 +3,10 @@ "ConversationId": "123456", "RequestId": "7890", "TransactionStatus": "failed", - "Reference": "aaa-bbb-ccc" + "TransactionReasonCode": { + "Code": "total.scoring.model.verification.fail" + }, + "Reference": "0987:1234-abcd" }, "Products": [ { @@ -11,6 +14,9 @@ "ExecutedStepName": "Execute Instant Verify", "ProductConfigurationName": "REDACTED_CONFIGURATION", "ProductStatus": "fail", + "ProductReason": { + "Code": "total.scoring.model.verification.fail" + }, "Items": [ { "ItemName": "Addr1Zip_StateMatch", @@ -30,11 +36,17 @@ }, { "ItemName": "IdentityOccupancyVerified", - "ItemStatus": "pass" + "ItemStatus": "fail", + "ItemReason": { + "Code": "occupancy_not_verified_fail" + } }, { "ItemName": "AddrDeliverable", - "ItemStatus": "pass" + "ItemStatus": "fail", + "ItemReason": { + "Code": "addr_not_deliverable_fail" + } }, { "ItemName": "AddrNotHighRisk", @@ -42,14 +54,10 @@ }, { "ItemName": "DOBFullVerified", - "ItemStatus": "fail" + "ItemStatus": "pass" }, { "ItemName": "DOBYearVerified", - "ItemStatus": "fail" - }, - { - "ItemName": "SSNLowIssuance", "ItemStatus": "pass" }, { diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_and_address_failure_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_and_address_failure_response.json new file mode 100644 index 00000000000..69e46a3d403 --- /dev/null +++ b/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_and_address_failure_response.json @@ -0,0 +1,82 @@ +{ + "Status": { + "ConversationId": "123456", + "RequestId": "7890", + "TransactionStatus": "failed", + "TransactionReasonCode": { + "Code": "total.scoring.model.verification.fail" + }, + "Reference": "0987:1234-abcd" + }, + "Products": [ + { + "ProductType": "InstantVerify", + "ExecutedStepName": "Execute Instant Verify", + "ProductConfigurationName": "REDACTED_CONFIGURATION", + "ProductStatus": "fail", + "ProductReason": { + "Code": "total.scoring.model.verification.fail" + }, + "Items": [ + { + "ItemName": "Addr1Zip_StateMatch", + "ItemStatus": "pass" + }, + { + "ItemName": "SsnFullNameMatch", + "ItemStatus": "pass" + }, + { + "ItemName": "SsnDeathMatchVerification", + "ItemStatus": "pass" + }, + { + "ItemName": "SSNSSAValid", + "ItemStatus": "pass" + }, + { + "ItemName": "IdentityOccupancyVerified", + "ItemStatus": "fail", + "ItemReason": { + "Code": "occupancy_not_verified_fail" + } + }, + { + "ItemName": "AddrDeliverable", + "ItemStatus": "fail", + "ItemReason": { + "Code": "addr_not_deliverable_fail" + } + }, + { + "ItemName": "AddrNotHighRisk", + "ItemStatus": "pass" + }, + { + "ItemName": "DOBFullVerified", + "ItemStatus": "fail", + "ItemReason": { + "Code": "dob_full_verified_fail" + }, + "ItemInformationDetails": [ + { + "Name": "DX", + "Value": "dob_unable_to_verify_dx" + } + ] + }, + { + "ItemName": "DOBYearVerified", + "ItemStatus": "fail", + "ItemReason": { + "Code": "dob_year_not_verfied_fail" + } + }, + { + "ItemName": "LexIDDeathMatch", + "ItemStatus": "pass" + } + ] + } + ] +} diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/year_of_birth_fail_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_failure_response.json similarity index 67% rename from spec/fixtures/proofing/lexis_nexis/instant_verify/year_of_birth_fail_response.json rename to spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_failure_response.json index ee8614e36e8..d9caa607828 100644 --- a/spec/fixtures/proofing/lexis_nexis/instant_verify/year_of_birth_fail_response.json +++ b/spec/fixtures/proofing/lexis_nexis/instant_verify/date_of_birth_failure_response.json @@ -3,7 +3,10 @@ "ConversationId": "123456", "RequestId": "7890", "TransactionStatus": "failed", - "Reference": "aaa-bbb-ccc" + "TransactionReasonCode": { + "Code": "total.scoring.model.verification.fail" + }, + "Reference": "0987:1234-abcd" }, "Products": [ { @@ -11,6 +14,9 @@ "ExecutedStepName": "Execute Instant Verify", "ProductConfigurationName": "REDACTED_CONFIGURATION", "ProductStatus": "fail", + "ProductReason": { + "Code": "total.scoring.model.verification.fail" + }, "Items": [ { "ItemName": "Addr1Zip_StateMatch", @@ -42,11 +48,23 @@ }, { "ItemName": "DOBFullVerified", - "ItemStatus": "fail" + "ItemStatus": "fail", + "ItemReason": { + "Code": "dob_full_verified_fail" + }, + "ItemInformationDetails": [ + { + "Name": "DX", + "Value": "dob_unable_to_verify_dx" + } + ] }, { "ItemName": "DOBYearVerified", - "ItemStatus": "pass" + "ItemStatus": "fail", + "ItemReason": { + "Code": "dob_year_not_verfied_fail" + } }, { "ItemName": "LexIDDeathMatch", diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/error_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/error_response.json index 1cfd180a12c..55f5e05af81 100644 --- a/spec/fixtures/proofing/lexis_nexis/instant_verify/error_response.json +++ b/spec/fixtures/proofing/lexis_nexis/instant_verify/error_response.json @@ -1,7 +1,7 @@ { "Status": { - "ConversationId": "5556787618334595970", - "RequestId": "1234-abcd", + "ConversationId": "123456", + "RequestId": "7890", "TransactionStatus": "error", "TransactionReasonCode": { "Code": "invalid_transaction_initiate" diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/failed_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/failed_response.json deleted file mode 100644 index 74238ea02e3..00000000000 --- a/spec/fixtures/proofing/lexis_nexis/instant_verify/failed_response.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "Status": { - "ConversationId": "31000123456789", - "RequestId": "13936762", - "TransactionStatus": "failed", - "TransactionReasonCode": { - "Code": "total.scoring.model.verification.fail" - }, - "Reference": "1234-abcd", - "ServerInfo": "ASERVER-W7D" - }, - "Products": [ - { - "ProductType": "Discovery", - "ExecutedStepName": "Discovery", - "ProductStatus": "pass" - }, - { - "ProductType": "SomeOtherProduct", - "ExecutedStepName": "SomeOtherProduct", - "ProductStatus": "fail", - "ProductReason": { - "Code": "individual_not_found" - } - }, - { - "ProductType": "InstantVerify", - "ExecutedStepName": "InstantVerify", - "ProductStatus": "fail", - "ProductReason": { - "Code": "total.scoring.model.verification.fail" - }, - "Items": [ - { - "ItemName": "FirstName", - "ItemStatus": "fail", - "ItemReason": { - "Code": "first_name_does_not_match" - } - } - ] - } - ] -} diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/identity_not_found_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/identity_not_found_response.json new file mode 100644 index 00000000000..96dfaff4ce9 --- /dev/null +++ b/spec/fixtures/proofing/lexis_nexis/instant_verify/identity_not_found_response.json @@ -0,0 +1,85 @@ +{ + "Status": { + "ConversationId": "123456", + "RequestId": "7890", + "TransactionStatus": "failed", + "TransactionReasonCode": { + "Code": "total.scoring.model.verification.fail" + }, + "Reference": "0987:1234-abcd" + }, + "Products": [ + { + "ProductType": "InstantVerify", + "ExecutedStepName": "Execute Instant Verify", + "ProductConfigurationName": "REDACTED_CONFIGURATION", + "ProductStatus": "fail", + "ProductReason": { + "Code": "total.scoring.model.verification.fail" + }, + "Items": [ + { + "ItemName": "Addr1Zip_StateMatch", + "ItemStatus": "pass" + }, + { + "ItemName": "SsnFullNameMatch", + "ItemStatus": "fail", + "ItemReason": { + "Code": "identity_not_found_fail" + } + }, + { + "ItemName": "SsnDeathMatchVerification", + "ItemStatus": "pass" + }, + { + "ItemName": "SSNSSAValid", + "ItemStatus": "pass" + }, + { + "ItemName": "IdentityOccupancyVerified", + "ItemStatus": "fail", + "ItemReason": { + "Code": "identity_not_found_fail" + } + }, + { + "ItemName": "AddrDeliverable", + "ItemStatus": "pass" + }, + { + "ItemName": "AddrNotHighRisk", + "ItemStatus": "pass" + }, + { + "ItemName": "DOBFullVerified", + "ItemStatus": "fail", + "ItemReason": { + "Code": "identity_not_found_fail" + }, + "ItemInformationDetails": [ + { + "Name": "DX", + "Value": "dob_unable_to_verify_dx" + } + ] + }, + { + "ItemName": "DOBYearVerified", + "ItemStatus": "fail", + "ItemReason": { + "Code": "identity_not_found_fail" + } + }, + { + "ItemName": "LexIDDeathMatch", + "ItemStatus": "fail", + "ItemReason": { + "Code": "identity_not_found_fail" + } + } + ] + } + ] +} diff --git a/spec/fixtures/proofing/lexis_nexis/instant_verify/successful_response.json b/spec/fixtures/proofing/lexis_nexis/instant_verify/successful_response.json index fc4a261d7b2..f54c56cb696 100644 --- a/spec/fixtures/proofing/lexis_nexis/instant_verify/successful_response.json +++ b/spec/fixtures/proofing/lexis_nexis/instant_verify/successful_response.json @@ -1,33 +1,25 @@ { "Status": { - "ConversationId": "8624642277235233040", - "RequestId": "13936712", + "ConversationId": "123456", + "RequestId": "7890", "TransactionStatus": "passed", - "Reference": "Reference1", - "ServerInfo": "ASERVER-W7D" + "Reference": "Reference1" }, "Products": [ { - "ProductType": "Discovery", - "ExecutedStepName": "Discovery", - "ProductStatus": "pass" - }, - { - "ProductType": "Velocity", - "ExecutedStepName": "Velocity", + "ProductType": "InstantVerify", + "ExecutedStepName": "Execute Instant Verify", + "ProductConfigurationName": "REDACTED_CONFIGURATION", "ProductStatus": "pass", "Items": [ { - "ItemName": "FREQUENCY", + "ItemName": "Addr1Zip_StateMatch", "ItemStatus": "pass" - } - ] - }, - { - "ProductType": "InstantVerify", - "ExecutedStepName": "InstantVerify", - "ProductStatus": "pass", - "Items": [ + }, + { + "ItemName": "SsnFullNameMatch", + "ItemStatus": "pass" + }, { "ItemName": "SsnDeathMatchVerification", "ItemStatus": "pass" @@ -36,6 +28,18 @@ "ItemName": "SSNSSAValid", "ItemStatus": "pass" }, + { + "ItemName": "IdentityOccupancyVerified", + "ItemStatus": "pass" + }, + { + "ItemName": "AddrDeliverable", + "ItemStatus": "pass" + }, + { + "ItemName": "AddrNotHighRisk", + "ItemStatus": "pass" + }, { "ItemName": "DOBFullVerified", "ItemStatus": "pass" @@ -45,11 +49,8 @@ "ItemStatus": "pass" }, { - "ItemName": "OFAC", - "ItemStatus": "pass", - "ItemReason": { - "Code": "ipatriot" - } + "ItemName": "LexIDDeathMatch", + "ItemStatus": "pass" } ] } diff --git a/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb b/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb index f20a3c5faeb..240741933b0 100644 --- a/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb +++ b/spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb @@ -13,11 +13,19 @@ delivery_method: :sms, ) end + let(:irs_attempts_api_tracker) do + instance_double( + IrsAttemptsApi::Tracker, + idv_phone_otp_submitted_rate_limited: true, + ) + end describe '#submit' do def try_submit(code) described_class.new( - user: user, user_phone_confirmation_session: user_phone_confirmation_session, + user: user, + user_phone_confirmation_session: user_phone_confirmation_session, + irs_attempts_api_tracker: irs_attempts_api_tracker, ).submit(code: code) end @@ -64,6 +72,10 @@ def try_submit(code) context 'when the code is expired' do let(:phone_confirmation_otp_sent_at) { 11.minutes.ago } + before do + allow(IrsAttemptsApi::Tracker).to receive(:new).and_return(irs_attempts_api_tracker) + end + it 'returns an unsuccessful result' do result = try_submit(phone_confirmation_otp_code) @@ -84,6 +96,7 @@ def try_submit(code) expect(user.second_factor_attempts_count).to eq(3) expect(user.second_factor_locked_at).to be_within(1.second).of(Time.zone.now) + expect(irs_attempts_api_tracker).to have_received(:idv_phone_otp_submitted_rate_limited) end end diff --git a/spec/javascripts/packages/device/index-spec.js b/spec/javascripts/packages/device/index-spec.js index ff68f8f8346..7df25cf03dc 100644 --- a/spec/javascripts/packages/device/index-spec.js +++ b/spec/javascripts/packages/device/index-spec.js @@ -1,26 +1,101 @@ -import { isLikelyMobile, hasMediaAccess, isCameraCapableMobile } from '@18f/identity-device'; +import { + isLikelyMobile, + hasMediaAccess, + isCameraCapableMobile, + isIPad, +} from '@18f/identity-device'; + +describe('isIPad', () => { + let originalUserAgent; + let originalTouchPoints; + + beforeEach(() => { + originalUserAgent = navigator.userAgent; + originalTouchPoints = navigator.maxTouchPoints; + navigator.maxTouchPoints = 0; + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + writable: true, + }); + Object.defineProperty(navigator, 'maxTouchPoints', { + writable: true, + }); + }); + + afterEach(() => { + navigator.userAgent = originalUserAgent; + navigator.maxTouchPoints = originalTouchPoints; + }); + + it('returns true if ipad is in the user agent string (old format)', () => { + navigator.userAgent = + 'Mozilla/5.0(iPad; U; CPU iPhone OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B314 Safari/531.21.10'; + + expect(isIPad()).to.be.true(); + }); + + it('returns false if the user agent is Macintosh but with 0 maxTouchPoints', () => { + navigator.userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'; + + expect(isIPad()).to.be.false(); + }); + + it('returns true if the user agent is Macintosh but with 5 maxTouchPoints', () => { + navigator.userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'; + navigator.maxTouchPoints = 5; + + expect(isIPad()).to.be.true(); + }); + + it('returns false for non-Apple userAgent, even with 5 macTouchPoints', () => { + navigator.userAgent = + 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.5195.58 Mobile Safari/537.36'; + navigator.maxTouchPoints = 5; + + expect(isIPad()).to.be.false(); + }); +}); describe('isLikelyMobile', () => { let originalUserAgent; + let originalTouchPoints; + beforeEach(() => { originalUserAgent = navigator.userAgent; + originalTouchPoints = navigator.maxTouchPoints; + navigator.maxTouchPoints = 0; Object.defineProperty(navigator, 'userAgent', { configurable: true, writable: true, }); + Object.defineProperty(navigator, 'maxTouchPoints', { + writable: true, + }); }); afterEach(() => { navigator.userAgent = originalUserAgent; + navigator.maxTouchPoints = originalTouchPoints; }); - it('returns false if not mobile', () => { + it('returns false if not mobile and has no touchpoints', () => { navigator.userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'; + navigator.maxTouchPoints = 0; expect(isLikelyMobile()).to.be.false(); }); + it('returns true if there is an Apple user agent and 5 maxTouchPoints', () => { + navigator.userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'; + navigator.maxTouchPoints = 5; + + expect(isLikelyMobile()).to.be.true(); + }); + it('returns true if likely mobile', () => { navigator.userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index 15dbaca8be3..a35516391dc 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -1,4 +1,4 @@ -import { Crypto } from '@peculiar/webcrypto'; +import { webcrypto } from 'crypto'; import chai from 'chai'; import dirtyChai from 'dirty-chai'; import sinonChai from 'sinon-chai'; @@ -26,7 +26,7 @@ const windowGlobals = Object.fromEntries( ); Object.assign(global, windowGlobals); global.window.fetch = () => Promise.reject(new Error('Fetch must be stubbed')); -global.window.crypto = new Crypto(); // In the future (Node >=15), use native webcrypto: https://nodejs.org/api/webcrypto.html +global.window.crypto = webcrypto; global.window.URL.createObjectURL = createObjectURLAsDataURL; global.window.URL.revokeObjectURL = () => {}; Object.defineProperty(global.window.Image.prototype, 'src', { @@ -37,3 +37,7 @@ Object.defineProperty(global.window.Image.prototype, 'src', { useCleanDOM(dom); useConsoleLogSpy(); + +// Remove after upgrading to React 18 +// See: https://github.com/facebook/react/issues/20756#issuecomment-780945678 +delete global.MessageChannel; diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index 0fd9d945ebb..4fba546d105 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -1,5 +1,81 @@ require 'rails_helper' +RSpec.shared_examples 'enrollment with a status update' do |passed:, status:| + it 'logs a message with common attributes' do + freeze_time do + pending_enrollment.update( + enrollment_established_at: Time.zone.now - 3.days, + status_check_attempted_at: Time.zone.now - 15.minutes, + status_updated_at: Time.zone.now - 2.days, + ) + + job.perform(Time.zone.now) + end + + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment status updated', + enrollment_code: pending_enrollment.enrollment_code, + enrollment_id: pending_enrollment.id, + minutes_since_last_status_check: 15.0, + minutes_since_last_status_update: 2.days.in_minutes, + minutes_to_completion: 3.days.in_minutes, + passed: passed, + ) + end + + it 'updates the status of the enrollment and profile appropriately' do + freeze_time do + pending_enrollment.update( + status_check_attempted_at: Time.zone.now - 1.day, + status_updated_at: Time.zone.now - 2.days, + ) + job.perform(Time.zone.now) + + pending_enrollment.reload + expect(pending_enrollment.status_updated_at).to eq(Time.zone.now) + expect(pending_enrollment.status_check_attempted_at).to eq(Time.zone.now) + end + + expect(pending_enrollment.status).to eq(status) + + expect(pending_enrollment.profile.active).to eq(passed) + end +end + +RSpec.shared_examples 'enrollment encountering an exception' do |exception_class: nil, + exception_message: nil, + reason: 'Request exception'| + it 'logs an error message and leaves the enrollment and profile pending' do + job.perform(Time.zone.now) + pending_enrollment.reload + + expect(pending_enrollment.pending?).to eq(true) + expect(pending_enrollment.profile.active).to eq(false) + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Exception raised', + reason: reason, + enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, + exception_class: exception_class, + exception_message: exception_message, + ) + end + + it 'updates the status_check_attempted_at timestamp' do + freeze_time do + pending_enrollment.update( + status_check_attempted_at: Time.zone.now - 1.day, + status_updated_at: Time.zone.now - 2.days, + ) + job.perform(Time.zone.now) + + pending_enrollment.reload + expect(pending_enrollment.status_updated_at).to eq(Time.zone.now - 2.days) + expect(pending_enrollment.status_check_attempted_at).to eq(Time.zone.now) + end + end +end + RSpec.describe GetUspsProofingResultsJob do include UspsIppHelper @@ -11,84 +87,50 @@ allow(job).to receive(:analytics).and_return(job_analytics) allow(IdentityConfig.store).to receive(:get_usps_proofing_results_job_reprocess_delay_minutes). and_return(reprocess_delay_minutes) + stub_request_token end describe '#perform' do describe 'IPP enabled' do - # this passing enrollment shouldn't be included when the job collects - # enrollments that need their status checked - let!(:passed_enrollment) { create(:in_person_enrollment, :passed) } - - let!(:pending_enrollment) do - create( - :in_person_enrollment, - status: :pending, - enrollment_code: SecureRandom.hex(16), - selected_location_details: { name: 'FRIENDSHIP' }, - ) - end - let!(:pending_enrollment_2) do - create( - :in_person_enrollment, - status: :pending, - enrollment_code: SecureRandom.hex(16), - selected_location_details: { name: 'BALTIMORE' }, - ) - end - let!(:pending_enrollment_3) do - create( - :in_person_enrollment, - status: :pending, - enrollment_code: SecureRandom.hex(16), - selected_location_details: { name: 'WASHINGTON' }, - ) - end - let!(:pending_enrollment_4) do - create( - :in_person_enrollment, - status: :pending, - enrollment_code: SecureRandom.hex(16), - selected_location_details: { name: 'ARLINGTON' }, - ) - end - let(:pending_enrollments) do + let!(:pending_enrollments) do [ - pending_enrollment, - pending_enrollment_2, - pending_enrollment_3, - pending_enrollment_4, + create(:in_person_enrollment, :pending, selected_location_details: { name: 'BALTIMORE' }), + create( + :in_person_enrollment, :pending, + selected_location_details: { name: 'FRIENDSHIP' } + ), + create( + :in_person_enrollment, :pending, + selected_location_details: { name: 'WASHINGTON' } + ), + create(:in_person_enrollment, :pending, selected_location_details: { name: 'ARLINGTON' }), + create(:in_person_enrollment, :pending, selected_location_details: { name: 'DEANWOOD' }), ] end + let(:pending_enrollment) { pending_enrollments[0] } before do + allow(InPersonEnrollment).to receive(:needs_usps_status_check). + and_return([pending_enrollment]) allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) end it 'requests the enrollments that need their status checked' do - stub_request_token stub_request_passed_proofing_results - allow(InPersonEnrollment).to receive(:needs_usps_status_check).and_return([]) - - job.perform(Time.zone.now) + freeze_time do + job.perform(Time.zone.now) - failure_message = 'expected call to InPersonEnrollment#needs_usps_status_check' \ - ' with beginless range starting about 5 minutes ago' - expect(InPersonEnrollment).to( - have_received(:needs_usps_status_check). - with( - satisfy do |v| - v.begin.nil? && ((Time.zone.now - v.end) / 60).between?( - reprocess_delay_minutes - 0.25, reprocess_delay_minutes + 0.25 - ) - end, - ), - failure_message, - ) + expect(InPersonEnrollment).to( + have_received(:needs_usps_status_check). + with(...reprocess_delay_minutes.minutes.ago), + ) + end end it 'records the last attempted status check regardless of response code and contents' do - stub_request_token + allow(InPersonEnrollment).to receive(:needs_usps_status_check). + and_return(pending_enrollments) stub_request_proofing_results_with_responses( request_failed_proofing_results_args, request_in_progress_proofing_results_args, @@ -101,61 +143,84 @@ 'failed test precondition: pending enrollments must not have status check time set', ) - start_time = Time.zone.now + freeze_time do + job.perform(Time.zone.now) - job.perform(Time.zone.now) + expect( + pending_enrollments. + map(&:reload). + pluck(:status_check_attempted_at), + ).to( + all(eq Time.zone.now), + 'job must update status check time for all pending enrollments', + ) + end + end - expected_range = start_time...(Time.zone.now) + it 'logs a message when the job starts' do + allow(InPersonEnrollment).to receive(:needs_usps_status_check). + and_return(pending_enrollments) + stub_request_proofing_results_with_responses( + request_failed_proofing_results_args, + request_in_progress_proofing_results_args, + request_in_progress_proofing_results_args, + request_failed_proofing_results_args, + ) - failure_message = 'job must update status check time for all pending enrollments' - expect( - pending_enrollments. - map(&:reload). - pluck(:status_check_attempted_at), - ).to( - all( - satisfy { |i| expected_range.cover?(i) }, - ), - failure_message, + job.perform(Time.zone.now) + + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Job started', + enrollments_count: 5, + reprocess_delay_minutes: 2.0, ) end - it 'logs details about a failed proofing' do - stub_request_token - stub_request_failed_proofing_results - + it 'logs a message with counts of various outcomes when the job completes' do allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) + and_return(pending_enrollments) + stub_request_proofing_results_with_responses( + request_passed_proofing_results_args, + request_in_progress_proofing_results_args, + { status: 500 }, + request_failed_proofing_results_args, + request_expired_proofing_results_args, + ) job.perform(Time.zone.now) - pending_enrollment.reload - - expect(pending_enrollment.failed?).to be_truthy - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Enrollment failed proofing', - reason: 'Failed status', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, - failure_reason: 'Clerk indicates that ID name or address does not match source data.', - fraud_suspected: false, - primary_id_type: 'Uniformed Services identification card', - proofing_state: 'PA', - secondary_id_type: 'Deed of Trust', - transaction_end_date_time: '12/17/2020 034055', - transaction_start_date_time: '12/17/2020 033855', + 'GetUspsProofingResultsJob: Job completed', + enrollments_checked: 5, + enrollments_errored: 1, + enrollments_expired: 1, + enrollments_failed: 1, + enrollments_in_progress: 1, + enrollments_passed: 1, ) + + expect( + job_analytics.events['GetUspsProofingResultsJob: Job completed']. + first[:duration_seconds], + ).to be >= 0.0 + end + + context 'when an enrollment does not have a unique ID' do + it 'generates a backwards-compatible unique ID' do + pending_enrollment.update(unique_id: nil) + stub_request_passed_proofing_results + expect(pending_enrollment).to receive(:usps_unique_id).and_call_original + + job.perform(Time.zone.now) + + expect(pending_enrollment.unique_id).not_to be_nil + end end describe 'sending emails' do it 'sends proofing failed email on response with failed status' do - stub_request_token stub_request_failed_proofing_results - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) user = pending_enrollment.user user.email_addresses.each do |email_address| @@ -174,12 +239,8 @@ end it 'sends proofing verifed email on 2xx responses with valid JSON' do - stub_request_token stub_request_passed_proofing_results - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) user = pending_enrollment.user user.email_addresses.each do |email_address| @@ -199,15 +260,11 @@ context 'a custom delay greater than zero is set' do it 'uses the custom delay' do - stub_request_token stub_request_passed_proofing_results allow(IdentityConfig.store). to(receive(:in_person_results_delay_in_hours).and_return(5)) - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) user = pending_enrollment.user user.email_addresses.each do |email_address| @@ -221,15 +278,11 @@ context 'a custom delay of zero is set' do it 'does not delay sending the email' do - stub_request_token stub_request_passed_proofing_results allow(IdentityConfig.store). to(receive(:in_person_results_delay_in_hours).and_return(0)) - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) user = pending_enrollment.user user.email_addresses.each do |email_address| @@ -242,182 +295,179 @@ end end - it 'updates enrollment records and activates profiles on response with passed status' do - stub_request_token - stub_request_passed_proofing_results - - start_time = Time.zone.now - - job.perform(Time.zone.now) + context 'when an enrollment passes' do + before(:each) do + stub_request_passed_proofing_results + end - expected_range = start_time...(Time.zone.now) + it_behaves_like('enrollment with a status update', passed: true, status: 'passed') - pending_enrollments.each do |enrollment| - enrollment.reload - expect(enrollment.passed?).to be_truthy - expect(enrollment.status_updated_at).to satisfy do |timestamp| - expected_range.cover?(timestamp) - end - expect(enrollment.profile.active).to be(true) + it 'logs details about the success' do + job.perform(Time.zone.now) expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Enrollment passed proofing', + 'GetUspsProofingResultsJob: Enrollment status updated', + fraud_suspected: false, reason: 'Successful status update', - enrollment_id: enrollment.id, - enrollment_code: enrollment.enrollment_code, ) end end - it 'receives a non-hash value' do - stub_request_token - stub_request_proofing_results_with_responses({}) - - job.perform(Time.zone.now) - - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Exception raised', - reason: 'Bad response structure', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, - ) - end - - it 'receives an unsupported status' do - stub_request_token - stub_request_passed_proofing_unsupported_status_results - - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - - job.perform(Time.zone.now) + context 'when an enrollment fails' do + before(:each) do + stub_request_failed_proofing_results + end - pending_enrollment.reload + it_behaves_like('enrollment with a status update', passed: false, status: 'failed') - expect(pending_enrollment.pending?).to be_truthy + it 'logs failure details' do + job.perform(Time.zone.now) - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Enrollment failed proofing', - reason: 'Unsupported status', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, - status: 'Not supported', - ) + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment status updated', + failure_reason: 'Clerk indicates that ID name or address does not match source data.', + fraud_suspected: false, + primary_id_type: 'Uniformed Services identification card', + proofing_state: 'PA', + reason: 'Failed status', + secondary_id_type: 'Deed of Trust', + transaction_end_date_time: '12/17/2020 034055', + transaction_start_date_time: '12/17/2020 033855', + ) + end end - it 'reports a high-priority error on 2xx responses with invalid JSON' do - stub_request_token - stub_request_proofing_results_with_invalid_response - - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - - job.perform(Time.zone.now) + context 'when an enrollment passes proofing with an unsupported ID' do + before(:each) do + stub_request_passed_proofing_unsupported_id_results + end - pending_enrollment.reload + it_behaves_like('enrollment with a status update', passed: false, status: 'failed') - expect(pending_enrollment.pending?).to be_truthy + it 'logs a message about the unsupported ID' do + job.perform Time.zone.now - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Exception raised', - reason: 'Request exception', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, - ) + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment status updated', + fraud_suspected: false, + primary_id_type: 'Not supported', + reason: 'Unsupported ID type', + ) + end end - it 'reports a low-priority error on 4xx responses' do - stub_request_token - stub_request_proofing_results_with_responses({ status: 400 }) - - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) - - job.perform(Time.zone.now) + context 'when an enrollment expires' do + before(:each) do + stub_request_expired_proofing_results + end - pending_enrollment.reload + it_behaves_like('enrollment with a status update', passed: false, status: 'expired') - expect(pending_enrollment.pending?).to be_truthy + it 'logs that the enrollment expired' do + job.perform(Time.zone.now) - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Exception raised', - reason: 'Request exception', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, - ) + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment status updated', + reason: 'Enrollment has expired', + ) + end end - it 'marks enrollments as expired when USPS says they have expired' do - stub_request_token - stub_request_expired_proofing_results + context 'when USPS returns a non-hash response' do + before(:each) do + stub_request_proofing_results_with_responses({}) + end - job.perform(Time.zone.now) + it_behaves_like('enrollment encountering an exception', reason: 'Bad response structure') + end - pending_enrollments.each do |enrollment| - enrollment.reload - expect(enrollment.expired?).to be_truthy + context 'when USPS returns an unexpected status' do + before(:each) do + stub_request_passed_proofing_unsupported_status_results end - end - it 'ignores enrollments when USPS says the customer has not been to the post office' do - stub_request_token - stub_request_in_progress_proofing_results + it_behaves_like('enrollment encountering an exception', reason: 'Unsupported status') - job.perform(Time.zone.now) + it 'logs the status received' do + job.perform(Time.zone.now) + pending_enrollment.reload - pending_enrollments.each do |enrollment| - enrollment.reload - expect(enrollment.pending?).to be_truthy + expect(pending_enrollment.pending?).to be_truthy + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Exception raised', + status: 'Not supported', + ) end end - it 'reports a high-priority error on 5xx responses' do - stub_request_token - stub_request_proofing_results_with_responses({ status: 500 }) + context 'when USPS returns invalid JSON' do + before(:each) do + stub_request_proofing_results_with_invalid_response + end - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) + it_behaves_like( + 'enrollment encountering an exception', + exception_class: 'Faraday::ParsingError', + exception_message: "809: unexpected token at 'invalid'", + ) + end - job.perform(Time.zone.now) + context 'when USPS returns a 4xx status code' do + before(:each) do + stub_request_proofing_results_with_responses({ status: 400 }) + end - pending_enrollment.reload + it_behaves_like( + 'enrollment encountering an exception', + exception_class: 'Faraday::BadRequestError', + exception_message: 'the server responded with status 400', + ) + end - expect(pending_enrollment.pending?).to be_truthy + context 'when USPS returns a 5xx status code' do + before(:each) do + stub_request_proofing_results_with_responses({ status: 500 }) + end - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Exception raised', - reason: 'Request exception', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, + it_behaves_like( + 'enrollment encountering an exception', + exception_class: 'Faraday::ServerError', + exception_message: 'the server responded with status 500', ) end - it 'fails enrollment for unsupported ID types' do - stub_request_token - stub_request_passed_proofing_unsupported_id_results - - allow(InPersonEnrollment).to receive(:needs_usps_status_check). - and_return([pending_enrollment]) + context 'when there is no status update' do + before(:each) do + stub_request_in_progress_proofing_results + end - expect(pending_enrollment.pending?).to be_truthy + it 'updates the timestamp but does not update the status or log a message' do + freeze_time do + pending_enrollment.update( + status_check_attempted_at: Time.zone.now - 1.day, + status_updated_at: Time.zone.now - 1.day, + ) + job.perform(Time.zone.now) - job.perform Time.zone.now + pending_enrollment.reload + expect(pending_enrollment.status_updated_at).to eq(Time.zone.now - 1.day) + expect(pending_enrollment.status_check_attempted_at).to eq(Time.zone.now) + end - expect(pending_enrollment.reload.failed?).to be_truthy + expect(pending_enrollment.profile.active).to eq(false) + expect(pending_enrollment.pending?).to be_truthy - expect(job_analytics).to have_logged_event( - 'GetUspsProofingResultsJob: Enrollment failed proofing', - reason: 'Unsupported ID type', - enrollment_id: pending_enrollment.id, - enrollment_code: pending_enrollment.enrollment_code, - primary_id_type: 'Not supported', - ) + expect(job_analytics).not_to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment status updated', + ) + end end end describe 'IPP disabled' do before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(false) + allow(IdentityConfig.store).to receive(:usps_mock_fallback).and_return(false) end it 'does not request any enrollment records' do diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index ba00436c8d6..41cc781c177 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -37,11 +37,15 @@ let(:state_id_proofer) do instance_double(Proofing::Aamva::Proofer, class: Proofing::Aamva::Proofer) end + let(:ddp_proofer) { Proofing::Mock::DdpMockClient.new } let(:trace_id) { SecureRandom.uuid } let(:user) { create(:user, :signed_up) } let(:threatmetrix_session_id) { SecureRandom.uuid } let(:threatmetrix_request_id) { Proofing::Mock::DdpMockClient::TRANSACTION_ID } let(:request_ip) { Faker::Internet.ip_v4_address } + let(:issuer) { 'fake-issuer' } + let(:friendly_name) { 'fake-name' } + let(:app_id) { 'fake-app-id' } let(:ddp_response_body) do JSON.parse(LexisNexisFixtures.ddp_success_redacted_response_json, symbolize_names: true) end @@ -56,6 +60,7 @@ user_id: user.id, threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result[:result] @@ -75,156 +80,89 @@ user_id: user.id, threatmetrix_session_id: threatmetrix_session_id, request_ip: request_ip, + issuer: issuer, ) end - context 'webmock lexisnexis and threatmetrix' do + context 'with threatmetrix enabled for the service provider' do before do - stub_request( - :post, - 'https://lexisnexis.example.com/restws/identity/v2/abc123/aaa/conversation', - ).to_return(body: lexisnexis_response.to_json) - stub_request( - :post, - 'https://www.example.com/api/session-query', - ).to_return(body: LexisNexisFixtures.ddp_success_response_json) - - allow(IdentityConfig.store).to receive(:proofer_mock_fallback).and_return(false) - allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled). - and_return(true) - - allow(IdentityConfig.store).to receive(:lexisnexis_account_id).and_return('abc123') - allow(IdentityConfig.store).to receive(:lexisnexis_request_mode).and_return('aaa') - allow(IdentityConfig.store).to receive(:lexisnexis_username).and_return('aaa') - allow(IdentityConfig.store).to receive(:lexisnexis_password).and_return('aaa') - allow(IdentityConfig.store).to receive(:lexisnexis_base_url). - and_return('https://lexisnexis.example.com/') - allow(IdentityConfig.store).to receive(:lexisnexis_instant_verify_workflow). - and_return('aaa') - - allow(instance).to receive(:state_id_proofer).and_return(state_id_proofer) - - allow(state_id_proofer).to receive(:proof). - and_return(Proofing::Result.new(transaction_id: aamva_transaction_id)) - end - - let(:lexisnexis_response) do - { - 'Status' => { - 'TransactionStatus' => 'passed', - 'ConversationId' => lexisnexis_transaction_id, - 'Reference' => lexisnexis_reference, - }, - } - end - - it 'returns results and adds threatmetrix proofing components' do - perform - - result = document_capture_session.load_proofing_result[:result] - - expect(result).to eq( - exception: nil, - errors: {}, - success: true, - timed_out: false, - context: { - should_proof_state_id: true, - stages: { - resolution: { - client: Proofing::LexisNexis::InstantVerify::Proofer.vendor_name, - errors: {}, - exception: nil, - success: true, - timed_out: false, - transaction_id: lexisnexis_transaction_id, - reference: lexisnexis_reference, - }, - state_id: { - client: Proofing::Aamva::Proofer.vendor_name, - errors: {}, - exception: nil, - success: true, - timed_out: false, - transaction_id: aamva_transaction_id, - }, - threatmetrix: { - client: Proofing::Mock::DdpMockClient.vendor_name, - errors: {}, - exception: nil, - success: true, - timed_out: false, - transaction_id: threatmetrix_request_id, - response_body: ddp_response_body, - }, - }, - }, - transaction_id: lexisnexis_transaction_id, - reference: lexisnexis_reference, + ServiceProvider.create( + issuer: issuer, + friendly_name: friendly_name, + app_id: app_id, + device_profiling_enabled: true, ) - proofing_component = user.proofing_component - expect(proofing_component.threatmetrix).to equal(true) - expect(proofing_component.threatmetrix_review_status).to eq('pass') end + context 'webmock lexisnexis and threatmetrix' do + before do + stub_request( + :post, + 'https://lexisnexis.example.com/restws/identity/v2/abc123/aaa/conversation', + ).to_return(body: lexisnexis_response.to_json) + stub_request( + :post, + 'https://www.example.com/api/session-query', + ).to_return(body: LexisNexisFixtures.ddp_success_response_json) + + allow(IdentityConfig.store).to receive(:proofer_mock_fallback).and_return(false) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled). + and_return(true) + + allow(IdentityConfig.store).to receive(:lexisnexis_account_id).and_return('abc123') + allow(IdentityConfig.store).to receive(:lexisnexis_request_mode).and_return('aaa') + allow(IdentityConfig.store).to receive(:lexisnexis_username).and_return('aaa') + allow(IdentityConfig.store).to receive(:lexisnexis_password).and_return('aaa') + allow(IdentityConfig.store).to receive(:lexisnexis_base_url). + and_return('https://lexisnexis.example.com/') + allow(IdentityConfig.store).to receive(:lexisnexis_instant_verify_workflow). + and_return('aaa') + + allow(instance).to receive(:state_id_proofer).and_return(state_id_proofer) + + allow(state_id_proofer).to receive(:proof). + and_return(Proofing::Result.new(transaction_id: aamva_transaction_id)) + end - context 'failed response from lexisnexis' do - let(:should_proof_state_id) { true } let(:lexisnexis_response) do { 'Status' => { + 'TransactionStatus' => 'passed', 'ConversationId' => lexisnexis_transaction_id, 'Reference' => lexisnexis_reference, - 'Workflow' => 'foobar.baz', - 'TransactionStatus' => 'error', - 'TransactionReasonCode' => { - 'Code' => 'invalid_transaction_initiate', - }, - }, - 'Information' => { - 'InformationType' => 'error-details', - 'Code' => 'invalid_transaction_initiate', - 'Description' => 'Error: Invalid Transaction Initiate', - 'DetailDescription' => [ - { 'Text' => 'Date of Birth is not a valid date' }, - ], }, } end - it 'has a failed response' do + it 'returns results and adds threatmetrix proofing components' do perform result = document_capture_session.load_proofing_result[:result] - expect(result).to match( + expect(result).to eq( exception: nil, - errors: { - base: [ - a_string_starting_with( - 'Response error with code \'invalid_transaction_initiate\':', - ), - ], - }, - success: false, + errors: {}, + success: true, timed_out: false, context: { should_proof_state_id: true, stages: { resolution: { client: Proofing::LexisNexis::InstantVerify::Proofer.vendor_name, - errors: { - base: [ - a_string_starting_with( - 'Response error with code \'invalid_transaction_initiate\':', - ), - ], - }, + errors: {}, exception: nil, - success: false, + success: true, timed_out: false, transaction_id: lexisnexis_transaction_id, reference: lexisnexis_reference, }, + state_id: { + client: Proofing::Aamva::Proofer.vendor_name, + errors: {}, + exception: nil, + success: true, + timed_out: false, + transaction_id: aamva_transaction_id, + }, threatmetrix: { client: Proofing::Mock::DdpMockClient.vendor_name, errors: {}, @@ -239,80 +177,424 @@ transaction_id: lexisnexis_transaction_id, reference: lexisnexis_reference, ) + proofing_component = user.proofing_component + expect(proofing_component.threatmetrix).to equal(true) + expect(proofing_component.threatmetrix_review_status).to eq('pass') end - end - context 'no threatmetrix_session_id' do - let(:threatmetrix_session_id) { nil } - it 'does not attempt to create a ddp proofer' do - perform + context 'failed response from lexisnexis' do + let(:should_proof_state_id) { true } + let(:lexisnexis_response) do + { + 'Status' => { + 'ConversationId' => lexisnexis_transaction_id, + 'Reference' => lexisnexis_reference, + 'Workflow' => 'foobar.baz', + 'TransactionStatus' => 'error', + 'TransactionReasonCode' => { + 'Code' => 'invalid_transaction_initiate', + }, + }, + 'Information' => { + 'InformationType' => 'error-details', + 'Code' => 'invalid_transaction_initiate', + 'Description' => 'Error: Invalid Transaction Initiate', + 'DetailDescription' => [ + { 'Text' => 'Date of Birth is not a valid date' }, + ], + }, + } + end + + it 'has a failed response' do + perform + + result = document_capture_session.load_proofing_result[:result] + + expect(result).to match( + exception: nil, + errors: { + base: [ + a_string_starting_with( + 'Response error with code \'invalid_transaction_initiate\':', + ), + ], + }, + success: false, + timed_out: false, + context: { + should_proof_state_id: true, + stages: { + resolution: { + client: Proofing::LexisNexis::InstantVerify::Proofer.vendor_name, + errors: { + base: [ + a_string_starting_with( + 'Response error with code \'invalid_transaction_initiate\':', + ), + ], + }, + exception: nil, + success: false, + timed_out: false, + transaction_id: lexisnexis_transaction_id, + reference: lexisnexis_reference, + }, + threatmetrix: { + client: Proofing::Mock::DdpMockClient.vendor_name, + errors: {}, + exception: nil, + success: true, + timed_out: false, + transaction_id: threatmetrix_request_id, + response_body: ddp_response_body, + }, + }, + }, + transaction_id: lexisnexis_transaction_id, + reference: lexisnexis_reference, + ) + end + end + + context 'no threatmetrix_session_id' do + let(:threatmetrix_session_id) { nil } + it 'does not attempt to create a ddp proofer' do + perform - expect(instance).not_to receive(:lexisnexis_ddp_proofer) + expect(instance).not_to receive(:lexisnexis_ddp_proofer) + end end end end - context 'stubbing vendors' do + context 'with threatmetrix disabled for the service provider' do before do - allow(instance).to receive(:resolution_proofer).and_return(resolution_proofer) - allow(instance).to receive(:state_id_proofer).and_return(state_id_proofer) - allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled). - and_return(true) + ServiceProvider.create( + issuer: issuer, + friendly_name: friendly_name, + app_id: app_id, + device_profiling_enabled: false, + ) end - - context 'with a successful response from the proofer' do + context 'webmock lexisnexis and threatmetrix' do before do - expect(resolution_proofer).to receive(:proof). - and_return(Proofing::Result.new) - expect(state_id_proofer).to receive(:proof). - and_return(Proofing::Result.new) + stub_request( + :post, + 'https://lexisnexis.example.com/restws/identity/v2/abc123/aaa/conversation', + ).to_return(body: lexisnexis_response.to_json) + stub_request( + :post, + 'https://www.example.com/api/session-query', + ).to_return(body: LexisNexisFixtures.ddp_success_response_json) + + allow(IdentityConfig.store).to receive(:proofer_mock_fallback).and_return(false) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled). + and_return(true) + + allow(IdentityConfig.store).to receive(:lexisnexis_account_id).and_return('abc123') + allow(IdentityConfig.store).to receive(:lexisnexis_request_mode).and_return('aaa') + allow(IdentityConfig.store).to receive(:lexisnexis_username).and_return('aaa') + allow(IdentityConfig.store).to receive(:lexisnexis_password).and_return('aaa') + allow(IdentityConfig.store).to receive(:lexisnexis_base_url). + and_return('https://lexisnexis.example.com/') + allow(IdentityConfig.store).to receive(:lexisnexis_instant_verify_workflow). + and_return('aaa') + + allow(instance).to receive(:state_id_proofer).and_return(state_id_proofer) + + allow(state_id_proofer).to receive(:proof). + and_return(Proofing::Result.new(transaction_id: aamva_transaction_id)) end - it 'logs the trace_id and timing info for ProofResolution and the Threatmetrix info' do - expect(instance).to receive(:logger_info_hash).ordered.with( - hash_including( - name: 'ThreatMetrix', - user_id: user.uuid, - threatmetrix_request_id: Proofing::Mock::DdpMockClient::TRANSACTION_ID, - threatmetrix_success: true, - ), - ) - - expect(instance).to receive(:logger_info_hash).ordered.with( - hash_including( - :timing, - name: 'ProofResolution', - trace_id: trace_id, - ), - ) + let(:lexisnexis_response) do + { + 'Status' => { + 'TransactionStatus' => 'passed', + 'ConversationId' => lexisnexis_transaction_id, + 'Reference' => lexisnexis_reference, + }, + } + end + it 'returns results' do perform + result = document_capture_session.load_proofing_result[:result] + + expect(result).to eq( + exception: nil, + errors: {}, + success: true, + timed_out: false, + context: { + should_proof_state_id: true, + stages: { + resolution: { + client: Proofing::LexisNexis::InstantVerify::Proofer.vendor_name, + errors: {}, + exception: nil, + success: true, + timed_out: false, + transaction_id: lexisnexis_transaction_id, + reference: lexisnexis_reference, + }, + state_id: { + client: Proofing::Aamva::Proofer.vendor_name, + errors: {}, + exception: nil, + success: true, + timed_out: false, + transaction_id: aamva_transaction_id, + }, + }, + }, + transaction_id: lexisnexis_transaction_id, + reference: lexisnexis_reference, + ) proofing_component = user.proofing_component - expect(proofing_component.threatmetrix).to equal(true) - expect(proofing_component.threatmetrix_review_status).to eq('pass') + expect(proofing_component&.threatmetrix).to be_nil + end + + context 'failed response from lexisnexis' do + let(:should_proof_state_id) { true } + let(:lexisnexis_response) do + { + 'Status' => { + 'ConversationId' => lexisnexis_transaction_id, + 'Reference' => lexisnexis_reference, + 'Workflow' => 'foobar.baz', + 'TransactionStatus' => 'error', + 'TransactionReasonCode' => { + 'Code' => 'invalid_transaction_initiate', + }, + }, + 'Information' => { + 'InformationType' => 'error-details', + 'Code' => 'invalid_transaction_initiate', + 'Description' => 'Error: Invalid Transaction Initiate', + 'DetailDescription' => [ + { 'Text' => 'Date of Birth is not a valid date' }, + ], + }, + } + end + + it 'has a failed response' do + perform + + result = document_capture_session.load_proofing_result[:result] + + expect(result).to match( + exception: nil, + errors: { + base: [ + a_string_starting_with( + 'Response error with code \'invalid_transaction_initiate\':', + ), + ], + }, + success: false, + timed_out: false, + context: { + should_proof_state_id: true, + stages: { + resolution: { + client: Proofing::LexisNexis::InstantVerify::Proofer.vendor_name, + errors: { + base: [ + a_string_starting_with( + 'Response error with code \'invalid_transaction_initiate\':', + ), + ], + }, + exception: nil, + success: false, + timed_out: false, + transaction_id: lexisnexis_transaction_id, + reference: lexisnexis_reference, + }, + }, + }, + transaction_id: lexisnexis_transaction_id, + reference: lexisnexis_reference, + ) + end + end + + context 'no threatmetrix_session_id' do + let(:threatmetrix_session_id) { nil } + it 'does not attempt to create a ddp proofer' do + perform + + expect(instance).not_to receive(:lexisnexis_ddp_proofer) + end end end + end - context 'does not call state id with an unsuccessful response from the proofer' do - it 'posts back to the callback url' do - expect(resolution_proofer).to receive(:proof). - and_return(Proofing::Result.new(exception: 'error')) - expect(state_id_proofer).not_to receive(:proof) + context 'with threatmetrix enabled for the service provider' do + before do + ServiceProvider.create( + issuer: issuer, + friendly_name: friendly_name, + app_id: app_id, + device_profiling_enabled: true, + ) + end + context 'stubbing vendors and threatmetrix' do + before do + allow(instance).to receive(:resolution_proofer).and_return(resolution_proofer) + allow(instance).to receive(:state_id_proofer).and_return(state_id_proofer) + allow(instance).to receive(:lexisnexis_ddp_proofer).and_return(ddp_proofer) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled). + and_return(true) + end - perform + context 'with a successful response from the proofer' do + before do + expect(resolution_proofer).to receive(:proof). + and_return(Proofing::Result.new) + expect(state_id_proofer).to receive(:proof). + and_return(Proofing::Result.new) + end + + it 'logs the trace_id and timing info for ProofResolution and the Threatmetrix info' do + expect(instance).to receive(:logger_info_hash).ordered.with( + hash_including( + name: 'ThreatMetrix', + user_id: user.uuid, + threatmetrix_request_id: Proofing::Mock::DdpMockClient::TRANSACTION_ID, + threatmetrix_success: true, + ), + ) + + expect(instance).to receive(:logger_info_hash).ordered.with( + hash_including( + :timing, + name: 'ProofResolution', + trace_id: trace_id, + ), + ) + + perform + + proofing_component = user.proofing_component + expect(proofing_component.threatmetrix).to equal(true) + expect(proofing_component.threatmetrix_review_status).to eq('pass') + end + + context 'nil response body from ddp' do + let(:ddp_result) { Proofing::Result.new(response_body: nil) } + + before do + expect(ddp_proofer).to receive(:proof).and_return(ddp_result) + end + + it 'does not blow up' do + perform + + result = document_capture_session.load_proofing_result[:result] + + expect(result[:context][:stages][:threatmetrix][:response_body]). + to eq(error: 'TMx response body was empty') + end + end + end + + context 'does not call state id with an unsuccessful response from the proofer' do + it 'posts back to the callback url' do + expect(resolution_proofer).to receive(:proof). + and_return(Proofing::Result.new(exception: 'error')) + expect(state_id_proofer).not_to receive(:proof) + + perform + end + end + + context 'no state_id proof' do + let(:should_proof_state_id) { false } + + it 'does not call state_id proof if resolution proof is successful' do + expect(resolution_proofer).to receive(:proof). + and_return(Proofing::Result.new) + + expect(state_id_proofer).not_to receive(:proof) + perform + end end end + end + + context 'with threatmetrix disabled for the service provider' do + before do + ServiceProvider.create( + issuer: issuer, + friendly_name: friendly_name, + app_id: app_id, + device_profiling_enabled: false, + ) + end + context 'stubbing vendors and threatmetrix' do + before do + allow(instance).to receive(:resolution_proofer).and_return(resolution_proofer) + allow(instance).to receive(:state_id_proofer).and_return(state_id_proofer) + allow(instance).to receive(:lexisnexis_ddp_proofer).and_return(ddp_proofer) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled). + and_return(true) + end - context 'no state_id proof' do - let(:should_proof_state_id) { false } + context 'with a successful response from the proofer' do + before do + expect(resolution_proofer).to receive(:proof). + and_return(Proofing::Result.new) + expect(state_id_proofer).to receive(:proof). + and_return(Proofing::Result.new) + end + + it 'logs the trace_id and timing info for ProofResolution info' do + expect(instance).to receive(:logger_info_hash).ordered.with( + hash_including( + :timing, + name: 'ProofResolution', + trace_id: trace_id, + ), + ) + + perform + + expect(user.proofing_component&.threatmetrix).to be_nil + end + + context 'nil response body from ddp' do + let(:ddp_result) { Proofing::Result.new(response_body: nil) } + + before do + expect(ddp_proofer).to receive(:proof).and_return(ddp_result) + end + end + end - it 'does not call state_id proof if resolution proof is successful' do - expect(resolution_proofer).to receive(:proof). - and_return(Proofing::Result.new) + context 'does not call state id with an unsuccessful response from the proofer' do + it 'posts back to the callback url' do + expect(resolution_proofer).to receive(:proof). + and_return(Proofing::Result.new(exception: 'error')) + expect(state_id_proofer).not_to receive(:proof) - expect(state_id_proofer).not_to receive(:proof) - perform + perform + end + end + + context 'no state_id proof' do + let(:should_proof_state_id) { false } + + it 'does not call state_id proof if resolution proof is successful' do + expect(resolution_proofer).to receive(:proof). + and_return(Proofing::Result.new) + + expect(state_id_proofer).not_to receive(:proof) + perform + end end end end diff --git a/spec/lib/utf8_sanitizer_spec.rb b/spec/lib/utf8_sanitizer_spec.rb index e6068906fe4..f060a2e1239 100644 --- a/spec/lib/utf8_sanitizer_spec.rb +++ b/spec/lib/utf8_sanitizer_spec.rb @@ -39,7 +39,7 @@ end it 'blocks null bytes in the params' do - post '/test', params: { some: [ 'aaa', { value: "\x00" } ] } + post '/test', params: { some: ['aaa', { value: "\x00" }] } expect(last_response).to be_bad_request end diff --git a/spec/models/in_person_enrollment_spec.rb b/spec/models/in_person_enrollment_spec.rb index 6afa3dfe4e4..fad93503f0e 100644 --- a/spec/models/in_person_enrollment_spec.rb +++ b/spec/models/in_person_enrollment_spec.rb @@ -66,6 +66,28 @@ end end + describe 'Triggers' do + it 'generates a unique ID if one is not provided' do + user = create(:user) + profile = create(:profile, :gpo_verification_pending, user: user) + expect(InPersonEnrollment).to receive(:generate_unique_id).and_call_original + + enrollment = create(:in_person_enrollment, user: user, profile: profile) + + expect(enrollment.unique_id).not_to be_nil + end + + it 'does not generated a unique ID if one is provided' do + user = create(:user) + profile = create(:profile, :gpo_verification_pending, user: user) + expect(InPersonEnrollment).not_to receive(:generate_unique_id) + + enrollment = create(:in_person_enrollment, user: user, profile: profile, unique_id: '1234') + + expect(enrollment.unique_id).to eq('1234') + end + end + describe 'needs_usps_status_check' do let(:check_interval) { ...1.hour.ago } let!(:passed_enrollment) { create(:in_person_enrollment, :passed) } @@ -103,4 +125,59 @@ end end end + + describe 'minutes_since_established' do + let(:enrollment) do + create( + :in_person_enrollment, :passed, enrollment_established_at: Time.zone.now - 2.hours + ) + end + + it 'returns number of minutes since enrollment was established' do + expect(enrollment.minutes_since_established).to be_within(0.01).of(120) + end + + it 'returns nil if enrollment has not been established' do + enrollment.status = 'establishing' + enrollment.enrollment_established_at = nil + + expect(enrollment.minutes_since_established).to eq(nil) + end + end + + describe 'minutes_since_last_status_check' do + let(:enrollment) do + create( + :in_person_enrollment, :passed, status_check_attempted_at: Time.zone.now - 2.hours + ) + end + + it 'returns number of minutes since last status check' do + expect(enrollment.minutes_since_last_status_check).to be_within(0.01).of(120) + end + + it 'returns nil if enrollment has not been status-checked' do + enrollment.status_check_attempted_at = nil + + expect(enrollment.minutes_since_last_status_check).to eq(nil) + end + end + + describe 'minutes_since_status_updated' do + let(:enrollment) do + enrollment = create(:in_person_enrollment, :passed) + enrollment.status_updated_at = (Time.zone.now - 2.hours) + enrollment + end + + it 'returns number of minutes since the status was updated' do + expect(enrollment.minutes_since_last_status_update).to be_within(0.01).of(120) + end + + it 'returns nil if enrollment status has not been updated' do + enrollment.status_updated_at = nil + + expect(enrollment.minutes_since_last_status_update).to eq(nil) + end + end end diff --git a/spec/scripts/changelog_check_spec.rb b/spec/scripts/changelog_check_spec.rb index de30504decb..81dac6be249 100644 --- a/spec/scripts/changelog_check_spec.rb +++ b/spec/scripts/changelog_check_spec.rb @@ -86,23 +86,21 @@ - Security: Upgrade Rails to patch vulnerability ([#6041](https://github.com/18F/identity-idp/pull/6041), [#6042](https://github.com/18F/identity-idp/pull/6042)) CHANGELOG end - end - - describe '#generate_changelog' do - it 'capitalizes subcategory and capitalizes first letter of change description' do - git_log = <<~COMMIT - title: Add LOGIN_TASK_LOG_LEVEL env var (#6037) - body:- Lets us set log level to minimize STDOUT output - from Identity::Hostdata (downloading files from S3, etc) - - * changelog: Improvements, authentication, provide better authentication (LG-4515) - DELIMITER - COMMIT + it 'sorts changelog by subcategory' do + commits = [ + git_fixtures['squashed_commit_with_one_commit'], + git_fixtures['squashed_commit_2'], + ] + git_log = commits.pluck('commit_log').join("\n") changelogs = generate_changelog(git_log) + formatted_changelog = format_changelog(changelogs) - expect(changelogs.first.subcategory).to eq('Authentication') - expect(changelogs.first.change).to start_with('P') + expect(formatted_changelog).to eq <<~CHANGELOG.chomp + ## Internal + - Logging: Update logging flow ([#9999](https://github.com/18F/identity-idp/pull/9999)) + - Security: Upgrade Rails to patch vulnerability ([#6041](https://github.com/18F/identity-idp/pull/6041)) + CHANGELOG end end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 29a72925743..51a67e3453a 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -14,9 +14,21 @@ let(:applicant) { { foo: 'bar' } } let(:trace_id) { SecureRandom.uuid } let(:request_ip) { Faker::Internet.ip_v4_address } + let(:issuer) { 'fake-issuer' } + let(:friendly_name) { 'fake-name' } + let(:app_id) { 'fake-app-id' } let(:agent) { Idv::Agent.new(applicant) } + before do + ServiceProvider.create( + issuer: issuer, + friendly_name: friendly_name, + app_id: app_id, + device_profiling_enabled: true, + ) + end + describe '#proof_resolution' do let(:document_capture_session) { DocumentCaptureSession.new(result_id: SecureRandom.hex) } @@ -32,6 +44,7 @@ user_id: user.id, threatmetrix_session_id: nil, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result.result @@ -48,6 +61,7 @@ user_id: user.id, threatmetrix_session_id: nil, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result.result expect(result[:context][:stages][:state_id]).to include( @@ -69,6 +83,7 @@ user_id: user.id, threatmetrix_session_id: nil, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result.result expect(result[:errors][:ssn]).to eq ['Unverified SSN.'] @@ -84,6 +99,7 @@ user_id: user.id, threatmetrix_session_id: nil, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result.result @@ -105,6 +121,7 @@ user_id: user.id, threatmetrix_session_id: nil, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result.result @@ -129,6 +146,7 @@ user_id: user.id, threatmetrix_session_id: nil, request_ip: request_ip, + issuer: issuer, ) result = document_capture_session.load_proofing_result.result diff --git a/spec/services/idv/steps/ipp/ssn_step_spec.rb b/spec/services/idv/steps/ipp/ssn_step_spec.rb index d994d1b23cb..0b62bb7c2d1 100644 --- a/spec/services/idv/steps/ipp/ssn_step_spec.rb +++ b/spec/services/idv/steps/ipp/ssn_step_spec.rb @@ -5,7 +5,15 @@ let(:params) { { doc_auth: { ssn: ssn } } } let(:session) { { sp: { issuer: service_provider.issuer } } } let(:user) { build(:user) } - let(:service_provider) { create(:service_provider) } + let(:service_provider_device_profiling_enabled) { true } + let(:service_provider) do + create( + :service_provider, + issuer: 'http://sp.example.com', + app_id: '123', + device_profiling_enabled: service_provider_device_profiling_enabled, + ) + end let(:attempts_api) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } let(:threatmetrix_session_id) { nil } let(:controller) do @@ -48,24 +56,78 @@ end end - context 'with proofing device profiling collecting enabled' do - it 'does not add a threatmetrix session id to flow session' do - allow(IdentityConfig.store). - to receive(:proofing_device_profiling_collecting_enabled). - and_return(true) - step.extra_view_variables + context 'with service provider device profiling enabled' do + let(:service_provider_device_profiling_enabled) { true } + + context 'with proofing device profiling collecting enabled' do + it 'adds a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled). + and_return(true) + step.extra_view_variables - expect(flow.flow_session[:threatmetrix_session_id]).to eq(nil) + expect(flow.flow_session[:threatmetrix_session_id]).to_not eq(nil) + end + + it 'does not change threatmetrix_session_id when updating ssn' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled). + and_return(true) + step.call + session_id = flow.flow_session[:threatmetrix_session_id] + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to eq(session_id) + end end + end - it 'does not change threatmetrix_session_id when updating ssn' do - allow(IdentityConfig.store). - to receive(:proofing_device_profiling_collecting_enabled). - and_return(true) - step.call - session_id = flow.flow_session[:threatmetrix_session_id] - step.extra_view_variables - expect(flow.flow_session[:threatmetrix_session_id]).to eq(session_id) + context 'with service provider device profiling disabled' do + let(:service_provider_device_profiling_enabled) { false } + + context 'with proofing device profiling collecting enabled' do + it 'does not add a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(true) + step.extra_view_variables + + expect(flow.flow_session[:threatmetrix_session_id]).to be_nil + end + + it 'does not change threatmetrix_session_id when updating ssn' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(true) + step.call + session_id = flow.flow_session[:threatmetrix_session_id] + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to eq(session_id) + end + end + end + + context 'with service provider device profiling enabled' do + let(:service_provider_device_profiling_enabled) { true } + + context 'with proofing device profiling collecting disabled' do + it 'still adds a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled). + and_return(false) + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to_not eq(nil) + end + end + end + + context 'with service provider device profiling disabled' do + let(:service_provider_device_profiling_enabled) { false } + + context 'with proofing device profiling collecting disabled' do + it 'does not add a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(false) + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to be_nil + end end end end diff --git a/spec/services/idv/steps/ipp/verify_step_spec.rb b/spec/services/idv/steps/ipp/verify_step_spec.rb index 6d70b6ac5ba..5c7951548b5 100644 --- a/spec/services/idv/steps/ipp/verify_step_spec.rb +++ b/spec/services/idv/steps/ipp/verify_step_spec.rb @@ -59,6 +59,7 @@ threatmetrix_session_id: nil, user_id: anything, request_ip: request.remote_ip, + issuer: anything, ) step.call diff --git a/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb b/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb index ca76b141fe6..019cdc6b7be 100644 --- a/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb +++ b/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb @@ -13,6 +13,7 @@ instance_double( 'controller', analytics: FakeAnalytics.new, + irs_attempts_api_tracker: IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new, current_sp: service_provider, current_user: user, flash: {}, diff --git a/spec/services/idv/steps/ssn_step_spec.rb b/spec/services/idv/steps/ssn_step_spec.rb index eabd8395dc9..56f440d3de6 100644 --- a/spec/services/idv/steps/ssn_step_spec.rb +++ b/spec/services/idv/steps/ssn_step_spec.rb @@ -7,11 +7,13 @@ let(:params) { { doc_auth: {} } } let(:session) { { sp: { issuer: service_provider.issuer } } } let(:attempts_api) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } + let(:service_provider_device_profiling_enabled) { true } let(:service_provider) do create( :service_provider, issuer: 'http://sp.example.com', app_id: '123', + device_profiling_enabled: service_provider_device_profiling_enabled, ) end let(:controller) do @@ -80,34 +82,77 @@ end end - context 'with proofing device profiling collecting enabled' do - it 'adds a session id to flow session' do - allow(IdentityConfig.store). - to receive(:proofing_device_profiling_collecting_enabled). - and_return(true) - step.extra_view_variables + context 'with service provider device profiling enabled' do + let(:service_provider_device_profiling_enabled) { true } + + context 'with proofing device profiling collecting enabled' do + it 'adds a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled). + and_return(true) + step.extra_view_variables + + expect(flow.flow_session[:threatmetrix_session_id]).to_not eq(nil) + end + + it 'does not change threatmetrix_session_id when updating ssn' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(true) + step.call + session_id = flow.flow_session[:threatmetrix_session_id] + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to eq(session_id) + end + end + end - expect(flow.flow_session[:threatmetrix_session_id]).to_not eq(nil) + context 'with service provider device profiling disabled' do + let(:service_provider_device_profiling_enabled) { false } + + context 'with proofing device profiling collecting enabled' do + it 'does not add a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(true) + step.extra_view_variables + + expect(flow.flow_session[:threatmetrix_session_id]).to be_nil + end + + it 'does not change threatmetrix_session_id when updating ssn' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(true) + step.call + session_id = flow.flow_session[:threatmetrix_session_id] + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to eq(session_id) + end end + end - it 'does not change threatmetrix_session_id when updating ssn' do - allow(IdentityConfig.store). - to receive(:proofing_device_profiling_collecting_enabled). - and_return(true) - step.call - session_id = flow.flow_session[:threatmetrix_session_id] - step.extra_view_variables - expect(flow.flow_session[:threatmetrix_session_id]).to eq(session_id) + context 'with service provider device profiling enabled' do + let(:service_provider_device_profiling_enabled) { true } + + context 'with proofing device profiling collecting disabled' do + it 'still adds a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled). + and_return(false) + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to_not eq(nil) + end end end - context 'with proofing device profiling collecting disabled' do - it 'does not add a session id to flow session' do - allow(IdentityConfig.store). - to receive(:proofing_device_profiling_collecting_enabled). - and_return(false) - step.extra_view_variables - expect(flow.flow_session[:threatmetrix_session_id]).to eq(nil) + context 'with service provider device profiling disabled' do + let(:service_provider_device_profiling_enabled) { false } + + context 'with proofing device profiling collecting disabled' do + it 'does not add a session id to flow session' do + allow(IdentityConfig.store). + to receive(:proofing_device_profiling_collecting_enabled).and_return(false) + step.extra_view_variables + expect(flow.flow_session[:threatmetrix_session_id]).to be_nil + end end end end diff --git a/spec/services/idv/steps/verify_step_spec.rb b/spec/services/idv/steps/verify_step_spec.rb index a56dd7e3981..c6f4729da6d 100644 --- a/spec/services/idv/steps/verify_step_spec.rb +++ b/spec/services/idv/steps/verify_step_spec.rb @@ -65,6 +65,7 @@ threatmetrix_session_id: nil, user_id: user.id, request_ip: request.remote_ip, + issuer: anything, ) step.call diff --git a/spec/services/idv/steps/verify_wait_step_show_spec.rb b/spec/services/idv/steps/verify_wait_step_show_spec.rb index 3c53a241809..77073ea5c20 100644 --- a/spec/services/idv/steps/verify_wait_step_show_spec.rb +++ b/spec/services/idv/steps/verify_wait_step_show_spec.rb @@ -6,6 +6,8 @@ let(:user) { build(:user) } let(:issuer) { 'test_issuer' } let(:service_provider) { build(:service_provider, issuer: issuer) } + let(:resolution_transaction_id) { Proofing::Mock::ResolutionMockClient::TRANSACTION_ID } + let(:threatmetrix_transaction_id) { Proofing::Mock::DdpMockClient::TRANSACTION_ID } let(:request) { FakeRequest.new } @@ -13,6 +15,7 @@ instance_double( 'controller', analytics: FakeAnalytics.new, + irs_attempts_api_tracker: IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new, current_sp: service_provider, current_user: user, flash: {}, @@ -24,7 +27,8 @@ let(:idv_result) do { - context: { stages: { resolution: {} } }, + context: { stages: { resolution: { transaction_id: resolution_transaction_id }, + threatmetrix: { transaction_id: threatmetrix_transaction_id } } }, errors: {}, exception: nil, success: true, @@ -71,11 +75,21 @@ end it 'adds costs' do + allow(IdentityConfig.store).to receive(:proofing_device_profiling_collecting_enabled). + and_return(true) + allow(IdentityConfig.store).to receive(:lexisnexis_threatmetrix_enabled).and_return(true) + step.call - expect(SpCost.where(issuer: issuer).map(&:cost_type)).to contain_exactly( - 'lexis_nexis_resolution', - ) + sp_costs = SpCost.where(issuer: issuer, cost_type: 'lexis_nexis_resolution') + expect(sp_costs[0].transaction_id).to eq(resolution_transaction_id) + sp_costs = SpCost.where(issuer: issuer, cost_type: 'threatmetrix') + expect(sp_costs[0].transaction_id).to eq(threatmetrix_transaction_id) + + proofing_cost = ProofingCost.last + expect(proofing_cost.user_id).to eq(user.id) + expect(proofing_cost.threatmetrix_count).to eq(1) + expect(proofing_cost.lexis_nexis_resolution_count).to eq(1) end it 'clears pii from session' do diff --git a/spec/services/irs_attempts_api/envelope_encryptor_spec.rb b/spec/services/irs_attempts_api/envelope_encryptor_spec.rb index 8b7cba7c1da..17012b5c5a0 100644 --- a/spec/services/irs_attempts_api/envelope_encryptor_spec.rb +++ b/spec/services/irs_attempts_api/envelope_encryptor_spec.rb @@ -4,17 +4,19 @@ let(:public_key) { private_key.public_key } describe '.encrypt' do it 'returns encrypted result' do - text = 'test' + text = Idp::Constants::MOCK_IDV_APPLICANT[:first_name] time = Time.zone.now result = IrsAttemptsApi::EnvelopeEncryptor.encrypt( data: text, timestamp: time, public_key: public_key, ) expect(result.encrypted_data).to_not eq text + expect(result.encrypted_data).to_not include(text) + expect(Base64.decode64(result.encrypted_data)).to_not include(text) end it 'filename includes digest and truncated timestamp' do - text = 'test' + text = Idp::Constants::MOCK_IDV_APPLICANT[:first_name] time = Time.zone.now result = IrsAttemptsApi::EnvelopeEncryptor.encrypt( data: text, timestamp: time, @@ -31,7 +33,7 @@ describe '.decrypt' do it 'returns decrypted text' do - text = 'test' + text = Idp::Constants::MOCK_IDV_APPLICANT[:first_name] time = Time.zone.now result = IrsAttemptsApi::EnvelopeEncryptor.encrypt( data: text, timestamp: time, diff --git a/spec/services/irs_attempts_api/redis_client_spec.rb b/spec/services/irs_attempts_api/redis_client_spec.rb index 61d35b33612..56b312aae90 100644 --- a/spec/services/irs_attempts_api/redis_client_spec.rb +++ b/spec/services/irs_attempts_api/redis_client_spec.rb @@ -9,7 +9,9 @@ event_type: 'test_event', session_id: 'test-session-id', occurred_at: Time.zone.now, - event_metadata: { 'foo' => 'bar' }, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, ) event_key = event.event_key jwe = event.to_jwe @@ -34,7 +36,9 @@ event_type: 'test_event', session_id: 'test-session-id', occurred_at: now, - event_metadata: { 'foo' => 'bar' }, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, ) event_key = event.event_key jwe = event.to_jwe @@ -57,13 +61,17 @@ event_type: 'test_event', session_id: 'test-session-id', occurred_at: time1, - event_metadata: { 'foo' => 'bar' }, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, ) event2 = IrsAttemptsApi::AttemptEvent.new( event_type: 'test_event', session_id: 'test-session-id', occurred_at: time2, - event_metadata: { 'foo' => 'bar' }, + event_metadata: { + first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name], + }, ) jwe1 = event1.to_jwe jwe2 = event2.to_jwe diff --git a/spec/services/irs_attempts_api/tracker_spec.rb b/spec/services/irs_attempts_api/tracker_spec.rb index e3826510a16..85e9bd860c9 100644 --- a/spec/services/irs_attempts_api/tracker_spec.rb +++ b/spec/services/irs_attempts_api/tracker_spec.rb @@ -44,6 +44,17 @@ end end + it 'does not store events in plaintext in redis' do + freeze_time do + subject.track_event(:event, first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + + events = IrsAttemptsApi::RedisClient.new.read_events(timestamp: Time.zone.now) + + expect(events.keys.first).to_not include('first_name') + expect(events.values.first).to_not include(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + end + end + context 'the current session is not an IRS attempt API session' do let(:enabled_for_session) { false } diff --git a/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb b/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb index ad9b6da39bc..3026c8157bb 100644 --- a/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb +++ b/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb @@ -27,6 +27,9 @@ config: LexisNexisFixtures.example_config, ) end + let(:issuer) { 'fake-issuer' } + let(:friendly_name) { 'fake-name' } + let(:app_id) { 'fake-app-id' } describe '#send' do context 'when the request times out' do @@ -61,6 +64,12 @@ subject(:result) { instance.proof(applicant) } before do + ServiceProvider.create( + issuer: issuer, + friendly_name: friendly_name, + app_id: app_id, + device_profiling_enabled: true, + ) stub_request(:post, verification_request.url). to_return(body: response_body, status: 200) end diff --git a/spec/services/proofing/lexis_nexis/ddp/response_redacter_spec.rb b/spec/services/proofing/lexis_nexis/ddp/response_redacter_spec.rb new file mode 100644 index 00000000000..5f0576a9d7a --- /dev/null +++ b/spec/services/proofing/lexis_nexis/ddp/response_redacter_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +describe Proofing::LexisNexis::Ddp::ResponseRedacter do + let(:json) do + Proofing::LexisNexis::Ddp::ResponseRedacter. + redact(sample_hash) + end + + describe 'self.redact' do + let(:sample_hash) do + { + 'unknown_key' => 'dangerous data', + 'first_name' => 'unsafe first name', + 'ssn_hash' => 'unsafe ssn hash', + 'review_status' => 'safe value', + 'summary_risk_score' => 'safe value', + 'fraudpoint.score' => 'safe value', + } + end + context 'hash with mixed known and unknown keys' do + it 'redacts values of unknown keys and allows known keys' do + expect(json).to eq( + 'unknown_key' => '[redacted]', + 'first_name' => '[redacted]', + 'ssn_hash' => '[redacted]', + 'review_status' => 'safe value', + 'summary_risk_score' => 'safe value', + 'fraudpoint.score' => 'safe value', + ) + end + end + + context 'nil hash argument' do + let(:sample_hash) do + nil + end + it 'produces an error about an empty body' do + expect(json[:error]).to eq('TMx response body was empty') + end + end + + context 'mismatched data type argument' do + let(:sample_hash) do + [] + end + it 'produces an error about malformed body' do + expect(json[:error]).to eq('TMx response body was malformed') + end + end + + context 'empty hash agrument' do + let(:sample_hash) do + {} + end + it 'passes the empty hash onward' do + expect(json).to eq({}) + end + end + end +end diff --git a/spec/services/proofing/lexis_nexis/instant_verify/proofing_spec.rb b/spec/services/proofing/lexis_nexis/instant_verify/proofing_spec.rb index 59de75db66a..7e18580ad24 100644 --- a/spec/services/proofing/lexis_nexis/instant_verify/proofing_spec.rb +++ b/spec/services/proofing/lexis_nexis/instant_verify/proofing_spec.rb @@ -72,7 +72,7 @@ end context 'when the response is a not a full match' do - let(:response_body) { LexisNexisFixtures.instant_verify_year_of_birth_fail_response_json } + let(:response_body) { LexisNexisFixtures.instant_verify_date_of_birth_fail_response_json } it 'is a failure result' do result = subject.proof(applicant) diff --git a/spec/services/proofing/lexis_nexis/phone_finder/proofing_spec.rb b/spec/services/proofing/lexis_nexis/phone_finder/proofing_spec.rb index c7ec272b6a9..45dc5b3c68f 100644 --- a/spec/services/proofing/lexis_nexis/phone_finder/proofing_spec.rb +++ b/spec/services/proofing/lexis_nexis/phone_finder/proofing_spec.rb @@ -35,7 +35,7 @@ context 'when the response is a failure' do let(:response_body) do - LexisNexisFixtures.instant_verify_date_of_birth_full_fail_response_json + LexisNexisFixtures.instant_verify_date_of_birth_fail_response_json end it 'is a failure result' do diff --git a/spec/services/proofing/lexis_nexis/response_spec.rb b/spec/services/proofing/lexis_nexis/response_spec.rb index cfafbdc308e..aec12df23a6 100644 --- a/spec/services/proofing/lexis_nexis/response_spec.rb +++ b/spec/services/proofing/lexis_nexis/response_spec.rb @@ -28,12 +28,12 @@ describe '#verification_errors' do context 'with a failed verification' do - let(:response_body) { LexisNexisFixtures.instant_verify_failure_response_json } + let(:response_body) { LexisNexisFixtures.instant_verify_identity_not_found_response_json } it 'returns a hash of errors' do errors = subject.verification_errors expect(errors).to be_a(Hash) - expect(errors).to include(:base, :SomeOtherProduct, :InstantVerify) + expect(errors).to include(:base, :'Execute Instant Verify') end end @@ -48,7 +48,7 @@ end context 'failed' do - let(:response_body) { LexisNexisFixtures.instant_verify_failure_response_json } + let(:response_body) { LexisNexisFixtures.instant_verify_identity_not_found_response_json } it { expect(subject.verification_status).to eq('failed') } context 'with a transaction error' do @@ -72,7 +72,7 @@ errors = subject.verification_errors expect(errors).to be_a(Hash) - expect(errors).to include(:base, :SomeOtherProduct, :InstantVerify) + expect(errors).to include(:base, :'Execute Instant Verify') expect(errors[:base]).to eq("Invalid status in response body: 'fake_status'") end end diff --git a/spec/services/proofing/lexis_nexis/verification_error_parser_spec.rb b/spec/services/proofing/lexis_nexis/verification_error_parser_spec.rb index 2ed093b22ab..05b2b29b413 100644 --- a/spec/services/proofing/lexis_nexis/verification_error_parser_spec.rb +++ b/spec/services/proofing/lexis_nexis/verification_error_parser_spec.rb @@ -1,12 +1,14 @@ require 'rails_helper' RSpec.describe Proofing::LexisNexis::VerificationErrorParser do - let(:response_body) { JSON.parse(LexisNexisFixtures.instant_verify_failure_response_json) } + let(:response_body) do + JSON.parse(LexisNexisFixtures.instant_verify_identity_not_found_response_json) + end subject(:error_parser) { described_class.new(response_body) } describe '#initialize' do let(:response_body) do - JSON.parse(LexisNexisFixtures.instant_verify_year_of_birth_fail_response_json) + JSON.parse(LexisNexisFixtures.instant_verify_date_of_birth_fail_response_json) end end diff --git a/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb b/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb index ca76389f42c..9ac55bc0299 100644 --- a/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb +++ b/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb @@ -60,7 +60,7 @@ expect(applicant.state).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:state]) expect(applicant.zip_code).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:zipcode]) expect(applicant.email).to eq('no-reply@login.gov') - expect(applicant.unique_id).to be_a(String) + expect(applicant.unique_id).to eq(enrollment.unique_id) proofer.request_enroll(applicant) end @@ -68,11 +68,29 @@ subject.schedule_in_person_enrollment(user, pii) end - it 'sets enrollment status to pending and sets enrollment established at date' do + context 'when the enrollment does not have a unique ID' do + it 'uses the deprecated InPersonEnrollment#usps_unique_id value to create the enrollment' do + enrollment.update(unique_id: nil) + proofer = UspsInPersonProofing::Mock::Proofer.new + mock = double + + expect(UspsInPersonProofing::Proofer).to receive(:new).and_return(mock) + expect(mock).to receive(:request_enroll) do |applicant| + expect(applicant.unique_id).to eq(enrollment.usps_unique_id) + + proofer.request_enroll(applicant) + end + + subject.schedule_in_person_enrollment(user, pii) + end + end + + it 'sets enrollment status to pending and sets established at date and unique id' do subject.schedule_in_person_enrollment(user, pii) expect(user.in_person_enrollments.first.status).to eq('pending') expect(user.in_person_enrollments.first.enrollment_established_at).to_not be_nil + expect(user.in_person_enrollments.first.unique_id).to_not be_nil end it 'sends verification emails' do diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index 85844fa09f6..72ab82ccc9b 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -41,7 +41,7 @@ def begin_in_person_proofing(_user = nil) mock_doc_auth_attention_with_barcode attach_and_submit_images - click_button t('idv.troubleshooting.options.verify_in_person') + click_link t('idv.troubleshooting.options.verify_in_person') end def complete_location_step(_user = nil) @@ -92,4 +92,14 @@ def expect_in_person_step_indicator_current_step(text) expect_step_indicator_current_step(text) end + + def expect_in_person_gpo_step_indicator_current_step(text) + # Ensure that GPO letter step is shown in the step indicator. + expect(page).to have_css( + '.step-indicator__step', + text: t('step_indicator.flows.idv.get_a_letter'), + ) + + expect_in_person_step_indicator_current_step(text) + end end diff --git a/spec/support/idv_examples/gpo_otp_verification_step.rb b/spec/support/idv_examples/gpo_otp_verification_step.rb deleted file mode 100644 index f2a6a8f1485..00000000000 --- a/spec/support/idv_examples/gpo_otp_verification_step.rb +++ /dev/null @@ -1,96 +0,0 @@ -shared_examples 'gpo otp verification step' do |sp| - let(:otp) { 'ABC123' } - let(:profile) do - create( - :profile, - deactivation_reason: :gpo_verification_pending, - pii: { ssn: '123-45-6789', dob: '1970-01-01' }, - ) - end - let(:gpo_confirmation_code) do - create( - :gpo_confirmation_code, - profile: profile, - otp_fingerprint: Pii::Fingerprinter.fingerprint(otp), - ) - end - let(:user) { profile.user } - - it 'prompts for confirmation code at sign in' do - sign_in_from_sp(sp) - - expect(current_path).to eq idv_gpo_verify_path - expect(page).to have_content t('idv.messages.gpo.resend') - - gpo_confirmation_code - fill_in t('forms.verify_profile.name'), with: otp - click_button t('forms.verify_profile.submit') - - expect(user.events.account_verified.size).to eq 1 - expect(page).to_not have_content(t('account.index.verification.reactivate_button')) - - if %i[saml oidc].include?(sp) - expect(current_path).to eq(sign_up_completed_path) - - click_agree_and_continue - - if sp == :saml - expect(current_path).to eq(test_saml_decode_assertion_path) - elsif sp == :oidc - redirect_uri = URI(current_url) - - expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') - end - else - expect(current_path).to eq account_path - end - end - - it 'renders an error for an expired GPO OTP' do - sign_in_from_sp(sp) - - gpo_confirmation_code.update(code_sent_at: 11.days.ago) - fill_in t('forms.verify_profile.name'), with: otp - click_button t('forms.verify_profile.submit') - - expect(current_path).to eq idv_gpo_verify_path - expect(page).to have_content t('errors.messages.gpo_otp_expired') - - user.reload - - expect(user.events.account_verified.size).to eq 0 - expect(user.active_profile).to be_nil - end - - it 'allows a user to resend a letter' do - allow(Base32::Crockford).to receive(:encode).and_return(otp) - - sign_in_from_sp(sp) - - expect(GpoConfirmation.count).to eq(0) - expect(GpoConfirmationCode.count).to eq(0) - - click_on t('idv.messages.gpo.resend') - click_on t('idv.buttons.mail.send') - - expect(GpoConfirmation.count).to eq(1) - expect(GpoConfirmationCode.count).to eq(1) - expect(current_path).to eq idv_come_back_later_path - - confirmation_code = GpoConfirmationCode.first - otp_fingerprint = Pii::Fingerprinter.fingerprint(otp) - - expect(confirmation_code.otp_fingerprint).to eq(otp_fingerprint) - expect(confirmation_code.profile).to eq(profile) - end - - def sign_in_from_sp(sp) - visit_idp_from_sp_with_ial2(sp) - - if %i[saml oidc].include?(sp) - sign_in_via_branded_page(user) - else - sign_in_live_with_2fa(user) - end - end -end diff --git a/spec/support/lexis_nexis_fixtures.rb b/spec/support/lexis_nexis_fixtures.rb index 30e83cb5264..574bb82a6c5 100644 --- a/spec/support/lexis_nexis_fixtures.rb +++ b/spec/support/lexis_nexis_fixtures.rb @@ -57,23 +57,30 @@ def instant_verify_success_response_json JSON.parse(raw).to_json end - def instant_verify_failure_response_json - raw = read_fixture_file_at_path('instant_verify/failed_response.json') + def instant_verify_error_response_json + raw = read_fixture_file_at_path('instant_verify/error_response.json') JSON.parse(raw).to_json end - def instant_verify_error_response_json - raw = read_fixture_file_at_path('instant_verify/error_response.json') + def instant_verify_identity_not_found_response_json + raw = read_fixture_file_at_path('instant_verify/identity_not_found_response.json') JSON.parse(raw).to_json end - def instant_verify_year_of_birth_fail_response_json - raw = read_fixture_file_at_path('instant_verify/year_of_birth_fail_response.json') + def instant_verify_date_of_birth_fail_response_json + raw = read_fixture_file_at_path('instant_verify/date_of_birth_failure_response.json') JSON.parse(raw).to_json end - def instant_verify_date_of_birth_full_fail_response_json - raw = read_fixture_file_at_path('instant_verify/date_of_birth_full_fail_response.json') + def instant_verify_address_fail_response_json + raw = read_fixture_file_at_path('instant_verify/address_failure_response.json') + JSON.parse(raw).to_json + end + + def instant_verify_date_of_birth_and_address_fail_response_json + raw = read_fixture_file_at_path( + 'instant_verify/date_of_birth_and_address_failure_response.json', + ) JSON.parse(raw).to_json end diff --git a/spec/support/usps_ipp_helper.rb b/spec/support/usps_ipp_helper.rb index d35200414ac..2a46b8a17d5 100644 --- a/spec/support/usps_ipp_helper.rb +++ b/spec/support/usps_ipp_helper.rb @@ -38,9 +38,15 @@ def stub_request_enroll_invalid_response def stub_request_expired_proofing_results stub_request(:post, %r{/ivs-ippaas-api/IPPRest/resources/rest/getProofingResults}).to_return( + **request_expired_proofing_results_args, + ) + end + + def request_expired_proofing_results_args + { status: 400, body: UspsInPersonProofing::Mock::Fixtures. request_expired_proofing_results_response - ) + } end def stub_request_failed_proofing_results @@ -71,9 +77,15 @@ def stub_request_passed_proofing_unsupported_status_results def stub_request_passed_proofing_results stub_request(:post, %r{/ivs-ippaas-api/IPPRest/resources/rest/getProofingResults}).to_return( + **request_passed_proofing_results_args, + ) + end + + def request_passed_proofing_results_args + { status: 200, body: UspsInPersonProofing::Mock::Fixtures. request_passed_proofing_results_response - ) + } end def stub_request_in_progress_proofing_results diff --git a/spec/views/idv/come_back_later/show.html.erb_spec.rb b/spec/views/idv/come_back_later/show.html.erb_spec.rb index c6b577555b3..83004c5d39f 100644 --- a/spec/views/idv/come_back_later/show.html.erb_spec.rb +++ b/spec/views/idv/come_back_later/show.html.erb_spec.rb @@ -2,11 +2,13 @@ describe 'idv/come_back_later/show.html.erb' do let(:sp_name) { '🔒🌐💻' } + let(:step_indicator_steps) { Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS_GPO } before do @decorated_session = instance_double(ServiceProviderSessionDecorator) allow(@decorated_session).to receive(:sp_name).and_return(sp_name) allow(view).to receive(:decorated_session).and_return(@decorated_session) + allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) end context 'with an SP' do @@ -55,16 +57,12 @@ end end - it 'shows step indicator with pending status' do + it 'shows step indicator with current step' do render expect(view.content_for(:pre_flash_content)).to have_css( '.step-indicator__step--current', - text: t('step_indicator.flows.idv.verify_phone_or_address'), - ) - expect(view.content_for(:pre_flash_content)).to have_css( - '.step-indicator__step--complete', - text: t('step_indicator.flows.idv.secure_account'), + text: t('step_indicator.flows.idv.get_a_letter'), ) end end diff --git a/spec/views/idv/gpo/index.html.erb_spec.rb b/spec/views/idv/gpo/index.html.erb_spec.rb index a627f5c332d..3db3acecfa7 100644 --- a/spec/views/idv/gpo/index.html.erb_spec.rb +++ b/spec/views/idv/gpo/index.html.erb_spec.rb @@ -4,6 +4,7 @@ let(:letter_already_sent) { false } let(:user_needs_address_otp_verification) { false } let(:go_back_path) { nil } + let(:step_indicator_steps) { Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS } let(:presenter) do user = build_stubbed(:user, :signed_up) Idv::GpoPresenter.new(user, {}) @@ -11,6 +12,7 @@ before do allow(view).to receive(:go_back_path).and_return(go_back_path) + allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) allow(presenter).to receive(:letter_already_sent?).and_return(letter_already_sent) allow(presenter).to receive(:user_needs_address_otp_verification?). diff --git a/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb b/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb index 6796f9afd2b..1bccbe79489 100644 --- a/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb +++ b/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb @@ -23,9 +23,11 @@ ) end let(:presenter) { Idv::InPerson::ReadyToVerifyPresenter.new(enrollment: enrollment) } + let(:step_indicator_steps) { Idv::Flows::InPersonFlow::STEP_INDICATOR_STEPS } before do assign(:presenter, presenter) + allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) end context 'with enrollment where current address matches id' do diff --git a/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb b/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb index 81df22a560e..78a350c4fa0 100644 --- a/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb +++ b/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb @@ -3,6 +3,7 @@ describe 'idv/inherited_proofing/get_started.html.erb' do let(:flow_session) { {} } let(:sp_name) { nil } + let(:locale) { nil } before do @decorated_session = instance_double(ServiceProviderSessionDecorator) @@ -17,4 +18,36 @@ expect(rendered).to have_button(t('inherited_proofing.buttons.continue')) end + + describe 'I18n' do + before do + view.locale = locale + + render template: 'idv/inherited_proofing/get_started' + end + + context 'when rendered using the default locale' do + let(:locale) { nil } + + it 'renders the correct language' do + expect(rendered).to have_content('Get started verifying your identity') + end + end + + context 'when rendered using the French (:fr) locale' do + let(:locale) { :fr } + + it 'renders the correct language' do + expect(rendered).to have_content('Commencez à vérifier votre identité') + end + end + + context 'when rendered using the Spanish (:es) locale' do + let(:locale) { :es } + + it 'renders using the correct locale' do + expect(rendered).to have_content('Empiece con la verificación de su identidad') + end + end + end end diff --git a/spec/views/idv/otp_delivery_method/new.html.erb_spec.rb b/spec/views/idv/otp_delivery_method/new.html.erb_spec.rb index de831c6ec3d..97b0accbd72 100644 --- a/spec/views/idv/otp_delivery_method/new.html.erb_spec.rb +++ b/spec/views/idv/otp_delivery_method/new.html.erb_spec.rb @@ -2,11 +2,13 @@ describe 'idv/otp_delivery_method/new.html.erb' do let(:gpo_letter_available) { false } + let(:step_indicator_steps) { Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS } before do allow(view).to receive(:user_signing_up?).and_return(false) allow(view).to receive(:user_fully_authenticated?).and_return(true) allow(view).to receive(:gpo_letter_available).and_return(gpo_letter_available) + allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) end subject(:rendered) { render template: 'idv/otp_delivery_method/new' } diff --git a/spec/views/idv/phone/new.html.erb_spec.rb b/spec/views/idv/phone/new.html.erb_spec.rb index c9043852844..eef92ef544c 100644 --- a/spec/views/idv/phone/new.html.erb_spec.rb +++ b/spec/views/idv/phone/new.html.erb_spec.rb @@ -2,11 +2,13 @@ describe 'idv/phone/new.html.erb' do let(:gpo_letter_available) { false } + let(:step_indicator_steps) { Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS } before do allow(view).to receive(:user_signing_up?).and_return(false) allow(view).to receive(:user_fully_authenticated?).and_return(true) allow(view).to receive(:gpo_letter_available).and_return(gpo_letter_available) + allow(view).to receive(:step_indicator_steps).and_return(step_indicator_steps) @idv_form = Idv::PhoneForm.new(user: build_stubbed(:user), previous_params: nil) end diff --git a/spec/views/idv/review/new.html.erb_spec.rb b/spec/views/idv/review/new.html.erb_spec.rb index 632669872a4..6aa51f33264 100644 --- a/spec/views/idv/review/new.html.erb_spec.rb +++ b/spec/views/idv/review/new.html.erb_spec.rb @@ -9,6 +9,8 @@ before do user = build_stubbed(:user, :signed_up) allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:step_indicator_steps). + and_return(Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS) @applicant = { first_name: 'Some', last_name: 'One', @@ -20,7 +22,6 @@ zipcode: '12345', phone: '+1 (213) 555-0000', } - @step_indicator_steps = Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS render end diff --git a/spec/views/idv/shared/_ssn.html.erb_spec.rb b/spec/views/idv/shared/_ssn.html.erb_spec.rb index b79af8c70b6..7f50ed52030 100644 --- a/spec/views/idv/shared/_ssn.html.erb_spec.rb +++ b/spec/views/idv/shared/_ssn.html.erb_spec.rb @@ -27,10 +27,9 @@ to receive(:lexisnexis_threatmetrix_org_id).and_return(lexisnexis_threatmetrix_org_id) render partial: 'idv/shared/ssn', locals: { - flow_session: { - threatmetrix_session_id: session_id, - }, + flow_session: {}, success_alert_enabled: false, + threatmetrix_session_id: session_id, updating_ssn: updating_ssn, } end diff --git a/spec/views/shared/_step_indicator.html.erb_spec.rb b/spec/views/shared/_step_indicator.html.erb_spec.rb index 264276098b1..8b92939c4b3 100644 --- a/spec/views/shared/_step_indicator.html.erb_spec.rb +++ b/spec/views/shared/_step_indicator.html.erb_spec.rb @@ -75,12 +75,12 @@ end context 'explicit step status' do - let(:steps) { [{ name: :one, status: :pending }, { name: :two }] } + let(:steps) { [{ name: :one, status: :complete }, { name: :two }] } let(:current_step) { :two } it 'renders with status' do expect(rendered).to have_css( - '.step-indicator__step--pending', + '.step-indicator__step--complete', text: t('step_indicator.flows.example.one'), ) end @@ -138,4 +138,12 @@ end end end + + context 'with invalid step' do + let(:current_step) { :missing } + + it 'renders without a current step' do + expect(rendered).not_to have_css('.step-indicator__step--current') + end + end end diff --git a/spec/views/shared/_step_indicator_step.html.erb_spec.rb b/spec/views/shared/_step_indicator_step.html.erb_spec.rb index b38e4bf59b5..6d2fd386847 100644 --- a/spec/views/shared/_step_indicator_step.html.erb_spec.rb +++ b/spec/views/shared/_step_indicator_step.html.erb_spec.rb @@ -24,7 +24,6 @@ it 'renders incomplete step' do expect(rendered).to have_selector('.step-indicator__step') expect(rendered).not_to have_selector('.step-indicator__step--current') - expect(rendered).not_to have_selector('.step-indicator__step--pending') expect(rendered).not_to have_selector('.step-indicator__step--complete') end @@ -39,7 +38,6 @@ it 'renders current step' do expect(rendered).to have_selector('.step-indicator__step') expect(rendered).to have_selector('.step-indicator__step--current') - expect(rendered).not_to have_selector('.step-indicator__step--pending') expect(rendered).not_to have_selector('.step-indicator__step--complete') end @@ -53,7 +51,6 @@ it 'renders pending step' do expect(rendered).to have_selector('.step-indicator__step') - expect(rendered).to have_selector('.step-indicator__step--pending') expect(rendered).not_to have_selector('.step-indicator__step--current') expect(rendered).not_to have_selector('.step-indicator__step--complete') end @@ -70,7 +67,6 @@ expect(rendered).to have_selector('.step-indicator__step') expect(rendered).to have_selector('.step-indicator__step--complete') expect(rendered).not_to have_selector('.step-indicator__step--current') - expect(rendered).not_to have_selector('.step-indicator__step--pending') end it 'renders accessible indicator' do diff --git a/yarn.lock b/yarn.lock index 8148b515f38..f89d3b8af6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1189,34 +1189,6 @@ "@parcel/css-linux-x64-musl" "1.12.2" "@parcel/css-win32-x64-msvc" "1.12.2" -"@peculiar/asn1-schema@^2.0.27": - version "2.0.27" - resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.27.tgz#1ee3b2b869ff3200bcc8ec60e6c87bd5a6f03fe0" - integrity sha512-1tIx7iL3Ma3HtnNS93nB7nhyI0soUJypElj9owd4tpMrRDmeJ8eZubsdq1sb0KSaCs5RqZNoABCP6m5WtnlVhQ== - dependencies: - "@types/asn1js" "^2.0.0" - asn1js "^2.0.26" - pvtsutils "^1.1.1" - tslib "^2.0.3" - -"@peculiar/json-schema@^1.1.12": - version "1.1.12" - resolved "https://registry.yarnpkg.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz#fe61e85259e3b5ba5ad566cb62ca75b3d3cd5339" - integrity sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w== - dependencies: - tslib "^2.0.0" - -"@peculiar/webcrypto@^1.1.6": - version "1.1.6" - resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.1.6.tgz#484bb58be07149e19e873861b585b0d5e4f83b7b" - integrity sha512-xcTjouis4Y117mcsJslWAGypwhxtXslkVdRp7e3tHwtuw0/xCp1te8RuMMv/ia5TsvxomcyX/T+qTbRZGLLvyA== - dependencies: - "@peculiar/asn1-schema" "^2.0.27" - "@peculiar/json-schema" "^1.1.12" - pvtsutils "^1.1.2" - tslib "^2.1.0" - webcrypto-core "^1.2.0" - "@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1": version "1.8.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.1.tgz#e7df00f98a203324f6dc7cc606cad9d4a8ab2217" @@ -1303,11 +1275,6 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0" integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A== -"@types/asn1js@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@types/asn1js/-/asn1js-2.0.0.tgz#10ca75692575744d0117098148a8dc84cbee6682" - integrity sha512-Jjzp5EqU0hNpADctc/UqhiFbY1y2MqIxBVa2S4dBlbnZHTLPMuggoL5q43X63LpsOIINRDirBjP56DUUKIUWIA== - "@types/body-parser@*": version "1.19.2" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" @@ -1619,15 +1586,15 @@ semver "^7.3.5" tsutils "^3.21.0" -"@typescript-eslint/parser@^5.12.0": - version "5.12.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.12.0.tgz#0ca669861813df99ce54916f66f524c625ed2434" - integrity sha512-MfSwg9JMBojMUoGjUmX+D2stoQj1CBYTCP0qnnVtu9A+YQXVKNtLjasYh+jozOcrb/wau8TCfWOkQTiOAruBog== +"@typescript-eslint/parser@^5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.2.tgz#3ddf323d3ac85a25295a55fcb9c7a49ab4680ddd" + integrity sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA== dependencies: - "@typescript-eslint/scope-manager" "5.12.0" - "@typescript-eslint/types" "5.12.0" - "@typescript-eslint/typescript-estree" "5.12.0" - debug "^4.3.2" + "@typescript-eslint/scope-manager" "5.36.2" + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/typescript-estree" "5.36.2" + debug "^4.3.4" "@typescript-eslint/scope-manager@5.12.0": version "5.12.0" @@ -1637,6 +1604,14 @@ "@typescript-eslint/types" "5.12.0" "@typescript-eslint/visitor-keys" "5.12.0" +"@typescript-eslint/scope-manager@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" + integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + "@typescript-eslint/type-utils@5.12.0": version "5.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.12.0.tgz#aaf45765de71c6d9707c66ccff76ec2b9aa31bb6" @@ -1651,6 +1626,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.12.0.tgz#5b4030a28222ee01e851836562c07769eecda0b8" integrity sha512-JowqbwPf93nvf8fZn5XrPGFBdIK8+yx5UEGs2QFAYFI8IWYfrzz+6zqlurGr2ctShMaJxqwsqmra3WXWjH1nRQ== +"@typescript-eslint/types@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" + integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== + "@typescript-eslint/typescript-estree@5.12.0": version "5.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.12.0.tgz#cabf545fd592722f0e2b4104711e63bf89525cd2" @@ -1664,6 +1644,19 @@ semver "^7.3.5" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" + integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== + dependencies: + "@typescript-eslint/types" "5.36.2" + "@typescript-eslint/visitor-keys" "5.36.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + "@typescript-eslint/utils@5.12.0": version "5.12.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.12.0.tgz#92fd3193191621ab863add2f553a7b38b65646af" @@ -1684,6 +1677,14 @@ "@typescript-eslint/types" "5.12.0" eslint-visitor-keys "^3.0.0" +"@typescript-eslint/visitor-keys@5.36.2": + version "5.36.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" + integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== + dependencies: + "@typescript-eslint/types" "5.36.2" + eslint-visitor-keys "^3.3.0" + "@ungap/promise-all-settled@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" @@ -2060,13 +2061,6 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= -asn1js@^2.0.26: - version "2.0.26" - resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-2.0.26.tgz#0a6d435000f556a96c6012969d9704d981b71251" - integrity sha512-yG89F0j9B4B0MKIcFyWWxnpZPLaNTjCj4tkE3fjbAoo0qmpGw0PYYqSbX/4ebnd9Icn8ZgK4K1fvDyEtW1JYtQ== - dependencies: - pvutils latest - assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -2722,7 +2716,14 @@ debug@2.6.9, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@4.3.3, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +debug@4.3.3: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== @@ -5498,18 +5499,6 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pvtsutils@^1.1.1, pvtsutils@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.1.2.tgz#483d72f4baa5e354466e68ff783ce8a9e2810030" - integrity sha512-Yfm9Dsk1zfEpOWCaJaHfqtNXAFWNNHMFSCLN6jTnhuCCBCC2nqge4sAgo7UrkRBoAAYIL8TN/6LlLoNfZD/b5A== - dependencies: - tslib "^2.1.0" - -pvutils@latest: - version "1.0.17" - resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.0.17.tgz#ade3c74dfe7178944fe44806626bd2e249d996bf" - integrity sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ== - qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" @@ -5929,10 +5918,10 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.3.4, semver@^7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== +semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" @@ -6564,7 +6553,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0: +tslib@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== @@ -6628,10 +6617,10 @@ typedarray-to-buffer@^4.0.0: resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-4.0.0.tgz#cdd2933c61dd3f5f02eda5d012d441f95bfeb50a" integrity sha512-6dOYeZfS3O9RtRD1caom0sMxgK59b27+IwoNy8RDPsmslSGOyU+mpTamlaIW7aNKi90ZQZ9DFaZL3YRoiSCULQ== -typescript@^4.5.5: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^4.8.2: + version "4.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.2.tgz#e3b33d5ccfb5914e4eeab6699cf208adee3fd790" + integrity sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw== unbox-primitive@^1.0.1: version "1.0.1" @@ -6778,17 +6767,6 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" -webcrypto-core@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.2.0.tgz#44fda3f9315ed6effe9a1e47466e0935327733b5" - integrity sha512-p76Z/YLuE4CHCRdc49FB/ETaM4bzM3roqWNJeGs+QNY1fOTzKTOVnhmudW1fuO+5EZg6/4LG9NJ6gaAyxTk9XQ== - dependencies: - "@peculiar/asn1-schema" "^2.0.27" - "@peculiar/json-schema" "^1.1.12" - asn1js "^2.0.26" - pvtsutils "^1.1.2" - tslib "^2.1.0" - webcrypto-shim@^0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/webcrypto-shim/-/webcrypto-shim-0.1.7.tgz#da8be23061a0451cf23b424d4a9b61c10f091c12"