diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index beb72072569..70d18d33390 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -449,7 +449,7 @@ trigger_devops: - echo "View your applications deployment progress at https://argocd.reviewapp.identitysandbox.gov/applications/argocd/${CI_ENVIRONMENT_SLUG}?view=tree&resource=" - echo "DNS may take a while to propagate, so be patient if it doesn't show up right away" - echo "To access the rails console, first run 'aws-vault exec sandbox-power -- aws eks update-kubeconfig --name reviewapp'" - - echo "Then run aws-vault exec sandbox-power -- kubectl exec -it service/$CI_ENVIRONMENT_SLUG-login-chart-idp -n review-apps -- /app/bin/rails console" + - echo "Then run aws-vault exec sandbox-power -- kubectl exec -it service/$CI_ENVIRONMENT_SLUG-idp -n review-apps -- /app/bin/rails console" - echo "Address of IDP review app:" - echo https://$CI_ENVIRONMENT_SLUG.reviewapps.identitysandbox.gov - echo "Address of PIVCAC review app:" diff --git a/.rubocop.yml b/.rubocop.yml index 20ec9a534fe..3b952f5e1c4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -843,6 +843,9 @@ Rails/Blank: Rails/CompactBlank: Enabled: false +Rails/DangerousColumnNames: + Enabled: true + Rails/Delegate: Enabled: false @@ -868,6 +871,12 @@ Rails/DynamicFindBy: Rails/EagerEvaluationLogMessage: Enabled: true +Rails/EnumSyntax: + Enabled: true + +Rails/EnvLocal: + Enabled: true + Rails/ExpandedDateRange: Enabled: true @@ -932,6 +941,9 @@ Rails/PluckInWhere: Rails/Present: Enabled: false +Rails/RedundantActiveRecordAllMethod: + Enabled: true + Rails/RedundantPresenceValidationOnBelongsTo: Enabled: false @@ -959,6 +971,9 @@ Rails/RootPathnameMethods: Rails/RootPublicPath: Enabled: true +Rails/SelectMap: + Enabled: true + Rails/ShortI18n: Enabled: true @@ -998,6 +1013,9 @@ Rails/TransactionExitStatement: Rails/UnusedIgnoredColumns: Enabled: true +Rails/UnusedRenderContent: + Enabled: true + Rails/WhereEquals: Enabled: true @@ -1013,6 +1031,9 @@ Rails/WhereNot: Rails/WhereNotWithMultipleConditions: Enabled: true +Rails/WhereRange: + Enabled: false + RSpec/LeakyConstantDeclaration: Enabled: true diff --git a/Gemfile b/Gemfile index dd61c0f101b..e96adfe135d 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" } ruby "~> #{File.read(File.join(__dir__, '.ruby-version')).strip}" -gem 'rails', '~> 7.1.3' +gem 'rails', '~> 7.1.4' gem 'ahoy_matey', '~> 3.0' # pod identity requires 3.188.0 @@ -120,7 +120,7 @@ group :development, :test do gem 'rspec-rails', '~> 6.0' gem 'rubocop', '~> 1.62.0', require: false gem 'rubocop-performance', '~> 1.20.2', require: false - gem 'rubocop-rails', '>= 2.5.2', require: false + gem 'rubocop-rails', '>= 2.26.2', require: false gem 'rubocop-rspec', require: false gem 'sqlite3', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index 586968a65db..9dbc4929498 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,35 +79,35 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + actioncable (7.1.4.1) + actionpack (= 7.1.4.1) + activesupport (= 7.1.4.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionmailbox (7.1.4.1) + actionpack (= 7.1.4.1) + activejob (= 7.1.4.1) + activerecord (= 7.1.4.1) + activestorage (= 7.1.4.1) + activesupport (= 7.1.4.1) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionmailer (7.1.4.1) + actionpack (= 7.1.4.1) + actionview (= 7.1.4.1) + activejob (= 7.1.4.1) + activesupport (= 7.1.4.1) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (7.1.4.1) + actionview (= 7.1.4.1) + activesupport (= 7.1.4.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -115,35 +115,35 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.4) - actionpack (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + actiontext (7.1.4.1) + actionpack (= 7.1.4.1) + activerecord (= 7.1.4.1) + activestorage (= 7.1.4.1) + activesupport (= 7.1.4.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (7.1.4.1) + activesupport (= 7.1.4.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3.4) - activesupport (= 7.1.3.4) + activejob (7.1.4.1) + activesupport (= 7.1.4.1) globalid (>= 0.3.6) - activemodel (7.1.3.4) - activesupport (= 7.1.3.4) - activerecord (7.1.3.4) - activemodel (= 7.1.3.4) - activesupport (= 7.1.3.4) + activemodel (7.1.4.1) + activesupport (= 7.1.4.1) + activerecord (7.1.4.1) + activemodel (= 7.1.4.1) + activesupport (= 7.1.4.1) timeout (>= 0.4.0) - activestorage (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activesupport (= 7.1.3.4) + activestorage (7.1.4.1) + actionpack (= 7.1.4.1) + activejob (= 7.1.4.1) + activerecord (= 7.1.4.1) + activesupport (= 7.1.4.1) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (7.1.4.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -343,7 +343,7 @@ GEM ffi (~> 1.0) globalid (1.2.1) activesupport (>= 6.1) - good_job (3.21.1) + good_job (3.99.1) activejob (>= 6.0.0) activerecord (>= 6.0.0) concurrent-ruby (>= 1.0.2) @@ -523,20 +523,20 @@ GEM rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails (7.1.3.4) - actioncable (= 7.1.3.4) - actionmailbox (= 7.1.3.4) - actionmailer (= 7.1.3.4) - actionpack (= 7.1.3.4) - actiontext (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activemodel (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + rails (7.1.4.1) + actioncable (= 7.1.4.1) + actionmailbox (= 7.1.4.1) + actionmailer (= 7.1.4.1) + actionpack (= 7.1.4.1) + actiontext (= 7.1.4.1) + actionview (= 7.1.4.1) + activejob (= 7.1.4.1) + activemodel (= 7.1.4.1) + activerecord (= 7.1.4.1) + activestorage (= 7.1.4.1) + activesupport (= 7.1.4.1) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 7.1.4.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -551,9 +551,9 @@ GEM rails-i18n (7.0.6) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + railties (7.1.4.1) + actionpack (= 7.1.4.1) + activesupport (= 7.1.4.1) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -633,10 +633,11 @@ GEM rubocop-performance (1.20.2) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.20.2) + rubocop-rails (2.26.2) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) rubocop-rspec (2.24.1) rubocop (~> 1.33) rubocop-capybara (~> 2.17) @@ -836,7 +837,7 @@ DEPENDENCIES rack-test (>= 1.1.0) rack-timeout rack_session_access (>= 0.2.0) - rails (~> 7.1.3) + rails (~> 7.1.4) rails-controller-testing (>= 1.0.4) redacted_struct redis (>= 3.2.0) @@ -851,7 +852,7 @@ DEPENDENCIES rspec_junit_formatter rubocop (~> 1.62.0) rubocop-performance (~> 1.20.2) - rubocop-rails (>= 2.5.2) + rubocop-rails (>= 2.26.2) rubocop-rspec ruby-progressbar ruby-saml diff --git a/Makefile b/Makefile index b779ffaf0de..f1ee0b813e3 100644 --- a/Makefile +++ b/Makefile @@ -239,7 +239,7 @@ normalize_yaml: ## Normalizes YAML files (alphabetizes keys, fixes line length, optimize_svg: ## Optimizes SVG images # Exclusions: # - `login-icon-bimi.svg` is hand-optimized and includes required metadata that would be stripped by SVGO - find app/assets/images public -name '*.svg' -not -name 'login-icon-bimi.svg' | xargs ./node_modules/.bin/svgo + find app/assets/images public -name '*.svg' -not -name 'login-icon-bimi.svg' -not -name 'selfie-capture-accept-help.svg' | xargs ./node_modules/.bin/svgo optimize_assets: optimize_svg ## Optimizes all assets diff --git a/app/assets/images/idv/selfie-capture-accept-help.svg b/app/assets/images/idv/selfie-capture-accept-help.svg new file mode 100644 index 00000000000..4fe255bebae --- /dev/null +++ b/app/assets/images/idv/selfie-capture-accept-help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/idv/selfie-capture-help.svg b/app/assets/images/idv/selfie-capture-help.svg new file mode 100644 index 00000000000..a90ed990f52 --- /dev/null +++ b/app/assets/images/idv/selfie-capture-help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/components/login_button_component.scss b/app/components/login_button_component.scss index 7ddb4c301bd..cfc411966ce 100644 --- a/app/components/login_button_component.scss +++ b/app/components/login_button_component.scss @@ -9,8 +9,7 @@ @include u-text('primary-darker'); &:hover { - @include u-bg('primary-lighter'); - @include u-text('primary-darker'); + @include u-bg('primary-light'); } } @@ -22,9 +21,8 @@ &:hover { @include u-border(1px); - @include u-bg('white'); - @include u-text('primary-darker'); @include u-border('base'); + @include u-bg('primary-lighter'); } } @@ -33,7 +31,6 @@ @include u-text('white'); &:hover { - @include u-bg('primary-darker'); - @include u-text('white'); + @include u-bg('primary-darkest'); } } diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index d592617a455..1108da94480 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -185,6 +185,7 @@ def async_state_done(current_async_state) state: pii[:state], state_id_jurisdiction: pii[:state_id_jurisdiction], state_id_number: pii[:state_id_number], + state_id_type: pii[:state_id_type], extra: { address_edited: !!idv_session.address_edited, address_line2_present: !pii[:address2].blank?, @@ -203,9 +204,7 @@ def async_state_done(current_async_state) }, ) - threatmetrix_reponse_body = form_response.extra.dig( - :proofing_results, :context, :stages, :threatmetrix, :response_body - ) + threatmetrix_reponse_body = delete_threatmetrix_response_body(form_response) if threatmetrix_reponse_body.present? analytics.idv_threatmetrix_response_body( response_body: threatmetrix_reponse_body, @@ -275,12 +274,14 @@ def idv_result_to_form_response( state: nil, state_id_jurisdiction: nil, state_id_number: nil, + state_id_type: nil, extra: {} ) state_id = result.dig(:context, :stages, :state_id) if state_id state_id[:state] = state if state state_id[:state_id_jurisdiction] = state_id_jurisdiction if state_id_jurisdiction + state_id[:state_id_type] = state_id_type if state_id_type if state_id_number state_id[:state_id_number] = StringRedacter.redact_alphanumeric(state_id_number) @@ -314,6 +315,18 @@ def move_applicant_to_idv_session idv_session.applicant['uuid'] = current_user.uuid end + def delete_threatmetrix_response_body(form_response) + threatmetrix_result = form_response.extra.dig( + :proofing_results, + :context, + :stages, + :threatmetrix, + ) + return if threatmetrix_result.blank? + + threatmetrix_result.delete(:response_body) + end + def add_cost(token, transaction_id: nil) Db::SpCost::AddSpCost.call(current_sp, token, transaction_id: transaction_id) end diff --git a/app/controllers/concerns/two_factor_authenticatable_methods.rb b/app/controllers/concerns/two_factor_authenticatable_methods.rb index bcfee78cc87..a69ecdbfe9f 100644 --- a/app/controllers/concerns/two_factor_authenticatable_methods.rb +++ b/app/controllers/concerns/two_factor_authenticatable_methods.rb @@ -90,17 +90,6 @@ def check_already_authenticated redirect_to after_sign_in_path_for(current_user) end - def check_sp_required_mfa_bypass(auth_method:) - return unless service_provider_mfa_policy.user_needs_sp_auth_method_verification? - return if service_provider_mfa_policy.phishing_resistant_required? && - TwoFactorAuthenticatable::AuthMethod.phishing_resistant?(auth_method) - if service_provider_mfa_policy.piv_cac_required? && - auth_method == TwoFactorAuthenticatable::AuthMethod::PIV_CAC - return - end - prompt_to_verify_sp_required_mfa - end - def reset_attempt_count_if_user_no_longer_locked_out return unless current_user.no_longer_locked_out? diff --git a/app/controllers/idv/hybrid_mobile/entry_controller.rb b/app/controllers/idv/hybrid_mobile/entry_controller.rb index fabf2f413de..5cc4f9a94b0 100644 --- a/app/controllers/idv/hybrid_mobile/entry_controller.rb +++ b/app/controllers/idv/hybrid_mobile/entry_controller.rb @@ -53,13 +53,6 @@ def validate_document_capture_session_id result = Idv::DocumentCaptureSessionForm.new(document_capture_session_uuid).submit - event_properties = result.to_h.tap do |properties| - # See LG-8890 for context - properties[:doc_capture_user_id?] = session[:doc_capture_user_id].present? - end - - analytics.track_event 'Doc Auth', event_properties - if result.success? reset_session diff --git a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb index 71090f7e300..a3045d5e85f 100644 --- a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb @@ -31,7 +31,7 @@ def show document_capture_session = DocumentCaptureSession.find_by( uuid: document_capture_session_uuid, ) - document_capture_session.socure_docv_token = document_response.dig( + document_capture_session.socure_docv_transaction_token = document_response.dig( :data, :docvTransactionToken, ) diff --git a/app/controllers/idv/socure/document_capture_controller.rb b/app/controllers/idv/socure/document_capture_controller.rb index 214918d95f7..9ea30963c4c 100644 --- a/app/controllers/idv/socure/document_capture_controller.rb +++ b/app/controllers/idv/socure/document_capture_controller.rb @@ -41,7 +41,7 @@ def show uuid: document_capture_session_uuid, ) - document_capture_session.socure_docv_token = document_response.dig( + document_capture_session.socure_docv_transaction_token = document_response.dig( :data, :docvTransactionToken, ) diff --git a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb index e792cc3fc86..5f3ab20849e 100644 --- a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb +++ b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb @@ -6,7 +6,6 @@ class BackupCodeVerificationController < ApplicationController include NewDeviceConcern prepend_before_action :authenticate_user - before_action :check_sp_required_mfa def show analytics.multi_factor_auth_enter_backup_code_visit(context: context) @@ -80,9 +79,5 @@ def backup_code_params def handle_valid_backup_code redirect_to after_sign_in_path_for(current_user) end - - def check_sp_required_mfa - check_sp_required_mfa_bypass(auth_method: 'backup_code') - end end end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 57b3d5b6012..728d8620ce6 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -6,7 +6,6 @@ class OtpVerificationController < ApplicationController include MfaSetupConcern include NewDeviceConcern - before_action :check_sp_required_mfa before_action :confirm_multiple_factors_enabled before_action :redirect_if_blank_phone, only: [:show] before_action :confirm_voice_capability, only: [:show] @@ -197,10 +196,6 @@ def confirmation_for_add_phone? UserSessionContext.confirmation_context?(context) && user_fully_authenticated? end - def check_sp_required_mfa - check_sp_required_mfa_bypass(auth_method: params[:otp_delivery_preference]) - end - def assign_phone if updating_existing_number? phone_changed diff --git a/app/controllers/two_factor_authentication/totp_verification_controller.rb b/app/controllers/two_factor_authentication/totp_verification_controller.rb index 72e0983d558..5577e37a5ed 100644 --- a/app/controllers/two_factor_authentication/totp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/totp_verification_controller.rb @@ -5,7 +5,6 @@ class TotpVerificationController < ApplicationController include TwoFactorAuthenticatable include NewDeviceConcern - before_action :check_sp_required_mfa before_action :confirm_totp_enabled def show @@ -57,9 +56,5 @@ def authenticator_view_data two_factor_authentication_method: 'authenticator', }.merge(generic_data) end - - def check_sp_required_mfa - check_sp_required_mfa_bypass(auth_method: 'authenticator') - end end end diff --git a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb index c58a1967315..f546783cbe4 100644 --- a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb +++ b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb @@ -6,7 +6,6 @@ class WebauthnVerificationController < ApplicationController include TwoFactorAuthenticatable include NewDeviceConcern - before_action :check_sp_required_mfa before_action :confirm_webauthn_enabled, only: :show def show @@ -124,10 +123,6 @@ def form ) end - def check_sp_required_mfa - check_sp_required_mfa_bypass(auth_method: 'webauthn') - end - def platform_authenticator_param? params[:platform].to_s == 'true' end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 14e5fd4df50..95c4149e587 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -39,7 +39,7 @@ def create return process_rate_limited if session_bad_password_count_max_exceeded? return process_locked_out_user if current_user && user_locked_out?(current_user) return process_rate_limited if rate_limited? - return process_failed_captcha if !valid_captcha_result? + return process_failed_captcha unless valid_captcha_result? || log_captcha_failures_only? rate_limit_password_failure = true self.resource = warden.authenticate!(auth_options) @@ -204,7 +204,10 @@ def handle_valid_authentication def track_authentication_attempt user = user_from_params || AnonymousUser.new - success = current_user.present? && !user_locked_out?(user) && valid_captcha_result? + success = current_user.present? && + !user_locked_out?(user) && + (valid_captcha_result? || log_captcha_failures_only?) + analytics.email_and_password_auth( success: success, user_id: user.uuid, @@ -308,6 +311,10 @@ def randomize_check_password? SecureRandom.random_number(IdentityConfig.store.compromised_password_randomizer_value) >= IdentityConfig.store.compromised_password_randomizer_threshold end + + def log_captcha_failures_only? + IdentityConfig.store.sign_in_recaptcha_log_failures_only + end end def unsafe_redirect_error(_exception) diff --git a/app/javascript/packages/compose-components/README.md b/app/javascript/packages/compose-components/README.md deleted file mode 100644 index fec7f76731e..00000000000 --- a/app/javascript/packages/compose-components/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# `@18f/identity-compose-components` - -A utility function to compose a set of React components and their props to a single component. - -Convenient for flattening a deeply-nested arrangement of context providers, for example. - -## Example - -```jsx -const App = composeComponents( - [FirstContext.Provider, { value: 1 }], - [SecondContext.Provider, { value: 2 }], - AppRoot, -); - -render(App, document.getElementById('app-root')); -``` diff --git a/app/javascript/packages/compose-components/index.js b/app/javascript/packages/compose-components/index.js deleted file mode 100644 index 5dce9d65faf..00000000000 --- a/app/javascript/packages/compose-components/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import { createElement } from 'react'; - -/** @typedef {import('react').ComponentType

} ComponentType @template P */ - -/** - * @typedef {[ComponentType

, P]} NormalizedComponentPair - * - * @template P - */ - -/** - * @typedef {[ComponentType

, P]|[ComponentType

]|ComponentType

} ComponentPair - * - * @template P - */ - -/** - * A utility function to compose a set of React components and their props to a single component. - * - * Convenient for flattening a deeply-nested arrangement of context providers, for example. - * - * @example - * ```jsx - * const App = composeComponents( - * [FirstContext.Provider, { value: 1 }], - * [SecondContext.Provider, { value: 2 }], - * AppRoot, - * ); - * - * render(App, document.getElementById('app-root')); - * ``` - * - * @param {...ComponentPair<*>} components - * - * @return {ComponentType<*>} - */ -export function composeComponents(...components) { - return function ComposedComponent() { - /** @type {JSX.Element?} */ - let element = null; - for (let i = components.length - 1; i >= 0; i--) { - const componentPair = /** @type {NormalizedComponentPair<*>} */ ( - Array.isArray(components[i]) ? components[i] : [components[i]] - ); - const [ComponentType, props] = componentPair; - element = createElement(ComponentType, props, element); - } - - return element; - }; -} diff --git a/app/javascript/packages/compose-components/index.spec.jsx b/app/javascript/packages/compose-components/index.spec.jsx deleted file mode 100644 index ba0e3f9c39b..00000000000 --- a/app/javascript/packages/compose-components/index.spec.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext, useContext } from 'react'; -import { render } from '@testing-library/react'; -import { composeComponents } from './index.js'; - -describe('composeComponents', () => { - it('composes components', () => { - const FirstContext = createContext(null); - const SecondContext = createContext(null); - function AppRoot() { - return ( - <> - {useContext(FirstContext)} - {useContext(SecondContext)} - - ); - } - - const ComposedComponent = composeComponents( - [FirstContext.Provider, { value: 1 }], - [SecondContext.Provider, { value: 2 }], - [({ children }) => <>{children}3], - AppRoot, - ); - - const { getByText } = render(); - - expect(getByText('123')).to.be.ok(); - }); -}); diff --git a/app/javascript/packages/compose-components/package.json b/app/javascript/packages/compose-components/package.json deleted file mode 100644 index 29fcd9d2b79..00000000000 --- a/app/javascript/packages/compose-components/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@18f/identity-compose-components", - "private": true, - "version": "1.0.0", - "dependencies": { - "react": "^17.0.2" - }, - "sideEffects": false -} diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 8e5e0778869..e40bee7fb58 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -327,12 +327,11 @@ function AcuantCapture( } = useContext(AcuantContext); const { isMockClient } = useContext(UploadContext); const { trackEvent } = useContext(AnalyticsContext); - const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext); + const { isSelfieCaptureEnabled, immediatelyBeginCapture } = useContext(SelfieCaptureContext); const fullScreenRef = useRef(null); const inputRef = useRef(null); const isForceUploading = useRef(false); const isSuppressingClickLogging = useRef(false); - const [isCapturingEnvironment, setIsCapturingEnvironment] = useState(false); const [ownErrorMessage, setOwnErrorMessage] = useState(null); const [hasStartedCropping, setHasStartedCropping] = useState(false); useMemo(() => setOwnErrorMessage(null), [value]); @@ -350,6 +349,9 @@ function AcuantCapture( // This hook does that. const isBackOfId = name === 'back'; useLogCameraInfo({ isBackOfId, hasStartedCropping }); + const [isCapturingEnvironment, setIsCapturingEnvironment] = useState( + selfieCapture && immediatelyBeginCapture, + ); const { failedCaptureAttempts, diff --git a/app/javascript/packages/document-capture/components/acuant-selfie-camera.tsx b/app/javascript/packages/document-capture/components/acuant-selfie-camera.tsx index 6063dce58b6..45b668081eb 100644 --- a/app/javascript/packages/document-capture/components/acuant-selfie-camera.tsx +++ b/app/javascript/packages/document-capture/components/acuant-selfie-camera.tsx @@ -157,12 +157,12 @@ function AcuantSelfieCamera({ CAPTURE_ALT: t('doc_auth.info.selfie_capture.action.capture'), }; const cleanupSelfieCamera = () => { - window.AcuantPassiveLiveness.end(); + window.AcuantPassiveLiveness?.end(); setIsActive(false); }; const startSelfieCamera = () => { - window.AcuantPassiveLiveness.start(faceCaptureCallback, faceDetectionStates); + window.AcuantPassiveLiveness?.start(faceCaptureCallback, faceDetectionStates); setIsActive(true); }; diff --git a/app/javascript/packages/document-capture/components/acuant-selfie-instructions.spec.tsx b/app/javascript/packages/document-capture/components/acuant-selfie-instructions.spec.tsx new file mode 100644 index 00000000000..c29f0378e8f --- /dev/null +++ b/app/javascript/packages/document-capture/components/acuant-selfie-instructions.spec.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react'; +import AcuantSelfieInstructions from './acuant-selfie-instructions'; + +describe('SelfieInstructions', () => { + let getByText; + let queryAllByRole; + + beforeEach(() => { + const renderedComponent = render(); + getByText = renderedComponent.getByText; + queryAllByRole = renderedComponent.queryAllByRole; + }); + + it('renders the header', () => { + expect(getByText('doc_auth.headings.selfie_instructions.howto')).to.exist(); + }); + + it('renders the instruction graphics', () => { + expect(queryAllByRole('img').length).to.equal(2); + }); + + it('renders the first instruction block', () => { + expect(getByText('doc_auth.info.selfie_capture_help_1')).to.exist(); + }); + + it('renders the second instruction block', () => { + expect(getByText('doc_auth.info.selfie_capture_help_2')).to.exist(); + }); +}); diff --git a/app/javascript/packages/document-capture/components/acuant-selfie-instructions.tsx b/app/javascript/packages/document-capture/components/acuant-selfie-instructions.tsx new file mode 100644 index 00000000000..715eb1b666e --- /dev/null +++ b/app/javascript/packages/document-capture/components/acuant-selfie-instructions.tsx @@ -0,0 +1,26 @@ +import { getAssetPath } from '@18f/identity-assets'; +import { t } from '@18f/identity-i18n'; + +export default function AcuantSelfieInstructions() { + return ( + <> +

+ {t('doc_auth.headings.selfie_instructions.howto')} +
+
+ {t('doc_auth.alt.selfie_help_1')} +
{t('doc_auth.info.selfie_capture_help_1')}
+
+
+ {t('doc_auth.alt.selfie_help_2')} +
{t('doc_auth.info.selfie_capture_help_2')}
+
+ + ); +} diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.spec.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.spec.tsx index cc37fdf45aa..b50b883d9b7 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.spec.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.spec.tsx @@ -5,7 +5,6 @@ import { toFormEntryError } from '@18f/identity-document-capture/services/upload import { I18nContext } from '@18f/identity-react-i18n'; import { I18n } from '@18f/identity-i18n'; import { expect } from 'chai'; -import { composeComponents } from '@18f/identity-compose-components'; describe('DocumentCaptureReviewIssues', () => { const DEFAULT_OPTIONS = { @@ -46,54 +45,51 @@ describe('DocumentCaptureReviewIssues', () => { context('with doc error', () => { it('renders for non doc type failure', () => { - const props = { - isFailedDocType: false, - remainingSubmitAttempts: 2, - unknownFieldErrors: [ - { - field: 'general', - error: toFormEntryError({ field: 'network', message: 'general error' }), - }, - ], - errors: [ - { - field: 'front', - error: toFormEntryError({ field: 'front', message: 'front side error' }), - }, - { - field: 'back', - error: toFormEntryError({ field: 'back', message: 'back side error' }), - }, - ], - }; - const App = composeComponents( - [ - InPersonContext.Provider, - { - value: { - inPersonURL: '/verify/doc_capture', - }, - }, - ], - [ - I18nContext.Provider, - { - value: new I18n({ - strings: { - 'idv.failure.attempts_html': 'You have %{count} attempts remaining.', - }, - }), - }, - ], - [ - DocumentCaptureReviewIssues, - { - ...DEFAULT_OPTIONS, - ...props, - }, - ], + const { getByText, getByLabelText, getByRole, getAllByRole } = render( + + + + + , ); - const { getByText, getByLabelText, getByRole, getAllByRole } = render(); + const h1 = screen.getByRole('heading', { name: 'doc_auth.headings.review_issues', level: 1 }); expect(h1).to.be.ok(); diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx index 7489c732fb1..d5cf14d439a 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx @@ -7,8 +7,7 @@ import type { FormStepComponentProps } from '@18f/identity-form-steps'; import GeneralError from './general-error'; import TipList from './tip-list'; import { SelfieCaptureContext } from '../context'; -import { DocumentCaptureSubheaderOne } from './documents-and-selfie-step'; -import { DocumentsCaptureStep } from './documents-step'; +import { DocumentCaptureSubheaderOne, DocumentsCaptureStep } from './documents-step'; import { SelfieCaptureStep } from './selfie-step'; import type { ReviewIssuesStepValue } from './review-issues-step'; @@ -48,9 +47,7 @@ function DocumentCaptureReviewIssues({ return ( <> {t('doc_auth.headings.review_issues')} - {isSelfieCaptureEnabled && ( - - )} + {isSelfieCaptureEnabled && } )} diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index 7bdbfa29934..6ffda351cab 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -7,7 +7,6 @@ import { useDidUpdateEffect } from '@18f/identity-react-hooks'; import type { FormStep } from '@18f/identity-form-steps'; import { getConfigValue } from '@18f/identity-config'; import { UploadFormEntriesError } from '../services/upload'; -import DocumentsAndSelfieStep from './documents-and-selfie-step'; import SelfieStep from './selfie-step'; import DocumentsStep from './documents-step'; import InPersonPrepareStep from './in-person-prepare-step'; @@ -39,7 +38,7 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { const { t } = useI18n(); const { flowPath } = useContext(UploadContext); const { trackSubmitEvent, trackVisitEvent } = useContext(AnalyticsContext); - const { isSelfieCaptureEnabled, docAuthSeparatePagesEnabled } = useContext(SelfieCaptureContext); + const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext); const { inPersonFullAddressEntryEnabled, inPersonURL, skipDocAuth, skipDocAuthFromHandoff } = useContext(InPersonContext); useDidUpdateEffect(onStepChange, [stepName]); @@ -54,11 +53,6 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { : InPersonLocationPostOfficeSearchStep; // Define different states to be used in human readable array declaration - const documentAndSelfieFormStep: FormStep = { - name: 'documentsAndSelfie', - form: DocumentsAndSelfieStep, - title: t('doc_auth.headings.document_capture'), - }; const documentFormStep: FormStep = { name: 'documents', form: DocumentsStep, @@ -70,9 +64,9 @@ function DocumentCapture({ onStepChange = () => {} }: DocumentCaptureProps) { title: t('doc_auth.headings.selfie_capture'), }; const documentsFormSteps: FormStep[] = - isSelfieCaptureEnabled && docAuthSeparatePagesEnabled && submissionError === undefined + isSelfieCaptureEnabled && submissionError === undefined ? [documentFormStep, selfieFormStep] - : [documentAndSelfieFormStep]; + : [documentFormStep]; const reviewFormStep: FormStep = { name: 'review', form: diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx index 746e1cf5b11..0a85cea92e1 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx @@ -57,10 +57,9 @@ function DocumentSideAcuantCapture({ }: DocumentSideAcuantCaptureProps) { const error = errors.find(({ field }) => field === side)?.error; const { changeStepCanComplete } = useContext(FormStepsContext); - const { isSelfieCaptureEnabled, isSelfieDesktopTestMode, docAuthSeparatePagesEnabled } = - useContext(SelfieCaptureContext); + const { isSelfieCaptureEnabled, isSelfieDesktopTestMode } = useContext(SelfieCaptureContext); const isUploadAllowed = isSelfieDesktopTestMode || !isSelfieCaptureEnabled; - const stepCanComplete = docAuthSeparatePagesEnabled && !isReviewStep ? undefined : true; + const stepCanComplete = !isReviewStep ? undefined : true; return ( -
- {isSelfieCaptureEnabled && '1. '} - {t('doc_auth.headings.document_capture_subheader_id')} - - ); -} -export default function DocumentsAndSelfieStep({ - value = {}, - onChange = () => {}, - errors = [], - onError = () => {}, - registerField = () => undefined, -}: FormStepComponentProps) { - const { t } = useI18n(); - const { isMobile } = useContext(DeviceContext); - const { isLastStep } = useContext(FormStepsContext); - const { flowPath } = useContext(UploadContext); - const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext); - const pageHeaderText = isSelfieCaptureEnabled - ? t('doc_auth.headings.document_capture_with_selfie') - : t('doc_auth.headings.document_capture'); - const defaultSideProps: DefaultSideProps = { - registerField, - onChange, - errors, - onError, - }; - return ( - <> - {flowPath === 'hybrid' && } - {pageHeaderText} - {isSelfieCaptureEnabled && ( - - )} - - - {isSelfieCaptureEnabled && ( - - )} - {isLastStep ? : } - - - ); -} diff --git a/app/javascript/packages/document-capture/components/documents-step.tsx b/app/javascript/packages/document-capture/components/documents-step.tsx index 8fbeafbc196..cbbb0075af8 100644 --- a/app/javascript/packages/document-capture/components/documents-step.tsx +++ b/app/javascript/packages/document-capture/components/documents-step.tsx @@ -1,6 +1,10 @@ import { useContext } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; -import { FormStepComponentProps, FormStepsButton } from '@18f/identity-form-steps'; +import { + FormStepComponentProps, + FormStepsButton, + FormStepsContext, +} from '@18f/identity-form-steps'; import { Cancel } from '@18f/identity-verify-flow'; import HybridDocCaptureWarning from './hybrid-doc-capture-warning'; import TipList from './tip-list'; @@ -51,6 +55,7 @@ export default function DocumentsStep({ registerField = () => undefined, }: FormStepComponentProps) { const { t } = useI18n(); + const { isLastStep } = useContext(FormStepsContext); const { isMobile } = useContext(DeviceContext); const { flowPath } = useContext(UploadContext); const defaultSideProps: DefaultSideProps = { @@ -77,7 +82,7 @@ export default function DocumentsStep({ value={value} isReviewStep={false} /> - + {isLastStep ? : } ); diff --git a/app/javascript/packages/document-capture/components/selfie-step.tsx b/app/javascript/packages/document-capture/components/selfie-step.tsx index 20a930e1d96..05521b021b9 100644 --- a/app/javascript/packages/document-capture/components/selfie-step.tsx +++ b/app/javascript/packages/document-capture/components/selfie-step.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useState } from 'react'; import { useI18n } from '@18f/identity-react-i18n'; import { FormStepComponentProps, @@ -6,6 +6,9 @@ import { FormStepsContext, } from '@18f/identity-form-steps'; import { Cancel } from '@18f/identity-verify-flow'; +import { SpinnerButton } from '@18f/identity-spinner-button'; +import AcuantSelfieInstructions from './acuant-selfie-instructions'; +import SelfieCaptureContext from '../context/selfie-capture'; import HybridDocCaptureWarning from './hybrid-doc-capture-warning'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; import TipList from './tip-list'; @@ -20,12 +23,15 @@ export function SelfieCaptureStep({ defaultSideProps, selfieValue, isReviewStep, + showHelp, }: { defaultSideProps: DefaultSideProps; selfieValue: ImageValue; isReviewStep: boolean; + showHelp: boolean; }) { const { t } = useI18n(); + return ( <>

{t('doc_auth.headings.document_capture_subheader_selfie')}

@@ -40,13 +46,17 @@ export function SelfieCaptureStep({ t('doc_auth.tips.document_capture_selfie_text4'), ]} /> - + + {showHelp && } + {!showHelp && ( + + )} ); } @@ -58,8 +68,29 @@ export default function SelfieStep({ onError = () => {}, registerField = () => undefined, }: FormStepComponentProps) { + const { t } = useI18n(); const { isLastStep } = useContext(FormStepsContext); const { flowPath } = useContext(UploadContext); + const { showHelpInitially } = useContext(SelfieCaptureContext); + const [showHelp, setShowHelp] = useState(showHelpInitially); + + function TakeSelfieButton() { + return ( +
+ { + setShowHelp(false); + }} + type="button" + isBig + isWide + > + {t('doc_auth.buttons.take_picture')} + +
+ ); + } const defaultSideProps: DefaultSideProps = { registerField, @@ -74,8 +105,11 @@ export default function SelfieStep({ defaultSideProps={defaultSideProps} selfieValue={value.selfie} isReviewStep={false} + showHelp={showHelp} /> - {isLastStep ? : } + {showHelp && } + {!showHelp && isLastStep && } + {!showHelp && !isLastStep && } ); diff --git a/app/javascript/packages/document-capture/context/selfie-capture.tsx b/app/javascript/packages/document-capture/context/selfie-capture.tsx index 7bb505658ff..6115ccd7f48 100644 --- a/app/javascript/packages/document-capture/context/selfie-capture.tsx +++ b/app/javascript/packages/document-capture/context/selfie-capture.tsx @@ -10,15 +10,21 @@ interface SelfieCaptureProps { */ isSelfieDesktopTestMode: boolean; /** - * Specify whether to seperate seflie upload and document upload into seperate form steps/pages + * Specify whether to show help and an action button before showing + * the capture component. */ - docAuthSeparatePagesEnabled: boolean; + showHelpInitially: boolean; + /** + * Specify whether we should try to capture using Acuant immediately + */ + immediatelyBeginCapture: boolean; } const SelfieCaptureContext = createContext({ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, - docAuthSeparatePagesEnabled: false, + showHelpInitially: true, + immediatelyBeginCapture: false, }); SelfieCaptureContext.displayName = 'SelfieCaptureContext'; diff --git a/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts b/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts index a8cd451edf1..01a33ed2338 100644 --- a/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts +++ b/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts @@ -46,6 +46,7 @@ describe('verifyWebauthnDevice', () => { transports: ['internal', 'hybrid'], }, ], + userVerification: 'discouraged', timeout: 800000, }, }; diff --git a/app/javascript/packages/webauthn/verify-webauthn-device.ts b/app/javascript/packages/webauthn/verify-webauthn-device.ts index ee39c201621..8afa3092fb8 100644 --- a/app/javascript/packages/webauthn/verify-webauthn-device.ts +++ b/app/javascript/packages/webauthn/verify-webauthn-device.ts @@ -39,6 +39,7 @@ async function verifyWebauthnDevice({ challenge: new Uint8Array(JSON.parse(userChallenge)), rpId: window.location.hostname, allowCredentials: credentials.map(mapVerifyCredential), + userVerification: 'discouraged', timeout: 800000, }, })) as PublicKeyCredential; diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 9e6b6283ee2..d21223f415b 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -1,5 +1,4 @@ import { render } from 'react-dom'; -import { composeComponents } from '@18f/identity-compose-components'; import { DocumentCapture, DeviceContext, @@ -39,10 +38,10 @@ interface AppRootData { howToVerifyURL: string; previousStepUrl: string; docAuthSelfieDesktopTestMode: string; + accountUrl: string; locationsUrl: string; addressSearchUrl: string; sessionsUrl: string; - docAuthSeparatePagesEnabled: string; } const appRoot = document.getElementById('document-capture-form')!; @@ -111,7 +110,6 @@ const { howToVerifyUrl, previousStepUrl, docAuthSelfieDesktopTestMode, - docAuthSeparatePagesEnabled, locationsUrl: locationsURL, addressSearchUrl: addressSearchURL, sessionsUrl: sessionsURL, @@ -122,94 +120,86 @@ try { parsedUsStatesTerritories = JSON.parse(usStatesTerritories); } catch (e) {} -const App = composeComponents( - [MarketingSiteContextProvider, { helpCenterRedirectURL, securityAndPrivacyHowItWorksURL }], - [DeviceContext.Provider, { value: device }], - [ - InPersonContext.Provider, - { - value: { - inPersonURL, - locationsURL, - addressSearchURL, - inPersonOutageMessageEnabled: inPersonOutageMessageEnabled === 'true', - inPersonOutageExpectedUpdateDate, - inPersonFullAddressEntryEnabled: inPersonFullAddressEntryEnabled === 'true', - optedInToInPersonProofing: optedInToInPersonProofing === 'true', - usStatesTerritories: parsedUsStatesTerritories, - skipDocAuth: skipDocAuth === 'true', - skipDocAuthFromHandoff: skipDocAuthFromHandoff === 'true', - howToVerifyURL: howToVerifyUrl, - previousStepURL: previousStepUrl, - }, - }, - ], - [AnalyticsContextProvider, { trackEvent }], - [ - AcuantContextProvider, - { - sdkSrc: acuantVersion && `/acuant/${acuantVersion}/AcuantJavascriptWebSdk.min.js`, - cameraSrc: acuantVersion && `/acuant/${acuantVersion}/AcuantCamera.min.js`, - passiveLivenessOpenCVSrc: acuantVersion && `/acuant/${acuantVersion}/opencv.min.js`, - passiveLivenessSrc: getSelfieCaptureEnabled() - ? acuantVersion && `/acuant/${acuantVersion}/AcuantPassiveLiveness.min.js` - : undefined, - credentials: getMetaContent('acuant-sdk-initialization-creds'), - endpoint: getMetaContent('acuant-sdk-initialization-endpoint'), - glareThreshold, - sharpnessThreshold, - }, - ], - [ - UploadContextProvider, - { - endpoint: String(appRoot.getAttribute('data-endpoint')), - statusEndpoint: String(appRoot.getAttribute('data-status-endpoint')), - statusPollInterval: Number(appRoot.getAttribute('data-status-poll-interval-ms')), - isMockClient, - formData, - flowPath, - }, - ], - [ - FlowContext.Provider, - { - value: { - accountURL, - cancelURL, - currentStep: 'document_capture', - }, - }, - ], - [ - ServiceProviderContextProvider, - { - value: getServiceProvider(), - }, - ], - [ - SelfieCaptureContext.Provider, - { - value: { - isSelfieCaptureEnabled: getSelfieCaptureEnabled(), - isSelfieDesktopTestMode: String(docAuthSelfieDesktopTestMode) === 'true', - docAuthSeparatePagesEnabled: String(docAuthSeparatePagesEnabled) === 'true', - }, - }, - ], - [ - FailedCaptureAttemptsContextProvider, - { - maxCaptureAttemptsBeforeNativeCamera: Number(maxCaptureAttemptsBeforeNativeCamera), - maxSubmissionAttemptsBeforeNativeCamera: Number(maxSubmissionAttemptsBeforeNativeCamera), - }, - ], - [ - DocumentCapture, - { - onStepChange: () => extendSession(sessionsURL), - }, - ], +render( + + + + + + + + + + + extendSession(sessionsURL)} /> + + + + + + + + + + , + appRoot, ); - -render(, appRoot); diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb index 3b5296b4264..ea870cb815b 100644 --- a/app/jobs/get_usps_proofing_results_job.rb +++ b/app/jobs/get_usps_proofing_results_job.rb @@ -263,7 +263,7 @@ def handle_unsupported_id_type(enrollment, response) enrollment.profile.deactivate_due_to_in_person_verification_cancelled # send SMS and email send_enrollment_status_sms_notification(enrollment: enrollment) - send_failed_email(enrollment.user, enrollment) + send_failed_email(enrollment:, visited_location_name: response['proofingPostOffice']) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated( **email_analytics_attributes(enrollment), email_type: 'Failed unsupported ID type', @@ -305,7 +305,9 @@ def handle_expired_status_update(enrollment, response, response_message) end begin - send_deadline_passed_email(enrollment.user, enrollment) unless enrollment.deadline_passed_sent + unless enrollment.deadline_passed_sent + send_deadline_passed_email(enrollment: enrollment, visited_location_name: 'none') + end rescue StandardError => err NewRelic::Agent.notice_error(err) analytics(user: enrollment.user). @@ -403,15 +405,16 @@ def handle_failed_status(enrollment, response) enrollment.profile.deactivate_due_to_in_person_verification_cancelled # send SMS and email send_enrollment_status_sms_notification(enrollment: enrollment) + visited_location_name = response['proofingPostOffice'] if response['fraudSuspected'] - send_failed_fraud_email(enrollment.user, enrollment) + send_failed_fraud_email(enrollment:, visited_location_name:) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated( **email_analytics_attributes(enrollment), email_type: 'Failed fraud suspected', job_name: self.class.name, ) else - send_failed_email(enrollment.user, enrollment) + send_failed_email(enrollment:, visited_location_name:) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated( **email_analytics_attributes(enrollment), email_type: 'Failed', @@ -441,7 +444,7 @@ def handle_successful_status_update(enrollment, response) # send SMS and email send_enrollment_status_sms_notification(enrollment: enrollment) - send_verified_email(enrollment.user, enrollment) + send_verified_email(enrollment:, visited_location_name: response['proofingPostOffice']) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated( **email_analytics_attributes(enrollment), email_type: 'Success', @@ -467,7 +470,7 @@ def handle_passed_with_fraud_review_pending(enrollment, response) ) # send email - send_please_call_email(enrollment.user, enrollment) + send_please_call_email(enrollment:, visited_location_name: response['proofingPostOffice']) analytics(user: enrollment.user). idv_in_person_usps_proofing_results_job_please_call_email_initiated( **email_analytics_attributes(enrollment), @@ -493,7 +496,7 @@ def handle_unsupported_secondary_id(enrollment, response) enrollment.profile.deactivate_due_to_in_person_verification_cancelled # send SMS and email send_enrollment_status_sms_notification(enrollment: enrollment) - send_failed_email(enrollment.user, enrollment) + send_failed_email(enrollment:, visited_location_name: response['proofingPostOffice']) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated( **email_analytics_attributes(enrollment), email_type: 'Failed unsupported secondary ID', @@ -542,51 +545,55 @@ def process_enrollment_response(enrollment, response) end end - def send_verified_email(user, enrollment) - user.confirmed_email_addresses.each do |email_address| + def send_verified_email(enrollment:, visited_location_name:) + enrollment.user.confirmed_email_addresses.each do |email_address| # rubocop:disable IdentityIdp/MailLaterLinter - UserMailer.with(user: user, email_address: email_address).in_person_verified( + UserMailer.with(user: enrollment.user, email_address: email_address).in_person_verified( enrollment: enrollment, + visited_location_name: visited_location_name, ).deliver_later(**notification_delivery_params(enrollment)) # rubocop:enable IdentityIdp/MailLaterLinter end end - def send_deadline_passed_email(user, enrollment) + def send_deadline_passed_email(enrollment:, visited_location_name:) # rubocop:disable IdentityIdp/MailLaterLinter - user.confirmed_email_addresses.each do |email_address| - UserMailer.with(user: user, email_address: email_address).in_person_deadline_passed( - enrollment: enrollment, - ).deliver_later + enrollment.user.confirmed_email_addresses.each do |email_address| + UserMailer.with(user: enrollment.user, email_address: email_address). + in_person_deadline_passed(enrollment: enrollment, + visited_location_name: visited_location_name).deliver_later # rubocop:enable IdentityIdp/MailLaterLinter end end - def send_failed_email(user, enrollment) - user.confirmed_email_addresses.each do |email_address| + def send_failed_email(enrollment:, visited_location_name:) + enrollment.user.confirmed_email_addresses.each do |email_address| # rubocop:disable IdentityIdp/MailLaterLinter - UserMailer.with(user: user, email_address: email_address).in_person_failed( + UserMailer.with(user: enrollment.user, email_address: email_address).in_person_failed( enrollment: enrollment, + visited_location_name: visited_location_name, ).deliver_later(**notification_delivery_params(enrollment)) # rubocop:enable IdentityIdp/MailLaterLinter end end - def send_failed_fraud_email(user, enrollment) - user.confirmed_email_addresses.each do |email_address| + def send_failed_fraud_email(enrollment:, visited_location_name:) + enrollment.user.confirmed_email_addresses.each do |email_address| # rubocop:disable IdentityIdp/MailLaterLinter - UserMailer.with(user: user, email_address: email_address).in_person_failed_fraud( + UserMailer.with(user: enrollment.user, email_address: email_address).in_person_failed_fraud( enrollment: enrollment, + visited_location_name: visited_location_name, ).deliver_later(**notification_delivery_params(enrollment)) # rubocop:enable IdentityIdp/MailLaterLinter end end - def send_please_call_email(user, enrollment) - user.confirmed_email_addresses.each do |email_address| + def send_please_call_email(enrollment:, visited_location_name:) + enrollment.user.confirmed_email_addresses.each do |email_address| # rubocop:disable IdentityIdp/MailLaterLinter - UserMailer.with(user: user, email_address: email_address).in_person_please_call( + UserMailer.with(user: enrollment.user, email_address: email_address).in_person_please_call( enrollment: enrollment, + visited_location_name: visited_location_name, ).deliver_later(**notification_delivery_params(enrollment)) # rubocop:enable IdentityIdp/MailLaterLinter end diff --git a/app/jobs/good_job_v4_ready_job.rb b/app/jobs/good_job_v4_ready_job.rb new file mode 100644 index 00000000000..0c8b9a41fd4 --- /dev/null +++ b/app/jobs/good_job_v4_ready_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class GoodJobV4ReadyJob < ApplicationJob + queue_as :default + + def perform + IdentityJobLogSubscriber.new.logger.info( + { + name: 'good_job_v4_ready', + ready: GoodJob.v4_ready?, + }.to_json, + ) + + true + end +end diff --git a/app/jobs/reports/protocols_report.rb b/app/jobs/reports/protocols_report.rb index a785aeab121..ddad8d5c079 100644 --- a/app/jobs/reports/protocols_report.rb +++ b/app/jobs/reports/protocols_report.rb @@ -8,6 +8,11 @@ class ProtocolsReport < BaseReport attr_accessor :report_date + def initialize(report_date = nil, *args, **rest) + @report_date = report_date + super(*args, **rest) + end + def perform(report_date) return unless IdentityConfig.store.s3_reports_enabled @@ -33,10 +38,14 @@ def perform(report_date) private def weekly_protocols_emailable_reports - Reporting::ProtocolsReport.new( + report.as_emailable_reports + end + + def report + @report ||= Reporting::ProtocolsReport.new( issuers: nil, time_range: report_date.all_week, - ).as_emailable_reports + ) end def report_configs diff --git a/app/jobs/socure_reason_code_download_job.rb b/app/jobs/socure_reason_code_download_job.rb new file mode 100644 index 00000000000..7cbf521e4cd --- /dev/null +++ b/app/jobs/socure_reason_code_download_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class SocureReasonCodeDownloadJob < ApplicationJob + include JobHelpers::StaleJobHelper + + queue_as :low + + discard_on JobHelpers::StaleJobHelper::StaleJobError + + def perform + return unless IdentityConfig.store.idv_socure_reason_code_download_enabled + + result = Proofing::Socure::ReasonCodes::Importer.new.synchronize + analytics.idv_socure_reason_code_download(**result.to_h) + end + + def analytics + Analytics.new( + user: AnonymousUser.new, + request: nil, + sp: nil, + session: {}, + ) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 1f2e17fb19c..7fe5e30640d 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -273,12 +273,13 @@ def in_person_completion_survey end end - def in_person_deadline_passed(enrollment:) + def in_person_deadline_passed(enrollment:, visited_location_name:) with_user_locale(user) do @header = t('user_mailer.in_person_deadline_passed.header') @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new( enrollment: enrollment, url_options: url_options, + visited_location_name: visited_location_name, ) mail( to: email_address.email, @@ -337,12 +338,13 @@ def in_person_ready_to_verify_reminder(enrollment:) end end - def in_person_verified(enrollment:) + def in_person_verified(enrollment:, visited_location_name:) with_user_locale(user) do @hide_title = true @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new( enrollment: enrollment, url_options: url_options, + visited_location_name: visited_location_name, ) mail( to: email_address.email, @@ -351,11 +353,12 @@ def in_person_verified(enrollment:) end end - def in_person_failed(enrollment:) + def in_person_failed(enrollment:, visited_location_name:) with_user_locale(user) do @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new( enrollment: enrollment, url_options: url_options, + visited_location_name: visited_location_name, ) mail( to: email_address.email, @@ -364,11 +367,12 @@ def in_person_failed(enrollment:) end end - def in_person_failed_fraud(enrollment:) + def in_person_failed_fraud(enrollment:, visited_location_name:) with_user_locale(user) do @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new( enrollment: enrollment, url_options: url_options, + visited_location_name: visited_location_name, ) mail( to: email_address.email, @@ -377,11 +381,12 @@ def in_person_failed_fraud(enrollment:) end end - def in_person_please_call(enrollment:) + def in_person_please_call(enrollment:, visited_location_name:) with_user_locale(user) do @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new( enrollment: enrollment, url_options: url_options, + visited_location_name: visited_location_name, ) @hide_title = true mail( diff --git a/app/models/event.rb b/app/models/event.rb index db39c7a6657..75e33e20959 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -4,7 +4,7 @@ class Event < ApplicationRecord belongs_to :user belongs_to :device - enum event_type: { + enum :event_type, { account_created: 1, phone_confirmed: 2, password_changed: 3, diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index 2dd814fac4d..2490bf46db2 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -20,7 +20,7 @@ class InPersonEnrollment < ApplicationRecord STATUS_EXPIRED = 'expired' STATUS_CANCELLED = 'cancelled' - enum status: { + enum :status, { STATUS_ESTABLISHING.to_sym => 0, STATUS_PENDING.to_sym => 1, STATUS_PASSED.to_sym => 2, diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb index 132a0f79b78..7320b24b951 100644 --- a/app/models/phone_configuration.rb +++ b/app/models/phone_configuration.rb @@ -8,7 +8,7 @@ class PhoneConfiguration < ApplicationRecord encrypted_attribute(name: :phone) - enum delivery_preference: { sms: 0, voice: 1 } + enum :delivery_preference, { sms: 0, voice: 1 } def formatted_phone PhoneFormatter.format(phone) diff --git a/app/models/profile.rb b/app/models/profile.rb index b4c6a2fd5e9..1e29f7dada1 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -21,7 +21,7 @@ class Profile < ApplicationRecord class_name: 'InPersonEnrollment', foreign_key: :profile_id, inverse_of: :profile, dependent: :destroy - enum deactivation_reason: { + enum :deactivation_reason, { password_reset: 1, encryption_error: 2, gpo_verification_pending_NO_LONGER_USED: 3, # deprecated @@ -29,12 +29,12 @@ class Profile < ApplicationRecord in_person_verification_pending_NO_LONGER_USED: 5, # deprecated } - enum fraud_pending_reason: { + enum :fraud_pending_reason, { threatmetrix_review: 1, threatmetrix_reject: 2, } - enum idv_level: { + enum :idv_level, { legacy_unsupervised: 1, legacy_in_person: 2, unsupervised_with_selfie: 3, diff --git a/app/models/socure_reason_code.rb b/app/models/socure_reason_code.rb new file mode 100644 index 00000000000..1e26999eac3 --- /dev/null +++ b/app/models/socure_reason_code.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SocureReasonCode < ApplicationRecord + def self.active + where(deactivated_at: nil) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 5a6accc2dbe..b5eb603eddd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,7 +25,7 @@ class User < ApplicationRecord MAX_RECENT_EVENTS = 5 MAX_RECENT_DEVICES = 5 - enum otp_delivery_preference: { sms: 0, voice: 1 } + enum :otp_delivery_preference, { sms: 0, voice: 1 } # rubocop:disable Rails/HasManyOrHasOneDependent # identities need to be orphaned to prevent UUID reuse diff --git a/app/presenters/idv/in_person/verification_results_email_presenter.rb b/app/presenters/idv/in_person/verification_results_email_presenter.rb index 3fae3d98a69..55a79260aa4 100644 --- a/app/presenters/idv/in_person/verification_results_email_presenter.rb +++ b/app/presenters/idv/in_person/verification_results_email_presenter.rb @@ -5,18 +5,15 @@ module InPerson class VerificationResultsEmailPresenter include Rails.application.routes.url_helpers - attr_reader :enrollment, :url_options + attr_reader :enrollment, :url_options, :visited_location_name # update to user's time zone when out of pilot USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'].dup.freeze - def initialize(enrollment:, url_options:) + def initialize(enrollment:, url_options:, visited_location_name:) @enrollment = enrollment @url_options = url_options - end - - def location_name - enrollment.selected_location_details['name'] + @visited_location_name = visited_location_name end def formatted_verified_date diff --git a/app/services/access_token_verifier.rb b/app/services/access_token_verifier.rb index d203a25b22a..49372e308b2 100644 --- a/app/services/access_token_verifier.rb +++ b/app/services/access_token_verifier.rb @@ -37,7 +37,7 @@ def validate_access_token end def load_identity(access_token) - identity = ServiceProviderIdentity.where(access_token: access_token).take + identity = ServiceProviderIdentity.find_by(access_token: access_token) if identity && OutOfBandSessionAccessor.new(identity.rails_session_id).ttl.positive? @identity = identity diff --git a/app/services/agency_identity_linker.rb b/app/services/agency_identity_linker.rb index 5a7dcc45775..0f0396649ab 100644 --- a/app/services/agency_identity_linker.rb +++ b/app/services/agency_identity_linker.rb @@ -16,25 +16,25 @@ def link_identity def self.for(user:, service_provider:) agency = service_provider.agency - ai = AgencyIdentity.where(user: user, agency: agency).take + ai = AgencyIdentity.find_by(user: user, agency: agency) return ai if ai.present? - spi = ServiceProviderIdentity.where( + spi = ServiceProviderIdentity.find_by( user: user, service_provider: service_provider.issuer, - ).take + ) return nil unless spi.present? new(spi).link_identity end def self.sp_identity_from_uuid_and_sp(uuid, service_provider) - ai = AgencyIdentity.where(uuid: uuid).take + ai = AgencyIdentity.find_by(uuid: uuid) criteria = if ai { user_id: ai.user_id, service_provider: service_provider } else { uuid: uuid, service_provider: service_provider } end - ServiceProviderIdentity.where(criteria).take + ServiceProviderIdentity.find_by(criteria) end private @@ -53,11 +53,11 @@ def create_agency_identity_for_sp end def agency_identity - ai = AgencyIdentity.where(uuid: @sp_identity.uuid).take + ai = AgencyIdentity.find_by(uuid: @sp_identity.uuid) return ai if ai - sp = ServiceProvider.where(issuer: @sp_identity.service_provider).take + sp = ServiceProvider.find_by(issuer: @sp_identity.service_provider) return unless agency_id(sp) - AgencyIdentity.where(agency_id: agency_id, user_id: @sp_identity.user_id).take + AgencyIdentity.find_by(agency_id: agency_id, user_id: @sp_identity.user_id) end def agency_id(service_provider = nil) diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d410f516c4e..2b5f851659e 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -4622,6 +4622,31 @@ def idv_session_error_visited( ) end + # Socure Reason Codes were downloaded and synced against persisted codes in the database + # @param [Boolean] success Result from Socure KYC API call + # @param [Hash] errors Result from resolution proofing + # @param [String] exception Exception that occured during download or synchronizaiton + # @param [Array] added_reason_codes New reason codes that were added to the database + # @param [Array] deactivated_reason_codes Old reason codes that were deactivated + def idv_socure_reason_code_download( + success: true, + errors: nil, + exception: nil, + added_reason_codes: nil, + deactivated_reason_codes: nil, + **extra + ) + track_event( + :idv_socure_reason_code_download, + success:, + errors:, + exception:, + added_reason_codes:, + deactivated_reason_codes:, + **extra, + ) + end + # Logs a Socure KYC result alongside a resolution result for later comparison. # @param [Hash] socure_result Result from Socure KYC API call # @param [Hash] resolution_result Result from resolution proofing diff --git a/app/services/create_new_device_alert.rb b/app/services/create_new_device_alert.rb index 7cda3582010..cdbb443ba63 100644 --- a/app/services/create_new_device_alert.rb +++ b/app/services/create_new_device_alert.rb @@ -8,7 +8,7 @@ def perform(now) User.where( sql_query_for_users_with_new_device, tvalue: now - IdentityConfig.store.new_device_alert_delay_in_minutes.minutes, - ).each do |user| + ).find_each do |user| emails_sent += 1 if expire_sign_in_notification_timeframe_and_send_alert(user) end diff --git a/app/services/gpo_reminder_sender.rb b/app/services/gpo_reminder_sender.rb index 741595c2773..47d49472918 100644 --- a/app/services/gpo_reminder_sender.rb +++ b/app/services/gpo_reminder_sender.rb @@ -8,7 +8,7 @@ def send_emails(for_letters_sent_before) IdentityConfig.store.usps_confirmation_max_days.days.ago..for_letters_sent_before profiles_due_for_reminder(for_letters_sent_before).each do |profile| next if profile.user.active_profile - profile.gpo_confirmation_codes.all.each do |gpo_code| + profile.gpo_confirmation_codes.find_each do |gpo_code| next if gpo_code.reminder_sent_at next unless reminder_eligible_range.cover?(gpo_code.created_at) diff --git a/app/services/proofing/socure/reason_codes/api_client.rb b/app/services/proofing/socure/reason_codes/api_client.rb new file mode 100644 index 00000000000..11c3730f748 --- /dev/null +++ b/app/services/proofing/socure/reason_codes/api_client.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module ReasonCodes + class ApiClient + class ApiClientError < StandardError; end + + def download_reason_codes + http_response = make_reason_code_http_request + http_response.body['reasonCodes'] + rescue Faraday::ConnectionFailed, + Faraday::ServerError, + Faraday::SSLError, + Faraday::TimeoutError, + Faraday::ClientError, + Faraday::ParsingError => e + raise ApiClientError, e.message + end + + private + + def make_reason_code_http_request + conn = Faraday.new do |f| + f.request :instrumentation, name: 'request_metric.faraday' + f.response :raise_error + f.response :json + f.options.timeout = IdentityConfig.store.socure_reason_code_timeout_in_seconds + f.options.read_timeout = IdentityConfig.store.socure_reason_code_timeout_in_seconds + f.options.open_timeout = IdentityConfig.store.socure_reason_code_timeout_in_seconds + f.options.write_timeout = IdentityConfig.store.socure_reason_code_timeout_in_seconds + end + + conn.get(url, { group: true }, headers) do |req| + req.options.context = { service_name: 'socure_reason_codes' } + end + end + + def headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "SocureApiKey #{IdentityConfig.store.socure_reason_code_api_key}", + } + end + + def url + URI.join( + IdentityConfig.store.socure_reason_code_base_url, + '/api/3.0/reasoncodes', + ).to_s + end + end + end + end +end diff --git a/app/services/proofing/socure/reason_codes/importer.rb b/app/services/proofing/socure/reason_codes/importer.rb new file mode 100644 index 00000000000..4e7fbc41991 --- /dev/null +++ b/app/services/proofing/socure/reason_codes/importer.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module ReasonCodes + class Importer + class ImportError < StandardError; end + + attr_reader :downloaded_reason_codes, + :added_reason_code_records, + :deactivated_reason_code_records + + def initialize + @added_reason_code_records = [] + @deactivated_reason_code_records = [] + end + + def synchronize + @downloaded_reason_codes = api_client.download_reason_codes + + if !downloaded_reason_codes.is_a?(Hash) || downloaded_reason_codes.empty? + message = "Expected #{downloaded_reason_codes.inspect} to be a hash of reason codes" + raise ImportError, message + end + + create_or_update_downloaded_reason_codes + deactivate_missing_reason_codes + + FormResponse.new( + success: true, + errors: nil, + extra: { + added_reason_codes: format_reason_code_records(added_reason_code_records), + deactivated_reason_codes: format_reason_code_records(deactivated_reason_code_records), + }, + ) + rescue ApiClient::ApiClientError, + ImportError => e + FormResponse.new( + success: false, + errors: nil, + extra: { exception: e.inspect }, + ) + end + + def api_client + @api_client ||= ApiClient.new + end + + private + + def create_or_update_downloaded_reason_codes + downloaded_reason_codes.each do |group, reason_codes| + reason_codes.each do |code, description| + reason_code = SocureReasonCode.find_or_initialize_by(code: code) + added_reason_code_records.push(reason_code) unless reason_code.persisted? + + reason_code.group = group + reason_code.description = description + reason_code.added_at ||= Time.zone.now + reason_code.deactivated_at = nil + reason_code.save! + end + end + end + + def deactivate_missing_reason_codes + active_codes = downloaded_reason_codes.flat_map do |_group, reason_codes| + reason_codes.keys + end + deactivated_at = Time.zone.now + SocureReasonCode.active.where.not(code: active_codes).find_each do |reason_code| + reason_code.update!(deactivated_at:) + deactivated_reason_code_records.push(reason_code) + end + end + + def format_reason_code_records(socure_reason_codes) + socure_reason_codes.map { |r| r.attributes.slice('code', 'group', 'description') } + end + end + end + end +end diff --git a/app/services/usps_in_person_proofing/enrollment_helper.rb b/app/services/usps_in_person_proofing/enrollment_helper.rb index 859cc82dfbe..0ac3955ec3d 100644 --- a/app/services/usps_in_person_proofing/enrollment_helper.rb +++ b/app/services/usps_in_person_proofing/enrollment_helper.rb @@ -92,7 +92,7 @@ def cancel_stale_establishing_enrollments_for_user(user) user. in_person_enrollments. where(status: :establishing). - each(&:cancelled!) + find_each(&:cancelled!) end def usps_proofer 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 41d00eeebdb..0e03425babd 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 @@ -232,24 +232,24 @@

<%= t('in_person_proofing.body.expect.info') %>

+ <% if @presenter.service_provider_homepage_url.blank? %> + <%= t('in_person_proofing.body.barcode.close_window') %> + <% end %> +

+ +<%= render PageFooterComponent.new do %> <% if @presenter.service_provider_homepage_url.present? %> <%= render ClickObserverComponent.new(event_name: 'IdV: user clicked sp link on ready to verify page') do %> - <%= t( - 'in_person_proofing.body.barcode.return_to_partner_html', - link_html: link_to( - t( - 'in_person_proofing.body.barcode.return_to_partner_link', - sp_name: @presenter.sp_name, - ), - @presenter.service_provider_homepage_url, + <%= link_to( + t( + 'in_person_proofing.body.barcode.return_to_partner_link', + sp_name: @presenter.sp_name, ), + @presenter.service_provider_homepage_url, + class: 'display-inline-block padding-bottom-1', ) %> <% end %> - <% else %> - <%= t('in_person_proofing.body.barcode.close_window') %> +
<% end %> -

- -<%= render PageFooterComponent.new do %> - <%= link_to t('in_person_proofing.body.barcode.cancel_link_text'), idv_cancel_path(step: 'barcode') %> + <%= link_to t('in_person_proofing.body.barcode.cancel_link_text'), idv_cancel_path(step: 'barcode'), class: 'display-inline-block padding-top-1' %> <% end %> diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 8a92f506e7b..fc94c9754b9 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -44,7 +44,6 @@ previous_step_url: @previous_step_url, locations_url: idv_in_person_usps_locations_url, sessions_url: api_internal_sessions_path, - doc_auth_separate_pages_enabled: IdentityConfig.store.doc_auth_separate_pages_enabled, address_search_url: '', } %> <%= simple_form_for( diff --git a/app/views/two_factor_authentication/options/no_option.html.erb b/app/views/two_factor_authentication/options/no_option.html.erb deleted file mode 100644 index 3a0066adb7b..00000000000 --- a/app/views/two_factor_authentication/options/no_option.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% self.title = t('titles.no_auth_option') %> - -<%= t('two_factor_authentication.no_auth_option') %> diff --git a/app/views/user_mailer/in_person_deadline_passed.html.erb b/app/views/user_mailer/in_person_deadline_passed.html.erb index 84c1af7e87d..fa65f223b93 100644 --- a/app/views/user_mailer/in_person_deadline_passed.html.erb +++ b/app/views/user_mailer/in_person_deadline_passed.html.erb @@ -1,11 +1,6 @@

<%= t('user_mailer.in_person_deadline_passed.body.greeting') %>

- <%= t( - 'user_mailer.in_person_deadline_passed.body.canceled', - app_name: APP_NAME, - location: @presenter.location_name, - date: @presenter.formatted_verified_date, - ) %> + <%= t('user_mailer.in_person_deadline_passed.body.canceled') %>

<%= t( diff --git a/app/views/user_mailer/in_person_failed.html.erb b/app/views/user_mailer/in_person_failed.html.erb index 4fe54f94d4c..cc2a875aad3 100644 --- a/app/views/user_mailer/in_person_failed.html.erb +++ b/app/views/user_mailer/in_person_failed.html.erb @@ -2,7 +2,7 @@

<%= t( 'user_mailer.in_person_failed.intro', - location: @presenter.location_name, + location: @presenter.visited_location_name, date: @presenter.formatted_verified_date, ) %>

diff --git a/app/views/user_mailer/in_person_failed_fraud.html.erb b/app/views/user_mailer/in_person_failed_fraud.html.erb index c92eb8e8e1e..83c61f441fb 100644 --- a/app/views/user_mailer/in_person_failed_fraud.html.erb +++ b/app/views/user_mailer/in_person_failed_fraud.html.erb @@ -3,7 +3,7 @@ <%= t( 'user_mailer.in_person_failed_suspected_fraud.body.intro', app_name: APP_NAME, - location: @presenter.location_name, + location: @presenter.visited_location_name, date: @presenter.formatted_verified_date, ) %>

diff --git a/app/views/user_mailer/in_person_verified.html.erb b/app/views/user_mailer/in_person_verified.html.erb index e1378c107f5..fdd2e7ef8e1 100644 --- a/app/views/user_mailer/in_person_verified.html.erb +++ b/app/views/user_mailer/in_person_verified.html.erb @@ -10,7 +10,7 @@ <%= t('user_mailer.in_person_verified.greeting') %>
<%= t( 'user_mailer.in_person_verified.intro', - location: @presenter.location_name, + location: @presenter.visited_location_name, date: @presenter.formatted_verified_date, ) %>

diff --git a/config/application.yml.default b/config/application.yml.default index cb15308f3a6..5ef389edbf4 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -17,6 +17,8 @@ aamva_auth_request_timeout: 5.0 aamva_auth_url: 'https://example.org:12345/auth/url' aamva_cert_enabled: true +aamva_private_key: '' +aamva_public_key: '' aamva_supported_jurisdictions: '["AL","AR","AZ","CO","CT","DC","DE","FL","GA","HI","IA","ID","IL","IN","KS","KY","MA","MD","ME","MI","MO","MS","MT","NC","ND","NE","NJ","NM","NV","OH","OR","PA","RI","SC","SD","TN","TX","VA","VT","WA","WI","WV","WY"]' aamva_verification_request_timeout: 5.0 aamva_verification_url: https://example.org:12345/verification/url @@ -38,6 +40,8 @@ allowed_verified_within_providers: '[]' asset_host: '' async_stale_job_timeout_seconds: 300 async_wait_timeout_seconds: 60 +attribute_encryption_key: +attribute_encryption_key_queue: '[]' available_locales: 'en,es,fr,zh' aws_http_retry_limit: 2 aws_http_retry_max_delay: 1 @@ -63,6 +67,8 @@ component_previews_enabled: false compromised_password_randomizer_threshold: 900 compromised_password_randomizer_value: 1000 country_phone_number_overrides: '{}' +dashboard_api_token: '' +dashboard_url: https://dashboard.demo.login.gov database_advisory_locks_enabled: false database_host: '' database_name: '' @@ -99,7 +105,6 @@ doc_auth_max_attempts: 5 doc_auth_max_capture_attempts_before_native_camera: 3 doc_auth_max_submission_attempts_before_native_camera: 3 doc_auth_selfie_desktop_test_mode: false -doc_auth_separate_pages_enabled: false doc_auth_supported_country_codes: '["US", "GU", "VI", "AS", "MP", "PR", "USA" ,"GUM", "VIR", "ASM", "MNP", "PRI"]' doc_auth_vendor: 'mock' doc_auth_vendor_default: 'mock' @@ -108,6 +113,7 @@ doc_auth_vendor_socure_percent: 0 doc_auth_vendor_switching_enabled: false doc_capture_polling_enabled: true doc_capture_request_valid_for_minutes: 15 +domain_name: login.gov drop_off_report_config: '[{"emails":["ursula@example.com"],"issuers": ["urn:gov:gsa:openidconnect.profiles:sp:sso:agency_name:app_name"]}]' email_from: no-reply@login.gov email_from_display_name: Login.gov @@ -136,6 +142,8 @@ good_job_queues: 'default:5;low:1;*' gpo_designated_receiver_pii: '{}' gpo_max_profile_age_to_send_letter_in_days: 30 hide_phone_mfa_signup: false +hmac_fingerprinter_key: +hmac_fingerprinter_key_queue: '[]' identity_pki_disabled: false identity_pki_local_dev: false idv_acuant_sdk_upgrade_a_b_testing_enabled: false @@ -149,6 +157,7 @@ idv_max_attempts: 5 idv_min_age_years: 13 idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 +idv_socure_reason_code_download_enabled: false idv_socure_shadow_mode_enabled: false idv_sp_required: false in_person_completion_survey_url: 'https://login.gov' @@ -196,8 +205,12 @@ lexisnexis_phone_finder_workflow: customers.gsa2.phonefinder.workflow lexisnexis_request_mode: testing ################################################################### # LexisNexis DDP/ThreatMetrix ##################################### +lexisnexis_threatmetrix_api_key: +lexisnexis_threatmetrix_base_url: lexisnexis_threatmetrix_js_signing_cert: '' lexisnexis_threatmetrix_mock_enabled: true +lexisnexis_threatmetrix_org_id: +lexisnexis_threatmetrix_policy: lexisnexis_threatmetrix_support_code: ABCD lexisnexis_threatmetrix_timeout: 1.0 # TrueID DocAuth Integration @@ -219,6 +232,7 @@ login_otp_confirmation_max_attempts: 10 logins_per_email_and_ip_bantime: 60 logins_per_email_and_ip_limit: 5 logins_per_email_and_ip_period: 60 +logins_per_ip_limit: 20 logins_per_ip_period: 60 logins_per_ip_track_only_mode: false logo_upload_enabled: false @@ -241,6 +255,7 @@ openid_connect_content_security_form_action_enabled: false openid_connect_redirect: client_side_js openid_connect_redirect_issuer_override_map: '{}' openid_connect_redirect_uuid_override_map: '{}' +otp_delivery_blocklist_findtime: 5 otp_delivery_blocklist_maxretry: 10 otp_expiration_warning_seconds: 150 otp_min_attempts_remaining_warning_count: 3 @@ -253,6 +268,7 @@ outbound_connection_check_timeout: 5 outbound_connection_check_url: 'https://checkip.amazonaws.com' participate_in_dap: false password_max_attempts: 3 +password_pepper: personal_key_retired: true phone_carrier_registration_blocklist_array: '[]' phone_confirmation_max_attempt_window_in_minutes: 1_440 @@ -270,6 +286,7 @@ pinpoint_voice_configs: '[]' pinpoint_voice_pool_size: 5 piv_cac_service_timeout: 5.0 piv_cac_service_url: https://localhost:8443/ +piv_cac_verify_token_secret: piv_cac_verify_token_url: https://localhost:8443/ poll_rate_for_verify_in_seconds: 3 prometheus_exporter: false @@ -323,15 +340,21 @@ ruby_workers_idv_enabled: true rules_of_use_horizon_years: 5 rules_of_use_updated_at: '2022-01-19T00:00:00Z' # Production has a newer timestamp than this, update directly in S3 s3_public_reports_enabled: false +s3_report_bucket_prefix: login-gov.reports +s3_report_public_bucket_prefix: login-gov-pubdata s3_reports_enabled: false +saml_endpoint_configs: '[]' saml_secret_rotation_enabled: false +scrypt_cost: 10000$8$1$ second_mfa_reminder_account_age_in_days: 30 second_mfa_reminder_sign_in_count: 10 +secret_key_base: seed_agreements_data: true service_provider_request_ttl_hours: 24 ses_configuration_set_name: '' session_check_delay: 30 session_check_frequency: 30 +session_encryption_key: session_encryptor_alert_enabled: false session_timeout_in_minutes: 15 session_timeout_warning_seconds: 150 @@ -340,28 +363,35 @@ short_term_phone_otp_max_attempt_window_in_seconds: 10 short_term_phone_otp_max_attempts: 2 show_unsupported_passkey_platform_authentication_setup: false show_user_attribute_deprecation_warnings: false +sign_in_recaptcha_log_failures_only: false sign_in_recaptcha_percent_tested: 0 sign_in_recaptcha_score_threshold: 0.0 sign_in_user_id_per_ip_attempt_window_exponential_factor: 1.1 sign_in_user_id_per_ip_attempt_window_in_minutes: 720 sign_in_user_id_per_ip_attempt_window_max_minutes: 43_200 sign_in_user_id_per_ip_max_attempts: 50 +skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:dev", "urn:gov:gsa:SAML:2.0.profiles:sp:sso:int"]' socure_document_request_endpoint: '' socure_enabled: false socure_idplus_api_key: '' socure_idplus_base_url: '' socure_idplus_timeout_in_seconds: 5 +socure_reason_code_api_key: '' +socure_reason_code_base_url: '' +socure_reason_code_timeout_in_seconds: 5 socure_webhook_enabled: false socure_webhook_secret_key: '' socure_webhook_secret_key_queue: '[]' sp_handoff_bounce_max_seconds: 2 sp_issuer_user_counts_report_configs: '[]' +state_tracking_enabled: true team_ada_email: '' team_all_login_emails: '[]' team_daily_fraud_metrics_emails: '[]' team_daily_reports_emails: '[]' team_monthly_fraud_metrics_emails: '[]' team_ursula_email: '' +telephony_adapter: test test_ssn_allowed_list: '' totp_code_interval: 30 unauthorized_scope_enabled: false @@ -381,7 +411,11 @@ usps_ipp_transliteration_enabled: false usps_ipp_username: '' usps_mock_fallback: true usps_upload_enabled: false +usps_upload_sftp_directory: '' +usps_upload_sftp_host: '' +usps_upload_sftp_password: '' usps_upload_sftp_timeout: 5 +usps_upload_sftp_username: '' valid_authn_contexts: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3", "http://idmanagement.gov/ns/assurance/ial/1", "http://idmanagement.gov/ns/assurance/ial/2", "http://idmanagement.gov/ns/assurance/ial/0", "http://idmanagement.gov/ns/assurance/ial/2?strict=true", "http://idmanagement.gov/ns/assurance/ial/2?bio=preferred", "http://idmanagement.gov/ns/assurance/ial/2?bio=required", "urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", "http://idmanagement.gov/ns/assurance/aal/2", "http://idmanagement.gov/ns/assurance/aal/3", "http://idmanagement.gov/ns/assurance/aal/3?hspd12=true","http://idmanagement.gov/ns/assurance/aal/2?phishing_resistant=true","http://idmanagement.gov/ns/assurance/aal/2?hspd12=true"]' valid_authn_contexts_semantic: '["http://idmanagement.gov/ns/assurance/loa/1", "http://idmanagement.gov/ns/assurance/loa/3", "http://idmanagement.gov/ns/assurance/ial/1", "http://idmanagement.gov/ns/assurance/ial/2", "http://idmanagement.gov/ns/assurance/ial/0", "http://idmanagement.gov/ns/assurance/ial/2?strict=true", "http://idmanagement.gov/ns/assurance/ial/2?bio=preferred", "http://idmanagement.gov/ns/assurance/ial/2?bio=required", "urn:gov:gsa:ac:classes:sp:PasswordProtectedTransport:duo", "http://idmanagement.gov/ns/assurance/aal/2", "http://idmanagement.gov/ns/assurance/aal/3", "http://idmanagement.gov/ns/assurance/aal/3?hspd12=true","http://idmanagement.gov/ns/assurance/aal/2?phishing_resistant=true","http://idmanagement.gov/ns/assurance/aal/2?hspd12=true", "urn:acr.login.gov:auth-only", "urn:acr.login.gov:verified","urn:acr.login.gov:verified-facial-match-preferred","urn:acr.login.gov:verified-facial-match-required"]' vendor_status_idv_scheduled_maintenance_finish: '' @@ -427,7 +461,6 @@ development: in_person_send_proofing_notifications_enabled: true logins_per_ip_limit: 5 logo_upload_enabled: true - otp_delivery_blocklist_findtime: 5 password_pepper: f22d4b2cafac9066fe2f4416f5b7a32c phone_recaptcha_score_threshold: 0.5 piv_cac_verify_token_secret: ee7f20f44cdc2ba0c6830f70470d1d1d059e1279cdb58134db92b35947b1528ef5525ece5910cf4f2321ab989a618feea12ef95711dbc62b9601e8520a34ee12 @@ -439,7 +472,6 @@ development: s3_report_bucket_prefix: '' s3_report_public_bucket_prefix: '' saml_endpoint_configs: '[{"suffix":"2023","secret_key_passphrase":"trust-but-verify"},{"suffix":"2024","secret_key_passphrase":"trust-but-verify"}]' - scrypt_cost: 10000$8$1$ secret_key_base: development_secret_key_base session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 show_unsupported_passkey_platform_authentication_setup: true @@ -447,8 +479,7 @@ development: sign_in_recaptcha_score_threshold: 0.3 skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:localhost"]' socure_idplus_base_url: 'https://sandbox.socure.us' - state_tracking_enabled: true - telephony_adapter: test + socure_reason_code_base_url: 'https://sandbox.socure.us' use_dashboard_service_providers: true usps_eipp_sponsor_id: '222222222222222' usps_ipp_sponsor_id: '111111111111111' @@ -463,61 +494,37 @@ development: # production: aamva_auth_url: 'https://authentication-cert.aamva.org/Authentication/Authenticate.svc' - aamva_private_key: '' - aamva_public_key: '' aamva_verification_url: 'https://verificationservices-cert.aamva.org:18449/dldv/2.1/online' - attribute_encryption_key: - attribute_encryption_key_queue: '[]' available_locales: 'en,es,fr' biometric_ial_enabled: false - dashboard_api_token: '' - dashboard_url: https://dashboard.demo.login.gov disable_email_sending: false disable_logout_get_request: false - domain_name: login.gov email_registrations_per_ip_track_only_mode: true enable_test_routes: false enable_usps_verification: false feature_select_email_to_share_enabled: false feature_valid_authn_contexts_semantic_enabled: false - hmac_fingerprinter_key: - hmac_fingerprinter_key_queue: '[]' idv_sp_required: true invalid_gpo_confirmation_zipcode: '' lexisnexis_threatmetrix_mock_enabled: false - logins_per_ip_limit: 20 logins_per_ip_period: 20 logins_per_ip_track_only_mode: true openid_connect_content_security_form_action_enabled: true openid_connect_redirect: server_side - otp_delivery_blocklist_findtime: 5 participate_in_dap: true - password_pepper: - piv_cac_verify_token_secret: raise_on_component_validation_error: false recaptcha_mock_validator: false redis_throttle_url: redis://redis.login.gov.internal:6379/1 redis_url: redis://redis.login.gov.internal:6379 report_timeout: 1_000_000 ruby_workers_idv_enabled: false - s3_report_bucket_prefix: login-gov.reports - s3_report_public_bucket_prefix: login-gov-pubdata s3_reports_enabled: true - saml_endpoint_configs: '[]' - scrypt_cost: 10000$8$1$ - secret_key_base: seed_agreements_data: false - session_encryption_key: session_encryptor_alert_enabled: true - skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:dev", "urn:gov:gsa:SAML:2.0.profiles:sp:sso:int"]' state_tracking_enabled: false telephony_adapter: pinpoint use_kms: true usps_auth_token_refresh_job_enabled: true - usps_upload_sftp_directory: '' - usps_upload_sftp_host: '' - usps_upload_sftp_password: '' - usps_upload_sftp_username: '' test: aamva_private_key: 123abc @@ -528,7 +535,6 @@ test: attribute_encryption_key: 2086dfbd15f5b0c584f3664422a1d3409a0d2aa6084f65b6ba57d64d4257431c124158670c7655e45cabe64194f7f7b6c7970153c285bdb8287ec0c4f7553e25 attribute_encryption_key_queue: '[{ "key": "11111111111111111111111111111111" }, { "key": "22222222222222222222222222222222" }]' dashboard_api_token: 123ABC - dashboard_url: https://dashboard.demo.login.gov doc_auth_max_attempts: 4 doc_auth_selfie_desktop_test_mode: true doc_capture_polling_enabled: false @@ -575,13 +581,11 @@ test: skip_encryption_allowed_list: '[]' socure_webhook_secret_key: 'secret-key' socure_webhook_secret_key_queue: '["old-key-one", "old-key-two"]' - state_tracking_enabled: true team_ada_email: 'ada@example.com' team_all_login_emails: '["b@example.com", "c@example.com"]' team_daily_fraud_metrics_emails: '["g@example.com", "h@example.com"]' team_daily_reports_emails: '["a@example.com", "d@example.com"]' team_monthly_fraud_metrics_emails: '["e@example.com", "f@example.com"]' - telephony_adapter: test test_ssn_allowed_list: '999999999' totp_code_interval: 3 usps_eipp_sponsor_id: '222222222222222' diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 5559da9bb78..be5602d67ce 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -164,6 +164,11 @@ class: 'ThreatMetrixJsVerificationJob', cron: cron_1h, }, + # Periodically check whether we can upgrade to GoodJob V4 + good_job_v4_ready: { + class: 'GoodJobV4ReadyJob', + cron: cron_1h, + }, # Reject profiles that have been in fraud_review_pending for 30 days fraud_rejection: { class: 'FraudRejectionDailyJob', @@ -242,6 +247,11 @@ cron: cron_monthly, args: -> { [Time.zone.yesterday.end_of_day] }, }, + # Download and store Socure reason codes + socure_reason_code_download: { + class: 'SocureReasonCodeDownloadJob', + cron: cron_every_monday, + }, }.compact end # rubocop:enable Metrics/BlockLength diff --git a/config/locales/en.yml b/config/locales/en.yml index 471fca4dacb..ebd7685fc38 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -493,6 +493,8 @@ devise.sessions.signed_out: You are now signed out. doc_auth.accessible_labels.camera_video_capture_instructions: We will automatically take the picture doc_auth.accessible_labels.camera_video_capture_label: Viewfinder with frame to center your ID doc_auth.accessible_labels.document_capture_dialog: Document capture +doc_auth.alt.selfie_help_1: A person with their face in a green oval. +doc_auth.alt.selfie_help_2: A finger taps a checkmark under the face to confirm the photo. doc_auth.buttons.add_new_photos: Add new photos doc_auth.buttons.close: Close doc_auth.buttons.continue: Continue @@ -578,9 +580,7 @@ doc_auth.headings.document_capture: Add photos of your driver’s license or sta doc_auth.headings.document_capture_back: Back of your ID doc_auth.headings.document_capture_front: Front of your ID doc_auth.headings.document_capture_selfie: Photo of your face -doc_auth.headings.document_capture_subheader_id: Driver’s license or state ID card doc_auth.headings.document_capture_subheader_selfie: Add a photo of your face -doc_auth.headings.document_capture_with_selfie: Add photos of your ID and a photo of yourself doc_auth.headings.front: Front of your driver’s license or state ID doc_auth.headings.how_to_verify: Choose how you want to verify your identity doc_auth.headings.hybrid_handoff: How would you like to add your ID? @@ -592,6 +592,7 @@ doc_auth.headings.review_issues: Check your images and try again doc_auth.headings.secure_account: Secure your account doc_auth.headings.selfie: Photo of your face doc_auth.headings.selfie_capture: Capture photo of yourself +doc_auth.headings.selfie_instructions.howto: How to take your photo doc_auth.headings.ssn: Enter your Social Security number doc_auth.headings.ssn_update: Update your Social Security number doc_auth.headings.text_message: We sent a message to your phone @@ -638,6 +639,8 @@ doc_auth.info.no_ssn: You must have a Social Security number to finish verifying doc_auth.info.review_examples_of_photos: Review examples of how to take clear photos of your ID. doc_auth.info.secure_account: We’ll encrypt your account when you re-enter your password. Encryption means your data is protected and only you will be able to access or change your information. doc_auth.info.selfie_capture_content: We’ll check that you are the person on your ID. +doc_auth.info.selfie_capture_help_1: Line up your face with the green circle. Hold still and wait for the tool to capture a photo. +doc_auth.info.selfie_capture_help_2: After your photo is automatically captured, tap the green checkmark to accept the photo. doc_auth.info.selfie_capture_status.face_close_to_border: Too close to the frame doc_auth.info.selfie_capture_status.face_not_found: Face not found doc_auth.info.selfie_capture_status.face_too_small: Face too small @@ -1196,8 +1199,7 @@ in_person_proofing.body.barcode.location_details: Location details in_person_proofing.body.barcode.questions: Questions? in_person_proofing.body.barcode.retail_hours: Retail hours in_person_proofing.body.barcode.retail_hours_closed: Closed -in_person_proofing.body.barcode.return_to_partner_html: You may now %{link_html} to complete any next steps you can access until your identity has been verified. -in_person_proofing.body.barcode.return_to_partner_link: sign out and return to %{sp_name} +in_person_proofing.body.barcode.return_to_partner_link: Return to %{sp_name} in_person_proofing.body.barcode.what_to_expect: What to expect at the Post Office in_person_proofing.body.cta.button: Try in person in_person_proofing.body.cta.prompt_detail: You may be able to verify your identity at a participating Post Office near you. @@ -1595,7 +1597,6 @@ titles.idv.reset_password: Reset Password titles.idv.verify_info: Verify your information titles.mfa_setup.face_touch_unlock_confirmation: Face or touch unlock added titles.mfa_setup.suggest_second_mfa: You’ve added your first authentication method! Add a second method as a backup. -titles.no_auth_option: No sign-in method found titles.openid_connect.authorization: OpenID Connect Authorization titles.openid_connect.logout: OpenID Connect Logout titles.passwords.change: Change the password for your account @@ -1686,7 +1687,6 @@ two_factor_authentication.max_otp_requests_reached: For your security, your acco two_factor_authentication.max_personal_key_login_attempts_reached: For your security, your account is temporarily locked because you have entered the personal key incorrectly too many times. two_factor_authentication.max_piv_cac_login_attempts_reached: For your security, your account is temporarily locked because you have presented your piv/cac credential incorrectly too many times. two_factor_authentication.mobile_terms_of_service: Mobile terms of service -two_factor_authentication.no_auth_option: No authentication option could be found for you to sign in. two_factor_authentication.opt_in.error_retry: Sorry, we are having trouble opting you in. Please try again. two_factor_authentication.opt_in.opted_out_html: You’ve opted out of receiving text messages at %{phone_number_html}. You can opt in and receive a security code again to that phone number. two_factor_authentication.opt_in.opted_out_last_30d_html: You’ve opted out of receiving text messages at %{phone_number_html} within the last 30 days. We can only opt in a phone number once every 30 days. diff --git a/config/locales/es.yml b/config/locales/es.yml index 32d1903b196..3497461ffe1 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -504,6 +504,8 @@ devise.sessions.signed_out: Cerró su sesión. doc_auth.accessible_labels.camera_video_capture_instructions: Tomaremos la foto automáticamente doc_auth.accessible_labels.camera_video_capture_label: Visor con marco para centrar su identificación doc_auth.accessible_labels.document_capture_dialog: Captura de documento +doc_auth.alt.selfie_help_1: Una persona con el rostro dentro de un óvalo verde. +doc_auth.alt.selfie_help_2: Un dedo toca una marca de verificación debajo de la cara para confirmar la foto. doc_auth.buttons.add_new_photos: Añadir nuevas fotos doc_auth.buttons.close: Cerrar doc_auth.buttons.continue: Continuar @@ -589,9 +591,7 @@ doc_auth.headings.document_capture: Añade fotos de tu licencia de conducir o cr doc_auth.headings.document_capture_back: Reverso de su identificación doc_auth.headings.document_capture_front: Frente de su identificación doc_auth.headings.document_capture_selfie: Fotografía de su cara -doc_auth.headings.document_capture_subheader_id: Licencia de conducir o identificación estatal doc_auth.headings.document_capture_subheader_selfie: Añada una foto de su cara -doc_auth.headings.document_capture_with_selfie: Agregue fotos de su identificación y una foto de usted doc_auth.headings.front: Frente de su licencia de conducir o identificación estatal doc_auth.headings.how_to_verify: Elija cómo desea verificar su identidad doc_auth.headings.hybrid_handoff: '¿Cómo desea añadir su identificación?' @@ -603,6 +603,7 @@ doc_auth.headings.review_issues: Revise sus imágenes e inténtelo de nuevo doc_auth.headings.secure_account: Proteja su cuenta doc_auth.headings.selfie: Fotografía de su cara doc_auth.headings.selfie_capture: Capture una foto de usted +doc_auth.headings.selfie_instructions.howto: 'Cómo tomar su fotografía:' doc_auth.headings.ssn: Ingrese su número de Seguro Social doc_auth.headings.ssn_update: Actualice su número de Seguro Social doc_auth.headings.text_message: Enviamos un mensaje a su teléfono @@ -649,6 +650,8 @@ doc_auth.info.no_ssn: Debe tener un número de Seguro Social para finalizar la v doc_auth.info.review_examples_of_photos: Vea ejemplos de cómo tomar fotos nítidas de su identificación. doc_auth.info.secure_account: Cifraremos su cuenta cuando vuelva a ingresar su contraseña. Con el cifrado, sus datos están protegidos y solo usted puede acceder a su información o modificarla. doc_auth.info.selfie_capture_content: Revisaremos que usted sea la persona que figura en su identificación. +doc_auth.info.selfie_capture_help_1: Alinee su cara con el círculo verde. No se mueva y espere a que la cámara capture una foto. +doc_auth.info.selfie_capture_help_2: Después de la captura automática de su foto, toque la marca de verificación verde para aceptar la fotografía. doc_auth.info.selfie_capture_status.face_close_to_border: Demasiado cerca del marco doc_auth.info.selfie_capture_status.face_not_found: No se detectó el rostro doc_auth.info.selfie_capture_status.face_too_small: Rostro demasiado pequeño @@ -1207,8 +1210,7 @@ in_person_proofing.body.barcode.location_details: Detalles del lugar in_person_proofing.body.barcode.questions: '¿Tiene alguna pregunta?' in_person_proofing.body.barcode.retail_hours: Horario de atención al público in_person_proofing.body.barcode.retail_hours_closed: Cerrado -in_person_proofing.body.barcode.return_to_partner_html: Ahora puede %{link_html} para completar los pasos siguientes a los que tenga acceso hasta que se haya verificado su identidad. -in_person_proofing.body.barcode.return_to_partner_link: cerrar sesión y volver a %{sp_name} +in_person_proofing.body.barcode.return_to_partner_link: Volver a %{sp_name} in_person_proofing.body.barcode.what_to_expect: Qué esperar en la oficina de correos in_person_proofing.body.cta.button: Intentar en persona in_person_proofing.body.cta.prompt_detail: Es posible que pueda verificar su identidad en una oficina de correos participante cercana. @@ -1607,7 +1609,6 @@ titles.idv.reset_password: Restablecer la contraseña titles.idv.verify_info: Verifique su información titles.mfa_setup.face_touch_unlock_confirmation: Se agregó el desbloqueo facial o táctil titles.mfa_setup.suggest_second_mfa: Agregó su primer método de autenticación. Agregue un segundo método como respaldo. -titles.no_auth_option: No se encontró ningún método de inicio de sesión titles.openid_connect.authorization: Autorización de OpenID Connect titles.openid_connect.logout: Cierre de sesión de OpenID Connect titles.passwords.change: Cambie la contraseña de su cuenta @@ -1698,7 +1699,6 @@ two_factor_authentication.max_otp_requests_reached: Para su seguridad, su cuenta two_factor_authentication.max_personal_key_login_attempts_reached: Para su seguridad, su cuenta está bloqueada temporalmente porque ingresó mal la clave personal demasiadas veces. two_factor_authentication.max_piv_cac_login_attempts_reached: Para su seguridad, su cuenta está bloqueada temporalmente porque presentó mal su tarjeta PIV o CAC demasiadas veces. two_factor_authentication.mobile_terms_of_service: Condiciones del servicio móvil -two_factor_authentication.no_auth_option: No se pudo encontrar ninguna opción de autenticación para que iniciara sesión. two_factor_authentication.opt_in.error_retry: Lo sentimos, estamos teniendo problema para admitirlo; inténtelo de nuevo. two_factor_authentication.opt_in.opted_out_html: Optó por no recibir mensajes de texto en el %{phone_number_html}. Puede optar por recibir un código de seguridad de nuevo en ese número de teléfono. two_factor_authentication.opt_in.opted_out_last_30d_html: Optó por no recibir mensajes de texto en el %{phone_number_html} en los últimos 30 días. Solo podemos admitir un número telefónico una vez cada 30 días. diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f489171a2f5..e49b209a072 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -493,6 +493,8 @@ devise.sessions.signed_out: Vous êtes maintenant déconnecté. doc_auth.accessible_labels.camera_video_capture_instructions: Nous prendrons automatiquement la photo doc_auth.accessible_labels.camera_video_capture_label: Viseur avec cadre pour centrer votre pièce d’identité doc_auth.accessible_labels.document_capture_dialog: Capture du document +doc_auth.alt.selfie_help_1: Le visage d’une personne dans un ovale vert. +doc_auth.alt.selfie_help_2: Un doigt coche une case sous le visage pour valider la photo. doc_auth.buttons.add_new_photos: Ajouter de nouvelles photos doc_auth.buttons.close: Fermer doc_auth.buttons.continue: Suite @@ -578,9 +580,7 @@ doc_auth.headings.document_capture: Ajoutez des photos de votre permis de condui doc_auth.headings.document_capture_back: Verso de votre pièce d’identité doc_auth.headings.document_capture_front: Recto de votre carte d’identité doc_auth.headings.document_capture_selfie: Photo de votre visage -doc_auth.headings.document_capture_subheader_id: Permis de conduire ou carte d’identité d’un État doc_auth.headings.document_capture_subheader_selfie: Ajouter une photo de votre visage -doc_auth.headings.document_capture_with_selfie: Ajouter des photos de votre pièce d’identité et une photo de vous-même doc_auth.headings.front: Recto de votre permis de conduire ou de votre carte d’identité d’un État doc_auth.headings.how_to_verify: Choisir la manière dont vous souhaitez confirmer votre identité doc_auth.headings.hybrid_handoff: Comment voulez-vous ajouter votre pièce d’identité ? @@ -592,6 +592,7 @@ doc_auth.headings.review_issues: Vérifiez vos images et essayez à nouveau doc_auth.headings.secure_account: Sécuriser votre compte doc_auth.headings.selfie: Photo de votre visage doc_auth.headings.selfie_capture: Prenez-vous en photo +doc_auth.headings.selfie_instructions.howto: 'Comment vous prendre en photo :' doc_auth.headings.ssn: Saisir votre numéro de sécurité sociale doc_auth.headings.ssn_update: Mettre à jour votre numéro de sécurité sociale doc_auth.headings.text_message: Nous avons envoyé un message à votre téléphone @@ -638,6 +639,8 @@ doc_auth.info.no_ssn: Vous devez avoir un numéro de sécurité sociale pour ter doc_auth.info.review_examples_of_photos: Voir des exemples qui montrent comment prendre des photos nettes de votre pièce d’identité. doc_auth.info.secure_account: Nous chiffrons votre compte lorsque vous resaisissez votre mot de passe. Le chiffrement signifie que vos données sont protégées et que vous êtes le seul à pouvoir accéder à vos informations ou les modifier. doc_auth.info.selfie_capture_content: Nous vérifierons que vous êtes la personne figurant sur la pièce d’identité. +doc_auth.info.selfie_capture_help_1: Placez votre visage à l’intérieur du cercle vert. Ne bougez pas et attendez que l’appareil se déclenche. +doc_auth.info.selfie_capture_help_2: Une fois la photo prise automatiquement, cochez la case sous la photo pour l’accepter (la case deviendra verte). doc_auth.info.selfie_capture_status.face_close_to_border: Trop près du cadre doc_auth.info.selfie_capture_status.face_not_found: Visage non trouvé doc_auth.info.selfie_capture_status.face_too_small: Visage trop petit @@ -1196,8 +1199,7 @@ in_person_proofing.body.barcode.location_details: Détails de l’emplacement in_person_proofing.body.barcode.questions: Des questions ? in_person_proofing.body.barcode.retail_hours: Heures d’ouverture in_person_proofing.body.barcode.retail_hours_closed: Fermé -in_person_proofing.body.barcode.return_to_partner_html: Vous pouvez maintenant %{link_html} afin d’effectuer toutes les étapes suivantes auxquelles vous pouvez accéder jusqu’à ce que votre identité ait été vérifiée. -in_person_proofing.body.barcode.return_to_partner_link: vous déconnecter et retourner à %{sp_name} +in_person_proofing.body.barcode.return_to_partner_link: Retourner à %{sp_name} in_person_proofing.body.barcode.what_to_expect: À quoi s’attendre au bureau de poste in_person_proofing.body.cta.button: Essayer en personne in_person_proofing.body.cta.prompt_detail: Vous pourrez peut-être confirmer votre identité dans un bureau de poste participant près de chez vous. @@ -1595,7 +1597,6 @@ titles.idv.reset_password: Réinitialiser le mot de passe titles.idv.verify_info: Vérifier vos informations titles.mfa_setup.face_touch_unlock_confirmation: Déverrouillage facial ou tactile ajouté titles.mfa_setup.suggest_second_mfa: Vous avez ajouté votre première méthode d’authentification ! Ajoutez-en une deuxième en guise de sauvegarde. -titles.no_auth_option: Aucune méthode de connexion trouvée titles.openid_connect.authorization: Autorisation OpenID Connect titles.openid_connect.logout: Déconnexion OpenID Connect titles.passwords.change: Changer le mot de passe de votre compte @@ -1686,7 +1687,6 @@ two_factor_authentication.max_otp_requests_reached: Pour votre sécurité, votre two_factor_authentication.max_personal_key_login_attempts_reached: Pour votre sécurité, votre compte est temporairement verrouillé en raison de la saisie erronée de la clé personnelle à de trop nombreuses reprises. two_factor_authentication.max_piv_cac_login_attempts_reached: Pour votre sécurité, votre compte est temporairement verrouillé en raison de la présentation erronée de votre certificat PIV/CAC à de trop nombreuses reprises. two_factor_authentication.mobile_terms_of_service: Conditions de service mobile -two_factor_authentication.no_auth_option: Aucune option d’authentification n’a pu être trouvée pour vous connecter two_factor_authentication.opt_in.error_retry: Désolé, nous rencontrons actuellement des difficultés pour vous inscrire. Veuillez réessayer. two_factor_authentication.opt_in.opted_out_html: Vous avez choisi de ne pas recevoir de SMS au %{phone_number_html}. Vous pouvez vous inscrire et recevoir à nouveau un code de sécurité à ce numéro de téléphone. two_factor_authentication.opt_in.opted_out_last_30d_html: Vous avez choisi de ne plus recevoir de SMS au %{phone_number_html} au cours des 30 derniers jours. Nous ne pouvons inscrire un numéro de téléphone qu’une fois tous les 30 jours. diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 0e2b88a7b64..bcc3a9451e0 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -16,7 +16,7 @@ account_reset.delete_account.info: 被锁在账户之外时,取消账户应当 account_reset.delete_account.title: 删除账户应当是你最后的选择。 account_reset.pending.cancel_request: 取消请求 account_reset.pending.canceled: 我们已取消了你删除帐户的请求。 -account_reset.pending.confirm: 如果现在取消,你如果要删除账户的话,必须提出新请求并再等待%{interval} 。 +account_reset.pending.confirm: 如果现在取消,你如果要删除账户的话,必须提出新请求并再等待%{interval}。 account_reset.pending.header: 你已提出删除账户的请求。 account_reset.pending.wait_html: 要删除你的账户,有一个%{waiting_period}的等待期。你会在 %{interval} 收到电邮,向你说明如何完成删除。 account_reset.recovery_options.check_saved_credential: 查看你是否有已保存的凭据 @@ -504,6 +504,8 @@ devise.sessions.signed_out: 你现在已登出。 doc_auth.accessible_labels.camera_video_capture_instructions: 我们会自动拍张照片。 doc_auth.accessible_labels.camera_video_capture_label: 带框的取景器能把你身份证件放在中间。 doc_auth.accessible_labels.document_capture_dialog: 文档扫描 +doc_auth.alt.selfie_help_1: 人脸在绿色椭圆中。 +doc_auth.alt.selfie_help_2: 手指点击面孔下的勾选标记来确认照片。 doc_auth.buttons.add_new_photos: 添加新照片 doc_auth.buttons.close: 关闭 doc_auth.buttons.continue: 继续 @@ -577,7 +579,7 @@ doc_auth.errors.sharpness.top_msg_plural: 我们无法读取你的身份证件 doc_auth.errors.upload_error: 抱歉,我们这边出错了。 doc_auth.forms.captured_image: 扫描到的图像 doc_auth.forms.change_file: 更改文件 -doc_auth.forms.choose_file_html: 将文件拖到此处或者 从文件夹中选择。 +doc_auth.forms.choose_file_html: 将文件拖到此处或者从文件夹中选择。 doc_auth.forms.doc_success: 我们验证了你的信息 doc_auth.forms.selected_file: 被选文件 doc_auth.headings.address: 更新你的邮政地址 @@ -589,9 +591,7 @@ doc_auth.headings.document_capture: 添加你身份证件的照片 doc_auth.headings.document_capture_back: 你身份证件的背面 doc_auth.headings.document_capture_front: 你身份证件的正面 doc_auth.headings.document_capture_selfie: 你面部照片 -doc_auth.headings.document_capture_subheader_id: 驾照或州政府颁发的身份证件 doc_auth.headings.document_capture_subheader_selfie: 添加您面部照片 -doc_auth.headings.document_capture_with_selfie: 添加你身份证件和本人的照片 doc_auth.headings.front: 驾照或州政府颁发身份证件的正面 doc_auth.headings.how_to_verify: 选择你想如何验证身份 doc_auth.headings.hybrid_handoff: 你想怎么添加身份证件? @@ -603,6 +603,7 @@ doc_auth.headings.review_issues: 检查一下你的图片并再试一次 doc_auth.headings.secure_account: 保护你的账户安全 doc_auth.headings.selfie: 照片 doc_auth.headings.selfie_capture: 拍你本人照片 +doc_auth.headings.selfie_instructions.howto: '如何拍摄你本人的照片:' doc_auth.headings.ssn: 输入你的社会保障号码 doc_auth.headings.ssn_update: 更新你的社会保障号码 doc_auth.headings.text_message: 我们给你的手机发了短信 @@ -649,6 +650,8 @@ doc_auth.info.no_ssn: 你必须有社会保障号码才能完成身份验证。 doc_auth.info.review_examples_of_photos: 查看如何拍出身份证件清晰照片的示例。 doc_auth.info.secure_account: 我们会用你的密码对你的账户加密。加密意味着你的数据得到了保护,而且只有你能够访问或变更你的信息。 doc_auth.info.selfie_capture_content: 我们会查看你是身份证件上的人。 +doc_auth.info.selfie_capture_help_1: 把你的面部放在绿色圆圈里。请保持静止,等待工具拍照。 +doc_auth.info.selfie_capture_help_2: 你的照片被自动拍后,点击绿色勾选标志来接受照片。 doc_auth.info.selfie_capture_status.face_close_to_border: 距离相框太近 doc_auth.info.selfie_capture_status.face_not_found: 没找到面孔 doc_auth.info.selfie_capture_status.face_too_small: 面孔太小 @@ -1056,7 +1059,7 @@ idv.failure.phone.rate_limited.option_verify_by_mail_html: 通过普通邮件验 idv.failure.phone.rate_limited.options_header: 你可以: idv.failure.phone.timeout: 我们请你验证自己信息的请求已过期。请再试一次。 idv.failure.phone.warning.attempts_html.one: 出于安全考虑,你只能再试一次了。 -idv.failure.phone.warning.attempts_html.other: 出于安全考虑,你只能再试%{count}次 了。 +idv.failure.phone.warning.attempts_html.other: 出于安全考虑,你只能再试%{count}次了。 idv.failure.phone.warning.gpo.button: 通过普通邮件验证 idv.failure.phone.warning.gpo.explanation: 如果你没有别的电话号码可以尝试,那请通过普通邮件验证。 idv.failure.phone.warning.gpo.heading: 通过普通邮件验证 @@ -1134,14 +1137,14 @@ idv.messages.gpo.letter_on_the_way: 我们正在给你发信。 idv.messages.gpo.resend: 给我另外发送一封信 idv.messages.gpo.start_over_html: 如果该地址不对,你需要%{start_over_link_html}。 idv.messages.gpo.start_over_link_text: 重新开始并用你新地址进行验证。 -idv.messages.gpo.timeframe_html: 你会在 5 到 10 天 里收到带有验证码 的信。 +idv.messages.gpo.timeframe_html: 你会在 5 到 10 天 里收到带有验证码的信。 idv.messages.otp_delivery_method_description: 如果你在上面输入的是座机电话,请在下边选择“接听电话”。 idv.messages.phone.alert_html: '输入一个符合以下条件的电话号码:' idv.messages.phone.description: 我们会将你的号码与记录核对并给你发个一次性代码来验证你的身份。 idv.messages.phone.failed_number.alert_text: 我们无法将你与该号码匹配。 -idv.messages.phone.failed_number.gpo_alert_html: 试试 另一个 号码或者%{link_html}。 +idv.messages.phone.failed_number.gpo_alert_html: 试试 另一个号码或者%{link_html}。 idv.messages.phone.failed_number.gpo_verify_link: 通过普通邮件验证 -idv.messages.phone.failed_number.try_again_html: 试试 另一个 号码。 +idv.messages.phone.failed_number.try_again_html: 试试 另一个号码。 idv.messages.phone.rules: - 美国国内电话号码 - 你的主要号码(或者你最常用的号码) @@ -1209,8 +1212,7 @@ in_person_proofing.body.barcode.location_details: 详细地址信息 in_person_proofing.body.barcode.questions: 有问题吗? in_person_proofing.body.barcode.retail_hours: 营业时间 in_person_proofing.body.barcode.retail_hours_closed: 关闭 -in_person_proofing.body.barcode.return_to_partner_html: 你现在可以 %{link_html}来完成你可做的任何随后步骤,直到你的身份得到验证。 -in_person_proofing.body.barcode.return_to_partner_link: 登出 %{sp_name} 并返回 %{sp_name} +in_person_proofing.body.barcode.return_to_partner_link: 返回 %{sp_name} in_person_proofing.body.barcode.what_to_expect: 在邮局会发生什么 in_person_proofing.body.cta.button: 尝试亲身去 in_person_proofing.body.cta.prompt_detail: 你也许可以到附近一个参与本项目的邮局去亲身验证你的身份证件。 @@ -1252,7 +1254,7 @@ in_person_proofing.body.state_id.alert_message: 州政府颁发给你的身份 in_person_proofing.body.state_id.id_types: - 州驾照 - 州非驾照身份卡 -in_person_proofing.body.state_id.info_html: 输入 与州政府颁发的身份证件上完全一致的信息 我们将使用该信息来确认与你亲身出现所持身份证件上信息的一致性。 +in_person_proofing.body.state_id.info_html: 输入 与州政府颁发的身份证件上完全一致的信息我们将使用该信息来确认与你亲身出现所持身份证件上信息的一致性。 in_person_proofing.body.state_id.learn_more_link: 了解更多有关哪些身份证件可被接受的信息。 in_person_proofing.body.state_id.questions: 有问题吗? in_person_proofing.form.address.errors.unsupported_chars: 我们的系统无法读取以下字符: %{char_list}. 请替换那些字符后再试一次。 @@ -1608,7 +1610,6 @@ titles.idv.reset_password: 重设密码 titles.idv.verify_info: 验证你的信息 titles.mfa_setup.face_touch_unlock_confirmation: 人脸或触摸解锁已添加 titles.mfa_setup.suggest_second_mfa: 你已添加了第一个身份证实方法!添加第二个做备份 -titles.no_auth_option: 没找到登录方法 titles.openid_connect.authorization: OpenID Connect 授权 titles.openid_connect.logout: OpenID Connect 登出 titles.passwords.change: 更改你账户密码 @@ -1699,14 +1700,13 @@ two_factor_authentication.max_otp_requests_reached: 为了你的安全,你的 two_factor_authentication.max_personal_key_login_attempts_reached: 为了你的安全,你的账户暂时被锁住,因为你错误输入个人密钥太多次。 two_factor_authentication.max_piv_cac_login_attempts_reached: 为了你的安全,你的账户暂时被锁住,因为你错误提供 piv/cac 凭据太多次。 two_factor_authentication.mobile_terms_of_service: 移动服务条款 -two_factor_authentication.no_auth_option: 找不到身份证实选项让你登录。 two_factor_authentication.opt_in.error_retry: 抱歉,我们让你加入有困难。请再试一次。 two_factor_authentication.opt_in.opted_out_html: 你选择不在 %{phone_number_html} 接受短信。你可以选择加入并再在那个电话号码接受安全代码。 two_factor_authentication.opt_in.opted_out_last_30d_html: 你选择过去 30 天里不在 %{phone_number_html} 接受短信。我们每 30 天只能允许加入一次电话号码。 two_factor_authentication.opt_in.title: 我们无法向你电话号码发送安全代码。 two_factor_authentication.opt_in.wait_30d_opt_in: 30 天后,你可以选择加入并在那个电话号码接受安全代码。 two_factor_authentication.otp_delivery_preference.instruction: 你可以随时对此进行更改。如果你使用座机电话,请选择“接听电话”。 -two_factor_authentication.otp_delivery_preference.landline_warning_html: 输入的电话号码似乎是一个 座机电话。通过 %{phone_setup_path} 请求一次性代码。 +two_factor_authentication.otp_delivery_preference.landline_warning_html: 输入的电话号码似乎是一个座机电话。通过 %{phone_setup_path} 请求一次性代码。 two_factor_authentication.otp_delivery_preference.no_supported_options: 我们无法验证 %{location} 的电话号码。 two_factor_authentication.otp_delivery_preference.phone_call: 电话 two_factor_authentication.otp_delivery_preference.sms: 短信(SMS) @@ -1844,14 +1844,14 @@ user_mailer.email_confirmation_instructions.header: '%{intro}请点击下面的 user_mailer.email_confirmation_instructions.link_text: 确认电邮地址 user_mailer.email_confirmation_instructions.subject: 确认你的电邮 user_mailer.email_deleted.header: 一个电邮地址被从你的 %{app_name} 用户资料中删除。 -user_mailer.email_deleted.help_html: 如果你没有想删除这一电邮地址, 请访问 %{app_name_html} %{help_link_html} 或者 %{contact_link_html}。 +user_mailer.email_deleted.help_html: 如果你没有想删除这一电邮地址,请访问 %{app_name_html} %{help_link_html} 或者 %{contact_link_html}。 user_mailer.email_deleted.subject: 电邮地址已删除 user_mailer.help_link_text: 帮助中心 user_mailer.in_person_completion_survey.body.cta.callout: 点击下面的按钮来开始 user_mailer.in_person_completion_survey.body.cta.label: 填写我们的意见调查 user_mailer.in_person_completion_survey.body.greeting: 你好, user_mailer.in_person_completion_survey.body.intent: 我们想听听你在邮局亲身验证身份的经历。 -user_mailer.in_person_completion_survey.body.privacy_html: 你对这份意见调查的回复将会依照下面的 隐私和安全标准 得到保护。 +user_mailer.in_person_completion_survey.body.privacy_html: 你对这份意见调查的回复将会依照下面的 隐私和安全标准得到保护。 user_mailer.in_person_completion_survey.body.request_description: 填写一个简短、匿名的调查问卷,你的意见会帮助我们更好满足你的需求。 user_mailer.in_person_completion_survey.body.thanks: 感谢使用 %{app_name}。 user_mailer.in_person_completion_survey.header: 花点时间告诉我们,我们表现如何 @@ -1862,7 +1862,7 @@ user_mailer.in_person_deadline_passed.body.greeting: 你好, user_mailer.in_person_deadline_passed.body.restart: 你可以开始提出在 %{partner_agency}验证身份的新请求。 user_mailer.in_person_deadline_passed.header: 亲身验证身份的截止日期已过。 user_mailer.in_person_deadline_passed.subject: 你亲身验证身份的要求已过期。 -user_mailer.in_person_failed_suspected_fraud.body.help_center_html: 如需要更多帮助,可以访问我们的帮助中心 或寻求你试图访问的机构的帮助。 +user_mailer.in_person_failed_suspected_fraud.body.help_center_html: 如需要更多帮助,可以访问我们的帮助中心或寻求你试图访问的机构的帮助。 user_mailer.in_person_failed_suspected_fraud.body.intro: 我们知道你曾经试图通过 %{app_name} 验证身份,但是你的身份于 %{date}在 %{location} 邮局未能得到验证。 user_mailer.in_person_failed_suspected_fraud.greeting: 你好, user_mailer.in_person_failed_suspected_fraud.subject: 你的身份未能亲身被验证。 @@ -1918,10 +1918,10 @@ user_mailer.new_device_sign_in_before_2fa.subject: 你 %{app_name} 账户有新 user_mailer.password_changed.disavowal_link: 重设你的密码 user_mailer.password_changed.help_html: 如果你没做此更改, %{disavowal_link_html}。要得到更多帮助,请访问 %{app_name_html} %{help_link_html} 或者 %{contact_link_html}。 user_mailer.password_changed.intro_html: 你的 %{app_name_html} 账户有了新密码。 -user_mailer.personal_key_regenerated.help_html:

你的 %{app_name} 账户刚得到了一个新的 16 字符的个人密钥。你收到这一电邮是为了确保就是你。

如果你刚刚登入并重设了个人密钥,没问题!你无需做任何事情。

如果你刚才没有重设个人密钥,或者你不确定,请立即采取以下步骤来保护你账户安全:

  1. 更改你的密码 选择一个你在该账户没用过的密码。
  2. 登录你的 %{app_name} 账户 并确保你账户页面上的信息你都能认出,包括你进行双因素身份证实的方法,比如电话号码、身份证实 app 或安全密钥。
  3. 在你的%{app_name} 账户页面, 请求新个人密钥。 请记住,除非你在用该密钥登录一个使用 %{app_name} 的受到信任的网站,绝对不要将其与人分享。


谢谢,
%{app_name} 团队 +user_mailer.personal_key_regenerated.help_html:

你的 %{app_name} 账户刚得到了一个新的 16 字符的个人密钥。你收到这一电邮是为了确保就是你。

如果你刚刚登入并重设了个人密钥,没问题!你无需做任何事情。

如果你刚才没有重设个人密钥,或者你不确定,请立即采取以下步骤来保护你账户安全:

  1. 更改你的密码选择一个你在该账户没用过的密码。
  2. 登录你的 %{app_name} 账户并确保你账户页面上的信息你都能认出,包括你进行双因素身份证实的方法,比如电话号码、身份证实 app 或安全密钥。
  3. 在你的%{app_name} 账户页面,请求新个人密钥。请记住,除非你在用该密钥登录一个使用 %{app_name} 的受到信任的网站,绝对不要将其与人分享。


谢谢,
%{app_name} 团队 user_mailer.personal_key_regenerated.intro: 新个人密钥已发放 user_mailer.personal_key_regenerated.subject: 账户安全警告 -user_mailer.personal_key_sign_in.help_html:

你的 16 字符的个人密钥刚被用来登录你的 %{app_name} 账户。你收到这一电邮是为了确保就是你。

如果你刚用自己的个人密钥登入,没问题!你无需做任何事情。

如果你没用个人密钥登录,或者你不确定,请立即采取以下步骤来保护你账户安全:

  1. 更改你的密码 选择一个你在该账户没用过的密码。
  2. 登录你的 %{app_name} 账户 并确保你账户页面上的信息你都能认出,包括你进行双因素身份证实的方法,比如电话号码、身份证实 app 或者安全密钥。
  3. 在你的%{app_name} 账户页面, 请求新个人密钥。 请记住,除非你在用密钥登入一个使用%{app_name}的受到信任的网站,绝对不要将其与人分享。


谢谢,
%{app_name} 团队 +user_mailer.personal_key_sign_in.help_html:

你的 16 字符的个人密钥刚被用来登录你的 %{app_name} 账户。你收到这一电邮是为了确保就是你。

如果你刚用自己的个人密钥登入,没问题!你无需做任何事情。

如果你没用个人密钥登录,或者你不确定,请立即采取以下步骤来保护你账户安全:

  1. 更改你的密码选择一个你在该账户没用过的密码。
  2. 登录你的 %{app_name} 账户并确保你账户页面上的信息你都能认出,包括你进行双因素身份证实的方法,比如电话号码、身份证实 app 或者安全密钥。
  3. 在你的%{app_name} 账户页面,请求新个人密钥。请记住,除非你在用密钥登入一个使用%{app_name}的受到信任的网站,绝对不要将其与人分享。


谢谢,
%{app_name} 团队 user_mailer.personal_key_sign_in.intro: 个人密钥被用来登录 user_mailer.personal_key_sign_in.subject: 账户安全警告 user_mailer.phone_added.disavowal_link: 重设你的密码 diff --git a/db/primary_migrate/20241015154109_create_socure_reason_codes.rb b/db/primary_migrate/20241015154109_create_socure_reason_codes.rb new file mode 100644 index 00000000000..e91a1aa9e61 --- /dev/null +++ b/db/primary_migrate/20241015154109_create_socure_reason_codes.rb @@ -0,0 +1,16 @@ +class CreateSocureReasonCodes < ActiveRecord::Migration[7.1] + def change + create_table :socure_reason_codes do |t| + t.string :code, comment: 'sensitive=false' + t.string :group, comment: 'sensitive=false' + t.text :description, comment: 'sensitive=false' + t.datetime :added_at, comment: 'sensitive=false' + t.datetime :deactivated_at, comment: 'sensitive=false' + + t.timestamps comment: 'sensitive=false' + + t.index :code, unique: true + t.index :deactivated_at + end + end +end diff --git a/db/primary_migrate/20241017153042_rename_socure_docv_token_in_document_capture_session.rb b/db/primary_migrate/20241017153042_rename_socure_docv_token_in_document_capture_session.rb new file mode 100644 index 00000000000..ed63ffad9e0 --- /dev/null +++ b/db/primary_migrate/20241017153042_rename_socure_docv_token_in_document_capture_session.rb @@ -0,0 +1,5 @@ +class RenameSocureDocvTokenInDocumentCaptureSession < ActiveRecord::Migration[7.1] + def change + safety_assured { rename_column :document_capture_sessions, :socure_docv_token, :socure_docv_transaction_token } + end +end diff --git a/db/schema.rb b/db/schema.rb index ed1586ab63f..a20b704ba51 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.1].define(version: 2024_10_01_193936) do +ActiveRecord::Schema[7.1].define(version: 2024_10_17_153042) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" @@ -191,7 +191,7 @@ t.datetime "cancelled_at", precision: nil, comment: "sensitive=false" t.boolean "ocr_confirmation_pending", default: false, comment: "sensitive=false" t.string "last_doc_auth_result", comment: "sensitive=false" - t.string "socure_docv_token", comment: "sensitive=false" + t.string "socure_docv_transaction_token", comment: "sensitive=false" t.index ["result_id"], name: "index_document_capture_sessions_on_result_id" t.index ["user_id"], name: "index_document_capture_sessions_on_user_id" t.index ["uuid"], name: "index_document_capture_sessions_on_uuid" @@ -560,6 +560,18 @@ t.index ["user_id", "service_provider"], name: "index_sign_in_restrictions_on_user_id_and_service_provider", unique: true end + create_table "socure_reason_codes", force: :cascade do |t| + t.string "code", comment: "sensitive=false" + t.string "group", comment: "sensitive=false" + t.text "description", comment: "sensitive=false" + t.datetime "added_at", comment: "sensitive=false" + t.datetime "deactivated_at", comment: "sensitive=false" + t.datetime "created_at", null: false, comment: "sensitive=false" + t.datetime "updated_at", null: false, comment: "sensitive=false" + t.index ["code"], name: "index_socure_reason_codes_on_code", unique: true + t.index ["deactivated_at"], name: "index_socure_reason_codes_on_deactivated_at" + end + create_table "sp_costs", force: :cascade do |t| t.string "issuer", null: false, comment: "sensitive=false" t.integer "agency_id", null: false, comment: "sensitive=false" diff --git a/db/worker_jobs_migrate/20241021192429_recreate_good_job_cron_indexes_with_conditional.rb b/db/worker_jobs_migrate/20241021192429_recreate_good_job_cron_indexes_with_conditional.rb new file mode 100644 index 00000000000..56cab35c385 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192429_recreate_good_job_cron_indexes_with_conditional.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class RecreateGoodJobCronIndexesWithConditional < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)", + name: :index_good_jobs_on_cron_key_and_created_at_cond, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at_cond, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at) + add_index :good_jobs, [:cron_key, :created_at], + name: :index_good_jobs_on_cron_key_and_created_at, algorithm: :concurrently + end + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at) + add_index :good_jobs, [:cron_key, :cron_at], unique: true, + name: :index_good_jobs_on_cron_key_and_cron_at, algorithm: :concurrently + end + + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at_cond + end + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond) + remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at_cond + end + end + end + end +end diff --git a/db/worker_jobs_migrate/20241021192430_create_good_job_labels.rb b/db/worker_jobs_migrate/20241021192430_create_good_job_labels.rb new file mode 100644 index 00000000000..1ba514e8b4a --- /dev/null +++ b/db/worker_jobs_migrate/20241021192430_create_good_job_labels.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobLabels < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :labels) + end + end + + add_column :good_jobs, :labels, :text, array: true + end +end diff --git a/db/worker_jobs_migrate/20241021192431_create_good_job_labels_index.rb b/db/worker_jobs_migrate/20241021192431_create_good_job_labels_index.rb new file mode 100644 index 00000000000..65dedd477d8 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192431_create_good_job_labels_index.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateGoodJobLabelsIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", + name: :index_good_jobs_on_labels, algorithm: :concurrently + end + end + + dir.down do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) + remove_index :good_jobs, name: :index_good_jobs_on_labels + end + end + end + end +end diff --git a/db/worker_jobs_migrate/20241021192432_remove_good_job_active_id_index.rb b/db/worker_jobs_migrate/20241021192432_remove_good_job_active_id_index.rb new file mode 100644 index 00000000000..8601f071aa5 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192432_remove_good_job_active_id_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveGoodJobActiveIdIndex < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + remove_index :good_jobs, name: :index_good_jobs_on_active_job_id + end + end + + dir.down do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) + add_index :good_jobs, :active_job_id, name: :index_good_jobs_on_active_job_id + end + end + end + end +end diff --git a/db/worker_jobs_migrate/20241021192433_create_index_good_job_jobs_for_candidate_lookup.rb b/db/worker_jobs_migrate/20241021192433_create_index_good_job_jobs_for_candidate_lookup.rb new file mode 100644 index 00000000000..70e525626e9 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192433_create_index_good_job_jobs_for_candidate_lookup.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateIndexGoodJobJobsForCandidateLookup < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup) + end + end + + add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup, + algorithm: :concurrently + end +end diff --git a/db/worker_jobs_migrate/20241021192434_create_good_job_execution_error_backtrace.rb b/db/worker_jobs_migrate/20241021192434_create_good_job_execution_error_backtrace.rb new file mode 100644 index 00000000000..6b054b64060 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192434_create_good_job_execution_error_backtrace.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :error_backtrace) + end + end + + add_column :good_job_executions, :error_backtrace, :text, array: true + end +end diff --git a/db/worker_jobs_migrate/20241021192435_create_good_job_process_lock_ids.rb b/db/worker_jobs_migrate/20241021192435_create_good_job_process_lock_ids.rb new file mode 100644 index 00000000000..f1b70a8f2e4 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192435_create_good_job_process_lock_ids.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_jobs, :locked_by_id) + end + end + + add_column :good_jobs, :locked_by_id, :uuid + add_column :good_jobs, :locked_at, :datetime + add_column :good_job_executions, :process_id, :uuid + add_column :good_job_processes, :lock_type, :integer, limit: 2 + end +end diff --git a/db/worker_jobs_migrate/20241021192436_create_good_job_process_lock_indexes.rb b/db/worker_jobs_migrate/20241021192436_create_good_job_process_lock_indexes.rb new file mode 100644 index 00000000000..331825f7424 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192436_create_good_job_process_lock_indexes.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + reversible do |dir| + dir.up do + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + add_index :good_jobs, [:priority, :scheduled_at], + order: { priority: "ASC NULLS LAST", scheduled_at: :asc }, + where: "finished_at IS NULL AND locked_by_id IS NULL", + name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + add_index :good_jobs, :locked_by_id, + where: "locked_by_id IS NOT NULL", + name: :index_good_jobs_on_locked_by_id, + algorithm: :concurrently + end + + unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + add_index :good_job_executions, [:process_id, :created_at], + name: :index_good_job_executions_on_process_id_and_created_at, + algorithm: :concurrently + end + end + + dir.down do + remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) + remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id) + remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at) + end + end + end +end diff --git a/db/worker_jobs_migrate/20241021192437_create_good_job_execution_duration.rb b/db/worker_jobs_migrate/20241021192437_create_good_job_execution_duration.rb new file mode 100644 index 00000000000..fef37f07bc1 --- /dev/null +++ b/db/worker_jobs_migrate/20241021192437_create_good_job_execution_duration.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1] + def change + reversible do |dir| + dir.up do + # Ensure this incremental update migration is idempotent + # with monolithic install migration. + return if connection.column_exists?(:good_job_executions, :duration) + end + end + + add_column :good_job_executions, :duration, :interval + end +end diff --git a/db/worker_jobs_schema.rb b/db/worker_jobs_schema.rb index 3fc1dde2143..3398c6bca59 100644 --- a/db/worker_jobs_schema.rb +++ b/db/worker_jobs_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2023_10_18_163221) do +ActiveRecord::Schema[7.1].define(version: 2024_10_21_192437) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -41,13 +41,18 @@ t.datetime "finished_at" t.text "error" t.integer "error_event", limit: 2 + t.text "error_backtrace", array: true + t.uuid "process_id" + t.interval "duration" t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" end create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "state" + t.integer "lock_type", limit: 2 end create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -79,15 +84,21 @@ t.integer "executions_count" t.text "job_class" t.integer "error_event", limit: 2 + t.text "labels", array: true + t.uuid "locked_by_id" + t.datetime "locked_at" t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" - t.index ["active_job_id"], name: "index_good_jobs_on_active_job_id" t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" - t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at" - t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at", unique: true + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)" + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))" t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end diff --git a/dockerfiles/application.yaml b/dockerfiles/application.yaml index 85aaf229651..94447e9e008 100644 --- a/dockerfiles/application.yaml +++ b/dockerfiles/application.yaml @@ -81,6 +81,18 @@ spec: - op: add path: /data/REDIS_IRS_ATTEMPTS_API_URL value: "redis://{{ENVIRONMENT}}-redis.review-apps:6379/2" + - op: add + path: /data/RAILS_OFFLINE + value: "true" + - op: add + path: /data/LOGIN_DATACENTER + value: "true" + - op: add + path: /data/LOGIN_ENV + value: "review-app" + - op: add + path: /data/LOGIN_DOMAIN + value: "identitysandbox.gov" - target: kind: ConfigMap name: idp-config-dbsetup @@ -142,6 +154,18 @@ spec: - op: add path: /data/REDIS_IRS_ATTEMPTS_API_URL value: "redis://{{ENVIRONMENT}}-redis.review-apps:6379/2" + - op: add + path: /data/RAILS_OFFLINE + value: "true" + - op: add + path: /data/LOGIN_DATACENTER + value: "true" + - op: add + path: /data/LOGIN_ENV + value: "review-app" + - op: add + path: /data/LOGIN_DOMAIN + value: "identitysandbox.gov" # Patch ConfigMap for Worker - target: kind: ConfigMap diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 1fd5df98fcf..6e0b04b2a07 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -122,7 +122,6 @@ def self.store config.add(:doc_auth_max_capture_attempts_before_native_camera, type: :integer) config.add(:doc_auth_max_submission_attempts_before_native_camera, type: :integer) config.add(:doc_auth_selfie_desktop_test_mode, type: :boolean) - config.add(:doc_auth_separate_pages_enabled, type: :boolean) config.add(:doc_auth_supported_country_codes, type: :json) config.add(:doc_auth_vendor, type: :string) config.add(:doc_auth_vendor_default, type: :string) @@ -175,6 +174,7 @@ def self.store config.add(:idv_min_age_years, type: :integer) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) config.add(:idv_send_link_max_attempts, type: :integer) + config.add(:idv_socure_reason_code_download_enabled, type: :boolean) config.add(:idv_socure_shadow_mode_enabled, type: :boolean) config.add(:idv_sp_required, type: :boolean) config.add(:in_person_completion_survey_url, type: :string) @@ -372,9 +372,6 @@ def self.store config.add(:s3_reports_enabled, type: :boolean) config.add(:saml_endpoint_configs, type: :json, options: { symbolize_names: true }) config.add(:saml_secret_rotation_enabled, type: :boolean) - config.add(:socure_idplus_api_key, type: :string) - config.add(:socure_idplus_base_url, type: :string) - config.add(:socure_idplus_timeout_in_seconds, type: :integer) config.add(:scrypt_cost, type: :string) config.add(:second_mfa_reminder_account_age_in_days, type: :integer) config.add(:second_mfa_reminder_sign_in_count, type: :integer) @@ -397,11 +394,17 @@ def self.store config.add(:sign_in_user_id_per_ip_attempt_window_in_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_attempt_window_max_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_max_attempts, type: :integer) + config.add(:sign_in_recaptcha_log_failures_only, type: :boolean) config.add(:sign_in_recaptcha_percent_tested, type: :integer) config.add(:sign_in_recaptcha_score_threshold, type: :float) config.add(:skip_encryption_allowed_list, type: :json) config.add(:socure_document_request_endpoint, type: :string) config.add(:socure_idplus_api_key, type: :string) + config.add(:socure_idplus_base_url, type: :string) + config.add(:socure_idplus_timeout_in_seconds, type: :integer) + config.add(:socure_reason_code_api_key, type: :string) + config.add(:socure_reason_code_base_url, type: :string) + config.add(:socure_reason_code_timeout_in_seconds, type: :integer) config.add(:socure_webhook_enabled, type: :boolean) config.add(:socure_enabled, type: :boolean) config.add(:socure_webhook_secret_key, type: :string) diff --git a/lib/identity_cors.rb b/lib/identity_cors.rb index 61cb1b8716e..e3b26b63a92 100644 --- a/lib/identity_cors.rb +++ b/lib/identity_cors.rb @@ -11,7 +11,7 @@ class IdentityCors ].freeze def self.allowed_origins_static_sites - return STATIC_SITE_ALLOWED_ORIGINS unless Rails.env.development? || Rails.env.test? + return STATIC_SITE_ALLOWED_ORIGINS unless Rails.env.local? allowed_origins = STATIC_SITE_ALLOWED_ORIGINS.dup allowed_origins << %r{https?://localhost(:\d+)?\z} allowed_origins << %r{https?://127\.0\.0\.1(:\d+)?\z} diff --git a/lib/identity_job_log_subscriber.rb b/lib/identity_job_log_subscriber.rb index baaad9fc29f..e9d981dd531 100644 --- a/lib/identity_job_log_subscriber.rb +++ b/lib/identity_job_log_subscriber.rb @@ -163,6 +163,8 @@ def error_or_warn( def default_attributes(event, job) { duration_ms: event.duration, + cpu_time_ms: event.cpu_time, + idle_time_ms: event.idle_time, timestamp: Time.zone.now, name: event.name, job_class: job.class.name, diff --git a/lib/reporting/authentication_report.rb b/lib/reporting/authentication_report.rb index e2dfe27eba9..eac43743d0d 100644 --- a/lib/reporting/authentication_report.rb +++ b/lib/reporting/authentication_report.rb @@ -211,7 +211,6 @@ def format_as_percent(numerator:, denominator:) end end -# rubocop:disable Rails/Output if __FILE__ == $PROGRAM_NAME options = Reporting::CommandLineOptions.new.parse!(ARGV) @@ -219,4 +218,3 @@ def format_as_percent(numerator:, denominator:) puts csv end end -# rubocop:enable Rails/Output diff --git a/lib/reporting/drop_off_report.rb b/lib/reporting/drop_off_report.rb index 6aaf6f4d2d9..7b6b45336c6 100644 --- a/lib/reporting/drop_off_report.rb +++ b/lib/reporting/drop_off_report.rb @@ -450,7 +450,6 @@ def cloudwatch_client end end -# rubocop:disable Rails/Output if __FILE__ == $PROGRAM_NAME options = Reporting::CommandLineOptions.new.parse!(ARGV, require_issuer: false) @@ -458,4 +457,3 @@ def cloudwatch_client puts csv end end -# rubocop:enable Rails/Output diff --git a/lib/reporting/mfa_report.rb b/lib/reporting/mfa_report.rb index 6bfcc4b66ce..79e3fbeb69d 100644 --- a/lib/reporting/mfa_report.rb +++ b/lib/reporting/mfa_report.rb @@ -182,7 +182,6 @@ def multi_factor_auth_table end end -# rubocop:disable Rails/Output if __FILE__ == $PROGRAM_NAME options = Reporting::CommandLineOptions.new.parse!(ARGV) @@ -190,4 +189,3 @@ def multi_factor_auth_table puts csv end end -# rubocop:enable Rails/Output diff --git a/lib/reporting/protocols_report.rb b/lib/reporting/protocols_report.rb index b68d3ef69ad..c7ee1e2a382 100644 --- a/lib/reporting/protocols_report.rb +++ b/lib/reporting/protocols_report.rb @@ -72,6 +72,10 @@ def as_emailable_reports title: 'Deprecated Parameter Usage', table: deprecated_parameters_table, ), + Reporting::EmailableReport.new( + title: 'Feature Usage', + table: feature_use_table, + ), ] end @@ -371,7 +375,6 @@ def to_percent(numerator, denominator) end end -# rubocop:disable Rails/Output if __FILE__ == $PROGRAM_NAME options = Reporting::CommandLineOptions.new.parse!(ARGV, require_issuer: false) @@ -379,4 +382,3 @@ def to_percent(numerator, denominator) puts csv end end -# rubocop:enable Rails/Output diff --git a/scripts/enforce-typescript-files.mjs b/scripts/enforce-typescript-files.mjs index 90341fe763f..f75d5f728bd 100755 --- a/scripts/enforce-typescript-files.mjs +++ b/scripts/enforce-typescript-files.mjs @@ -9,8 +9,6 @@ import glob from 'fast-glob'; // only ever shrink over time. Scripts which are loaded directly by Node.js should exist within // packages with a defined entrypoint. const LEGACY_FILE_EXCEPTIONS = [ - 'app/javascript/packages/compose-components/index.js', - 'app/javascript/packages/compose-components/index.spec.jsx', 'app/javascript/packages/device/index.js', 'app/javascript/packages/document-capture/index.js', 'app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx', diff --git a/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb index 1a5f2727c03..8d43b38e37f 100644 --- a/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/entry_controller_spec.rb @@ -15,7 +15,6 @@ let(:idv_vendor) { Idp::Constants::Vendors::MOCK } before do - stub_analytics allow(IdentityConfig.store).to receive(:doc_auth_vendor).and_return(idv_vendor) allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return(idv_vendor) end @@ -35,15 +34,6 @@ get :show, params: { 'document-capture-session': 'foo' } end - it 'logs an analytics event' do - expect(@analytics).to have_logged_event( - 'Doc Auth', - hash_including( - success: false, - errors: { session_uuid: ['invalid session'] }, - ), - ) - end it 'redirects to the root url' do expect(response).to redirect_to root_url end @@ -78,16 +68,6 @@ it 'redirects to the first step' do expect(response).to redirect_to idv_hybrid_mobile_socure_document_capture_url end - - it 'logs an analytics event' do - expect(@analytics).to have_logged_event( - 'Doc Auth', - hash_including( - success: true, - doc_capture_user_id?: false, - ), - ) - end end context 'doc auth vendor is lexis nexis' do @@ -96,16 +76,6 @@ it 'redirects to the first step' do expect(response).to redirect_to idv_hybrid_mobile_document_capture_url end - - it 'logs an analytics event' do - expect(@analytics).to have_logged_event( - 'Doc Auth', - hash_including( - success: true, - doc_capture_user_id?: false, - ), - ) - end end context 'but we already had a session' do @@ -127,16 +97,6 @@ expect(controller.session).to include(document_capture_session_uuid: session_uuid) end - it 'logs an analytics event' do - expect(@analytics).to have_logged_event( - 'Doc Auth', - hash_including( - success: true, - doc_capture_user_id?: true, - ), - ) - end - context 'doc auth vendor is socure' do let(:idv_vendor) { Idp::Constants::Vendors::SOCURE } diff --git a/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb index 7d3bf05ca5f..8cee30758b4 100644 --- a/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb @@ -150,7 +150,8 @@ it 'puts the docvTransactionToken into the document capture session' do document_capture_session.reload - expect(document_capture_session.socure_docv_token).to eq(docv_transaction_token) + expect(document_capture_session.socure_docv_transaction_token). + to eq(docv_transaction_token) end end end diff --git a/spec/controllers/idv/socure/document_capture_controller_spec.rb b/spec/controllers/idv/socure/document_capture_controller_spec.rb index e2fcfa51a1c..863f553eb3e 100644 --- a/spec/controllers/idv/socure/document_capture_controller_spec.rb +++ b/spec/controllers/idv/socure/document_capture_controller_spec.rb @@ -152,7 +152,8 @@ it 'puts the docvTransactionToken into the document capture session' do document_capture_session.reload - expect(document_capture_session.socure_docv_token).to eq(docv_transaction_token) + expect(document_capture_session.socure_docv_transaction_token). + to eq(docv_transaction_token) end end end diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index 4319aa75a17..9f453c60677 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -149,6 +149,7 @@ context: { stages: { threatmetrix: { + client: threatmetrix_client_id, transaction_id: 1, review_status: review_status, response_body: { @@ -226,9 +227,7 @@ context: hash_including( stages: hash_including( threatmetrix: hash_including( - response_body: hash_including( - client: threatmetrix_client_id, - ), + client: threatmetrix_client_id, ), ), ), @@ -390,6 +389,10 @@ }, ), ) + + event = @analytics.events['IdV: doc auth verify proofing results'].first + state_id = event.dig(:proofing_results, :context, :stages, :state_id) + expect(state_id).to match(a_hash_including(state_id_type: 'drivers_license')) end end diff --git a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb index 74b204390d2..3656a2d72ac 100644 --- a/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb @@ -12,76 +12,72 @@ end end - describe 'when not signed in' do - describe 'GET index' do + describe '#new' do + context 'when not signed in' do it 'redirects to root url' do get :new expect(response).to redirect_to(root_url) end end - end - describe 'when signed out' do - describe 'GET index' do + context 'when signed out' do it 'redirects to sign in page' do get :new expect(response).to redirect_to(new_user_session_url) end end - end - describe 'when signing in' do - before(:each) { stub_sign_in_before_2fa(user) } - let(:user) do - create(:user, :fully_registered, :with_piv_or_cac, with: { phone: '+1 (703) 555-0000' }) - end + context 'when signing in' do + before { stub_sign_in_before_2fa(user) } + + let(:user) do + create(:user, :fully_registered, :with_piv_or_cac, with: { phone: '+1 (703) 555-0000' }) + end - describe 'GET index' do it 'redirects to 2fa entry' do get :new + expect(response).to redirect_to(user_two_factor_authentication_url) end end - end - describe 'when signed in' do - before(:each) { stub_sign_in(user) } + context 'when signed in' do + before { stub_sign_in(user) } - context 'without associated piv/cac' do - let(:user) do - create(:user, :fully_registered, with: { phone: '+1 (703) 555-0000' }) - end - let(:nickname) { 'Card 1' } + context 'without associated piv/cac' do + let(:user) do + create(:user, :fully_registered, with: { phone: '+1 (703) 555-0000' }) + end + let(:nickname) { 'Card 1' } - before(:each) do - allow(PivCacService).to receive(:decode_token).with(good_token) { good_token_response } - allow(PivCacService).to receive(:decode_token).with(bad_token) { bad_token_response } - allow(subject).to receive(:user_session).and_return(piv_cac_nonce: nonce) - subject.user_session[:piv_cac_nickname] = nickname - end + before(:each) do + allow(PivCacService).to receive(:decode_token).with(good_token) { good_token_response } + allow(PivCacService).to receive(:decode_token).with(bad_token) { bad_token_response } + allow(subject).to receive(:user_session).and_return(piv_cac_nonce: nonce) + subject.user_session[:piv_cac_nickname] = nickname + end - let(:nonce) { 'nonce' } + let(:nonce) { 'nonce' } - let(:good_token) { 'good-token' } - let(:good_token_response) do - { - 'subject' => 'some dn', - 'uuid' => 'some-random-string', - 'nonce' => nonce, - } - end + let(:good_token) { 'good-token' } + let(:good_token_response) do + { + 'subject' => 'some dn', + 'uuid' => 'some-random-string', + 'nonce' => nonce, + } + end - let(:bad_token) { 'bad-token' } - let(:bad_token_response) do - { - 'error' => 'certificate.bad', - 'nonce' => nonce, - } - end + let(:bad_token) { 'bad-token' } + let(:bad_token_response) do + { + 'error' => 'certificate.bad', + 'nonce' => nonce, + } + end - describe 'GET index' do context 'when rendered without a token' do it 'renders the "new" template' do get :new diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index be407001213..6a535885546 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -341,33 +341,55 @@ and_return(:sign_in_recaptcha) end - it 'tracks unsuccessful authentication for failed reCAPTCHA' do - user = create(:user, :fully_registered) + context 'when configured to log failures only' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_log_failures_only). + and_return(true) + end - stub_analytics + it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do + user = create(:user, :fully_registered) - post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } - expect(@analytics).to have_logged_event( - 'Email and Password Authentication', - success: false, - user_id: user.uuid, - user_locked_out: false, - rate_limited: false, - valid_captcha_result: false, - captcha_validation_performed: true, - bad_password_count: 0, - remember_device: false, - sp_request_url_present: false, - ) + expect(response).to redirect_to user_two_factor_authentication_url + end end - it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do - user = create(:user, :fully_registered) + context 'when not configured to log failures only' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_log_failures_only). + and_return(false) + end - post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + it 'tracks unsuccessful authentication for failed reCAPTCHA' do + user = create(:user, :fully_registered) - expect(response).to redirect_to sign_in_security_check_failed_url + stub_analytics + + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + + expect(@analytics).to have_logged_event( + 'Email and Password Authentication', + success: false, + user_id: user.uuid, + user_locked_out: false, + rate_limited: false, + valid_captcha_result: false, + captcha_validation_performed: true, + bad_password_count: 0, + remember_device: false, + sp_request_url_present: false, + ) + end + + it 'redirects unsuccessful authentication for failed reCAPTCHA to failed page' do + user = create(:user, :fully_registered) + + post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } + + expect(response).to redirect_to sign_in_security_check_failed_url + end end end diff --git a/spec/features/account_reset/delete_account_spec.rb b/spec/features/account_reset/delete_account_spec.rb index 8b4966e29a5..8d426766b23 100644 --- a/spec/features/account_reset/delete_account_spec.rb +++ b/spec/features/account_reset/delete_account_spec.rb @@ -31,6 +31,7 @@ to have_content strip_tags( t('account_reset.request.delete_account'), ) + click_button t('account_reset.request.yes_continue') expect(page). @@ -48,7 +49,7 @@ reset_email - travel_to(Time.zone.now + 2.days + 1) do + travel_to(Time.zone.now + 2.days + 2) do AccountReset::GrantRequestsAndSendEmails.new.perform(Time.zone.today) open_last_email click_email_link_matching(/delete_account\?token/) diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 4c82b87b2c8..bc72841df9b 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -29,7 +29,6 @@ client: nil, errors: {}, exception: nil, - response_body: threatmetrix_response_body, review_status: 'pass', account_lex_id: 'super-cool-test-lex-id', session_id: 'super-cool-test-session-id', @@ -71,6 +70,10 @@ jurisdiction_in_maintenance_window: false } end + let(:state_id_resolution_with_id_type) do + state_id_resolution.merge(state_id_type: 'drivers_license') + end + let(:resolution_block) do { success: true, errors: {}, @@ -122,6 +125,12 @@ } end + let(:doc_auth_verify_proofing_results) do + base_proofing_results.deep_merge( + context: { stages: { state_id: state_id_resolution_with_id_type } }, + ) + end + let(:in_person_path_proofing_results) do { exception: nil, @@ -145,7 +154,7 @@ vendor_name: 'ResolutionMock', vendor_workflow: nil, verified_attributes: nil }, - state_id: state_id_resolution, + state_id: state_id_resolution_with_id_type, threatmetrix: threatmetrix_response, }, }, @@ -228,7 +237,7 @@ ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', - proofing_results: base_proofing_results + proofing_results: doc_auth_verify_proofing_results }, 'IdV: phone of record visited' => { @@ -348,7 +357,7 @@ ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'hybrid', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', - proofing_results: base_proofing_results + proofing_results: doc_auth_verify_proofing_results }, 'IdV: phone of record visited' => { @@ -465,7 +474,7 @@ ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', - proofing_results: base_proofing_results + proofing_results: doc_auth_verify_proofing_results }, 'IdV: phone of record visited' => { proofing_components: base_proofing_components, @@ -702,7 +711,7 @@ ), 'IdV: doc auth verify proofing results' => { success: true, errors: {}, flow_path: 'standard', address_edited: false, address_line2_present: false, analytics_id: 'Doc Auth', step: 'verify', - proofing_results: base_proofing_results + proofing_results: doc_auth_verify_proofing_results }, 'IdV: phone of record visited' => { @@ -828,7 +837,6 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: threatmetrix_response_body, } end @@ -909,7 +917,6 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: threatmetrix_response_body, } end @@ -959,7 +966,6 @@ review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: threatmetrix_response_body, } end @@ -970,292 +976,84 @@ end end end - - context 'in person path' do - let(:return_sp_url) { 'https://example.com/some/idv/ipp/url' } - - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled).and_return(false) - allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).and_return(true) - allow_any_instance_of(Idv::InPerson::ReadyToVerifyPresenter). - to receive(:service_provider_homepage_url).and_return(return_sp_url) - allow_any_instance_of(Idv::InPerson::ReadyToVerifyPresenter). - to receive(:sp_name).and_return(sp_friendly_name) - allow(IdentityConfig.store).to receive(:in_person_proofing_enforce_tmx). - and_return(true) - - start_idv_from_sp(:saml) - sign_in_and_2fa_user(user) - begin_in_person_proofing(user) - complete_all_in_person_proofing_steps(user, same_address_as_id: false) - complete_phone_step(user) - complete_enter_password_step(user) - acknowledge_and_confirm_personal_key - visit_help_center - visit_sp_from_in_person_ready_to_verify - end - - it 'records all of the events', allow_browser_log: true do - max_wait = Time.zone.now + 5.seconds - wait_for_event('IdV: user clicked what to bring link on ready to verify page', max_wait) - wait_for_event('IdV: user clicked sp link on ready to verify page', max_wait) - in_person_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end - end - - context 'proofing_device_profiling disabled' do - let(:proofing_device_profiling) { :disabled } - let(:idv_level) { 'legacy_in_person' } - let(:threatmetrix) { false } - let(:threatmetrix_response_body) { nil } - let(:threatmetrix_response) do - { - client: 'tmx_disabled', - success: true, - errors: {}, - exception: nil, - timed_out: false, - transaction_id: nil, - review_status: 'pass', - account_lex_id: nil, - session_id: nil, - response_body: threatmetrix_response_body, - } - end - - it 'records all of the events', allow_browser_log: true do - max_wait = Time.zone.now + 5.seconds - wait_for_event('IdV: user clicked what to bring link on ready to verify page', max_wait) - wait_for_event('IdV: user clicked sp link on ready to verify page', max_wait) - in_person_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end - end - end - - # wait for event to happen - def wait_for_event(event, wait) - frequency = 0.1.seconds - loop do - expect(fake_analytics).to have_logged_event(event) - return - rescue RSpec::Expectations::ExpectationNotMetError => err - raise err if wait - Time.zone.now < frequency - sleep frequency - next - end - end - end - - context 'Happy selfie path' do - before do - allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:success) - - perform_in_browser(:desktop) do + context 'Hybrid flow' do + context 'facial comparison not required - Happy path' do + before do sign_in_and_2fa_user(user) - visit_idp_from_sp_with_ial2(:oidc, facial_match_required: true) - complete_doc_auth_steps_before_document_capture_step - attach_images - attach_selfie - submit_images - - click_idv_continue - visit idv_ssn_url + visit_idp_from_sp_with_ial2(:oidc) + complete_welcome_step + complete_agreement_step + complete_hybrid_handoff_step + complete_document_capture_step complete_ssn_step complete_verify_step - fill_out_phone_form_ok('202-555-1212') - verify_phone_otp + complete_phone_step(user) complete_enter_password_step(user) acknowledge_and_confirm_personal_key end - end - - it 'records all of the events' do - happy_mobile_selfie_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end - end - - context 'proofing_device_profiling disabled' do - let(:proofing_device_profiling) { :disabled } - let(:threatmetrix) { false } - let(:threatmetrix_response_body) { nil } - let(:threatmetrix_response) do - { - client: 'tmx_disabled', - success: true, - errors: {}, - exception: nil, - timed_out: false, - transaction_id: nil, - review_status: 'pass', - account_lex_id: nil, - session_id: nil, - response_body: threatmetrix_response_body, - } - end it 'records all of the events' do aggregate_failures 'analytics events' do - happy_mobile_selfie_path_events.each do |event, attributes| + happy_path_events.each do |event, attributes| expect(fake_analytics).to have_logged_event(event, attributes) end end - end - end - end - context 'doc_auth_separate_pages_enabled is true' do - before do - allow(IdentityConfig.store).to receive(:doc_auth_separate_pages_enabled).and_return(true) - end - context 'Hybrid flow' do - context 'facial comparison not required - Happy path' do - before do - sign_in_and_2fa_user(user) - visit_idp_from_sp_with_ial2(:oidc) - complete_welcome_step - complete_agreement_step - complete_hybrid_handoff_step - complete_document_capture_step - complete_ssn_step - complete_verify_step - complete_phone_step(user) - complete_enter_password_step(user) - acknowledge_and_confirm_personal_key - end - - it 'records all of the events' do - aggregate_failures 'analytics events' do - happy_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end - end - - aggregate_failures 'populates data for each step of the Daily Dropoff Report' do - row = CSV.parse( - Reports::DailyDropoffsReport.new.tap do |r| - r.report_date = Time.zone.now - end.report_body, - headers: true, - ).first - - Reports::DailyDropoffsReport::STEPS.each do |step| - expect(row[step].to_i).to(be > 0, "step #{step} was counted") - end - end - end - context 'proofing_device_profiling disabled' do - let(:proofing_device_profiling) { :disabled } - let(:threatmetrix) { false } - let(:threatmetrix_response_body) { nil } - let(:threatmetrix_response) do - { - client: 'tmx_disabled', - success: true, - errors: {}, - exception: nil, - timed_out: false, - transaction_id: nil, - review_status: 'pass', - account_lex_id: nil, - session_id: nil, - response_body: threatmetrix_response_body, - } - end + aggregate_failures 'populates data for each step of the Daily Dropoff Report' do + row = CSV.parse( + Reports::DailyDropoffsReport.new.tap do |r| + r.report_date = Time.zone.now + end.report_body, + headers: true, + ).first - it 'records all of the events', allow_browser_log: true do - aggregate_failures 'analytics events' do - happy_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end - end + Reports::DailyDropoffsReport::STEPS.each do |step| + expect(row[step].to_i).to(be > 0, "step #{step} was counted") end end end - context 'facial comparison required - Happy path' do - before do - allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:success) - - perform_in_browser(:mobile) do - sign_in_and_2fa_user(user) - visit_idp_from_sp_with_ial2(:oidc, facial_match_required: true) - complete_doc_auth_steps_before_document_capture_step - attach_images - click_continue - attach_selfie - submit_images - - click_idv_continue - visit idv_ssn_url - complete_ssn_step - complete_verify_step - fill_out_phone_form_ok('202-555-1212') - verify_phone_otp - complete_enter_password_step(user) - acknowledge_and_confirm_personal_key - end - end - it 'records all of the events' do - happy_mobile_selfie_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end + context 'proofing_device_profiling disabled' do + let(:proofing_device_profiling) { :disabled } + let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } + let(:threatmetrix_response) do + { + client: 'tmx_disabled', + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: nil, + review_status: 'pass', + account_lex_id: nil, + session_id: nil, + } end - context 'proofing_device_profiling disabled' do - let(:proofing_device_profiling) { :disabled } - let(:threatmetrix) { false } - let(:threatmetrix_response_body) { nil } - let(:threatmetrix_response) do - { - client: 'tmx_disabled', - success: true, - errors: {}, - exception: nil, - timed_out: false, - transaction_id: nil, - review_status: 'pass', - account_lex_id: nil, - session_id: nil, - response_body: threatmetrix_response_body, - } - end - - it 'records all of the events' do - aggregate_failures 'analytics events' do - happy_mobile_selfie_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end + it 'records all of the events', allow_browser_log: true do + aggregate_failures 'analytics events' do + happy_path_events.each do |event, attributes| + expect(fake_analytics).to have_logged_event(event, attributes) end end end end end - context 'facial comparison not required - Happy path' do + context 'facial comparison required - Happy path' do before do - allow(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| - @sms_link = config[:link] - impl.call(**config) - end.at_least(1).times - - perform_in_browser(:desktop) do - sign_in_and_2fa_user(user) - visit_idp_from_sp_with_ial2(:oidc) - complete_welcome_step - complete_agreement_step - click_send_link - end + allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:success) perform_in_browser(:mobile) do - visit @sms_link - attach_and_submit_images - visit idv_hybrid_mobile_document_capture_url - end + sign_in_and_2fa_user(user) + visit_idp_from_sp_with_ial2(:oidc, facial_match_required: true) + complete_doc_auth_steps_before_document_capture_step + attach_images + click_continue + click_button 'Take photo' + attach_selfie + submit_images - perform_in_browser(:desktop) do click_idv_continue visit idv_ssn_url complete_ssn_step @@ -1268,21 +1066,8 @@ def wait_for_event(event, wait) end it 'records all of the events' do - aggregate_failures 'analytics events' do - happy_hybrid_path_events.each do |event, attributes| - expect(fake_analytics).to have_logged_event(event, attributes) - end - end - - aggregate_failures 'populates data for each step of the Daily Dropoff Report' do - row = CSV.parse( - Reports::DailyDropoffsReport.new.tap { |r| r.report_date = Time.zone.now }.report_body, - headers: true, - ).first - - Reports::DailyDropoffsReport::STEPS.each do |step| - expect(row[step].to_i).to(be > 0, "step #{step} was counted") - end + happy_mobile_selfie_path_events.each do |event, attributes| + expect(fake_analytics).to have_logged_event(event, attributes) end end @@ -1301,95 +1086,173 @@ def wait_for_event(event, wait) review_status: 'pass', account_lex_id: nil, session_id: nil, - response_body: threatmetrix_response_body, } end it 'records all of the events' do aggregate_failures 'analytics events' do - happy_hybrid_path_events.each do |event, attributes| + happy_mobile_selfie_path_events.each do |event, attributes| expect(fake_analytics).to have_logged_event(event, attributes) end end end end end - context 'in person path' do - let(:return_sp_url) { 'https://example.com/some/idv/ipp/url' } + end + context 'facial comparison not required - Happy path' do + before do + allow(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + @sms_link = config[:link] + impl.call(**config) + end.at_least(1).times - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled).and_return(false) - allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).and_return(true) - allow_any_instance_of(Idv::InPerson::ReadyToVerifyPresenter). - to receive(:service_provider_homepage_url).and_return(return_sp_url) - allow_any_instance_of(Idv::InPerson::ReadyToVerifyPresenter). - to receive(:sp_name).and_return(sp_friendly_name) - allow(IdentityConfig.store).to receive(:in_person_proofing_enforce_tmx). - and_return(true) - - start_idv_from_sp(:saml) + perform_in_browser(:desktop) do sign_in_and_2fa_user(user) - begin_in_person_proofing(user) - complete_all_in_person_proofing_steps(user, same_address_as_id: false) - complete_phone_step(user) + visit_idp_from_sp_with_ial2(:oidc) + complete_welcome_step + complete_agreement_step + click_send_link + end + + perform_in_browser(:mobile) do + visit @sms_link + attach_and_submit_images + visit idv_hybrid_mobile_document_capture_url + end + + perform_in_browser(:desktop) do + click_idv_continue + visit idv_ssn_url + complete_ssn_step + complete_verify_step + fill_out_phone_form_ok('202-555-1212') + verify_phone_otp complete_enter_password_step(user) acknowledge_and_confirm_personal_key - visit_help_center - visit_sp_from_in_person_ready_to_verify end + end - it 'records all of the events', allow_browser_log: true do - max_wait = Time.zone.now + 5.seconds - wait_for_event('IdV: user clicked what to bring link on ready to verify page', max_wait) - wait_for_event('IdV: user clicked sp link on ready to verify page', max_wait) - in_person_path_events.each do |event, attributes| + it 'records all of the events' do + aggregate_failures 'analytics events' do + happy_hybrid_path_events.each do |event, attributes| expect(fake_analytics).to have_logged_event(event, attributes) end end - context 'proofing_device_profiling disabled' do - let(:proofing_device_profiling) { :disabled } - let(:idv_level) { 'legacy_in_person' } - let(:threatmetrix) { false } - let(:threatmetrix_response_body) { nil } - let(:threatmetrix_response) do - { - client: 'tmx_disabled', - success: true, - errors: {}, - exception: nil, - timed_out: false, - transaction_id: nil, - review_status: 'pass', - account_lex_id: nil, - session_id: nil, - response_body: threatmetrix_response_body, - } + aggregate_failures 'populates data for each step of the Daily Dropoff Report' do + row = CSV.parse( + Reports::DailyDropoffsReport.new.tap { |r| r.report_date = Time.zone.now }.report_body, + headers: true, + ).first + + Reports::DailyDropoffsReport::STEPS.each do |step| + expect(row[step].to_i).to(be > 0, "step #{step} was counted") end + end + end - it 'records all of the events', allow_browser_log: true do - max_wait = Time.zone.now + 5.seconds - wait_for_event('IdV: user clicked what to bring link on ready to verify page', max_wait) - wait_for_event('IdV: user clicked sp link on ready to verify page', max_wait) - in_person_path_events.each do |event, attributes| + context 'proofing_device_profiling disabled' do + let(:proofing_device_profiling) { :disabled } + let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } + let(:threatmetrix_response) do + { + client: 'tmx_disabled', + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: nil, + review_status: 'pass', + account_lex_id: nil, + session_id: nil, + } + end + + it 'records all of the events' do + aggregate_failures 'analytics events' do + happy_hybrid_path_events.each do |event, attributes| expect(fake_analytics).to have_logged_event(event, attributes) end end end + end + end + + context 'in person path' do + let(:return_sp_url) { 'https://example.com/some/idv/ipp/url' } + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled).and_return(false) + allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).and_return(true) + allow_any_instance_of(Idv::InPerson::ReadyToVerifyPresenter). + to receive(:service_provider_homepage_url).and_return(return_sp_url) + allow_any_instance_of(Idv::InPerson::ReadyToVerifyPresenter). + to receive(:sp_name).and_return(sp_friendly_name) + allow(IdentityConfig.store).to receive(:in_person_proofing_enforce_tmx). + and_return(true) + + start_idv_from_sp(:saml) + sign_in_and_2fa_user(user) + begin_in_person_proofing(user) + complete_all_in_person_proofing_steps(user, same_address_as_id: false) + complete_phone_step(user) + complete_enter_password_step(user) + acknowledge_and_confirm_personal_key + visit_help_center + visit_sp_from_in_person_ready_to_verify + end + + it 'records all of the events', allow_browser_log: true do + max_wait = Time.zone.now + 5.seconds + wait_for_event('IdV: user clicked what to bring link on ready to verify page', max_wait) + wait_for_event('IdV: user clicked sp link on ready to verify page', max_wait) + in_person_path_events.each do |event, attributes| + expect(fake_analytics).to have_logged_event(event, attributes) + end + end - # wait for event to happen - def wait_for_event(event, wait) - frequency = 0.1.seconds - loop do - expect(fake_analytics).to have_logged_event(event) - return - rescue RSpec::Expectations::ExpectationNotMetError => err - raise err if wait - Time.zone.now < frequency - sleep frequency - next + context 'proofing_device_profiling disabled' do + let(:proofing_device_profiling) { :disabled } + let(:idv_level) { 'legacy_in_person' } + let(:threatmetrix) { false } + let(:threatmetrix_response_body) { nil } + let(:threatmetrix_response) do + { + client: 'tmx_disabled', + success: true, + errors: {}, + exception: nil, + timed_out: false, + transaction_id: nil, + review_status: 'pass', + account_lex_id: nil, + session_id: nil, + } + end + + it 'records all of the events', allow_browser_log: true do + max_wait = Time.zone.now + 5.seconds + wait_for_event('IdV: user clicked what to bring link on ready to verify page', max_wait) + wait_for_event('IdV: user clicked sp link on ready to verify page', max_wait) + in_person_path_events.each do |event, attributes| + expect(fake_analytics).to have_logged_event(event, attributes) end end end + + # wait for event to happen + def wait_for_event(event, wait) + frequency = 0.1.seconds + loop do + expect(fake_analytics).to have_logged_event(event) + return + rescue RSpec::Expectations::ExpectationNotMetError => err + raise err if wait - Time.zone.now < frequency + sleep frequency + next + end + end end end diff --git a/spec/features/idv/doc_auth/document_capture_spec.rb b/spec/features/idv/doc_auth/document_capture_spec.rb index 8b5f3f9d4ea..dd1f0adb23a 100644 --- a/spec/features/idv/doc_auth/document_capture_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_spec.rb @@ -131,6 +131,161 @@ end end + context 'facial match is required', allow_browser_log: true do + before do + allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + end + + it 'user can go through verification uploading ID and selfie on seprerate pages' do + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_images + click_continue + expect(page).to have_title(t('doc_auth.headings.selfie_capture')) + expect(page).to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + click_button 'Take photo' + attach_selfie + submit_images + expect(page).to have_content(t('doc_auth.headings.capture_complete')) + end + + it 'initial verification failure allows user to resubmit all images in 1 page' do + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_multiple_doc_auth_failures_both_sides.yml' + ), + ) + click_continue + click_button 'Take photo' + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_forces_error.yml' + ), + ) + submit_images + expect(page).to have_content(t('doc_auth.errors.rate_limited_heading')) + click_try_again + expect(page).to have_content(t('doc_auth.headings.review_issues')) + attach_images + submit_images + expect(page).to have_content(t('doc_auth.headings.capture_complete')) + end + end + + context 'standard desktop flow' do + before do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + end + + context 'rate limits calls to backend docauth vendor', allow_browser_log: true do + before do + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + + (max_attempts - 1).times do + attach_and_submit_images + click_on t('idv.failure.button.warning') + end + end + + it 'redirects to the rate limited error page' do + freeze_time do + attach_and_submit_images + timeout = distance_of_time_in_words( + RateLimiter.attempt_window_in_minutes(:idv_doc_auth).minutes, + ) + message = strip_tags(t('doc_auth.errors.rate_limited_text_html', timeout: timeout)) + expect(page).to have_content(message) + expect(page).to have_current_path(idv_session_errors_rate_limited_path) + end + end + + it 'logs the rate limited analytics event for doc_auth' do + attach_and_submit_images + expect(fake_analytics).to have_logged_event( + 'Rate Limit Reached', + limiter_type: :idv_doc_auth, + ) + end + + context 'successfully processes image on last attempt' do + before { DocAuth::Mock::DocAuthMockClient.reset! } + + it 'proceeds to the next page with valid info' do + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_and_submit_images + expect(page).to have_current_path(idv_ssn_url) + + visit idv_document_capture_path + + expect(page).to have_current_path(idv_session_errors_rate_limited_path) + end + end + end + + it 'catches network connection errors on post_front_image', allow_browser_log: true do + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + + attach_and_submit_images + + expect(page).to have_current_path(idv_document_capture_url) + expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) + end + + it 'does not track state if state tracking is disabled' do + allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(false) + attach_and_submit_images + + expect(DocAuthLog.find_by(user_id: @user.id).state).to be_nil + end + end + + context 'standard mobile flow' do + it 'proceeds to the next page with valid info' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + + expect(page).to have_current_path(idv_document_capture_url) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + + # doc auth is successful while liveness is not req'd + use_id_image('ial2_test_credential_no_liveness.yml') + submit_images + + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') + + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + end context 'selfie check' do before do allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) @@ -159,6 +314,7 @@ fill_out_ssn_form_ok click_idv_continue complete_verify_step + # expect(page).to have_content(t('doc_auth.headings.document_capture_selfie')) expect(page).to have_current_path(idv_phone_url) end end @@ -187,10 +343,12 @@ expect(max_submission_attempts_before_native_camera.to_i). to eq(ActiveSupport::Duration::SECONDS_PER_HOUR) expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect_doc_capture_page_header(t('doc_auth.headings.document_capture_with_selfie')) - expect_doc_capture_id_subheader + expect(page).to have_text(t('doc_auth.headings.document_capture')) + attach_images + click_continue expect_doc_capture_selfie_subheader - attach_liveness_images + click_button 'Take photo' + attach_selfie submit_images expect(page).to have_current_path(idv_ssn_url) @@ -211,7 +369,15 @@ # when there are multiple doc auth errors on front and back it 'shows the correct error message for the given error' do perform_in_browser(:mobile) do + click_continue use_id_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') + click_continue + click_button 'Take photo' + click_idv_submit_default + expect(page).not_to have_content(t('doc_auth.headings.capture_complete')) + expect(page).not_to have_content(t('doc_auth.errors.rate_limited_heading')) + expect(page).to have_title(t('doc_auth.headings.selfie_capture')) + use_selfie_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') submit_images @@ -230,12 +396,16 @@ # Wrong doc type is uploaded use_id_image('ial2_test_credential_wrong_doc_type.yml') + use_selfie_image('ial2_test_portrait_match_success.yml') submit_images expect_rate_limited_header(false) expect_try_taking_new_pictures(false) - expect_review_issues_body_message('doc_auth.errors.doc_type_not_supported_heading') + # eslint-disable-next-line + expect_review_issues_body_message( + 'doc_auth.errors.doc_type_not_supported_heading', + ) expect_review_issues_body_message('doc_auth.errors.doc.doc_type_check') expect_rate_limit_warning(max_attempts - 2) @@ -246,7 +416,10 @@ expect_resubmit_page_inline_selfie_error_message(false) # when there are multiple front doc auth errors - use_id_image('ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml') + use_id_image( + 'ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml', + ) + use_selfie_image( 'ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml', ) @@ -262,12 +435,17 @@ expect_to_try_again expect_resubmit_page_h1_copy - expect_resubmit_page_body_copy('doc_auth.errors.general.multiple_front_id_failures') + expect_resubmit_page_body_copy( + 'doc_auth.errors.general.multiple_front_id_failures', + ) expect_resubmit_page_inline_error_messages(1) expect_resubmit_page_inline_selfie_error_message(false) # when there are multiple back doc auth errors - use_id_image('ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml') + use_id_image( + 'ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml', + ) + use_selfie_image( 'ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml', ) @@ -283,12 +461,16 @@ expect_to_try_again expect_resubmit_page_h1_copy - expect_resubmit_page_body_copy('doc_auth.errors.general.multiple_back_id_failures') + expect_resubmit_page_body_copy( + 'doc_auth.errors.general.multiple_back_id_failures', + ) expect_resubmit_page_inline_error_messages(1) expect_resubmit_page_inline_selfie_error_message(false) # attention barcode with invalid pii is uploaded use_id_image('ial2_test_credential_barcode_attention_no_address.yml') + click_continue + use_selfie_image('ial2_test_portrait_match_success.yml') submit_images @@ -335,7 +517,6 @@ complete_up_to_how_to_verify_step_for_opt_in_ipp( facial_match_required: true, ) - complete_verify_step end end @@ -430,10 +611,12 @@ click_on t('forms.buttons.upload_photos') expect(page).to have_current_path(idv_document_capture_url) expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect_doc_capture_page_header(t('doc_auth.headings.document_capture_with_selfie')) - expect_doc_capture_id_subheader + expect(page).to have_text(t('doc_auth.headings.document_capture')) + attach_images + click_continue expect_doc_capture_selfie_subheader - attach_liveness_images + click_button 'Take photo' + attach_selfie submit_images expect(page).to have_current_path(idv_ssn_url) @@ -465,7 +648,7 @@ visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_hybrid_handoff_step - # we still have option to continue on handoff, since it's desktop no skip_hand_off + # still have option to continue handoff, since it's desktop no skip_hand_off expect(page).to have_current_path(idv_hybrid_handoff_path) expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) click_on t('in_person_proofing.headings.prepare') @@ -485,533 +668,6 @@ end end - context 'split doc auth flow', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - allow(IdentityConfig.store).to receive(:doc_auth_selfie_capture_enabled).and_return(true) - allow(IdentityConfig.store).to receive(:doc_auth_separate_pages_enabled).and_return(true) - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - end - it 'user can go through verification uploading ID and selfie on seprerate pages' do - expect(page).to have_current_path(idv_document_capture_url) - expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) - attach_images - click_continue - expect(page).to have_title(t('doc_auth.headings.selfie_capture')) - expect(page).to have_content(t('doc_auth.tips.document_capture_selfie_text1')) - attach_selfie - submit_images - expect(page).to have_content(t('doc_auth.headings.capture_complete')) - end - it 'initial verification failure allows user to resubmit all images in 1 page' do - attach_images( - Rails.root.join( - 'spec', 'fixtures', - 'ial2_test_credential_multiple_doc_auth_failures_both_sides.yml' - ), - ) - click_continue - attach_selfie( - Rails.root.join( - 'spec', 'fixtures', - 'ial2_test_credential_forces_error.yml' - ), - ) - submit_images - expect(page).to have_content(t('doc_auth.errors.rate_limited_heading')) - click_try_again - expect(page).to have_content(t('doc_auth.headings.review_issues')) - attach_images - attach_selfie - submit_images - expect(page).to have_content(t('doc_auth.headings.capture_complete')) - end - context 'standard desktop flow' do - before do - visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - end - - context 'rate limits calls to backend docauth vendor', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) - DocAuth::Mock::DocAuthMockClient.mock_response!( - method: :post_front_image, - response: DocAuth::Response.new( - success: false, - errors: { network: I18n.t('doc_auth.errors.general.network_error') }, - ), - ) - - (max_attempts - 1).times do - attach_and_submit_images - click_on t('idv.failure.button.warning') - end - end - - it 'redirects to the rate limited error page' do - freeze_time do - attach_and_submit_images - timeout = distance_of_time_in_words( - RateLimiter.attempt_window_in_minutes(:idv_doc_auth).minutes, - ) - message = strip_tags(t('doc_auth.errors.rate_limited_text_html', timeout: timeout)) - expect(page).to have_content(message) - expect(page).to have_current_path(idv_session_errors_rate_limited_path) - end - end - - it 'logs the rate limited analytics event for doc_auth' do - attach_and_submit_images - expect(fake_analytics).to have_logged_event( - 'Rate Limit Reached', - limiter_type: :idv_doc_auth, - ) - end - - context 'successfully processes image on last attempt' do - before { DocAuth::Mock::DocAuthMockClient.reset! } - - it 'proceeds to the next page with valid info' do - expect(page).to have_current_path(idv_document_capture_url) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - attach_and_submit_images - expect(page).to have_current_path(idv_ssn_url) - - visit idv_document_capture_path - - expect(page).to have_current_path(idv_session_errors_rate_limited_path) - end - end - end - - it 'catches network connection errors on post_front_image', allow_browser_log: true do - DocAuth::Mock::DocAuthMockClient.mock_response!( - method: :post_front_image, - response: DocAuth::Response.new( - success: false, - errors: { network: I18n.t('doc_auth.errors.general.network_error') }, - ), - ) - - attach_and_submit_images - - expect(page).to have_current_path(idv_document_capture_url) - expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) - end - - it 'does not track state if state tracking is disabled' do - allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(false) - attach_and_submit_images - - expect(DocAuthLog.find_by(user_id: @user.id).state).to be_nil - end - end - - context 'standard mobile flow' do - it 'proceeds to the next page with valid info' do - perform_in_browser(:mobile) do - visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - - expect(page).to have_current_path(idv_document_capture_url) - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - - # doc auth is successful while liveness is not req'd - use_id_image('ial2_test_credential_no_liveness.yml') - submit_images - - expect(page).to have_current_path(idv_ssn_url) - expect_costing_for_document - expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') - - fill_out_ssn_form_ok - click_idv_continue - complete_verify_step - expect(page).to have_current_path(idv_phone_url) - end - end - end - context 'selfie check' do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - end - - context 'when a selfie is not requested by SP' do - it 'proceeds to the next page with valid info, excluding a selfie image' do - perform_in_browser(:mobile) do - visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - - expect(page).to have_current_path(idv_document_capture_url) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - - attach_images - submit_images - - expect(page).to have_current_path(idv_ssn_url) - expect_costing_for_document - expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') - - expect(page).to have_current_path(idv_ssn_url) - fill_out_ssn_form_ok - click_idv_continue - complete_verify_step - # expect(page).to have_content(t('doc_auth.headings.document_capture_selfie')) - expect(page).to have_current_path(idv_phone_url) - end - end - end - - context 'when a selfie is required by the SP' do - context 'on mobile platform', allow_browser_log: true do - before do - # mock mobile device as cameraCapable, this allows us to process - allow_any_instance_of(ActionController::Parameters). - to receive(:[]).and_wrap_original do |impl, param_name| - param_name.to_sym == :skip_hybrid_handoff ? '' : impl.call(param_name) - end - end - - context 'with a passing selfie' do - it 'proceeds to the next page with valid info, including a selfie image' do - perform_in_browser(:mobile) do - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - - expect(page).to have_current_path(idv_document_capture_url) - expect(max_capture_attempts_before_native_camera.to_i). - to eq(ActiveSupport::Duration::SECONDS_PER_HOUR) - expect(max_submission_attempts_before_native_camera.to_i). - to eq(ActiveSupport::Duration::SECONDS_PER_HOUR) - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(page).to have_text(t('doc_auth.headings.document_capture')) - attach_images - click_continue - expect_doc_capture_selfie_subheader - attach_selfie - submit_images - - expect(page).to have_current_path(idv_ssn_url) - expect_costing_for_document - expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') - - expect(page).to have_current_path(idv_ssn_url) - fill_out_ssn_form_ok - click_idv_continue - complete_verify_step - expect(page).to have_current_path(idv_phone_url) - end - end - end - - context 'documents or selfie with error is uploaded' do - shared_examples 'it has correct error displays' do - # when there are multiple doc auth errors on front and back - it 'shows the correct error message for the given error' do - perform_in_browser(:mobile) do - click_continue - use_id_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') - click_continue - click_idv_submit_default - expect(page).not_to have_content(t('doc_auth.headings.capture_complete')) - expect(page).not_to have_content(t('doc_auth.errors.rate_limited_heading')) - expect(page).to have_title(t('doc_auth.headings.selfie_capture')) - - use_selfie_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') - submit_images - - expect_rate_limited_header(true) - - expect_try_taking_new_pictures - expect_review_issues_body_message('doc_auth.errors.general.no_liveness') - expect_rate_limit_warning(max_attempts - 1) - - expect_to_try_again - expect_resubmit_page_h1_copy - - expect_resubmit_page_body_copy('doc_auth.errors.general.no_liveness') - expect_resubmit_page_inline_error_messages(2) - expect_resubmit_page_inline_selfie_error_message(false) - - # Wrong doc type is uploaded - use_id_image('ial2_test_credential_wrong_doc_type.yml') - use_selfie_image('ial2_test_portrait_match_success.yml') - submit_images - - expect_rate_limited_header(false) - expect_try_taking_new_pictures(false) - # eslint-disable-next-line - expect_review_issues_body_message( - 'doc_auth.errors.doc_type_not_supported_heading', - ) - expect_review_issues_body_message('doc_auth.errors.doc.doc_type_check') - expect_rate_limit_warning(max_attempts - 2) - - expect_to_try_again - expect_resubmit_page_h1_copy - - expect_review_issues_body_message('doc_auth.errors.card_type') - expect_resubmit_page_inline_selfie_error_message(false) - - # when there are multiple front doc auth errors - use_id_image( - 'ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml', - ) - use_selfie_image( - 'ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml', - ) - submit_images - - expect_rate_limited_header(true) - expect_try_taking_new_pictures(false) - expect_review_issues_body_message( - 'doc_auth.errors.general.multiple_front_id_failures', - ) - expect_rate_limit_warning(max_attempts - 3) - - expect_to_try_again - expect_resubmit_page_h1_copy - - expect_resubmit_page_body_copy( - 'doc_auth.errors.general.multiple_front_id_failures', - ) - expect_resubmit_page_inline_error_messages(1) - expect_resubmit_page_inline_selfie_error_message(false) - - # when there are multiple back doc auth errors - use_id_image( - 'ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml', - ) - use_selfie_image( - 'ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml', - ) - submit_images - - expect_rate_limited_header(true) - expect_try_taking_new_pictures(false) - expect_review_issues_body_message( - 'doc_auth.errors.general.multiple_back_id_failures', - ) - expect_rate_limit_warning(max_attempts - 4) - - expect_to_try_again - expect_resubmit_page_h1_copy - - expect_resubmit_page_body_copy( - 'doc_auth.errors.general.multiple_back_id_failures', - ) - expect_resubmit_page_inline_error_messages(1) - expect_resubmit_page_inline_selfie_error_message(false) - - # attention barcode with invalid pii is uploaded - use_id_image('ial2_test_credential_barcode_attention_no_address.yml') - use_selfie_image('ial2_test_portrait_match_success.yml') - submit_images - - expect(page).to have_content(t('doc_auth.errors.alerts.address_check')) - expect(page).to have_current_path(idv_document_capture_path) - - click_try_again - - # And finally, after lots of errors, we can still succeed - attach_images - submit_images - - expect(page).to have_current_path(idv_ssn_path) - end - end - end - - context 'IPP enabled' do - let(:ipp_service_provider) do - create(:service_provider, :active, :in_person_proofing_enabled) - end - - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) - allow(IdentityConfig.store).to receive( - :in_person_proofing_opt_in_enabled, - ).and_return(true) - allow_any_instance_of(ServiceProvider).to receive( - :in_person_proofing_enabled, - ).and_return(true) - allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(99) - allow(IdentityConfig.store).to receive(:allowed_biometric_ial_providers). - and_return([ipp_service_provider.issuer]) - allow(IdentityConfig.store).to receive( - :allowed_valid_authn_contexts_semantic_providers, - ).and_return([ipp_service_provider.issuer]) - perform_in_browser(:mobile) do - visit_idp_from_sp_with_ial2( - :oidc, - **{ client_id: ipp_service_provider.issuer, - facial_match_required: true }, - ) - sign_in_and_2fa_user(@user) - complete_up_to_how_to_verify_step_for_opt_in_ipp( - facial_match_required: true, - ) - end - end - - it_should_behave_like 'it has correct error displays' - end - - context 'IPP not enabled' do - before do - allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(99) - perform_in_browser(:mobile) do - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - end - end - - it_should_behave_like 'it has correct error displays' - end - end - - context 'when selfie check is not enabled (flag off, and/or in production)' do - it 'proceeds to the next page with valid info, excluding a selfie image' do - perform_in_browser(:mobile) do - visit_idp_from_oidc_sp_with_ial2 - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_document_capture_step - - expect(page).to have_current_path(idv_document_capture_url) - expect(max_capture_attempts_before_native_camera).to eq( - IdentityConfig.store.doc_auth_max_capture_attempts_before_native_camera.to_s, - ) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - attach_images - submit_images - - expect(page).to have_current_path(idv_ssn_url) - expect_costing_for_document - expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') - - expect(page).to have_current_path(idv_ssn_url) - fill_out_ssn_form_ok - click_idv_continue - complete_verify_step - expect(page).to have_current_path(idv_phone_url) - end - end - end - end - - context 'on desktop' do - let(:desktop_selfie_mode) { false } - - before do - allow(IdentityConfig.store).to receive(:doc_auth_selfie_desktop_test_mode). - and_return(desktop_selfie_mode) - end - - describe 'when desktop selfie not allowed' do - it 'can only proceed to link sent page' do - perform_in_browser(:desktop) do - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_hybrid_handoff_step - # we still have option to continue - expect(page).to have_current_path(idv_hybrid_handoff_path) - expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) - expect(page).not_to have_content(t('doc_auth.headings.hybrid_handoff')) - expect(page).not_to have_content(t('doc_auth.info.upload_from_computer')) - click_on t('forms.buttons.send_link') - expect(page).to have_current_path(idv_link_sent_path) - end - end - end - - describe 'when desktop selfie is allowed' do - let(:desktop_selfie_mode) { true } - - it 'proceed to the next page with valid info, including a selfie image' do - perform_in_browser(:desktop) do - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_hybrid_handoff_step - # we still have option to continue on handoff, since it's desktop no skip_hand_off - expect(page).to have_current_path(idv_hybrid_handoff_path) - expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) - expect(page).not_to have_content(t('doc_auth.headings.hybrid_handoff')) - expect(page).to have_content(t('doc_auth.info.upload_from_computer')) - click_on t('forms.buttons.upload_photos') - expect(page).to have_current_path(idv_document_capture_url) - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(page).to have_text(t('doc_auth.headings.document_capture')) - attach_images - click_continue - expect_doc_capture_selfie_subheader - attach_selfie - submit_images - - expect(page).to have_current_path(idv_ssn_url) - expect_costing_for_document - expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') - - expect(page).to have_current_path(idv_ssn_url) - fill_out_ssn_form_ok - click_idv_continue - complete_verify_step - expect(page).to have_current_path(idv_phone_url) - end - end - - context 'when ipp is enabled' do - let(:in_person_doc_auth_button_enabled) { true } - let(:sp_ipp_enabled) { true } - - before do - allow(IdentityConfig.store).to receive(:in_person_doc_auth_button_enabled). - and_return(in_person_doc_auth_button_enabled) - allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).with(anything). - and_return(sp_ipp_enabled) - end - - describe 'when ipp is selected' do - it 'proceed to the next page and start ipp' do - perform_in_browser(:desktop) do - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) - sign_in_and_2fa_user(@user) - complete_doc_auth_steps_before_hybrid_handoff_step - # still have option to continue handoff, since it's desktop no skip_hand_off - expect(page).to have_current_path(idv_hybrid_handoff_path) - expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) - click_on t('in_person_proofing.headings.prepare') - expect(page).to have_current_path( - idv_document_capture_path({ step: 'hybrid_handoff' }), - ) - expect_step_indicator_current_step( - t('step_indicator.flows.idv.find_a_post_office'), - ) - expect_doc_capture_page_header(t('in_person_proofing.headings.prepare')) - end - end - end - end - end - end - end - end - end - def expect_rate_limited_header(expected_to_be_present) review_issues_h1_heading = strip_tags(t('doc_auth.errors.rate_limited_heading')) if expected_to_be_present diff --git a/spec/features/idv/doc_auth/hybrid_handoff_spec.rb b/spec/features/idv/doc_auth/hybrid_handoff_spec.rb index 71010891857..437db0408f3 100644 --- a/spec/features/idv/doc_auth/hybrid_handoff_spec.rb +++ b/spec/features/idv/doc_auth/hybrid_handoff_spec.rb @@ -11,248 +11,244 @@ IdentityConfig.store.idv_send_link_attempt_window_in_minutes end let(:facial_match_required) { false } - context 'split doc auth', allow_browser_log: true do + before do + if facial_match_required + visit_idp_from_oidc_sp_with_ial2( + facial_match_required: facial_match_required, + ) + end + sign_in_and_2fa_user + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + end + context 'on a desktop device send link' do before do - allow(IdentityConfig.store).to receive(:doc_auth_separate_pages_enabled).and_return(true) - if facial_match_required - visit_idp_from_oidc_sp_with_ial2( - facial_match_required: facial_match_required, - ) - end - sign_in_and_2fa_user - allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + complete_doc_auth_steps_before_hybrid_handoff_step end - context 'on a desktop device send link' do - before do - complete_doc_auth_steps_before_hybrid_handoff_step - end - - it 'has the forms with the expected aria attributes' do - mobile_form = find('#form-to-submit-photos-through-mobile') - desktop_form = find('#form-to-submit-photos-through-desktop') + it 'has the forms with the expected aria attributes' do + mobile_form = find('#form-to-submit-photos-through-mobile') + desktop_form = find('#form-to-submit-photos-through-desktop') - expect(mobile_form).to have_name(t('forms.buttons.send_link')) - expect(desktop_form).to have_name(t('forms.buttons.upload_photos')) - end - - it 'proceeds to link sent page when user chooses to use phone' do - click_send_link + expect(mobile_form).to have_name(t('forms.buttons.send_link')) + expect(desktop_form).to have_name(t('forms.buttons.upload_photos')) + end - expect(page).to have_current_path(idv_link_sent_path) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth hybrid handoff submitted', - hash_including(step: 'hybrid_handoff', destination: :link_sent), - ) - end + it 'proceeds to link sent page when user chooses to use phone' do + click_send_link - it 'proceeds to the next page with valid info', :js do - expect(Telephony).to receive(:send_doc_auth_link). - with(hash_including(to: '+1 415-555-0199')). - and_call_original + expect(page).to have_current_path(idv_link_sent_path) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth hybrid handoff submitted', + hash_including(step: 'hybrid_handoff', destination: :link_sent), + ) + end - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + it 'proceeds to the next page with valid info', :js do + expect(Telephony).to receive(:send_doc_auth_link). + with(hash_including(to: '+1 415-555-0199')). + and_call_original - fill_in :doc_auth_phone, with: '415-555-0199' - click_send_link - - expect(page).to have_current_path(idv_link_sent_path) - end + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - it 'does not proceed to the next page with invalid info', :js do - fill_in :doc_auth_phone, with: '' - click_send_link + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link - expect(page).to have_current_path(idv_hybrid_handoff_path, ignore_query: true) - end + expect(page).to have_current_path(idv_link_sent_path) + end - it 'sends a link that does not contain any underscores' do - # because URLs with underscores sometimes get messed up by carriers - expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| - expect(config[:link]).to_not include('_') + it 'does not proceed to the next page with invalid info', :js do + fill_in :doc_auth_phone, with: '' + click_send_link - impl.call(**config) - end + expect(page).to have_current_path(idv_hybrid_handoff_path, ignore_query: true) + end - fill_in :doc_auth_phone, with: '415-555-0199' - click_send_link + it 'sends a link that does not contain any underscores' do + # because URLs with underscores sometimes get messed up by carriers + expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + expect(config[:link]).to_not include('_') - expect(page).to have_current_path(idv_link_sent_path) + impl.call(**config) end - it 'does not proceed if Telephony raises an error' do - fill_in :doc_auth_phone, with: '225-555-1000' + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link - click_send_link + expect(page).to have_current_path(idv_link_sent_path) + end - expect(page).to have_current_path(idv_hybrid_handoff_path, ignore_query: true) - expect(page).to have_content I18n.t('telephony.error.friendly_message.generic') - end + it 'does not proceed if Telephony raises an error' do + fill_in :doc_auth_phone, with: '225-555-1000' - it 'displays error if user selects a country to which we cannot send SMS', js: true do - click_on t('components.phone_input.country_code_label') - within(page.find('.iti__country-container', visible: :all)) do - find('span', text: 'Sri Lanka').click - end - focused_input = page.find('.phone-input__number:focus') + click_send_link - error_message_id = focused_input[:'aria-describedby']&.split(' ')&.find do |id| - page.has_css?(".usa-error-message##{id}") - end - expect(error_message_id).to_not be_empty + expect(page).to have_current_path(idv_hybrid_handoff_path, ignore_query: true) + expect(page).to have_content I18n.t('telephony.error.friendly_message.generic') + end - error_message = page.find_by_id(error_message_id) - expect(error_message).to have_content( - t( - 'two_factor_authentication.otp_delivery_preference.sms_unsupported', - location: 'Sri Lanka', - ), - ) - click_send_link - expect(page.find(':focus')).to match_css('.phone-input__number') + it 'displays error if user selects a country to which we cannot send SMS', js: true do + click_on t('components.phone_input.country_code_label') + within(page.find('.iti__country-container', visible: :all)) do + find('span', text: 'Sri Lanka').click end + focused_input = page.find('.phone-input__number:focus') - it 'rate limits sending the link' do - user = user_with_2fa - sign_in_and_2fa_user(user) - complete_doc_auth_steps_before_hybrid_handoff_step - timeout = distance_of_time_in_words( - RateLimiter.attempt_window_in_minutes(:idv_send_link).minutes, - ) - allow(IdentityConfig.store).to receive(:idv_send_link_max_attempts). - and_return(idv_send_link_max_attempts) - - freeze_time do - idv_send_link_max_attempts.times do - expect(page).to_not have_content( - I18n.t('doc_auth.errors.send_link_limited', timeout: timeout), - ) - - fill_in :doc_auth_phone, with: '415-555-0199' - click_send_link - - expect(page).to have_current_path(idv_link_sent_path) - - click_doc_auth_back_link - end + error_message_id = focused_input[:'aria-describedby']&.split(' ')&.find do |id| + page.has_css?(".usa-error-message##{id}") + end + expect(error_message_id).to_not be_empty + + error_message = page.find_by_id(error_message_id) + expect(error_message).to have_content( + t( + 'two_factor_authentication.otp_delivery_preference.sms_unsupported', + location: 'Sri Lanka', + ), + ) + click_send_link + expect(page.find(':focus')).to match_css('.phone-input__number') + end - fill_in :doc_auth_phone, with: '415-555-0199' + it 'rate limits sending the link' do + user = user_with_2fa + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_hybrid_handoff_step + timeout = distance_of_time_in_words( + RateLimiter.attempt_window_in_minutes(:idv_send_link).minutes, + ) + allow(IdentityConfig.store).to receive(:idv_send_link_max_attempts). + and_return(idv_send_link_max_attempts) - click_send_link - expect(page).to have_current_path(idv_hybrid_handoff_path, ignore_query: true) - expect(page).to have_content( - I18n.t( - 'doc_auth.errors.send_link_limited', - timeout: timeout, - ), + freeze_time do + idv_send_link_max_attempts.times do + expect(page).to_not have_content( + I18n.t('doc_auth.errors.send_link_limited', timeout: timeout), ) - expect(page).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff')) - expect(page).to have_selector('h2', text: t('doc_auth.headings.upload_from_phone')) - end - expect(fake_analytics).to have_logged_event( - 'Rate Limit Reached', - limiter_type: :idv_send_link, - ) - # Manual expiration is needed for now since the RateLimiter uses - # Redis ttl instead of expiretime - RateLimiter.new(rate_limit_type: :idv_send_link, user: user).reset! - travel_to(Time.zone.now + idv_send_link_attempt_window_in_minutes.minutes) do fill_in :doc_auth_phone, with: '415-555-0199' click_send_link - expect(page).to have_current_path(idv_link_sent_path) - end - end - it 'includes expected URL parameters' do - expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| - params = Rack::Utils.parse_nested_query URI(config[:link]).query - - expect(params['document-capture-session']).to be_a_kind_of(String) + expect(page).to have_current_path(idv_link_sent_path) - impl.call(**config) + click_doc_auth_back_link end fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + expect(page).to have_current_path(idv_hybrid_handoff_path, ignore_query: true) + expect(page).to have_content( + I18n.t( + 'doc_auth.errors.send_link_limited', + timeout: timeout, + ), + ) + expect(page).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff')) + expect(page).to have_selector('h2', text: t('doc_auth.headings.upload_from_phone')) end + expect(fake_analytics).to have_logged_event( + 'Rate Limit Reached', + limiter_type: :idv_send_link, + ) - it 'sets requested_at on the capture session' do - doc_capture_session_uuid = nil - - expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| - params = Rack::Utils.parse_nested_query URI(config[:link]).query - doc_capture_session_uuid = params['document-capture-session'] - impl.call(**config) - end - + # Manual expiration is needed for now since the RateLimiter uses + # Redis ttl instead of expiretime + RateLimiter.new(rate_limit_type: :idv_send_link, user: user).reset! + travel_to(Time.zone.now + idv_send_link_attempt_window_in_minutes.minutes) do fill_in :doc_auth_phone, with: '415-555-0199' click_send_link + expect(page).to have_current_path(idv_link_sent_path) + end + end + + it 'includes expected URL parameters' do + expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + params = Rack::Utils.parse_nested_query URI(config[:link]).query + + expect(params['document-capture-session']).to be_a_kind_of(String) - document_capture_session = DocumentCaptureSession.find_by(uuid: doc_capture_session_uuid) - expect(document_capture_session).to be - expect(document_capture_session).to have_attributes(requested_at: a_kind_of(Time)) + impl.call(**config) end + + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link end - context 'on a desktop device and selfie is allowed' do - before do - complete_doc_auth_steps_before_hybrid_handoff_step + it 'sets requested_at on the capture session' do + doc_capture_session_uuid = nil + + expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + params = Rack::Utils.parse_nested_query URI(config[:link]).query + doc_capture_session_uuid = params['document-capture-session'] + impl.call(**config) end - describe 'when selfie is required by sp' do - let(:facial_match_required) { true } - it 'has expected UI elements' do - mobile_form = find('#form-to-submit-photos-through-mobile') - expect(mobile_form).to have_name(t('forms.buttons.send_link')) - expect(page).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff_selfie')) + fill_in :doc_auth_phone, with: '415-555-0199' + click_send_link + + document_capture_session = DocumentCaptureSession.find_by(uuid: doc_capture_session_uuid) + expect(document_capture_session).to be + expect(document_capture_session).to have_attributes(requested_at: a_kind_of(Time)) + end + end + + context 'on a desktop device and selfie is allowed' do + before do + complete_doc_auth_steps_before_hybrid_handoff_step + end + + describe 'when selfie is required by sp' do + let(:facial_match_required) { true } + it 'has expected UI elements' do + mobile_form = find('#form-to-submit-photos-through-mobile') + expect(mobile_form).to have_name(t('forms.buttons.send_link')) + expect(page).to have_selector('h1', text: t('doc_auth.headings.hybrid_handoff_selfie')) + end + context 'on a desktop choose ipp', js: true do + let(:in_person_doc_auth_button_enabled) { true } + let(:sp_ipp_enabled) { true } + before do + allow(IdentityConfig.store).to receive(:in_person_doc_auth_button_enabled). + and_return(in_person_doc_auth_button_enabled) + allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).with(anything). + and_return(sp_ipp_enabled) + complete_doc_auth_steps_before_hybrid_handoff_step end - context 'on a desktop choose ipp', js: true do - let(:in_person_doc_auth_button_enabled) { true } - let(:sp_ipp_enabled) { true } - before do - allow(IdentityConfig.store).to receive(:in_person_doc_auth_button_enabled). - and_return(in_person_doc_auth_button_enabled) - allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).with(anything). - and_return(sp_ipp_enabled) - complete_doc_auth_steps_before_hybrid_handoff_step - end - context 'when ipp is enabled' do - it 'proceeds to ipp if selected and can go back' do - expect(page).to have_content(strip_tags(t('doc_auth.info.hybrid_handoff_ipp_html'))) - click_on t('in_person_proofing.headings.prepare') - hybrid_step = { step: 'hybrid_handoff' } - expect(page).to have_current_path(idv_document_capture_path(hybrid_step)) - click_on t('forms.buttons.back') - expect(page).to have_current_path(idv_hybrid_handoff_path) - end + context 'when ipp is enabled' do + it 'proceeds to ipp if selected and can go back' do + expect(page).to have_content(strip_tags(t('doc_auth.info.hybrid_handoff_ipp_html'))) + click_on t('in_person_proofing.headings.prepare') + hybrid_step = { step: 'hybrid_handoff' } + expect(page).to have_current_path(idv_document_capture_path(hybrid_step)) + click_on t('forms.buttons.back') + expect(page).to have_current_path(idv_hybrid_handoff_path) end + end - context 'when ipp is disabled' do - let(:in_person_doc_auth_button_enabled) { false } - let(:sp_ipp_enabled) { false } - it 'has no ipp option can be selected' do - expect(page).to_not have_content( - strip_tags(t('doc_auth.info.hybrid_handoff_ipp_html')), - ) - expect(page).to_not have_content( - t('in_person_proofing.headings.prepare'), - ) - end + context 'when ipp is disabled' do + let(:in_person_doc_auth_button_enabled) { false } + let(:sp_ipp_enabled) { false } + it 'has no ipp option can be selected' do + expect(page).to_not have_content( + strip_tags(t('doc_auth.info.hybrid_handoff_ipp_html')), + ) + expect(page).to_not have_content( + t('in_person_proofing.headings.prepare'), + ) end end end + end - describe 'when selfie is not required by sp' do - let(:facial_match_required) { false } - it 'has expected UI elements' do - mobile_form = find('#form-to-submit-photos-through-mobile') - desktop_form = find('#form-to-submit-photos-through-desktop') + describe 'when selfie is not required by sp' do + let(:facial_match_required) { false } + it 'has expected UI elements' do + mobile_form = find('#form-to-submit-photos-through-mobile') + desktop_form = find('#form-to-submit-photos-through-desktop') - expect(mobile_form).to have_name(t('forms.buttons.send_link')) - expect(desktop_form).to have_name(t('forms.buttons.upload_photos')) - end + expect(mobile_form).to have_name(t('forms.buttons.send_link')) + expect(desktop_form).to have_name(t('forms.buttons.upload_photos')) end end end diff --git a/spec/features/idv/doc_auth/redo_document_capture_spec.rb b/spec/features/idv/doc_auth/redo_document_capture_spec.rb index a80aeaab178..1f960747a40 100644 --- a/spec/features/idv/doc_auth/redo_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/redo_document_capture_spec.rb @@ -1,838 +1,805 @@ require 'rails_helper' -RSpec.feature 'doc auth redo document capture', js: true, allowed_extra_analytics: [:*] do +RSpec.feature 'document capture step', :js do include IdvStepHelper include DocAuthHelper include DocCaptureHelper + include ActionView::Helpers::DateHelper + let(:max_attempts) { IdentityConfig.store.doc_auth_max_attempts } let(:fake_analytics) { FakeAnalytics.new } - before do + before(:each) do allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(ServiceProviderSession).to receive(:sp_name).and_return(@sp_name) end - context 'when barcode scan returns a warning', allow_browser_log: true do - let(:use_bad_ssn) { false } + before(:all) do + @sp_name = 'Test SP' + @user = user_with_2fa + end + + after(:all) { @user.destroy } + context 'standard desktop flow' do before do - sign_in_and_2fa_user + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_attention_with_barcode - attach_and_submit_images - click_idv_continue - - if use_bad_ssn - fill_out_ssn_form_with_ssn_that_fails_resolution - else - fill_out_ssn_form_ok - end - - click_idv_continue end - it 'shows a warning message to allow the user to return to upload new images' do - warning_link_text = t('doc_auth.headings.capture_scan_warning_link') - - expect(page).to have_css( - '[role="status"]', - text: strip_nbsp( - t( - 'doc_auth.headings.capture_scan_warning_html', - link_html: warning_link_text, + context 'rate limits calls to backend docauth vendor', allow_browser_log: true do + before do + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, ), - ), - ) - click_link warning_link_text - - expect(current_path).to eq(idv_hybrid_handoff_path) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth hybrid handoff visited', - hash_including(redo_document_capture: true), - ) - complete_hybrid_handoff_step - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth document_capture visited', - hash_including(redo_document_capture: true), - ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_and_submit_images - - expect(current_path).to eq(idv_ssn_path) - expect(page).to have_css('[role="status"]') # We verified your ID - complete_ssn_step - - expect(current_path).to eq(idv_verify_info_path) - check t('forms.ssn.show') - expect(page).to have_content(DocAuthHelper::GOOD_SSN) - end - - context 'with a bad SSN' do - let(:use_bad_ssn) { true } - - it 'shows a troubleshooting option to allow the user to cancel and return to SP' do - complete_verify_step - expect(page).to have_link( - t('links.cancel'), - href: idv_cancel_path(step: :invalid_session), ) - click_link t('links.cancel') - - expect(current_path).to eq(idv_cancel_path) + (max_attempts - 1).times do + attach_and_submit_images + click_on t('idv.failure.button.warning') + end end - end - context 'on mobile', driver: :headless_chrome_mobile do - it 'shows a warning message to allow the user to return to upload new images' do - warning_link_text = t('doc_auth.headings.capture_scan_warning_link') - - expect(page).to have_css( - '[role="status"]', - text: strip_nbsp( - t( - 'doc_auth.headings.capture_scan_warning_html', - link_html: warning_link_text, - ), - ), - ) - click_link warning_link_text + it 'redirects to the rate limited error page' do + freeze_time do + attach_and_submit_images + timeout = distance_of_time_in_words( + RateLimiter.attempt_window_in_minutes(:idv_doc_auth).minutes, + ) + message = strip_tags(t('doc_auth.errors.rate_limited_text_html', timeout: timeout)) + expect(page).to have_content(message) + expect(page).to have_current_path(idv_session_errors_rate_limited_path) + end + end - expect(current_path).to eq(idv_document_capture_path) + it 'logs the rate limited analytics event for doc_auth' do + attach_and_submit_images expect(fake_analytics).to have_logged_event( - 'IdV: doc auth document_capture visited', - hash_including(redo_document_capture: true), + 'Rate Limit Reached', + limiter_type: :idv_doc_auth, ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_and_submit_images + end - expect(current_path).to eq(idv_ssn_path) - expect(page).to have_css('[role="status"]') # We verified your ID - complete_ssn_step + context 'successfully processes image on last attempt' do + before { DocAuth::Mock::DocAuthMockClient.reset! } - expect(current_path).to eq(idv_verify_info_path) - check t('forms.ssn.show') - expect(page).to have_content(DocAuthHelper::GOOD_SSN) + it 'proceeds to the next page with valid info' do + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_and_submit_images + expect(page).to have_current_path(idv_ssn_url) + + visit idv_document_capture_path + + expect(page).to have_current_path(idv_session_errors_rate_limited_path) + end end end - end - shared_examples_for 'image re-upload allowed' do - it 'allows user to submit the same image again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3), + it 'catches network connection errors on post_front_image', allow_browser_log: true do + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), ) - DocAuth::Mock::DocAuthMockClient.reset! + attach_and_submit_images - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 2), - ) - expect(current_path).to eq(idv_ssn_path) - check t('forms.ssn.show') - end - end - shared_examples_for 'image re-upload not allowed' do - it 'stops user submitting the same image again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_images - # Error message without submit - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + expect(page).to have_current_path(idv_document_capture_url) + expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) end - end - shared_examples_for 'document and selfie images re-upload not allowed' do - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) - attach_selfie - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 1, - ) + it 'does not track state if state tracking is disabled' do + allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(false) + attach_and_submit_images - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 3, - ) + expect(DocAuthLog.find_by(user_id: @user.id).state).to be_nil end end - shared_examples_for 'inline error for 4xx status shown' do |status| - it "shows inline error for status #{status}" do - error = case status - when 438 - t('doc_auth.errors.http.image_load.failed_short') - when 439 - t('doc_auth.errors.http.pixel_depth.failed_short') - when 440 - t('doc_auth.errors.http.image_size.failed_short') - end - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: error, - ) + context 'standard mobile flow' do + it 'proceeds to the next page with valid info' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + + expect(page).to have_current_path(idv_document_capture_url) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + + # doc auth is successful while liveness is not req'd + use_id_image('ial2_test_credential_no_liveness.yml') + submit_images + + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') + + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end end end - context 'error due to data issue with 2xx status code', allow_browser_log: true do + + context 'facial match is required', allow_browser_log: true do before do - sign_in_and_2fa_user + allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step - mock_general_doc_auth_client_error(:get_results) - attach_and_submit_images - click_try_again end - it_behaves_like 'image re-upload not allowed' - end - context 'error due to data issue with 4xx status code with trueid', allow_browser_log: true do - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_trueid_http_non2xx_status(438) - attach_and_submit_images - # verify it's a network error - expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) - click_try_again + it 'user can go through verification uploading ID and selfie on seprerate pages' do + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + attach_images + click_continue + expect(page).to have_title(t('doc_auth.headings.selfie_capture')) + expect(page).to have_content(t('doc_auth.tips.document_capture_selfie_text1')) + click_button 'Take photo' + attach_selfie + submit_images + expect(page).to have_content(t('doc_auth.headings.capture_complete')) end - it_behaves_like 'image re-upload allowed' + it 'initial verification failure allows user to resubmit all images in 1 page' do + attach_images( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_multiple_doc_auth_failures_both_sides.yml' + ), + ) + click_continue + click_button 'Take photo' + attach_selfie( + Rails.root.join( + 'spec', 'fixtures', + 'ial2_test_credential_forces_error.yml' + ), + ) + submit_images + expect(page).to have_content(t('doc_auth.errors.rate_limited_heading')) + click_try_again + expect(page).to have_content(t('doc_auth.headings.review_issues')) + attach_images + submit_images + expect(page).to have_content(t('doc_auth.headings.capture_complete')) + end end - context 'error due to http status error but non 4xx status code with trueid', - allow_browser_log: true do + context 'standard desktop flow' do before do - sign_in_and_2fa_user + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_trueid_http_non2xx_status(500) - attach_and_submit_images - # verify it's a network error - expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) - click_try_again end - it_behaves_like 'image re-upload allowed' - end - context 'when selfie is enabled' do - context 'when doc auth is success and face match fails (2xx)', allow_browser_log: true do + context 'rate limits calls to backend docauth vendor', allow_browser_log: true do before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:fail) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_success_face_match_fail - attach_images - attach_selfie - submit_images - click_try_again - sleep(10) - end - - it_behaves_like 'document and selfie images re-upload not allowed' + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) - it 'shows current existing header' do - expect_doc_capture_page_header(t('doc_auth.headings.review_issues')) + (max_attempts - 1).times do + attach_and_submit_images + click_on t('idv.failure.button.warning') + end end - end - - context 'when doc auth passes and portrait match is not live', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_pass_and_portrait_match_not_live - attach_images - attach_selfie - submit_images - click_try_again - sleep(10) + it 'redirects to the rate limited error page' do + freeze_time do + attach_and_submit_images + timeout = distance_of_time_in_words( + RateLimiter.attempt_window_in_minutes(:idv_doc_auth).minutes, + ) + message = strip_tags(t('doc_auth.errors.rate_limited_text_html', timeout: timeout)) + expect(page).to have_content(message) + expect(page).to have_current_path(idv_session_errors_rate_limited_path) + end end - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') + it 'logs the rate limited analytics event for doc_auth' do + attach_and_submit_images expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), + 'Rate Limit Reached', + limiter_type: :idv_doc_auth, ) + end - attach_selfie - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 1, - ) + context 'successfully processes image on last attempt' do + before { DocAuth::Mock::DocAuthMockClient.reset! } - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 1, - ) - end - end + it 'proceeds to the next page with valid info' do + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_and_submit_images + expect(page).to have_current_path(idv_ssn_url) - context 'when doc auth fails and portrait match pass', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + visit idv_document_capture_path - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_failure_face_match_pass - attach_images - attach_selfie - submit_images - click_try_again - sleep(10) + expect(page).to have_current_path(idv_session_errors_rate_limited_path) + end end + end - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + it 'catches network connection errors on post_front_image', allow_browser_log: true do + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) - attach_selfie - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + attach_and_submit_images - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 2, - ) - end + expect(page).to have_current_path(idv_document_capture_url) + expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) end - context 'when doc auth and portrait match fail', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:fail) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_fail_face_match_fail - attach_images - attach_selfie - submit_images - click_try_again - sleep(10) - end + it 'does not track state if state tracking is disabled' do + allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(false) + attach_and_submit_images - it_behaves_like 'document and selfie images re-upload not allowed' + expect(DocAuthLog.find_by(user_id: @user.id).state).to be_nil end + end - context 'when pii validation fails', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - pii = Idp::Constants::MOCK_IDV_APPLICANT.dup - pii[:address1] = nil - allow_any_instance_of(DocAuth::LexisNexis::Responses::TrueIdResponse). - to receive(:pii_from_doc).and_return(Pii::StateId.new(**pii)) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user + context 'standard mobile flow' do + it 'proceeds to the next page with valid info' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_pass_face_match_pass_no_address1 - attach_images - attach_selfie - submit_images - click_try_again - sleep(10) - end - it 'shows selfie inline error messages for both front and back' do - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.general.multiple_front_id_failures'), - count: 1, - ) - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.general.multiple_back_id_failures'), - count: 1, - ) - end + expect(page).to have_current_path(idv_document_capture_url) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + # doc auth is successful while liveness is not req'd + use_id_image('ial2_test_credential_no_liveness.yml') + submit_images - attach_selfie - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 2, - ) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) end end end - - context 'split doc auth flow', allow_browser_log: true do + context 'selfie check' do before do - allow(IdentityConfig.store).to receive(:doc_auth_separate_pages_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) end - context 'when barcode scan returns a warning', allow_browser_log: true do - let(:use_bad_ssn) { false } - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_attention_with_barcode - attach_and_submit_images - click_idv_continue - - if use_bad_ssn - fill_out_ssn_form_with_ssn_that_fails_resolution - else - fill_out_ssn_form_ok - end - - click_idv_continue - end - - it 'shows a warning message to allow the user to return to upload new images' do - warning_link_text = t('doc_auth.headings.capture_scan_warning_link') - - expect(page).to have_css( - '[role="status"]', - text: strip_nbsp( - t( - 'doc_auth.headings.capture_scan_warning_html', - link_html: warning_link_text, - ), - ), - ) - click_link warning_link_text + context 'when a selfie is not requested by SP' do + it 'proceeds to the next page with valid info, excluding a selfie image' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step - expect(current_path).to eq(idv_hybrid_handoff_path) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth hybrid handoff visited', - hash_including(redo_document_capture: true), - ) - complete_hybrid_handoff_step - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth document_capture visited', - hash_including(redo_document_capture: true), - ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_and_submit_images + expect(page).to have_current_path(idv_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - expect(current_path).to eq(idv_ssn_path) - expect(page).to have_css('[role="status"]') # We verified your ID - complete_ssn_step + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) - expect(current_path).to eq(idv_verify_info_path) - check t('forms.ssn.show') - expect(page).to have_content(DocAuthHelper::GOOD_SSN) - end + attach_images + submit_images - context 'with a bad SSN' do - let(:use_bad_ssn) { true } + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') - it 'shows a troubleshooting option to allow the user to cancel and return to SP' do + expect(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue complete_verify_step - expect(page).to have_link( - t('links.cancel'), - href: idv_cancel_path(step: :invalid_session), - ) - - click_link t('links.cancel') - - expect(current_path).to eq(idv_cancel_path) + # expect(page).to have_content(t('doc_auth.headings.document_capture_selfie')) + expect(page).to have_current_path(idv_phone_url) end end + end - context 'on mobile', driver: :headless_chrome_mobile do - it 'shows a warning message to allow the user to return to upload new images' do - warning_link_text = t('doc_auth.headings.capture_scan_warning_link') - - expect(page).to have_css( - '[role="status"]', - text: strip_nbsp( - t( - 'doc_auth.headings.capture_scan_warning_html', - link_html: warning_link_text, - ), - ), - ) - click_link warning_link_text + context 'when a selfie is required by the SP' do + context 'on mobile platform', allow_browser_log: true do + before do + # mock mobile device as cameraCapable, this allows us to process + allow_any_instance_of(ActionController::Parameters). + to receive(:[]).and_wrap_original do |impl, param_name| + param_name.to_sym == :skip_hybrid_handoff ? '' : impl.call(param_name) + end + end - expect(current_path).to eq(idv_document_capture_path) - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth document_capture visited', - hash_including(redo_document_capture: true), - ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_and_submit_images + context 'with a passing selfie' do + it 'proceeds to the next page with valid info, including a selfie image' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + + expect(page).to have_current_path(idv_document_capture_url) + expect(max_capture_attempts_before_native_camera.to_i). + to eq(ActiveSupport::Duration::SECONDS_PER_HOUR) + expect(max_submission_attempts_before_native_camera.to_i). + to eq(ActiveSupport::Duration::SECONDS_PER_HOUR) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect(page).to have_text(t('doc_auth.headings.document_capture')) + attach_images + click_continue + expect_doc_capture_selfie_subheader + click_button 'Take photo' + attach_selfie + submit_images + + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') + + expect(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + end - expect(current_path).to eq(idv_ssn_path) - expect(page).to have_css('[role="status"]') # We verified your ID - complete_ssn_step + context 'documents or selfie with error is uploaded' do + shared_examples 'it has correct error displays' do + # when there are multiple doc auth errors on front and back + it 'shows the correct error message for the given error' do + perform_in_browser(:mobile) do + click_continue + use_id_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') + click_continue + click_button 'Take photo' + click_idv_submit_default + expect(page).not_to have_content(t('doc_auth.headings.capture_complete')) + expect(page).not_to have_content(t('doc_auth.errors.rate_limited_heading')) + expect(page).to have_title(t('doc_auth.headings.selfie_capture')) + + use_selfie_image('ial2_test_credential_multiple_doc_auth_failures_both_sides.yml') + submit_images + expect_rate_limited_header(true) + + expect_try_taking_new_pictures + expect_review_issues_body_message('doc_auth.errors.general.no_liveness') + expect_rate_limit_warning(max_attempts - 1) + + expect_to_try_again + expect_resubmit_page_h1_copy + + expect_resubmit_page_body_copy('doc_auth.errors.general.no_liveness') + expect_resubmit_page_inline_error_messages(2) + expect_resubmit_page_inline_selfie_error_message(false) + + # Wrong doc type is uploaded + use_id_image('ial2_test_credential_wrong_doc_type.yml') + + use_selfie_image('ial2_test_portrait_match_success.yml') + submit_images + + expect_rate_limited_header(false) + expect_try_taking_new_pictures(false) + # eslint-disable-next-line + expect_review_issues_body_message( + 'doc_auth.errors.doc_type_not_supported_heading', + ) + expect_review_issues_body_message('doc_auth.errors.doc.doc_type_check') + expect_rate_limit_warning(max_attempts - 2) + + expect_to_try_again + expect_resubmit_page_h1_copy + + expect_review_issues_body_message('doc_auth.errors.card_type') + expect_resubmit_page_inline_selfie_error_message(false) + + # when there are multiple front doc auth errors + use_id_image( + 'ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml', + ) + + use_selfie_image( + 'ial2_test_credential_multiple_doc_auth_failures_front_side_only.yml', + ) + submit_images + + expect_rate_limited_header(true) + expect_try_taking_new_pictures(false) + expect_review_issues_body_message( + 'doc_auth.errors.general.multiple_front_id_failures', + ) + expect_rate_limit_warning(max_attempts - 3) + + expect_to_try_again + expect_resubmit_page_h1_copy + + expect_resubmit_page_body_copy( + 'doc_auth.errors.general.multiple_front_id_failures', + ) + expect_resubmit_page_inline_error_messages(1) + expect_resubmit_page_inline_selfie_error_message(false) + + # when there are multiple back doc auth errors + use_id_image( + 'ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml', + ) + + use_selfie_image( + 'ial2_test_credential_multiple_doc_auth_failures_back_side_only.yml', + ) + submit_images + + expect_rate_limited_header(true) + expect_try_taking_new_pictures(false) + expect_review_issues_body_message( + 'doc_auth.errors.general.multiple_back_id_failures', + ) + expect_rate_limit_warning(max_attempts - 4) + + expect_to_try_again + expect_resubmit_page_h1_copy + + expect_resubmit_page_body_copy( + 'doc_auth.errors.general.multiple_back_id_failures', + ) + expect_resubmit_page_inline_error_messages(1) + expect_resubmit_page_inline_selfie_error_message(false) + + # attention barcode with invalid pii is uploaded + use_id_image('ial2_test_credential_barcode_attention_no_address.yml') + click_continue + use_selfie_image('ial2_test_portrait_match_success.yml') + submit_images + + expect(page).to have_content(t('doc_auth.errors.alerts.address_check')) + expect(page).to have_current_path(idv_document_capture_path) + + click_try_again + + # And finally, after lots of errors, we can still succeed + attach_images + submit_images + + expect(page).to have_current_path(idv_ssn_path) + end + end + end + + context 'IPP enabled' do + let(:ipp_service_provider) do + create(:service_provider, :active, :in_person_proofing_enabled) + end + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive( + :in_person_proofing_opt_in_enabled, + ).and_return(true) + allow_any_instance_of(ServiceProvider).to receive( + :in_person_proofing_enabled, + ).and_return(true) + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(99) + allow(IdentityConfig.store).to receive(:allowed_biometric_ial_providers). + and_return([ipp_service_provider.issuer]) + allow(IdentityConfig.store).to receive( + :allowed_valid_authn_contexts_semantic_providers, + ).and_return([ipp_service_provider.issuer]) + perform_in_browser(:mobile) do + visit_idp_from_sp_with_ial2( + :oidc, + **{ client_id: ipp_service_provider.issuer, + facial_match_required: true }, + ) + sign_in_and_2fa_user(@user) + complete_up_to_how_to_verify_step_for_opt_in_ipp( + facial_match_required: true, + ) + end + end + + it_should_behave_like 'it has correct error displays' + end + + context 'IPP not enabled' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(99) + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + end + end - expect(current_path).to eq(idv_verify_info_path) - check t('forms.ssn.show') - expect(page).to have_content(DocAuthHelper::GOOD_SSN) + it_should_behave_like 'it has correct error displays' + end end - end - end - shared_examples_for 'image re-upload allowed' do - it 'allows user to submit the same image again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3), - ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_and_submit_images - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 2), - ) - expect(current_path).to eq(idv_ssn_path) - check t('forms.ssn.show') + context 'when selfie check is not enabled (flag off, and/or in production)' do + it 'proceeds to the next page with valid info, excluding a selfie image' do + perform_in_browser(:mobile) do + visit_idp_from_oidc_sp_with_ial2 + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_document_capture_step + + expect(page).to have_current_path(idv_document_capture_url) + expect(max_capture_attempts_before_native_camera).to eq( + IdentityConfig.store.doc_auth_max_capture_attempts_before_native_camera.to_s, + ) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_images + submit_images + + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') + + expect(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + end end - end - shared_examples_for 'image re-upload not allowed' do - it 'stops user submitting the same image again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - attach_images - # Error message without submit - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) - end - end + context 'on desktop' do + let(:desktop_selfie_mode) { false } - shared_examples_for 'document and selfie images re-upload not allowed' do - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) - attach_selfie - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 1, - ) + before do + allow(IdentityConfig.store).to receive(:doc_auth_selfie_desktop_test_mode). + and_return(desktop_selfie_mode) + end - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 3, - ) - end - end + describe 'when desktop selfie not allowed' do + it 'can only proceed to link sent page' do + perform_in_browser(:desktop) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_hybrid_handoff_step + # we still have option to continue + expect(page).to have_current_path(idv_hybrid_handoff_path) + expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) + expect(page).not_to have_content(t('doc_auth.headings.hybrid_handoff')) + expect(page).not_to have_content(t('doc_auth.info.upload_from_computer')) + click_on t('forms.buttons.send_link') + expect(page).to have_current_path(idv_link_sent_path) + end + end + end - shared_examples_for 'inline error for 4xx status shown' do |status| - it "shows inline error for status #{status}" do - error = case status - when 438 - t('doc_auth.errors.http.image_load.failed_short') - when 439 - t('doc_auth.errors.http.pixel_depth.failed_short') - when 440 - t('doc_auth.errors.http.image_size.failed_short') + describe 'when desktop selfie is allowed' do + let(:desktop_selfie_mode) { true } + + it 'proceed to the next page with valid info, including a selfie image' do + perform_in_browser(:desktop) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_hybrid_handoff_step + # we still have option to continue on handoff, since it's desktop no skip_hand_off + expect(page).to have_current_path(idv_hybrid_handoff_path) + expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) + expect(page).not_to have_content(t('doc_auth.headings.hybrid_handoff')) + expect(page).to have_content(t('doc_auth.info.upload_from_computer')) + click_on t('forms.buttons.upload_photos') + expect(page).to have_current_path(idv_document_capture_url) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + expect(page).to have_text(t('doc_auth.headings.document_capture')) + attach_images + click_continue + expect_doc_capture_selfie_subheader + click_button 'Take photo' + attach_selfie + submit_images + + expect(page).to have_current_path(idv_ssn_url) + expect_costing_for_document + expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('MT') + + expect(page).to have_current_path(idv_ssn_url) + fill_out_ssn_form_ok + click_idv_continue + complete_verify_step + expect(page).to have_current_path(idv_phone_url) + end + end + + context 'when ipp is enabled' do + let(:in_person_doc_auth_button_enabled) { true } + let(:sp_ipp_enabled) { true } + + before do + allow(IdentityConfig.store).to receive(:in_person_doc_auth_button_enabled). + and_return(in_person_doc_auth_button_enabled) + allow(Idv::InPersonConfig).to receive(:enabled_for_issuer?).with(anything). + and_return(sp_ipp_enabled) + end + + describe 'when ipp is selected' do + it 'proceed to the next page and start ipp' do + perform_in_browser(:desktop) do + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + sign_in_and_2fa_user(@user) + complete_doc_auth_steps_before_hybrid_handoff_step + # still have option to continue handoff, since it's desktop no skip_hand_off + expect(page).to have_current_path(idv_hybrid_handoff_path) + expect(page).to have_content(t('doc_auth.headings.hybrid_handoff_selfie')) + click_on t('in_person_proofing.headings.prepare') + expect(page).to have_current_path( + idv_document_capture_path({ step: 'hybrid_handoff' }), + ) + expect_step_indicator_current_step( + t('step_indicator.flows.idv.find_a_post_office'), + ) + expect_doc_capture_page_header(t('in_person_proofing.headings.prepare')) end - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: error, - ) - end - end - context 'error due to data issue with 2xx status code', allow_browser_log: true do - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_general_doc_auth_client_error(:get_results) - attach_and_submit_images - click_try_again + end + end + end + end end - it_behaves_like 'image re-upload not allowed' end + end - context 'error due to data issue with 4xx status code with trueid', allow_browser_log: true do - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_trueid_http_non2xx_status(438) - attach_and_submit_images - # verify it's a network error - expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) - click_try_again - end - - it_behaves_like 'image re-upload allowed' + def expect_rate_limited_header(expected_to_be_present) + review_issues_h1_heading = strip_tags(t('doc_auth.errors.rate_limited_heading')) + if expected_to_be_present + expect(page).to have_content(review_issues_h1_heading) + else + expect(page).not_to have_content(review_issues_h1_heading) end + end - context 'error due to http status error but non 4xx status code with trueid', - allow_browser_log: true do - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_trueid_http_non2xx_status(500) - attach_and_submit_images - # verify it's a network error - expect(page).to have_content(I18n.t('doc_auth.errors.general.network_error')) - click_try_again - end - it_behaves_like 'image re-upload allowed' + def expect_try_taking_new_pictures(expected_to_be_present = true) + expected_message = strip_tags( + t('doc_auth.errors.rate_limited_subheading'), + ) + if expected_to_be_present + expect(page).to have_content expected_message + else + expect(page).not_to have_content expected_message end + end - context 'when selfie is enabled' do - context 'when doc auth is success and face match fails (2xx)', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:fail) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_success_face_match_fail - attach_images - click_continue - attach_selfie - submit_images - click_try_again - sleep(10) - end - - it_behaves_like 'document and selfie images re-upload not allowed' - - it 'shows current existing header' do - expect_doc_capture_page_header(t('doc_auth.headings.review_issues')) - end - end + def expect_review_issues_body_message(translation_key) + review_issues_body_message = strip_tags(t(translation_key)) + expect(page).to have_content(review_issues_body_message) + end - context 'when doc auth passes and portrait match is not live', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + def expect_rate_limit_warning(expected_remaining_attempts) + review_issues_rate_limit_warning = strip_tags( + t( + 'idv.failure.attempts_html', + count: expected_remaining_attempts, + ), + ) + expect(page).to have_content(review_issues_rate_limit_warning) + end - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_pass_and_portrait_match_not_live - attach_images - click_continue - attach_selfie - submit_images - click_try_again - sleep(10) - end + def expect_resubmit_page_h1_copy + resubmit_page_h1_copy = strip_tags(t('doc_auth.headings.review_issues')) + expect(page).to have_content(resubmit_page_h1_copy) + end - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + def expect_resubmit_page_body_copy(translation_key) + resubmit_page_body_copy = strip_tags(t(translation_key)) + expect(page).to have_content(resubmit_page_body_copy) + end - attach_selfie - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 1, - ) + def expect_resubmit_page_inline_error_messages(expected_count) + resubmit_page_inline_error_messages = strip_tags( + t('doc_auth.errors.general.fallback_field_level'), + ) + expect(page).to have_content(resubmit_page_inline_error_messages).exactly(expected_count) + end - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 1, - ) - end - end + def expect_resubmit_page_inline_selfie_error_message(should_be_present) + resubmit_page_inline_selfie_error_message = strip_tags( + t('doc_auth.errors.general.selfie_failure'), + ) + if should_be_present + expect(page).to have_content(resubmit_page_inline_selfie_error_message) + else + expect(page).not_to have_content(resubmit_page_inline_selfie_error_message) + end + end - context 'when doc auth fails and portrait match pass', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) + def expect_to_try_again + click_try_again + expect(page).to have_current_path(idv_document_capture_path) + end - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_failure_face_match_pass - attach_images - click_continue - attach_selfie - submit_images - click_try_again - sleep(10) - end + def use_id_image(filename) + expect(page).to have_content('Front of your ID') + attach_images Rails.root.join('spec', 'fixtures', filename) + end - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + def use_selfie_image(filename) + attach_selfie Rails.root.join('spec', 'fixtures', filename) + end - attach_selfie - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + def expect_costing_for_document + %i[acuant_front_image acuant_back_image acuant_result].each do |cost_type| + expect(costing_for(cost_type)).to be_present + end + end - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 2, - ) - end - end + def costing_for(cost_type) + SpCost.where(ial: 2, issuer: 'urn:gov:gsa:openidconnect:sp:server', cost_type: cost_type.to_s) + end +end - context 'when doc auth and portrait match fail', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - allow_any_instance_of(DocAuth::Response).to receive(:selfie_status).and_return(:fail) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_fail_face_match_fail - attach_images - click_continue - attach_selfie - submit_images - click_try_again - sleep(10) - end +RSpec.feature 'direct access to IPP on desktop', :js do + include IdvStepHelper + include DocAuthHelper - it_behaves_like 'document and selfie images re-upload not allowed' - end + context 'before handoff page' do + let(:sp_ipp_enabled) { true } + let(:in_person_proofing_opt_in_enabled) { true } + let(:facial_match_required) { true } + let(:user) { user_with_2fa } - context 'when pii validation fails', allow_browser_log: true do - before do - allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - pii = Idp::Constants::MOCK_IDV_APPLICANT.dup - pii[:address1] = nil - allow_any_instance_of(DocAuth::LexisNexis::Responses::TrueIdResponse). - to receive(:pii_from_doc).and_return(Pii::StateId.new(**pii)) - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_pass_face_match_pass_no_address1 - attach_images - click_continue - attach_selfie - submit_images - click_try_again - sleep(10) - end + before do + service_provider = create(:service_provider, :active, :in_person_proofing_enabled) + allow(IdentityConfig.store).to receive(:doc_auth_selfie_desktop_test_mode).and_return(false) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled).and_return( + in_person_proofing_opt_in_enabled, + ) + allow(IdentityConfig.store).to receive(:allowed_biometric_ial_providers). + and_return([service_provider.issuer]) + allow(IdentityConfig.store).to receive( + :allowed_valid_authn_contexts_semantic_providers, + ).and_return([service_provider.issuer]) + allow_any_instance_of(ServiceProvider).to receive(:in_person_proofing_enabled). + and_return(false) + visit_idp_from_sp_with_ial2( + :oidc, + **{ client_id: service_provider.issuer, + facial_match_required: facial_match_required }, + ) + sign_in_via_branded_page(user) + complete_doc_auth_steps_before_agreement_step - it 'shows selfie inline error messages for both front and back' do - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.general.multiple_front_id_failures'), - count: 1, - ) - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.general.multiple_back_id_failures'), - count: 1, - ) - end + visit idv_document_capture_path(step: 'hybrid_handoff') + end - it 'stops user submitting the same images again' do - expect(fake_analytics).to have_logged_event('IdV: doc auth document_capture visited') - expect(fake_analytics).to have_logged_event( - 'IdV: doc auth image upload form submitted', - hash_including(remaining_submit_attempts: 3, submit_attempts: 1), - ) - DocAuth::Mock::DocAuthMockClient.reset! - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + context 'when selfie is enabled' do + it 'redirects back to agreement page' do + expect(page).to have_current_path(idv_agreement_path) + end + end - attach_selfie - expect(page).not_to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - ) + context 'when selfie is disabled' do + let(:facial_match_required) { false } - attach_images - expect(page).to have_css( - '.usa-error-message[role="alert"]', - text: t('doc_auth.errors.doc.resubmit_failed_image'), - count: 2, - ) - end + it 'redirects back to agreement page' do + expect(page).to have_current_path(idv_agreement_path) end end end diff --git a/spec/features/idv/get_proofing_results_job_scenarios_spec.rb b/spec/features/idv/get_proofing_results_job_scenarios_spec.rb new file mode 100644 index 00000000000..e98b057d677 --- /dev/null +++ b/spec/features/idv/get_proofing_results_job_scenarios_spec.rb @@ -0,0 +1,739 @@ +require 'rails_helper' +require 'axe-rspec' + +RSpec.feature 'GetUspsProofingResultsJob Scenarios', js: true, allowed_extra_analytics: [:*] do + include OidcAuthHelper + include UspsIppHelper + include ActiveJob::TestHelper + + background do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:usps_mock_fallback).and_return(false) + allow(IdentityConfig.store).to receive(:in_person_proofing_enforce_tmx).and_return(true) + stub_request_token + ActiveJob::Base.queue_adapter = :test + end + + feature 'before/after password reset:' do + background do + @user = create(:user, :with_phone, :with_pending_in_person_enrollment) + @new_password = '$alty pickles' + end + + scenario 'User resets password and logs in before USPS proofing "passed"' do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user logs out + logout(@user) + # And the user visits USPS to complete their enrollment + # And USPS enrollment passed + stub_request_passed_proofing_results + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is active + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: true, + deactivation_reason: nil, + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + ) + end + + ['failed', 'cancelled', 'expired'].each do |status| + scenario "User resets password and logs in before USPS proofing \"#{status}\"" do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a deactivated profile due to in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user logs out + logout(@user) + # And the user visits USPS to complete their enrollment + # And USPS enrollment "failed|cancelled|expired" + stub_request_proofing_results(status, @user.in_person_enrollments.first.enrollment_code) + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "encryption_error" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "encryption_error" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + ) + end + end + + scenario 'User resets password without logging in before USPS proofing "passed"' do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a deactivated profile due to in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # And the user visits USPS to complete their enrollment + # And USPS enrollment passed + stub_request_passed_proofing_results + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is active + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: true, + deactivation_reason: nil, + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + ) + end + + ['failed', 'cancelled', 'expired'].each do |status| + scenario "User resets password without logging in before USPS proofing \"#{status}\"" do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # And the user visits USPS to complete their enrollment + # And USPS enrollment "failed|cancelled|expired" + stub_request_proofing_results(status, @user.in_person_enrollments.first.enrollment_code) + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "verification_cancelled" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'verification_cancelled', + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "verification_cancelled" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'verification_cancelled', + in_person_verification_pending_at: nil, + ) + end + end + + scenario 'User resets password with personal key after USPS proofing "passed"' do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user visits USPS to complete their enrollment + # And USPS enrollment passed + stub_request_passed_proofing_results + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is active + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: true, + deactivation_reason: nil, + in_person_verification_pending_at: nil, + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "password_reset" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'password_reset', + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + # Then the user is taken to the /account/reactivate/start page + expect(current_path).to eq(reactivate_account_path) + + # When the user attempts to reactivate account without their personal key + account_reactivation_with_personal_key(@user, @new_password) + + # Then the user is taken to the /sign_up/completed page + expect(current_path).to eq(sign_up_completed_path) + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is active + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: true, + deactivation_reason: nil, + in_person_verification_pending_at: nil, + ) + end + + scenario 'User resets password without personal key after USPS proofing "passed"' do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user visits USPS to complete their enrollment + # And USPS enrollment passed + stub_request_passed_proofing_results + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is active + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: true, + deactivation_reason: nil, + in_person_verification_pending_at: nil, + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "password_reset" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'password_reset', + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + # Then the user is taken to the /account/reactivate/start page + expect(current_path).to eq(reactivate_account_path) + + # When the user attempts to reactivate account without their personal key + account_reactivation_without_personal_key + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "password_reset" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'password_reset', + in_person_verification_pending_at: nil, + ) + end + + ['failed', 'cancelled', 'expired'].each do |status| + scenario "User resets password after USPS proofing \"#{status}\"" do + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + ) + + # When the user visits USPS to complete their enrollment + # And USPS enrollment "failed|cancelled|expired" + stub_request_proofing_results(status, @user.in_person_enrollments.first.enrollment_code) + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "verification_cancelled" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'verification_cancelled', + in_person_verification_pending_at: nil, + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "verification_cancelled" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'verification_cancelled', + in_person_verification_pending_at: nil, + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "failed|cancelled|expired" + expect(@user.in_person_enrollments.first).to have_attributes( + status: status, + ) + # And the user has a Profile that is deactivated with reason "verification_cancelled" + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'verification_cancelled', + in_person_verification_pending_at: nil, + ) + end + end + + scenario <<~EOS.squish do + User resets password and logs in before USPS proofing "passed" with fraud review pending + EOS + @user.in_person_enrollments.first.profile.update( + fraud_review_pending_at: 1.day.ago, + fraud_pending_reason: 'threatmetrix_review', + proofing_components: { threatmetrix_review_status: 'review' }, + ) + + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification and + # fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification and + # fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" and + # pending in person verification and fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: be_kind_of(Time), + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # When the user logs out + logout(@user) + # And the user visits USPS to complete their enrollment + # And USPS enrollment passed + stub_request_passed_proofing_results + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" and + # pending fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" and + # pending fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + end + + scenario <<~EOS.squish do + User resets password without logging in before USPS proofing "passed" with fraud review pending + EOS + @user.in_person_enrollments.first.profile.update( + fraud_review_pending_at: 1.day.ago, + fraud_pending_reason: 'threatmetrix_review', + proofing_components: { threatmetrix_review_status: 'review' }, + ) + + # Given the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification and + # fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # When the user resets their password + reset_password(@user, @new_password) + + # Then the user has an InPersonEnrollment with status "pending" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'pending', + ) + # And the user has a Profile that is deactivated pending in person verification and + # fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: be_kind_of(Time), + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # And the user visits USPS to complete their enrollment + # And USPS enrollment passed + stub_request_passed_proofing_results + # And GetUspsProofingResultsJob is performed + perform_get_usps_proofing_results_job(@user) + + # Then the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated pending fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: nil, + in_person_verification_pending_at: nil, + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + + # When the user logs in + login(@user, @new_password) + + # Then the user is taken to the /verify/welcome page + expect(current_path).to eq(idv_welcome_path) + # And the user has an InPersonEnrollment with status "passed" + expect(@user.in_person_enrollments.first).to have_attributes( + status: 'passed', + ) + # And the user has a Profile that is deactivated with reason "encryption_error" and + # pending fraud review + expect(@user.in_person_enrollments.first.profile).to have_attributes( + active: false, + deactivation_reason: 'encryption_error', + in_person_verification_pending_at: nil, + fraud_pending_reason: 'threatmetrix_review', + fraud_review_pending_at: be_kind_of(Time), + ) + end + end + + def reset_password(user, new_password) + visit_idp_from_ial2_oidc_sp + fill_forgot_password_form(user) + click_reset_password_link_from_email + + fill_in t('forms.passwords.edit.labels.password'), with: new_password + fill_in t('components.password_confirmation.confirm_label'), + with: new_password + fill_in t('components.password_confirmation.confirm_label'), + with: new_password + click_on t('forms.passwords.edit.buttons.submit') + user.reload + end + + def account_reactivation_without_personal_key + click_on t('links.account.reactivate.without_key') + click_on t('forms.buttons.continue') + end + + def account_reactivation_with_personal_key(user, password) + personal_key = user.personal_key.gsub(/\W/, '') + click_on t('links.account.reactivate.with_key') + fill_in t('forms.personal_key.confirmation_label'), with: personal_key + click_on t('forms.buttons.continue') + fill_in t('idv.form.password'), with: password + click_on t('forms.buttons.continue') + check t('forms.personal_key.required_checkbox') + click_on t('forms.buttons.continue') + end + + def login(user, password) + user.password = password + sign_in_live_with_2fa(user) + user.reload + end + + def logout(user) + visit sign_out_url + user.reload + end + + def perform_get_usps_proofing_results_job(user) + perform_enqueued_jobs do + GetUspsProofingResultsJob.new.perform(Time.zone.now) + end + + user.reload + end + + def stub_request_proofing_results(status, enrollment_code) + case status + when 'failed' + stub_request_failed_proofing_results + when 'cancelled' + stub_request_unexpected_invalid_enrollment_code( + { 'responseMessage' => "Enrollment code #{enrollment_code} does not exist" }, + ) + when 'expired' + stub_request_expired_id_ipp_proofing_results + else + throw "Status: #{status} not configured" + end + end +end diff --git a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb index 28e4281e3ec..9456bb8a772 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_mobile_spec.rb @@ -11,21 +11,109 @@ before do allow(FeatureManagement).to receive(:doc_capture_polling_enabled?).and_return(true) allow(IdentityConfig.store).to receive(:use_vot_in_sp_requests).and_return(true) - end - - before do allow(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| @sms_link = config[:link] impl.call(**config) end.at_least(1).times end - shared_examples_for 'hybrid flow doc auth' do + it 'proofs and hands off to mobile', js: true do + user = nil + + perform_in_browser(:desktop) do + visit_idp_from_sp_with_ial2(sp) + user = sign_up_and_2fa_ial1_user + + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + + expect(page).to have_content(t('doc_auth.headings.text_message')) + expect(page).to have_content(t('doc_auth.info.you_entered')) + expect(page).to have_content('+1 415-555-0199') + + # Confirm that Continue button is not shown when polling is enabled + expect(page).not_to have_content(t('doc_auth.buttons.continue')) + end + + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + + # Confirm that jumping to LinkSent page does not cause errors + visit idv_link_sent_url + expect(page).to have_current_path(root_url) + visit idv_hybrid_mobile_document_capture_url + + # Confirm that clicking cancel and then coming back doesn't cause errors + click_link 'Cancel' + visit idv_hybrid_mobile_document_capture_url + + # Confirm that jumping to Phone page does not cause errors + visit idv_phone_url + expect(page).to have_current_path(root_url) + visit idv_hybrid_mobile_document_capture_url + + # Confirm that jumping to Welcome page does not cause errors + visit idv_welcome_url + expect(page).to have_current_path(root_url) + visit idv_hybrid_mobile_document_capture_url + + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + attach_and_submit_images + + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) + expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + + # Confirm app disallows jumping back to DocumentCapture page + visit idv_hybrid_mobile_document_capture_url + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + end + + perform_in_browser(:desktop) do + expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) + expect(page).to have_current_path(idv_ssn_path) + + fill_out_ssn_form_ok + click_idv_continue + + expect(page).to have_content(t('headings.verify')) + complete_verify_step + + prefilled_phone = page.find(id: 'idv_phone_form_phone').value + + expect( + PhoneFormatter.format(prefilled_phone), + ).to eq( + PhoneFormatter.format(user.default_phone_configuration.phone), + ) + + fill_out_phone_form_ok + verify_phone_otp + + fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD + click_idv_continue + + acknowledge_and_confirm_personal_key + + validate_idv_completed_page(user) + click_agree_and_continue + + validate_return_to_sp + end + end + + context 'when facial confirmation is requested' do it 'proofs and hands off to mobile', js: true do user = nil perform_in_browser(:desktop) do - visit_idp_from_sp_with_ial2(sp) + visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + user = sign_up_and_2fa_ial1_user complete_doc_auth_steps_before_hybrid_handoff_step @@ -65,8 +153,8 @@ visit idv_hybrid_mobile_document_capture_url expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - attach_and_submit_images + attach_liveness_images + submit_images expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) @@ -110,101 +198,91 @@ validate_return_to_sp end end + end - context 'when facial confirmation is requested' do - it 'proofs and hands off to mobile', js: true do - user = nil + it 'shows the waiting screen correctly after cancelling from mobile and restarting', js: true do + user = nil - perform_in_browser(:desktop) do - visit_idp_from_oidc_sp_with_ial2(facial_match_required: true) + perform_in_browser(:desktop) do + user = sign_in_and_2fa_user + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link - user = sign_up_and_2fa_ial1_user + expect(page).to have_content(t('doc_auth.headings.text_message')) + end - complete_doc_auth_steps_before_hybrid_handoff_step - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link + expect(@sms_link).to be_present - expect(page).to have_content(t('doc_auth.headings.text_message')) - expect(page).to have_content(t('doc_auth.info.you_entered')) - expect(page).to have_content('+1 415-555-0199') + perform_in_browser(:mobile) do + visit @sms_link + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + click_on t('links.cancel') + click_on t('forms.buttons.cancel') # Yes, cancel + end - # Confirm that Continue button is not shown when polling is enabled - expect(page).not_to have_content(t('doc_auth.buttons.continue')) - end + perform_in_browser(:desktop) do + expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link - expect(@sms_link).to be_present + expect(page).to have_content(t('doc_auth.headings.text_message')) + end + end - perform_in_browser(:mobile) do - visit @sms_link + context 'user is rate limited on mobile' do + let(:max_attempts) { IdentityConfig.store.doc_auth_max_attempts } - # Confirm that jumping to LinkSent page does not cause errors - visit idv_link_sent_url - expect(page).to have_current_path(root_url) - visit idv_hybrid_mobile_document_capture_url + before do + allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) + DocAuth::Mock::DocAuthMockClient.mock_response!( + method: :post_front_image, + response: DocAuth::Response.new( + success: false, + errors: { network: I18n.t('doc_auth.errors.general.network_error') }, + ), + ) + end - # Confirm that clicking cancel and then coming back doesn't cause errors - click_link 'Cancel' - visit idv_hybrid_mobile_document_capture_url + it 'shows capture complete on mobile and error page on desktop', js: true do + user = nil - # Confirm that jumping to Phone page does not cause errors - visit idv_phone_url - expect(page).to have_current_path(root_url) - visit idv_hybrid_mobile_document_capture_url + perform_in_browser(:desktop) do + user = sign_in_and_2fa_user + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link - # Confirm that jumping to Welcome page does not cause errors - visit idv_welcome_url - expect(page).to have_current_path(root_url) - visit idv_hybrid_mobile_document_capture_url + expect(page).to have_content(t('doc_auth.headings.text_message')) + end - expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) - attach_liveness_images - submit_images + expect(@sms_link).to be_present - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) - expect(page).to have_text(t('doc_auth.instructions.switch_back')) - expect_step_indicator_current_step(t('step_indicator.flows.idv.verify_id')) + perform_in_browser(:mobile) do + visit @sms_link - # Confirm app disallows jumping back to DocumentCapture page - visit idv_hybrid_mobile_document_capture_url - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + (max_attempts - 1).times do + attach_and_submit_images + click_on t('idv.failure.button.warning') end - perform_in_browser(:desktop) do - expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) - expect(page).to have_current_path(idv_ssn_path) - - fill_out_ssn_form_ok - click_idv_continue - - expect(page).to have_content(t('headings.verify')) - complete_verify_step - - prefilled_phone = page.find(id: 'idv_phone_form_phone').value - - expect( - PhoneFormatter.format(prefilled_phone), - ).to eq( - PhoneFormatter.format(user.default_phone_configuration.phone), - ) - - fill_out_phone_form_ok - verify_phone_otp - - fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD - click_idv_continue - - acknowledge_and_confirm_personal_key + # final failure + attach_and_submit_images - validate_idv_completed_page(user) - click_agree_and_continue + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).not_to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) + end - validate_return_to_sp - end + perform_in_browser(:desktop) do + expect(page).to have_current_path(idv_session_errors_rate_limited_path, wait: 10) end end + end - it 'shows the waiting screen correctly after cancelling from mobile and restarting', js: true do + context 'barcode read error on mobile (redo document capture)' do + it 'continues to ssn on desktop when user selects Continue', js: true do user = nil perform_in_browser(:desktop) do @@ -220,266 +298,173 @@ perform_in_browser(:mobile) do visit @sms_link - expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - click_on t('links.cancel') - click_on t('forms.buttons.cancel') # Yes, cancel - end - - perform_in_browser(:desktop) do - expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link - expect(page).to have_content(t('doc_auth.headings.text_message')) - end - end + mock_doc_auth_attention_with_barcode + attach_and_submit_images + click_idv_continue - context 'user is rate limited on mobile' do - let(:max_attempts) { IdentityConfig.store.doc_auth_max_attempts } - - before do - allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) - DocAuth::Mock::DocAuthMockClient.mock_response!( - method: :post_front_image, - response: DocAuth::Response.new( - success: false, - errors: { network: I18n.t('doc_auth.errors.general.network_error') }, - ), - ) + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) end - it 'shows capture complete on mobile and error page on desktop', js: true do - user = nil - - perform_in_browser(:desktop) do - user = sign_in_and_2fa_user - complete_doc_auth_steps_before_hybrid_handoff_step - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link - - expect(page).to have_content(t('doc_auth.headings.text_message')) - end - - expect(@sms_link).to be_present + perform_in_browser(:desktop) do + expect(page).to have_current_path(idv_ssn_path, wait: 10) - perform_in_browser(:mobile) do - visit @sms_link + fill_out_ssn_form_ok + click_idv_continue - (max_attempts - 1).times do - attach_and_submit_images - click_on t('idv.failure.button.warning') - end + expect(page).to have_current_path(idv_verify_info_path, wait: 10) - # final failure - attach_and_submit_images + # verify pii is displayed + expect(page).to have_text('DAVID') + expect(page).to have_text('SAMPLE') + expect(page).to have_text('123 ABC AVE') - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - expect(page).not_to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) - expect(page).to have_text(t('doc_auth.instructions.switch_back')) - end + warning_link_text = t('doc_auth.headings.capture_scan_warning_link') + click_link warning_link_text - perform_in_browser(:desktop) do - expect(page).to have_current_path(idv_session_errors_rate_limited_path, wait: 10) - end + expect(current_path).to eq(idv_hybrid_handoff_path) + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link end - end - - context 'barcode read error on mobile (redo document capture)' do - it 'continues to ssn on desktop when user selects Continue', js: true do - user = nil - - perform_in_browser(:desktop) do - user = sign_in_and_2fa_user - complete_doc_auth_steps_before_hybrid_handoff_step - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link - - expect(page).to have_content(t('doc_auth.headings.text_message')) - end - - expect(@sms_link).to be_present - - perform_in_browser(:mobile) do - visit @sms_link - mock_doc_auth_attention_with_barcode - attach_and_submit_images - click_idv_continue - - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - expect(page).to have_content(strip_nbsp(t('doc_auth.headings.capture_complete'))) - expect(page).to have_text(t('doc_auth.instructions.switch_back')) - end - - perform_in_browser(:desktop) do - expect(page).to have_current_path(idv_ssn_path, wait: 10) - - fill_out_ssn_form_ok - click_idv_continue - - expect(page).to have_current_path(idv_verify_info_path, wait: 10) - - # verify pii is displayed - expect(page).to have_text('DAVID') - expect(page).to have_text('SAMPLE') - expect(page).to have_text('123 ABC AVE') - - warning_link_text = t('doc_auth.headings.capture_scan_warning_link') - click_link warning_link_text - - expect(current_path).to eq(idv_hybrid_handoff_path) - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link - end + perform_in_browser(:mobile) do + visit @sms_link - perform_in_browser(:mobile) do - visit @sms_link + DocAuth::Mock::DocAuthMockClient.reset! + attach_and_submit_images - DocAuth::Mock::DocAuthMockClient.reset! - attach_and_submit_images + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + end - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - end + perform_in_browser(:desktop) do + expect(page).to have_current_path(idv_ssn_path, wait: 10) + complete_ssn_step + expect(page).to have_current_path(idv_verify_info_path) + + # verify orig pii no longer displayed + expect(page).not_to have_text('DAVID') + expect(page).not_to have_text('SAMPLE') + expect(page).not_to have_text('123 ABC AVE') + # verify new pii from redo is displayed + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:last_name]) + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:address1]) - perform_in_browser(:desktop) do - expect(page).to have_current_path(idv_ssn_path, wait: 10) - complete_ssn_step - expect(page).to have_current_path(idv_verify_info_path) - - # verify orig pii no longer displayed - expect(page).not_to have_text('DAVID') - expect(page).not_to have_text('SAMPLE') - expect(page).not_to have_text('123 ABC AVE') - # verify new pii from redo is displayed - expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) - expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:last_name]) - expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:address1]) - - complete_verify_step - end + complete_verify_step end end + end - context 'barcode read error on desktop, redo document capture on mobile' do - it 'continues to ssn on desktop when user selects Continue', js: true do - user = nil - - perform_in_browser(:desktop) do - user = sign_in_and_2fa_user - complete_doc_auth_steps_before_document_capture_step - mock_doc_auth_attention_with_barcode - attach_and_submit_images - click_idv_continue - expect(page).to have_current_path(idv_ssn_path, wait: 10) - - fill_out_ssn_form_ok - click_idv_continue - - expect(page).to have_current_path(idv_verify_info_path, wait: 10) - - # verify pii is displayed - expect(page).to have_text('DAVID') - expect(page).to have_text('SAMPLE') - expect(page).to have_text('123 ABC AVE') - - warning_link_text = t('doc_auth.headings.capture_scan_warning_link') - click_link warning_link_text - - expect(current_path).to eq(idv_hybrid_handoff_path) - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link - end - - perform_in_browser(:mobile) do - visit @sms_link - - DocAuth::Mock::DocAuthMockClient.reset! - - expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - - visit(idv_hybrid_mobile_document_capture_url(selfie: true)) - expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url(selfie: true)) - expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + context 'barcode read error on desktop, redo document capture on mobile' do + it 'continues to ssn on desktop when user selects Continue', js: true do + user = nil - attach_and_submit_images + perform_in_browser(:desktop) do + user = sign_in_and_2fa_user + complete_doc_auth_steps_before_document_capture_step + mock_doc_auth_attention_with_barcode + attach_and_submit_images + click_idv_continue + expect(page).to have_current_path(idv_ssn_path, wait: 10) - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - end + fill_out_ssn_form_ok + click_idv_continue - perform_in_browser(:desktop) do - expect(page).to have_current_path(idv_ssn_path, wait: 10) - complete_ssn_step - expect(page).to have_current_path(idv_verify_info_path) - - # verify orig pii no longer displayed - expect(page).not_to have_text('DAVID') - expect(page).not_to have_text('SAMPLE') - expect(page).not_to have_text('123 ABC AVE') - # verify new pii from redo is displayed - expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) - expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:last_name]) - expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:address1]) - - complete_verify_step - end - end - end + expect(page).to have_current_path(idv_verify_info_path, wait: 10) - it 'prefills the phone number used on the phone step if the user has no MFA phone', :js do - user = create(:user, :with_authentication_app) + # verify pii is displayed + expect(page).to have_text('DAVID') + expect(page).to have_text('SAMPLE') + expect(page).to have_text('123 ABC AVE') - perform_in_browser(:desktop) do - start_idv_from_sp(facial_match_required: true) - sign_in_and_2fa_user(user) + warning_link_text = t('doc_auth.headings.capture_scan_warning_link') + click_link warning_link_text - complete_doc_auth_steps_before_hybrid_handoff_step + expect(current_path).to eq(idv_hybrid_handoff_path) clear_and_fill_in(:doc_auth_phone, phone_number) click_send_link end - expect(@sms_link).to be_present - perform_in_browser(:mobile) do visit @sms_link + DocAuth::Mock::DocAuthMockClient.reset! + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) - attach_liveness_images - submit_images + visit(idv_hybrid_mobile_document_capture_url(selfie: true)) + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url(selfie: true)) + expect(page).not_to have_content(t('doc_auth.headings.document_capture_selfie')) + + attach_and_submit_images expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - expect(page).to have_text(t('doc_auth.instructions.switch_back')) end perform_in_browser(:desktop) do expect(page).to have_current_path(idv_ssn_path, wait: 10) + complete_ssn_step + expect(page).to have_current_path(idv_verify_info_path) + + # verify orig pii no longer displayed + expect(page).not_to have_text('DAVID') + expect(page).not_to have_text('SAMPLE') + expect(page).not_to have_text('123 ABC AVE') + # verify new pii from redo is displayed + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:last_name]) + expect(page).to have_text(Idp::Constants::MOCK_IDV_APPLICANT[:address1]) - fill_out_ssn_form_ok - click_idv_continue - - expect(page).to have_content(t('headings.verify')) complete_verify_step - - prefilled_phone = page.find(id: 'idv_phone_form_phone').value - - expect( - PhoneFormatter.format(prefilled_phone), - ).to eq( - PhoneFormatter.format(phone_number), - ) end end end - it_behaves_like 'hybrid flow doc auth' + it 'prefills the phone number used on the phone step if the user has no MFA phone', :js do + user = create(:user, :with_authentication_app) - context 'split doc auth flow' do - before do - allow(IdentityConfig.store).to receive(:doc_auth_separate_pages_enabled).and_return(true) + perform_in_browser(:desktop) do + start_idv_from_sp(facial_match_required: true) + sign_in_and_2fa_user(user) + + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link end - it_behaves_like 'hybrid flow doc auth' + expect(@sms_link).to be_present + + perform_in_browser(:mobile) do + visit @sms_link + + expect(page).to have_current_path(idv_hybrid_mobile_document_capture_url) + + attach_liveness_images + submit_images + + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) + end + + perform_in_browser(:desktop) do + expect(page).to have_current_path(idv_ssn_path, wait: 10) + + fill_out_ssn_form_ok + click_idv_continue + + expect(page).to have_content(t('headings.verify')) + complete_verify_step + + prefilled_phone = page.find(id: 'idv_phone_form_phone').value + + expect( + PhoneFormatter.format(prefilled_phone), + ).to eq( + PhoneFormatter.format(phone_number), + ) + end end end diff --git a/spec/features/multiple_emails/sp_sign_in_spec.rb b/spec/features/multiple_emails/sp_sign_in_spec.rb index be6d75b55b6..33b0a5fc6f8 100644 --- a/spec/features/multiple_emails/sp_sign_in_spec.rb +++ b/spec/features/multiple_emails/sp_sign_in_spec.rb @@ -17,9 +17,8 @@ fill_in_code_with_last_phone_otp click_submit_default click_agree_and_continue if current_path == sign_up_completed_path - decoded_id_token = fetch_oidc_id_token_info - expect(decoded_id_token[:email]).to eq(emails.first) - expect(decoded_id_token[:all_emails]).to be_nil + expect(oidc_decoded_id_token[:email]).to eq(emails.first) + expect(oidc_decoded_id_token[:all_emails]).to be_nil Capybara.reset_session! end @@ -41,8 +40,7 @@ expect(current_path).to eq(sign_up_completed_path) click_agree_and_continue - decoded_id_token = fetch_oidc_id_token_info - expect(decoded_id_token[:email]).to eq(emails.second) + expect(oidc_decoded_id_token[:email]).to eq(emails.second) end scenario 'signing in with OIDC after deleting email linked to identity' do @@ -69,8 +67,7 @@ # Sign in again to partner application visit_idp_from_oidc_sp(scope: 'openid email') - decoded_id_token = fetch_oidc_id_token_info - expect(decoded_id_token[:email]).to eq(email1.email) + expect(oidc_decoded_id_token[:email]).to eq(email1.email) end scenario 'signing in with SAML sends the email address used to sign in' do @@ -161,8 +158,7 @@ click_submit_default click_agree_and_continue - decoded_id_token = fetch_oidc_id_token_info - expect(decoded_id_token[:all_emails]).to match_array(emails) + expect(oidc_decoded_id_token[:all_emails]).to match_array(emails) end scenario 'signing in with SAML sends all emails' do @@ -206,41 +202,4 @@ def visit_idp_from_oidc_sp(scope:) nonce: SecureRandom.hex, ) end - - def fetch_oidc_id_token_info - redirect_uri = URI(oidc_redirect_url) - redirect_params = Rack::Utils.parse_query(redirect_uri.query).with_indifferent_access - code = redirect_params[:code] - - jwt_payload = { - iss: 'urn:gov:gsa:openidconnect:sp:server', - sub: 'urn:gov:gsa:openidconnect:sp:server', - aud: api_openid_connect_token_url, - jti: SecureRandom.hex, - exp: 5.minutes.from_now.to_i, - } - - client_assertion = JWT.encode(jwt_payload, client_private_key, 'RS256') - client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' - - page.driver.post( - api_openid_connect_token_path, - grant_type: 'authorization_code', - code: code, - client_assertion_type: client_assertion_type, - client_assertion: client_assertion, - ) - - token_response = JSON.parse(page.body).with_indifferent_access - id_token = token_response[:id_token] - JWT.decode(id_token, nil, false).first.with_indifferent_access - end - - def client_private_key - @client_private_key ||= begin - OpenSSL::PKey::RSA.new( - File.read(Rails.root.join('keys', 'saml_test_sp.key')), - ) - end - end end diff --git a/spec/features/openid_connect/phishing_resistant_required_spec.rb b/spec/features/openid_connect/phishing_resistant_required_spec.rb index 45ed9cb2fd8..b3664efeccc 100644 --- a/spec/features/openid_connect/phishing_resistant_required_spec.rb +++ b/spec/features/openid_connect/phishing_resistant_required_spec.rb @@ -60,36 +60,42 @@ context 'with piv cac configured' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - it 'sends user to authenticate with piv cac' do + it 'sends user to authenticate with piv cac and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_requesting_aal3(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_piv_cac_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:piv_cac)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn configured' do let(:user) { create(:user, :fully_registered, :with_webauthn) } - it 'sends user to authenticate with webauthn' do + it 'sends user to authenticate with webauthn and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_requesting_aal3(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn platform configured' do let(:user) { create(:user, :fully_registered, :with_webauthn_platform) } - it 'sends user to authenticate with webauthn platform' do + it 'sends user to authenticate with webauthn platform and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_requesting_aal3(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url(platform: true)) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn_platform)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end @@ -132,36 +138,42 @@ context 'with piv cac configured' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - it 'sends user to authenticate with piv cac' do + it 'sends user to authenticate with piv cac and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_requesting_phishing_resistant(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_piv_cac_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:piv_cac)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn configured' do let(:user) { create(:user, :fully_registered, :with_webauthn) } - it 'sends user to authenticate with webauthn' do + it 'sends user to authenticate with webauthn and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_requesting_phishing_resistant(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn platform configured' do let(:user) { create(:user, :fully_registered, :with_webauthn_platform) } - it 'sends user to authenticate with webauthn platform' do + it 'sends user to authenticate with webauthn platform and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_requesting_phishing_resistant(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url(platform: true)) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn_platform)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end @@ -204,36 +216,42 @@ context 'with piv cac configured' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - it 'sends user to authenticate with piv cac' do + it 'sends user to authenticate with piv cac and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_defaulting_to_aal3(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_piv_cac_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:piv_cac)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn configured' do let(:user) { create(:user, :fully_registered, :with_webauthn) } - it 'sends user to authenticate with webauthn' do + it 'sends user to authenticate with webauthn and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_defaulting_to_aal3(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn platform configured' do let(:user) { create(:user, :fully_registered, :with_webauthn_platform) } - it 'sends user to authenticate with webauthn platform' do + it 'sends user to authenticate with webauthn platform and removes weaker options' do sign_in_before_2fa(user) visit_idp_from_ial1_oidc_sp_defaulting_to_aal3(prompt: 'select_account') - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url(platform: true)) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn_platform)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end @@ -262,4 +280,11 @@ end end end + + def has_2fa_option?(auth_method) + page.find("label[for='two_factor_options_form_selection_#{auth_method}']") + true + rescue Capybara::ElementNotFound + false + end end diff --git a/spec/features/openid_connect/vtr_spec.rb b/spec/features/openid_connect/vtr_spec.rb index 06683ad5da2..95b8281afbb 100644 --- a/spec/features/openid_connect/vtr_spec.rb +++ b/spec/features/openid_connect/vtr_spec.rb @@ -128,6 +128,6 @@ click_button(t('doc_auth.buttons.upload_picture')) - expect(page).to have_content(t('doc_auth.headings.document_capture_subheader_selfie')) + expect(page).to have_content(t('doc_auth.headings.document_capture')) end end diff --git a/spec/features/saml/phishing_resistant_required_spec.rb b/spec/features/saml/phishing_resistant_required_spec.rb index 329dddd5709..772a8b4b824 100644 --- a/spec/features/saml/phishing_resistant_required_spec.rb +++ b/spec/features/saml/phishing_resistant_required_spec.rb @@ -66,7 +66,7 @@ context 'with piv cac configured' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - it 'sends user to authenticate with piv cac' do + it 'sends user to authenticate with piv cac and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -75,15 +75,17 @@ authn_context: Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_piv_cac_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:piv_cac)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn configured' do let(:user) { create(:user, :fully_registered, :with_webauthn) } - it 'sends user to authenticate with webauthn' do + it 'sends user to authenticate with webauthn and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -92,15 +94,17 @@ authn_context: Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn platform configured' do let(:user) { create(:user, :fully_registered, :with_webauthn_platform) } - it 'sends user to authenticate with webauthn platform' do + it 'sends user to authenticate with webauthn platform and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -109,8 +113,10 @@ authn_context: Saml::Idp::Constants::AAL2_PHISHING_RESISTANT_AUTHN_CONTEXT_CLASSREF, }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url(platform: true)) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn_platform)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end @@ -157,7 +163,7 @@ context 'with piv cac configured' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - it 'sends user to authenticate with piv cac' do + it 'sends user to authenticate with piv cac and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -165,15 +171,17 @@ issuer: sp1_issuer, authn_context: Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_piv_cac_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:piv_cac)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn configured' do let(:user) { create(:user, :fully_registered, :with_webauthn) } - it 'sends user to authenticate with webauthn' do + it 'sends user to authenticate with webauthn and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -181,15 +189,17 @@ issuer: sp1_issuer, authn_context: Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn platform configured' do let(:user) { create(:user, :fully_registered, :with_webauthn_platform) } - it 'sends user to authenticate with webauthn platform' do + it 'sends user to authenticate with webauthn platform and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -197,8 +207,10 @@ issuer: sp1_issuer, authn_context: Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url(platform: true)) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn_platform)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end @@ -245,7 +257,7 @@ context 'with piv cac configured' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - it 'sends user to authenticate with piv cac' do + it 'sends user to authenticate with piv cac and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -253,15 +265,17 @@ issuer: aal3_issuer, authn_context: nil }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_piv_cac_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:piv_cac)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn configured' do let(:user) { create(:user, :fully_registered, :with_webauthn) } - it 'sends user to authenticate with webauthn' do + it 'sends user to authenticate with webauthn and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -269,15 +283,17 @@ issuer: aal3_issuer, authn_context: nil }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end context 'with webauthn platform configured' do let(:user) { create(:user, :fully_registered, :with_webauthn_platform) } - it 'sends user to authenticate with webauthn platform' do + it 'sends user to authenticate with webauthn platform and removes weaker options' do sign_in_before_2fa(user) visit_saml_authn_request_url( @@ -285,8 +301,10 @@ issuer: aal3_issuer, authn_context: nil }, ) - visit login_two_factor_path(otp_delivery_preference: 'sms') expect(current_url).to eq(login_two_factor_webauthn_url(platform: true)) + click_on t('two_factor_authentication.login_options_link_text') + expect(has_2fa_option?(:webauthn_platform)).to eq(true) + expect(has_2fa_option?(:sms)).to eq(false) end end @@ -324,4 +342,11 @@ end end end + + def has_2fa_option?(auth_method) + page.find("label[for='two_factor_options_form_selection_#{auth_method}']") + true + rescue Capybara::ElementNotFound + false + end end diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 0aff958e708..297a23f5efe 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -879,12 +879,14 @@ end end - context 'reCAPTCHA check fails' do + context 'when the reCAPTCHA check fails' do let(:user) { create(:user, :fully_registered) } before do allow(FeatureManagement).to receive(:sign_in_recaptcha_enabled?).and_return(true) allow(IdentityConfig.store).to receive(:recaptcha_mock_validator).and_return(true) + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_log_failures_only). + and_return(sign_in_recaptcha_log_failures_only) allow(IdentityConfig.store).to receive(:sign_in_recaptcha_score_threshold).and_return(0.2) allow(IdentityConfig.store).to receive(:sign_in_recaptcha_percent_tested).and_return(100) reload_ab_tests @@ -894,40 +896,16 @@ reload_ab_tests end - it 'redirects user to security check failed page' do - visit new_user_session_path - - asserted_expected_user = false - fake_analytics = FakeAnalytics.new - allow_any_instance_of(ApplicationController).to receive(:analytics). - and_wrap_original do |original| - original_analytics = original.call - if original_analytics.request.params[:controller] == 'users/sessions' && - original_analytics.request.params[:action] == 'create' - expect(original_analytics.user).to eq(user) - asserted_expected_user = true - end - - fake_analytics - end + context 'when configured to log failures only' do + let(:sign_in_recaptcha_log_failures_only) { true } + it_behaves_like 'logs reCAPTCHA event and redirects appropriately', + successful_sign_in: true + end - fill_in :user_recaptcha_mock_score, with: '0.1' - fill_in_credentials_and_submit(user.email, user.password) - expect(asserted_expected_user).to eq(true) - expect(fake_analytics).to have_logged_event( - 'reCAPTCHA verify result received', - recaptcha_result: { - assessment_id: kind_of(String), - success: true, - score: 0.1, - errors: [], - reasons: [], - }, - evaluated_as_valid: false, - score_threshold: 0.2, - form_class: 'RecaptchaMockForm', - ) - expect(current_path).to eq sign_in_security_check_failed_path + context 'when not configured to log failures only' do + let(:sign_in_recaptcha_log_failures_only) { false } + it_behaves_like 'logs reCAPTCHA event and redirects appropriately', + successful_sign_in: false end end diff --git a/spec/javascript/packages/document-capture/components/document-capture-spec.jsx b/spec/javascript/packages/document-capture/components/document-capture-spec.jsx index 2b5c3f4b15b..356b4c54d85 100644 --- a/spec/javascript/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/document-capture-spec.jsx @@ -372,9 +372,7 @@ describe('document-capture/components/document-capture', () => { it('does not show selfie on first page when doc auth seperated pages enabled', () => { const { queryByText } = render( - + , ); diff --git a/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx b/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx index a22d1deaa24..f45e22de68e 100644 --- a/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx +++ b/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx @@ -23,7 +23,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > @@ -47,7 +48,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: true, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > @@ -73,7 +75,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > @@ -95,7 +98,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: true, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > @@ -121,7 +125,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: false, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > @@ -149,7 +154,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: true, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > @@ -185,7 +191,8 @@ describe('DocumentSideAcuantCapture', () => { value={{ isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: true, - docAuthSeparatePagesEnabled: false, + showHelpInitially: false, + immediatelyBeginCapture: false, }} > diff --git a/spec/javascript/packages/document-capture/components/documents-and-selfie-step-spec.tsx b/spec/javascript/packages/document-capture/components/documents-step-spec.tsx similarity index 62% rename from spec/javascript/packages/document-capture/components/documents-and-selfie-step-spec.tsx rename to spec/javascript/packages/document-capture/components/documents-step-spec.tsx index a344d3198be..80bedd1160f 100644 --- a/spec/javascript/packages/document-capture/components/documents-and-selfie-step-spec.tsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.tsx @@ -1,5 +1,4 @@ import userEvent from '@testing-library/user-event'; -import { within } from '@testing-library/react'; import sinon from 'sinon'; import { expect } from 'chai'; import { t } from '@18f/identity-i18n'; @@ -9,15 +8,14 @@ import { FailedCaptureAttemptsContextProvider, SelfieCaptureContext, } from '@18f/identity-document-capture'; -import DocumentsAndSelfieStep from '@18f/identity-document-capture/components/documents-and-selfie-step'; -import { composeComponents } from '@18f/identity-compose-components'; +import DocumentsStep from '@18f/identity-document-capture/components/documents-step'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; -describe('document-capture/components/documents-and-selfie-step', () => { +describe('document-capture/components/documents-step', () => { it('renders with only front and back inputs by default', () => { const { getByLabelText, queryByLabelText } = render( - undefined} errors={[]} @@ -45,7 +43,7 @@ describe('document-capture/components/documents-and-selfie-step', () => { maxSubmissionAttemptsBeforeNativeCamera={3} failedFingerprints={{ front: [], back: [] }} > - { it('renders device-specific instructions', () => { let { getByText } = render( - undefined} errors={[]} @@ -87,7 +85,7 @@ describe('document-capture/components/documents-and-selfie-step', () => { expect(() => getByText('doc_auth.tips.document_capture_id_text4')).to.throw(); getByText = render( - undefined} errors={[]} @@ -105,7 +103,7 @@ describe('document-capture/components/documents-and-selfie-step', () => { const { getByText } = render( - undefined} errors={[]} @@ -126,7 +124,7 @@ describe('document-capture/components/documents-and-selfie-step', () => { const { queryByText } = render( - undefined} errors={[]} @@ -143,70 +141,27 @@ describe('document-capture/components/documents-and-selfie-step', () => { expect(queryByText(notExpectedText)).to.not.exist(); }); - context('selfie capture', () => { - it('renders with front, back, and selfie inputs when isSelfieCaptureEnabled is true', () => { - const App = composeComponents( - [ - SelfieCaptureContext.Provider, - { - value: { - isSelfieCaptureEnabled: true, - }, - }, - ], - [DocumentsAndSelfieStep], - ); - const { getAllByRole, getByText, getByRole, getByLabelText, queryByLabelText } = render( - , - ); - - const front = getByLabelText('doc_auth.headings.document_capture_front'); - const back = getByLabelText('doc_auth.headings.document_capture_back'); - const selfie = queryByLabelText('doc_auth.headings.document_capture_selfie'); - const pageHeader = getByRole('heading', { - name: 'doc_auth.headings.document_capture_with_selfie', - level: 1, - }); - const idHeader = getByRole('heading', { - name: '1. doc_auth.headings.document_capture_subheader_id', - level: 2, - }); - const selfieHeader = getByRole('heading', { - name: 'doc_auth.headings.document_capture_subheader_selfie', - level: 1, - }); - expect(front).to.be.ok(); - expect(back).to.be.ok(); - expect(selfie).to.be.ok(); - expect(pageHeader).to.be.ok(); - expect(idHeader).to.be.ok(); - expect(selfieHeader).to.be.ok(); - - const tipListHeader = getByText('doc_auth.tips.document_capture_selfie_selfie_text'); - expect(tipListHeader).to.be.ok(); - const lists = getAllByRole('list'); - const tipList = lists[1]; - expect(tipList).to.be.ok(); - const tipListItem = within(tipList).getAllByRole('listitem'); - tipListItem.forEach((li, idx) => { - expect(li.textContent).to.equals(`doc_auth.tips.document_capture_selfie_text${idx + 1}`); - }); - }); - }); - - it('renders with front, back when isSelfieCaptureEnabled is false', () => { - const App = composeComponents( - [ - SelfieCaptureContext.Provider, - { - value: { - isSelfieCaptureEnabled: false, - }, - }, - ], - [DocumentsAndSelfieStep], + it('renders only with front, back when isSelfieCaptureEnabled is true', () => { + const { getByRole, getByLabelText } = render( + + undefined} + errors={[]} + onError={() => undefined} + registerField={() => undefined} + unknownFieldErrors={[]} + toPreviousStep={() => undefined} + /> + , ); - const { queryByRole, getByRole, getByLabelText } = render(); const front = getByLabelText('doc_auth.headings.document_capture_front'); const back = getByLabelText('doc_auth.headings.document_capture_back'); @@ -214,14 +169,9 @@ describe('document-capture/components/documents-and-selfie-step', () => { name: 'doc_auth.headings.document_capture', level: 1, }); - const idHeader = queryByRole('heading', { - name: 'doc_auth.headings.document_capture_subheader_id', - level: 2, - }); expect(front).to.be.ok(); expect(back).to.be.ok(); expect(pageHeader).to.be.ok(); - expect(idHeader).to.be.not.ok(); }); }); diff --git a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx index 08910f65fd4..986c46446b3 100644 --- a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx @@ -11,7 +11,6 @@ import { I18n } from '@18f/identity-i18n'; import { I18nContext } from '@18f/identity-react-i18n'; import ReviewIssuesStep from '@18f/identity-document-capture/components/review-issues-step'; import { toFormEntryError } from '@18f/identity-document-capture/services/upload'; -import { composeComponents } from '@18f/identity-compose-components'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; @@ -180,18 +179,11 @@ describe('document-capture/components/review-issues-step', () => { }); it('renders with front, back, and selfie inputs when isSelfieCaptureEnabled is true', async () => { - const App = composeComponents( - [ - SelfieCaptureContext.Provider, - { - value: { - isSelfieCaptureEnabled: true, - }, - }, - ], - [ReviewIssuesStep, DEFAULT_PROPS], + const { getByLabelText, queryByLabelText, getByRole } = render( + + + , ); - const { getByLabelText, queryByLabelText, getByRole } = render(); await userEvent.click(getByRole('button', { name: 'idv.failure.button.warning' })); diff --git a/spec/javascript/packages/document-capture/components/selfie-step-spec.tsx b/spec/javascript/packages/document-capture/components/selfie-step-spec.tsx new file mode 100644 index 00000000000..0fec529a12d --- /dev/null +++ b/spec/javascript/packages/document-capture/components/selfie-step-spec.tsx @@ -0,0 +1,106 @@ +import userEvent from '@testing-library/user-event'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { FailedCaptureAttemptsContextProvider } from '@18f/identity-document-capture'; +import SelfieCaptureContext from '@18f/identity-document-capture/context/selfie-capture'; +import SelfieStep from '@18f/identity-document-capture/components/selfie-step'; +import { render } from '../../../support/document-capture'; +import { getFixtureFile } from '../../../support/file'; + +describe('document-capture/components/selfie-step', () => { + let getByLabelText; + let queryByLabelText; + + context('when initially shown', () => { + beforeEach(() => { + ({ queryByLabelText } = render( + undefined} + errors={[]} + onError={() => undefined} + registerField={() => undefined} + unknownFieldErrors={[]} + toPreviousStep={() => undefined} + />, + )); + }); + }); + + context('when show help is turned off ', () => { + beforeEach(() => { + ({ queryByLabelText } = render( + + undefined} + errors={[]} + onError={() => undefined} + registerField={() => undefined} + unknownFieldErrors={[]} + toPreviousStep={() => undefined} + /> + , + , + )); + }); + + it('renders with only selfie input', () => { + const front = queryByLabelText('doc_auth.headings.document_capture_front'); + const back = queryByLabelText('doc_auth.headings.document_capture_back'); + const selfie = queryByLabelText('doc_auth.headings.document_capture_selfie'); + + expect(front).to.not.exist(); + expect(back).to.not.exist(); + expect(selfie).to.be.ok(); + }); + }); + + it('calls onChange callback with uploaded image', async () => { + const onChange = sinon.stub(); + ({ getByLabelText } = render( + + + undefined} + registerField={() => undefined} + unknownFieldErrors={[]} + toPreviousStep={() => undefined} + /> + + , + , + )); + const file = await getFixtureFile('doc_auth_images/id-back.jpg'); + + await Promise.all([ + new Promise((resolve) => onChange.callsFake(resolve)), + userEvent.upload(getByLabelText('doc_auth.headings.document_capture_selfie'), file), + ]); + expect(onChange).to.have.been.calledWith({ + selfie: file, + selfie_image_metadata: sinon.match(/^\{.+\}$/), + }); + }); +}); diff --git a/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx b/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx index a02a9e3a7e6..796377d6da3 100644 --- a/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/context/selfie-capture-spec.jsx @@ -9,7 +9,8 @@ describe('document-capture/context/selfie-capture', () => { expect(result.current).to.have.keys([ 'isSelfieCaptureEnabled', 'isSelfieDesktopTestMode', - 'docAuthSeparatePagesEnabled', + 'showHelpInitially', + 'immediatelyBeginCapture', ]); expect(result.current.isSelfieCaptureEnabled).to.be.a('boolean'); }); diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index bb151c2a080..aa7a524833e 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -103,6 +103,8 @@ let(:send_proofing_notification_job) do double(InPerson::SendProofingNotificationJob) end + let(:no_visited_location_name) { 'none' } + let(:visited_location_name) { 'WILKES BARRE' } let(:enrollment) do create(:in_person_enrollment, :pending, :with_notification_phone_configuration) end @@ -324,6 +326,7 @@ it 'sends an in person deadline passed email' do expect(user_mailer).to have_received(:in_person_deadline_passed).with( enrollment: enrollment, + visited_location_name: no_visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with(no_args) end @@ -496,6 +499,7 @@ it 'sends an in person deadline passed email' do expect(user_mailer).to have_received(:in_person_deadline_passed).with( enrollment: enrollment, + visited_location_name: no_visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with(no_args) end @@ -694,6 +698,7 @@ it 'sends an in person deadline passed email' do expect(user_mailer).to have_received(:in_person_deadline_passed).with( enrollment: enrollment, + visited_location_name: no_visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with(no_args) end @@ -1539,6 +1544,7 @@ it 'sends the please call email' do expect(user_mailer).to have_received(:in_person_please_call).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -1650,6 +1656,7 @@ it 'sends the in person failed email' do expect(user_mailer).to have_received(:in_person_failed).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -1753,6 +1760,7 @@ it 'sends the in person failed email with delay' do expect(user_mailer).to have_received(:in_person_failed).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -1851,6 +1859,7 @@ it 'sends the in person verified email with a delay' do expect(user_mailer).to have_received(:in_person_verified).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -1950,6 +1959,7 @@ it 'sends the in person failed email with delay' do expect(user_mailer).to have_received(:in_person_failed).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -2070,6 +2080,7 @@ it 'sends the in person verified email with delay' do expect(user_mailer).to have_received(:in_person_verified).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -2168,6 +2179,7 @@ it 'sends the in person verified email' do expect(user_mailer).to have_received(:in_person_verified).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -2267,6 +2279,7 @@ it 'sends the in person verified email' do expect(user_mailer).to have_received(:in_person_verified).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -2371,6 +2384,7 @@ it 'sends the in person failed email' do expect(user_mailer).to have_received(:in_person_failed).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -2474,6 +2488,7 @@ it 'sends the in person failed fraud email with a delay' do expect(user_mailer).to have_received(:in_person_failed_fraud).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, @@ -2610,6 +2625,7 @@ it 'sends the in person verified email without delay' do expect(user_mailer).to have_received(:in_person_verified).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with(no_args) end @@ -2638,6 +2654,7 @@ it 'sends the in person verified email with a default 1 hour delay' do expect(user_mailer).to have_received(:in_person_verified).with( enrollment: enrollment, + visited_location_name: visited_location_name, ) expect(mail_deliverer).to have_received(:deliver_later).with( queue: :intentionally_delayed, diff --git a/spec/jobs/good_job_v4_ready_job_spec.rb b/spec/jobs/good_job_v4_ready_job_spec.rb new file mode 100644 index 00000000000..0a5c60645bb --- /dev/null +++ b/spec/jobs/good_job_v4_ready_job_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe GoodJobV4ReadyJob, type: :job do + describe '#perform' do + it 'logs goodjob v4 readiness' do + expect(Rails.logger).to receive(:info) do |str| + msg = JSON.parse(str, symbolize_names: true) + expect(msg).to eq( + { + name: 'good_job_v4_ready', + ready: GoodJob.v4_ready?, + }, + ) + end + + GoodJobV4ReadyJob.new.perform + end + end +end diff --git a/spec/jobs/in_person/send_proofing_notification_job_spec.rb b/spec/jobs/in_person/send_proofing_notification_job_spec.rb index af1eac95345..efdc8ef29ed 100644 --- a/spec/jobs/in_person/send_proofing_notification_job_spec.rb +++ b/spec/jobs/in_person/send_proofing_notification_job_spec.rb @@ -88,7 +88,7 @@ context 'enrollment does not exist' do it 'returns without doing anything' do - bad_id = (InPersonEnrollment.all.pluck(:id).max || 0) + 1 + bad_id = (InPersonEnrollment.pluck(:id).max || 0) + 1 job.perform(bad_id) expect(analytics).not_to have_logged_event('SendProofingNotificationJob: job started') expect(analytics).to have_logged_event('SendProofingNotificationJob: job skipped') diff --git a/spec/jobs/socure_reason_code_download_job_spec.rb b/spec/jobs/socure_reason_code_download_job_spec.rb new file mode 100644 index 00000000000..61beb823f80 --- /dev/null +++ b/spec/jobs/socure_reason_code_download_job_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SocureReasonCodeDownloadJob do + subject(:job) { described_class.new } + + let(:idv_socure_reason_code_download_enabled) { true } + let(:analytics) { FakeAnalytics.new } + + let(:api_response_body) do + { + 'reasonCodes' => { + 'ProductA' => { + 'A1' => 'test1', + 'A2' => 'test2', + }, + 'ProductB' => { + 'B2' => 'test3', + }, + }, + }.to_json + end + + before do + allow(IdentityConfig.store).to receive(:idv_socure_reason_code_download_enabled). + and_return(idv_socure_reason_code_download_enabled) + allow(IdentityConfig.store).to receive(:socure_reason_code_base_url). + and_return('https://example.org') + end + + describe '#perform' do + it 'downloads reason codes and writes them to the database' do + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_return( + headers: { 'Content-Type' => 'application/json' }, + body: api_response_body, + ) + + expect { job.perform }.to change { SocureReasonCode.count }.from(0).to(3) + end + + it 'logs an analytics event' do + allow(job).to receive(:analytics).and_return(analytics) + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_return( + headers: { 'Content-Type' => 'application/json' }, + body: api_response_body, + ) + + job.perform + + expect(analytics).to have_logged_event( + :idv_socure_reason_code_download, + success: true, + added_reason_codes: [ + { 'code' => 'A1', 'group' => 'ProductA', 'description' => 'test1' }, + { 'code' => 'A2', 'group' => 'ProductA', 'description' => 'test2' }, + { 'code' => 'B2', 'group' => 'ProductB', 'description' => 'test3' }, + ], + deactivated_reason_codes: [], + ) + end + + context 'when an error occurs downloading the codes' do + it 'logs the error' do + allow(job).to receive(:analytics).and_return(analytics) + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_timeout + + expect { job.perform }.to_not change { SocureReasonCode.count } + + expect(analytics).to have_logged_event( + :idv_socure_reason_code_download, + success: false, + exception: a_string_matching(/execution expired/), + ) + end + end + + context 'when the job is disabled' do + let(:idv_socure_reason_code_download_enabled) { false } + + it 'does not download codes and does not write anything to the database' do + allow(job).to receive(:analytics).and_return(analytics) + api_response_body = { 'reasonCodes' => { 'A1' => 'test1', 'B2' => 'test2' } }.to_json + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_return( + headers: { 'Content-Type' => 'application/json' }, + body: api_response_body, + ) + + expect { job.perform }.to_not change { SocureReasonCode.count } + end + end + end +end diff --git a/spec/lib/identity_config_spec.rb b/spec/lib/identity_config_spec.rb index 418a868b6ba..2610aaf78ca 100644 --- a/spec/lib/identity_config_spec.rb +++ b/spec/lib/identity_config_spec.rb @@ -8,6 +8,15 @@ describe '.key_types' do subject(:key_types) { Identity::Hostdata.config_builder.key_types } + it 'has defaults defined for all keys in default configuration' do + aggregate_failures do + key_types.keys.each do |key| + expect(default_yaml_config). + to have_key(key.to_s), "expected default configuration to include value for #{key}" + end + end + end + it 'has all _enabled keys as booleans' do aggregate_failures do key_types.select { |key, _type| key.to_s.end_with?('_enabled') }. diff --git a/spec/lib/identity_job_log_subscriber_spec.rb b/spec/lib/identity_job_log_subscriber_spec.rb index 4a96e177545..7162f1cbf0d 100644 --- a/spec/lib/identity_job_log_subscriber_spec.rb +++ b/spec/lib/identity_job_log_subscriber_spec.rb @@ -14,6 +14,8 @@ expect(json['job_class']).to eq('AddressProofingJob') expect(json.key?('trace_id')) expect(json.key?('duration_ms')) + expect(json.key?('cpu_time_ms')) + expect(json.key?('idle_time_ms')) expect(json.key?('job_id')) expect(json.key?('timestamp')) end @@ -65,6 +67,8 @@ 'RetryEvent', payload: { wait: 1, job: double('Job', job_id: '1', queue_name: 'Default', arguments: []) }, duration: 1, + cpu_time: 1, + idle_time: 1, name: 'TestEvent', ) @@ -85,6 +89,8 @@ error: double('Exception'), }, duration: 1, + cpu_time: 1, + idle_time: 1, name: 'TestEvent', ) @@ -120,6 +126,8 @@ expect(payload).to match( duration_ms: kind_of(Numeric), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), exception_class_warn: 'ActiveRecord::RecordNotUnique', exception_message_warn: /(cron_key, cron_at)/, job_class: 'HeartbeatJob', @@ -156,6 +164,8 @@ expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), exception_class_warn: 'Errno::ECONNREFUSED', exception_message_warn: 'Connection refused', job_class: 'RiscDeliveryJob', @@ -193,6 +203,8 @@ expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), halted: true, job_class: 'RiscDeliveryJob', job_id: job.job_id, @@ -229,6 +241,8 @@ expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), timestamp: kind_of(String), name: 'enqueue.active_job', job_class: 'RiscDeliveryJob', @@ -292,6 +306,8 @@ expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), exception_class_warn: 'Errno::ECONNREFUSED', exception_message_warn: 'Connection refused', job_class: 'RiscDeliveryJob', @@ -350,6 +366,8 @@ def perform(_); end expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), halted: true, job_class: 'RiscDeliveryJob', job_id: job.job_id, @@ -386,6 +404,8 @@ def perform(_); end expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), timestamp: kind_of(String), name: 'enqueue.active_job', job_class: 'RiscDeliveryJob', @@ -428,6 +448,8 @@ def perform(_); end expect(payload).to match( duration_ms: kind_of(Float), + cpu_time_ms: kind_of(Numeric), + idle_time_ms: kind_of(Numeric), timestamp: kind_of(String), name: 'enqueue.active_job', job_class: 'RiscDeliveryJob', diff --git a/spec/mailers/previews/report_mailer_preview.rb b/spec/mailers/previews/report_mailer_preview.rb index a3a831ebcf2..0529ee4646a 100644 --- a/spec/mailers/previews/report_mailer_preview.rb +++ b/spec/mailers/previews/report_mailer_preview.rb @@ -23,6 +23,21 @@ def monthly_key_metrics_report ) end + def protocols_report + date = Time.zone.yesterday + report = Reports::ProtocolsReport.new(date) + + stub_cloudwatch_client(report.send(:report)) + + ReportMailer.tables_report( + email: 'test@example.com', + subject: "Weekly Protocols Report - #{date}", + message: "Report: protocols-report #{date}", + attachment_format: :csv, + reports: report.send(:weekly_protocols_emailable_reports), + ) + end + def fraud_metrics_report fraud_metrics_report = Reports::FraudMetricsReport.new(Time.zone.yesterday) diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 05b63b0aaa8..ec81d914965 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -164,6 +164,7 @@ def in_person_completion_survey def in_person_deadline_passed UserMailer.with(user: user, email_address: email_address_record).in_person_deadline_passed( enrollment: in_person_enrollment_id_ipp, + visited_location_name: in_person_visited_location_name, ) end @@ -202,24 +203,28 @@ def in_person_ready_to_verify_reminder_enhanced_ipp_enabled def in_person_verified UserMailer.with(user: user, email_address: email_address_record).in_person_verified( enrollment: in_person_enrollment_id_ipp, + visited_location_name: in_person_visited_location_name, ) end def in_person_failed UserMailer.with(user: user, email_address: email_address_record).in_person_failed( enrollment: in_person_enrollment_id_ipp, + visited_location_name: in_person_visited_location_name, ) end def in_person_failed_fraud UserMailer.with(user: user, email_address: email_address_record).in_person_failed_fraud( enrollment: in_person_enrollment_id_ipp, + visited_location_name: in_person_visited_location_name, ) end def in_person_please_call UserMailer.with(user: user, email_address: email_address_record).in_person_please_call( enrollment: in_person_enrollment_id_ipp, + visited_location_name: in_person_visited_location_name, ) end @@ -301,6 +306,10 @@ def email_address_record unsaveable(EmailAddress.new(email: email_address)) end + def in_person_visited_location_name + 'ACQUAINTANCESHIP' + end + def in_person_enrollment_id_ipp unsaveable( InPersonEnrollment.new( diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index ff529f49665..87337726d98 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -565,6 +565,7 @@ def expect_email_body_to_have_help_and_contact_links :enhanced_ipp, ) end + let(:visited_location_name) { 'ACQUAINTANCESHIP' } describe '#in_person_ready_to_verify' do let(:mail) do @@ -875,6 +876,7 @@ def expect_email_body_to_have_help_and_contact_links let(:mail) do UserMailer.with(user: user, email_address: email_address).in_person_verified( enrollment: enrollment, + visited_location_name: visited_location_name, ) end @@ -895,6 +897,7 @@ def expect_email_body_to_have_help_and_contact_links let(:mail) do UserMailer.with(user: user, email_address: email_address).in_person_failed( enrollment: enrollment, + visited_location_name: visited_location_name, ) end @@ -914,6 +917,7 @@ def expect_email_body_to_have_help_and_contact_links let(:mail) do UserMailer.with(user: user, email_address: email_address).in_person_failed_fraud( enrollment: enrollment, + visited_location_name: visited_location_name, ) end @@ -925,6 +929,7 @@ def expect_email_body_to_have_help_and_contact_links let(:mail) do UserMailer.with(user: user, email_address: email_address).in_person_please_call( enrollment: enrollment, + visited_location_name: visited_location_name, ) end diff --git a/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb b/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb index 4b53c3ae6da..e320151a0a7 100644 --- a/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb +++ b/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Idv::InPerson::VerificationResultsEmailPresenter do include Rails.application.routes.url_helpers - let(:location_name) { 'FRIENDSHIP' } + let(:visited_location_name) { 'ACQUAINTANCESHIP' } let(:status_updated_at) { described_class::USPS_SERVER_TIMEZONE.parse('2022-07-14T00:00:00Z') } let(:sp) { nil } let(:current_address_matches_id) { true } @@ -12,16 +12,21 @@ :in_person_enrollment, :pending, service_provider: sp, - selected_location_details: { name: location_name }, + selected_location_details: { name: 'FRIENDSHIP' }, current_address_matches_id: current_address_matches_id, ) end - subject(:presenter) { described_class.new(enrollment: enrollment, url_options: {}) } + subject(:presenter) do + described_class.new( + enrollment: enrollment, url_options: {}, + visited_location_name: visited_location_name + ) + end - describe '#location_name' do - it 'returns the enrollment location name' do - expect(presenter.location_name).to eq(location_name) + describe 'visited_location_name' do + it 'returns the location that USPS reports the user visited for their proofing attempt' do + expect(presenter.visited_location_name).to eq(visited_location_name) end end diff --git a/spec/services/agency_identity_linker_spec.rb b/spec/services/agency_identity_linker_spec.rb index 32cde8fb0c2..991a90291ea 100644 --- a/spec/services/agency_identity_linker_spec.rb +++ b/spec/services/agency_identity_linker_spec.rb @@ -123,7 +123,7 @@ it 'persists the service provider identity as an agency identity' do expect(subject.uuid).to eq uuid - ai = AgencyIdentity.where(user: user, agency: agency).take + ai = AgencyIdentity.find_by(user: user, agency: agency) expect(subject).to eq ai end end diff --git a/spec/services/attribute_asserter_spec.rb b/spec/services/attribute_asserter_spec.rb index 9132a9e9ae0..e74d4328d20 100644 --- a/spec/services/attribute_asserter_spec.rb +++ b/spec/services/attribute_asserter_spec.rb @@ -18,14 +18,13 @@ let(:name_id_format) { Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT } let(:service_provider_ial) { 1 } let(:service_provider_aal) { nil } + let(:attribute_bundle) { nil } let(:service_provider) do - instance_double( - ServiceProvider, - issuer: 'http://localhost:3000', + create( + :service_provider, ial: service_provider_ial, default_aal: service_provider_aal, - metadata: {}, - semantic_authn_contexts_allowed?: false, + attribute_bundle:, ) end @@ -72,6 +71,11 @@ describe '#build' do context 'when an IAL2 request is made' do + before do + user.identities << identity + subject.build + end + [ Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, Saml::Idp::Constants::IAL_VERIFIED_ACR, @@ -85,12 +89,7 @@ context 'when the user has been proofed without facial match' do context 'custom bundle includes email, phone, and first_name' do - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) - subject.build - end + let(:attribute_bundle) { %w[email phone first_name] } it 'includes all requested attributes + uuid' do expect(user.asserted_attributes.keys). @@ -111,12 +110,7 @@ end context 'custom bundle includes dob' do - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[dob]) - subject.build - end + let(:attribute_bundle) { %w[dob] } it 'formats the dob in an international format' do expect(get_asserted_attribute(user, :dob)).to eq '1970-12-31' @@ -124,12 +118,7 @@ end context 'custom bundle includes zipcode' do - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[zipcode]) - subject.build - end + let(:attribute_bundle) { %w[zipcode] } it 'formats zipcode as 5 digits' do expect(get_asserted_attribute(user, :zipcode)).to eq '12345' @@ -137,12 +126,7 @@ end context 'bundle includes :ascii' do - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name ascii]) - subject.build - end + let(:attribute_bundle) { %w[email phone first_name ascii] } it 'skips ascii as an attribute' do expect(user.asserted_attributes.keys). @@ -155,12 +139,6 @@ end context 'Service Provider does not specify bundle' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(nil) - subject.build - end - context 'authn request does not specify bundle' do it 'only returns uuid, verified_at, aal, and ial' do expect(user.asserted_attributes.keys).to eq %i[uuid verified_at aal ial] @@ -186,11 +164,7 @@ end context 'Service Provider specifies empty bundle' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return([]) - subject.build - end + let(:attribute_bundle) { [] } it 'only includes uuid, verified_at, aal, and ial' do expect(user.asserted_attributes.keys).to eq(%i[uuid verified_at aal ial]) @@ -198,12 +172,7 @@ end context 'custom bundle has invalid attribute name' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle).and_return( - %w[email foo], - ) - subject.build - end + let(:attribute_bundle) { %w[email foo] } it 'silently skips invalid attribute name' do expect(user.asserted_attributes.keys).to eq(%i[uuid email verified_at aal ial]) @@ -211,10 +180,8 @@ end context 'x509 attributes included in the SP attribute bundle' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email x509_subject x509_issuer x509_presented]) - subject.build + let(:attribute_bundle) do + %w[email x509_subject x509_issuer x509_presented] end context 'user did not present piv/cac' do @@ -251,11 +218,6 @@ context 'when the user has been proofed with facial match' do let(:user) { create(:profile, :active, :verified, idv_level: :in_person).user } - before do - user.identities << identity - subject.build - end - it 'asserts IAL2' do expected_ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF expect(get_asserted_attribute(user, :ial)).to eq expected_ial @@ -266,11 +228,9 @@ context 'verified user and proofing VTR request' do let(:authn_context) { 'C1.C2.P1' } - + let(:attribute_bundle) { %w[email first_name last_name] } before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email first_name last_name]) subject.build end @@ -284,14 +244,14 @@ end context 'when an IAL1 request is made' do + before do + user.identities << identity + subject.build + end + context 'when the user has been proofed without facial match comparison' do context 'custom bundle includes email, phone, and first_name' do - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) - subject.build - end + let(:attribute_bundle) { %w[email phone first_name] } it 'only includes uuid, email, aal, and ial (no verified_at)' do expect(user.asserted_attributes.keys).to eq %i[uuid email aal ial] @@ -309,12 +269,7 @@ end context 'custom bundle includes verified_at' do - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email verified_at]) - subject.build - end + let(:attribute_bundle) { %w[email verified_at] } context 'the service provider is ial1' do let(:service_provider_ial) { 1 } @@ -343,12 +298,6 @@ end context 'Service Provider does not specify bundle' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(nil) - subject.build - end - context 'authn request does not specify bundle' do it 'only includes uuid, aal, and ial' do expect(user.asserted_attributes.keys).to eq %i[uuid aal ial] @@ -374,11 +323,7 @@ end context 'Service Provider specifies empty bundle' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return([]) - subject.build - end + let(:attribute_bundle) { [] } it 'only includes UUID, aal, and ial' do expect(user.asserted_attributes.keys).to eq(%i[uuid aal ial]) @@ -386,12 +331,7 @@ end context 'custom bundle has invalid attribute name' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle).and_return( - %w[email foo], - ) - subject.build - end + let(:attribute_bundle) { %w[email foo] } it 'silently skips invalid attribute name' do expect(user.asserted_attributes.keys).to eq(%i[uuid email aal ial]) @@ -399,11 +339,7 @@ end context 'x509 attributes included in the SP attribute bundle' do - before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email x509_subject x509_issuer x509_presented]) - subject.build - end + let(:attribute_bundle) { %w[email x509_subject x509_issuer x509_presented] } context 'user did not present piv/cac' do let(:user_session) do @@ -436,13 +372,7 @@ context 'request made with a VTR param' do let(:options) { { authn_context: 'C1.C2' } } - - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email]) - subject.build - end + let(:attribute_bundle) { %w[email] } it 'includes the correct bundle attributes' do expect(user.asserted_attributes.keys).to eq( @@ -456,11 +386,6 @@ context 'when the user has been proofed with facial match comparison' do let(:user) { create(:profile, :active, :verified, idv_level: :in_person).user } - before do - user.identities << identity - subject.build - end - it 'asserts IAL1' do expected_ial = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF expect(get_asserted_attribute(user, :ial)).to eq expected_ial @@ -469,6 +394,13 @@ end context 'verified user and IAL1 AAL3 request' do + let(:attribute_bundle) { %w[email phone first_name] } + + before do + user.identities << identity + subject.build + end + context 'service provider configured for AAL3' do let(:service_provider_aal) { 3 } let(:authn_context) do @@ -478,13 +410,6 @@ ] end - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) - subject.build - end - it 'asserts AAL3' do expected_aal = Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF expect(get_asserted_attribute(user, :aal)).to eq expected_aal @@ -499,13 +424,6 @@ ] end - before do - user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) - subject.build - end - it 'asserts AAL3' do expected_aal = Saml::Idp::Constants::AAL3_AUTHN_CONTEXT_CLASSREF expect(get_asserted_attribute(user, :aal)).to eq expected_aal @@ -543,6 +461,7 @@ context 'IAL2 service provider requests IALMAX with IAL2 user' do let(:service_provider_ial) { 2 } + let(:attribute_bundle) { %w[email phone first_name] } let(:options) do { authn_context: [ @@ -554,8 +473,6 @@ before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) ServiceProvider.find_by(issuer: sp1_issuer).update!(ial: 2) subject.build end @@ -574,6 +491,7 @@ context 'non-IAL2 service provider requests IALMAX with IAL2 user' do let(:service_provider_ial) { 1 } + let(:attribute_bundle) { %w[email phone first_name] } let(:options) do { authn_context: [ @@ -585,8 +503,6 @@ before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) ServiceProvider.find_by(issuer: sp1_issuer).update!(ial: 1) subject.build end @@ -611,14 +527,14 @@ } end + before do + user.identities << identity + subject.build + end + context 'when the user has been proofed with facial match' do let(:user) { facial_match_verified_user } - before do - user.identities << identity - subject.build - end - it 'asserts IAL2 with facial match comparison' do expected_ial = Saml::Idp::Constants::IAL2_BIO_REQUIRED_AUTHN_CONTEXT_CLASSREF expect(get_asserted_attribute(user, :ial)).to eq expected_ial @@ -626,11 +542,6 @@ end context 'when the user has been proofed without facial match' do - before do - user.identities << identity - subject.build - end - it 'asserts IAL2 (without facial match comparison)' do expected_ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF expect(get_asserted_attribute(user, :ial)).to eq expected_ial @@ -664,12 +575,10 @@ shared_examples 'unverified user' do let(:user) { create(:user, :fully_registered) } + let(:attribute_bundle) { %w[first_name last_name] } context 'custom bundle does not include email, phone' do before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle).and_return( - %w[first_name last_name], - ) subject.build end @@ -679,11 +588,9 @@ end context 'custom bundle includes all_emails' do + let(:attribute_bundle) { %w[all_emails] } before do create(:email_address, user: user) - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle).and_return( - %w[all_emails], - ) subject.build end @@ -696,10 +603,8 @@ end context 'custom bundle includes email, phone' do + let(:attribute_bundle) { %w[first_name last_name email phone] } before do - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle).and_return( - %w[first_name last_name email phone], - ) subject.build end @@ -733,6 +638,7 @@ end context 'with a deleted email' do + let(:attribute_bundle) { %w[email phone first_name] } let(:subject) do described_class.new( user: user, @@ -746,8 +652,6 @@ before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) create(:email_address, user:, email: 'email@example.com') ident = user.identities.last @@ -767,6 +671,7 @@ end context 'with a nil email id' do + let(:attribute_bundle) { %w[email phone first_name] } let(:subject) do described_class.new( user: user, @@ -780,8 +685,6 @@ before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) ident = user.identities.last ident.email_address_id = nil @@ -796,6 +699,7 @@ end context 'select email to send to partner feature is disabled' do + let(:attribute_bundle) { %w[first_name last_name email phone] } before do allow(IdentityConfig.store).to receive( :feature_select_email_to_share_enabled, @@ -816,8 +720,6 @@ before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) create(:email_address, user:, email: 'email@example.com') ident = user.identities.last @@ -837,6 +739,7 @@ end context 'with a nil email id' do + let(:attribute_bundle) { %w[first_name email phone] } let(:subject) do described_class.new( user: user, @@ -850,8 +753,6 @@ before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email phone first_name]) ident = user.identities.last ident.email_address_id = nil @@ -868,10 +769,9 @@ end describe 'aal attributes handling' do + let(:attribute_bundle) { %w[email] } before do user.identities << identity - allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). - and_return(%w[email]) subject.build end diff --git a/spec/services/funnel/registration/add_mfa_spec.rb b/spec/services/funnel/registration/add_mfa_spec.rb index 6fde1986586..58d977ff077 100644 --- a/spec/services/funnel/registration/add_mfa_spec.rb +++ b/spec/services/funnel/registration/add_mfa_spec.rb @@ -8,7 +8,7 @@ user = create(:user) user.id end - let(:funnel) { RegistrationLog.all.first } + let(:funnel) { RegistrationLog.first } it 'shows user is not fully registered with no mfa' do expect(funnel&.registered_at).to_not be_present diff --git a/spec/services/proofing/socure/reason_codes/api_client_spec.rb b/spec/services/proofing/socure/reason_codes/api_client_spec.rb new file mode 100644 index 00000000000..0d6fafa9f2f --- /dev/null +++ b/spec/services/proofing/socure/reason_codes/api_client_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +RSpec.describe Proofing::Socure::ReasonCodes::ApiClient do + before do + allow(IdentityConfig.store).to receive( + :socure_reason_code_base_url, + ).and_return( + 'https://example.org/', + ) + end + + it 'returns a parsed set or reason codes' do + api_response_body = { + 'reasonCodes' => { + 'ProductA' => { + 'A1' => 'test1', + 'A2' => 'test2', + }, + 'ProductB' => { + 'B2' => 'test3', + }, + }, + }.to_json + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_return( + headers: { 'Content-Type' => 'application/json' }, + body: api_response_body, + ) + + result = described_class.new.download_reason_codes + + expect(result).to eq( + 'ProductA' => { + 'A1' => 'test1', + 'A2' => 'test2', + }, + 'ProductB' => { + 'B2' => 'test3', + }, + ) + end + + context 'the authentication to the service fails' do + it 'raises an unauthorized error' do + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_return( + status: 401, + headers: { + 'Content-Type' => 'application/json', + }, + body: { + status: 'Error', + referenceId: 'a-big-unique-reference-id', + msg: 'Request-specific error message goes here', + }.to_json, + ) + + expect { described_class.new.download_reason_codes }.to raise_error( + Proofing::Socure::ReasonCodes::ApiClient::ApiClientError, + 'the server responded with status 401', + ) + end + end + + context 'there is a networking error in the request' do + it 'raises the error' do + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_timeout + + expect { described_class.new.download_reason_codes }.to raise_error( + Proofing::Socure::ReasonCodes::ApiClient::ApiClientError, + 'execution expired', + ) + end + end + + context 'the response includes invalid JSON' do + it 'raises a parsing error' do + stub_request(:get, 'https://example.org/api/3.0/reasoncodes?group=true').to_return( + headers: { 'Content-Type' => 'application/json' }, + body: '{;*[("', + ) + + expect { described_class.new.download_reason_codes }.to raise_error( + Proofing::Socure::ReasonCodes::ApiClient::ApiClientError, + "unexpected token at '{;*[(\"'", + ) + end + end +end diff --git a/spec/services/proofing/socure/reason_codes/importer_spec.rb b/spec/services/proofing/socure/reason_codes/importer_spec.rb new file mode 100644 index 00000000000..f14ef0ac5af --- /dev/null +++ b/spec/services/proofing/socure/reason_codes/importer_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +RSpec.describe Proofing::Socure::ReasonCodes::Importer do + describe '#download' do + let(:downloaded_reason_codes) do + { + 'ProductA' => { + 'A1' => 'test1', + 'A2' => 'test2', + }, + 'ProductB' => { + 'B2' => 'test3', + }, + } + end + + it 'adds reason codes that do not exist', :freeze_time do + allow(subject.api_client).to receive(:download_reason_codes). + and_return(downloaded_reason_codes) + + result = subject.synchronize + + expect(result.success?).to eq(true) + expect(result.to_h[:added_reason_codes]).to include( + 'code' => 'A1', + 'group' => 'ProductA', + 'description' => 'test1', + ) + + new_reason_code = SocureReasonCode.find_by(code: 'A1') + expect(new_reason_code.group).to eq('ProductA') + expect(new_reason_code.description).to eq('test1') + expect(new_reason_code.added_at).to be_within(1.second).of(Time.zone.now) + expect(new_reason_code.deactivated_at).to be_nil + end + + it 'deactivates reason codes that have been removed by Socure', :freeze_time do + SocureReasonCode.create( + code: 'C3', + group: 'ProductC', + description: 'test3', + added_at: 1.day.ago, + ) + + allow(subject.api_client).to receive(:download_reason_codes). + and_return(downloaded_reason_codes) + + result = subject.synchronize + expect(result.to_h[:deactivated_reason_codes]).to eq( + [{ 'code' => 'C3', 'group' => 'ProductC', 'description' => 'test3' }], + ) + + expect(result.success?).to eq(true) + + deactivated_reason_code = SocureReasonCode.find_by(code: 'C3') + expect(deactivated_reason_code.deactivated_at).to be_within(1.second).of(Time.zone.now) + end + + context 'the downloaded reason codes are malformed' do + it 'returns an unsuccessful response' do + allow(subject.api_client).to receive(:download_reason_codes). + and_return('malformed response') + + result = subject.synchronize + + expect(result.success?).to eq(false) + expect(result.to_h[:exception]).to include( + 'Expected "malformed response" to be a hash of reason codes', + ) + end + end + + context 'their is a networking error downloading codes' do + it 'returns an unsuccessful response' do + allow(subject.api_client).to receive( + :download_reason_codes, + ).and_raise( + Proofing::Socure::ReasonCodes::ApiClient::ApiClientError, + 'test error', + ) + + result = subject.synchronize + + expect(result.success?).to eq(false) + expect(result.to_h[:exception]).to eq( + '#', + ) + end + end + end +end diff --git a/spec/support/ab_tests_helper.rb b/spec/support/ab_tests_helper.rb index 8f8f4742a14..f37e504baa3 100644 --- a/spec/support/ab_tests_helper.rb +++ b/spec/support/ab_tests_helper.rb @@ -1,8 +1,10 @@ module AbTestsHelper def reload_ab_tests + # rubocop:disable Rails/FindEach AbTests.all.each do |(name, _)| AbTests.send(:remove_const, name) end + # rubocop:enable Rails/FindEach load('config/initializers/ab_tests.rb') end end diff --git a/spec/support/features/doc_capture_helper.rb b/spec/support/features/doc_capture_helper.rb index 48b0372f4ca..3b796c4f7ff 100644 --- a/spec/support/features/doc_capture_helper.rb +++ b/spec/support/features/doc_capture_helper.rb @@ -51,10 +51,6 @@ def expect_doc_capture_page_header(text) expect(page).to have_css('.page-heading', text: text, wait: 5) end - def expect_doc_capture_id_subheader - expect(page).to have_text(t('doc_auth.headings.document_capture_subheader_id')) - end - def expect_doc_capture_selfie_subheader expect(page).to have_text(t('doc_auth.headings.document_capture_subheader_selfie')) end diff --git a/spec/support/features/document_capture_step_helper.rb b/spec/support/features/document_capture_step_helper.rb index 7a69f19be41..179b333acfb 100644 --- a/spec/support/features/document_capture_step_helper.rb +++ b/spec/support/features/document_capture_step_helper.rb @@ -25,9 +25,8 @@ def attach_liveness_images( ) ) attach_images(file) - if IdentityConfig.store.doc_auth_separate_pages_enabled - click_continue - end + click_continue + click_button 'Take photo' if page.has_button? 'Take photo' attach_selfie end diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index 51266c3b647..5833ef4d25d 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -77,14 +77,10 @@ def link_text end def click_sp_link_in_person_ready_to_verify - expect(page).to have_content(sp_text) + expect(page).to have_content(link_text) click_link(link_text) end - def sp_text - t('in_person_proofing.body.barcode.return_to_partner_html', link_html: link_text) - end - def complete_enter_password_step(user = user_with_2fa) password = user.password || user_password fill_in 'Password', with: password diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index ea92023dd69..60ecb049ee1 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -292,8 +292,7 @@ def perform_in_browser(name) end def acknowledge_and_confirm_personal_key - checkbox_header = t('forms.personal_key.required_checkbox') - find('label', text: /#{checkbox_header}/).click + check t('forms.personal_key.required_checkbox') click_continue end diff --git a/spec/support/idv_examples/sp_handoff.rb b/spec/support/idv_examples/sp_handoff.rb index b071838fc51..282910ce4bd 100644 --- a/spec/support/idv_examples/sp_handoff.rb +++ b/spec/support/idv_examples/sp_handoff.rb @@ -1,5 +1,6 @@ RSpec.shared_examples 'sp handoff after identity verification' do |sp| include SamlAuthHelper + include OidcAuthHelper include IdvHelper include JavascriptDriverHelper @@ -134,43 +135,10 @@ def expect_csp_headers_to_be_present end def expect_successful_oidc_handoff - redirect_uri = URI(current_url) - redirect_params = Rack::Utils.parse_query(redirect_uri.query).with_indifferent_access - - expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') - expect(redirect_params[:state]).to eq(@state) - - code = redirect_params[:code] - expect(code).to be_present - - jwt_payload = { - iss: @client_id, - sub: @client_id, - aud: api_openid_connect_token_url, - jti: SecureRandom.hex, - exp: 5.minutes.from_now.to_i, - } - - client_assertion = JWT.encode(jwt_payload, client_private_key, 'RS256') - client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + token_response = oidc_decoded_token + decoded_id_token = oidc_decoded_id_token Capybara.using_driver(:desktop_rack_test) do - page.driver.post api_openid_connect_token_path, - grant_type: 'authorization_code', - code: code, - client_assertion_type: client_assertion_type, - client_assertion: client_assertion - - expect(page.status_code).to eq(200) - token_response = JSON.parse(page.body).with_indifferent_access - - id_token = token_response[:id_token] - expect(id_token).to be_present - - decoded_id_token, _headers = JWT.decode( - id_token, sp_public_key, true, algorithm: 'RS256' - ).map(&:with_indifferent_access) - sub = decoded_id_token[:sub] expect(sub).to be_present expect(decoded_id_token[:nonce]).to eq(@nonce) @@ -209,21 +177,4 @@ def expect_successful_saml_handoff end expect(xmldoc.phone_number.children.children.to_s).to eq(Phonelib.parse(profile_phone).e164) end - - def client_private_key - @client_private_key ||= begin - OpenSSL::PKey::RSA.new( - File.read(Rails.root.join('keys', 'saml_test_sp.key')), - ) - end - end - - def sp_public_key - page.driver.get api_openid_connect_certs_path - - expect(page.status_code).to eq(200) - certs_response = JSON.parse(page.body).with_indifferent_access - - JWT::JWK.import(certs_response[:keys].first).public_key - end end diff --git a/spec/support/matchers/accessibility.rb b/spec/support/matchers/accessibility.rb index 0f0de51e490..5221025ff52 100644 --- a/spec/support/matchers/accessibility.rb +++ b/spec/support/matchers/accessibility.rb @@ -3,6 +3,7 @@ match do |page| ['aria-describedby', 'aria-labelledby'].each do |idref_attribute| + # rubocop:disable Rails/FindEach page.all(:css, "[#{idref_attribute}]").each do |element| element[idref_attribute].split(' ').each do |referenced_id| page.find_by_id(referenced_id, visible: :all) @@ -10,6 +11,7 @@ rescue Capybara::ElementNotFound invalid_idref_messages << "[#{idref_attribute}=\"#{element[idref_attribute]}\"]" end + # rubocop:enable Rails/FindEach end invalid_idref_messages.blank? @@ -28,9 +30,11 @@ elements = [] match do |page| + # rubocop:disable Rails/FindEach page.all(:css, 'input[required]').each do |input| elements << input if input['aria-invalid'].blank? end + # rubocop:enable Rails/FindEach elements.empty? end diff --git a/spec/support/oidc_auth_helper.rb b/spec/support/oidc_auth_helper.rb index 58bac76d474..acfcfb75f79 100644 --- a/spec/support/oidc_auth_helper.rb +++ b/spec/support/oidc_auth_helper.rb @@ -1,4 +1,8 @@ +require_relative 'features/javascript_driver_helper' + module OidcAuthHelper + include JavascriptDriverHelper + OIDC_ISSUER = 'urn:gov:gsa:openidconnect:sp:server'.freeze OIDC_IAL1_ISSUER = 'urn:gov:gsa:openidconnect:sp:server_ial1'.freeze OIDC_AAL3_ISSUER = 'urn:gov:gsa:openidconnect:sp:server_requiring_aal3'.freeze @@ -155,6 +159,9 @@ def extract_redirect_url end def oidc_redirect_url + # Page will redirect automatically if JavaScript is enabled + return current_url if javascript_enabled? + case IdentityConfig.store.openid_connect_redirect when 'client_side' extract_meta_refresh_url @@ -164,4 +171,45 @@ def oidc_redirect_url current_url end end + + def oidc_decoded_token + return @oidc_decoded_token if defined?(@oidc_decoded_token) + redirect_uri = URI(oidc_redirect_url) + redirect_params = Rack::Utils.parse_query(redirect_uri.query).with_indifferent_access + code = redirect_params[:code] + + jwt_payload = { + iss: 'urn:gov:gsa:openidconnect:sp:server', + sub: 'urn:gov:gsa:openidconnect:sp:server', + aud: api_openid_connect_token_url, + jti: SecureRandom.hex, + exp: 5.minutes.from_now.to_i, + } + + client_private_key = OpenSSL::PKey::RSA.new( + File.read(Rails.root.join('keys', 'saml_test_sp.key')), + ) + client_assertion = JWT.encode(jwt_payload, client_private_key, 'RS256') + client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + + Capybara.using_driver(:desktop_rack_test) do + page.driver.post( + api_openid_connect_token_url, + grant_type: 'authorization_code', + code:, + client_assertion_type:, + client_assertion:, + ) + @oidc_decoded_token = JSON.parse(page.body).with_indifferent_access + end + end + + def oidc_decoded_id_token + @oidc_decoded_id_token ||= JWT.decode( + oidc_decoded_token[:id_token], + AppArtifacts.store.oidc_public_key, + true, + algorithm: 'RS256', + ).first.with_indifferent_access + end end diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 59b44b82b7a..042952d7b70 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -316,6 +316,56 @@ def user_with_broken_personal_key(scenario) end end +RSpec.shared_examples 'logs reCAPTCHA event and redirects appropriately' do |successful_sign_in:| + it 'logs reCAPTCHA event and redirects to the correct location' do + visit new_user_session_path + + asserted_expected_user = false + fake_analytics = FakeAnalytics.new + allow_any_instance_of(ApplicationController).to receive(:analytics). + and_wrap_original do |original| + original_analytics = original.call + if original_analytics.request.params[:controller] == 'users/sessions' && + original_analytics.request.params[:action] == 'create' + expect(original_analytics.user).to eq(user) + asserted_expected_user = true + end + + fake_analytics + end + + fill_in :user_recaptcha_mock_score, with: '0.1' + fill_in_credentials_and_submit(user.email, user.password) + expect(asserted_expected_user).to eq(true) + expect(fake_analytics).to have_logged_event( + 'reCAPTCHA verify result received', + recaptcha_result: { + assessment_id: kind_of(String), + success: true, + score: 0.1, + errors: [], + reasons: [], + }, + evaluated_as_valid: false, + score_threshold: 0.2, + form_class: 'RecaptchaMockForm', + ) + expect(fake_analytics).to have_logged_event( + 'Email and Password Authentication', + hash_including( + success: successful_sign_in, + valid_captcha_result: false, + captcha_validation_performed: true, + ), + ) + if successful_sign_in + expect(current_path).to eq login_two_factor_path(otp_delivery_preference: 'sms') + else + expect(current_path).to eq sign_in_security_check_failed_path + end + end +end + def ial1_sign_in_with_personal_key_goes_to_sp(sp) user = create_ial1_account_go_back_to_sp_and_sign_out(sp) old_personal_key = PersonalKeyGenerator.new(user).generate!