diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index cb6d9828ae3..00000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,300 +0,0 @@ -# Ruby CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-ruby/ for more details -# -version: 2.1 - -orbs: - browser-tools: circleci/browser-tools@1.1 - jq: circleci/jq@2.2.0 - slack: circleci/slack@3.4.2 - -executors: - # Common container definition used by all jobs - ruby_browsers: - docker: - # Specify the Ruby version you desire here - - image: cimg/ruby:3.0.4-browsers - environment: - CIRCLE_CI: 'true' - RAILS_ENV: test - BUNDLER_VERSION: 2.2.32 - # The base image sets NODE_VERSION environment variable, which we don't intend to use. Its - # presence will cause NVM to treat it as the default version. Unsetting it allows for NVM - # to use the version from .nvmrc instead. - NODE_VERSION: '' - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - - image: cimg/postgres:13.4 - environment: - POSTGRES_USER: circleci - - - image: redis:5.0.8 - -commands: - install-browser-tools-no-firefox: - steps: - - browser-tools/install-browser-tools: - install-firefox: false - install-geckodriver: false - node-install: - steps: - - run: - name: Switch Node.js version - command: | - wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash - export NVM_DIR="$HOME/.nvm" - . "$NVM_DIR/nvm.sh" --install - echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV; - echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV; - - run: - name: Print Node.js version - command: node -v - yarn-install: - steps: - - restore_cache: - keys: - - v2-identity-idp-yarn-{{ checksum "yarn.lock" }} - - v2-identity-idp-yarn- - - run: - name: Install Yarn - command: yarn install --frozen-lockfile --ignore-engines --cache-folder ~/.cache/yarn - - save_cache: - key: v2-identity-idp-yarn-{{ checksum "yarn.lock" }} - paths: - - ~/.cache/yarn - yarn-production-install: - steps: - - restore_cache: - keys: - - v2-identity-idp-yarn-production-{{ checksum "yarn.lock" }} - - v2-identity-idp-yarn-production - - run: - name: Install Yarn - command: yarn install --production --frozen-lockfile --ignore-engines --cache-folder ~/.cache/yarn - - save_cache: - key: v2-identity-idp-yarn-production-{{ checksum "yarn.lock" }} - paths: - - ~/.cache/yarn - - bundle-install: - steps: - - run: gem install bundler --version $BUNDLER_VERSION - - restore_cache: - keys: - - v3-identity-idp-bundle-{{ checksum "Gemfile.lock" }} - - run: - name: Install dependencies - command: | - bundle check || bundle install --deployment --jobs=4 --retry=3 --without deploy development doc production --path vendor/bundle - - save_cache: - key: v3-identity-idp-bundle-{{ checksum "Gemfile.lock" }} - paths: - - vendor/bundle -jobs: - setup: - working_directory: ~/identity-idp - executor: ruby_browsers - steps: - - checkout - - node-install - - yarn-install - - bundle-install - - run: - name: Test Setup - command: | - bundle exec rake assets:precompile - - persist_to_workspace: - root: . - paths: - - tmp/cache/assets - - public/assets - - public/packs - - ruby_test: - executor: ruby_browsers - - environment: - CC_TEST_REPORTER_ID: faecd27e9aed532634b3f4d3e251542d7de9457cfca96a94208a63270ef9b42e - COVERAGE: true - - parallelism: 5 - - working_directory: ~/identity-idp - - steps: - - install-browser-tools-no-firefox - - checkout - - node-install - - yarn-install - - bundle-install - - run: - name: Install AWS CLI - command: | - sudo apt-get update - sudo apt-get install python3-pip python-dev jq - sudo pip install awscli --ignore-installed six - - run: - name: Install Code Climate Test Reporter - command: | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - chmod +x ./cc-test-reporter - aws s3 --region us-west-2 rm s3://login-gov-test-coverage/coverage/$CIRCLE_PREVIOUS_BUILD_NUM --recursive - - run: - name: Wait for DB - command: dockerize -wait tcp://localhost:5432 -timeout 1m - - run: - name: Test Setup - command: | - cp config/application.yml.default config/application.yml - cp config/service_providers.localdev.yml config/service_providers.yml - cp config/agencies.localdev.yml config/agencies.yml - cp config/iaa_gtcs{.localdev,}.yml - cp config/iaa_orders{.localdev,}.yml - cp config/iaa_statuses{.localdev,}.yml - cp config/integration_statuses{.localdev,}.yml - cp config/integrations{.localdev,}.yml - cp config/partner_account_statuses{.localdev,}.yml - cp config/partner_accounts{.localdev,}.yml - cp -a keys.example keys - cp -a certs.example certs - cp pwned_passwords/pwned_passwords.txt.sample pwned_passwords/pwned_passwords.txt - bundle exec rake db:create db:migrate --trace - bundle exec rake db:seed - - attach_workspace: - at: . - - run: - name: Run Tests - command: | - mkdir /tmp/test-results - ./cc-test-reporter before-build - - bundle exec rake knapsack:rspec - - run: - name: Code Climate Test Coverage - command: | - aws s3 sync coverage/ "s3://login-gov-test-coverage/coverage-artifacts/$CIRCLE_BUILD_NUM/$CIRCLE_NODE_INDEX" --exclude '*' --include '.resultset.json' --include '.resultset.json.lock' - ./cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json" - aws s3 sync coverage/ "s3://login-gov-test-coverage/coverage/$CIRCLE_BUILD_NUM" - # collect reports - - store_test_results: - path: /tmp/test-results - - store_artifacts: - path: /tmp/test-results - destination: test-results - - deploy: - command: | - aws s3 sync "s3://login-gov-test-coverage/coverage/$CIRCLE_BUILD_NUM" coverage/ - ./cc-test-reporter sum-coverage --output - --parts $CIRCLE_NODE_TOTAL coverage/codeclimate.*.json | ./cc-test-reporter upload-coverage --input - - aws s3 sync "s3://login-gov-test-coverage/coverage-artifacts/$CIRCLE_BUILD_NUM" coverage/ - bundle exec spec/simplecov_merger.rb - mkdir coverage_summary - mv coverage/index.html coverage_summary/ - mv coverage/assets coverage_summary/ - mv coverage/.resultset.json coverage_summary/ - aws s3 --region us-west-2 rm s3://login-gov-test-coverage/coverage-artifacts/$CIRCLE_BUILD_NUM --recursive - - store_artifacts: - path: coverage_summary/ - - javascript_test: - working_directory: ~/identity-idp - executor: ruby_browsers - steps: - - install-browser-tools-no-firefox - - checkout - - node-install - - yarn-install - - run: - name: Run Tests - command: | - yarn test - - javascript_build: - working_directory: ~/identity-idp - executor: ruby_browsers - environment: - NODE_ENV: production - steps: - - checkout - - node-install - - yarn-production-install - - bundle-install - - run: - name: Run Tests - command: | - bundle exec rake assets:precompile - - lints: - working_directory: ~/identity-idp - executor: ruby_browsers - steps: - - checkout - - node-install - - yarn-install - - bundle-install - - run: - name: Run Lints - command: | - make lint - check-pinpoint-config: - executor: ruby_browsers - steps: - - checkout - - node-install - - yarn-install - - bundle-install - - run: - name: Check current AWS Pinpoint country support - command: |- - make lint_country_dialing_codes - - slack/status: - fail_only: true - failure_message: ':aws-emoji: :red_circle: AWS Pinpoint country configuration is out of date' - check_changelog: - docker: - - image: cimg/ruby:3.0 - steps: - - checkout - - run: - name: Check Changelog - command: |- - if [ -z "${CIRCLE_PULL_REQUEST}" ] || echo ${CIRCLE_BRANCH} | grep -q '^stages/' - then - echo "Skipping changelock check because this is not a PR or is a release branch" - exit 0 - else - ./scripts/changelog_check.rb -b main -s "${CIRCLE_BRANCH}" - fi - -workflows: - version: 2 - release: - jobs: - - setup - - check_changelog - - ruby_test: - requires: - - setup - - javascript_build: - requires: - - setup - - javascript_test: - requires: - - setup - - lints: - requires: - - setup - - daily-external-pinpoint-checker: - jobs: - - check-pinpoint-config - triggers: - - schedule: - # Once a day at 12pm - cron: '0 12 * * *' - filters: - branches: - only: - - main diff --git a/.rubocop.yml b/.rubocop.yml index 5e028ec093c..fac1220f417 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -227,6 +227,10 @@ Layout/MultilineHashBraceLayout: Layout/MultilineMethodCallBraceLayout: Enabled: true +Layout/MultilineMethodCallIndentation: + Enabled: true + EnforcedStyle: indented + Layout/MultilineMethodDefinitionBraceLayout: Enabled: true EnforcedStyle: symmetrical diff --git a/README.md b/README.md index c7c28948dd2..2632c32c585 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ Login.gov Identity Provider (IdP) ================================= -[![Build Status](https://circleci.com/gh/18F/identity-idp.svg?style=svg)](https://circleci.com/gh/18F/identity-idp) -[![Code Climate](https://api.codeclimate.com/v1/badges/e78d453f7cbcac64a664/maintainability)](https://codeclimate.com/github/18F/identity-idp/maintainability) -[![Test Coverage](https://api.codeclimate.com/v1/badges/e78d453f7cbcac64a664/test_coverage)](https://codeclimate.com/github/18F/identity-idp/test_coverage) - Login.gov is the public's one account for government. Use one account and password for secure, private access to participating government agencies. This repository contains the core code base and documentation for the identity management system powering secure.login.gov. diff --git a/app/components/button_component.rb b/app/components/button_component.rb index c23956f9b7e..20c3bd85281 100644 --- a/app/components/button_component.rb +++ b/app/components/button_component.rb @@ -1,19 +1,25 @@ class ButtonComponent < BaseComponent - attr_reader :action, :icon, :big, :wide, :outline, :tag_options + attr_reader :action, :icon, :big, :wide, :full_width, :outline, :unstyled, :danger, :tag_options def initialize( action: ->(**tag_options, &block) { button_tag(**tag_options, &block) }, icon: nil, big: false, wide: false, + full_width: false, outline: false, + unstyled: false, + danger: false, **tag_options ) @action = action @icon = icon @big = big @wide = wide + @full_width = full_width @outline = outline + @unstyled = unstyled + @danger = danger @tag_options = tag_options end @@ -21,7 +27,10 @@ def css_class classes = ['usa-button', *tag_options[:class]] classes << 'usa-button--big' if big classes << 'usa-button--wide' if wide + classes << 'usa-button--full-width' if full_width classes << 'usa-button--outline' if outline + classes << 'usa-button--unstyled' if unstyled + classes << 'usa-button--danger' if danger classes end diff --git a/app/components/submit_button_component.rb b/app/components/submit_button_component.rb new file mode 100644 index 00000000000..b03b1077b27 --- /dev/null +++ b/app/components/submit_button_component.rb @@ -0,0 +1,9 @@ +class SubmitButtonComponent < ButtonComponent + def initialize(big: true, wide: true, **tag_options) + super(big: big, wide: wide, **tag_options) + end + + def call + content_tag(:'lg-submit-button', super) + end +end diff --git a/app/components/submit_button_component.ts b/app/components/submit_button_component.ts new file mode 100644 index 00000000000..621b39ee535 --- /dev/null +++ b/app/components/submit_button_component.ts @@ -0,0 +1 @@ +import '@18f/identity-submit-button/submit-button-element'; diff --git a/app/components/validated_field_component.html.erb b/app/components/validated_field_component.html.erb index a52c89c3478..b685769e86a 100644 --- a/app/components/validated_field_component.html.erb +++ b/app/components/validated_field_component.html.erb @@ -21,9 +21,13 @@ describedby: [ *tag_options.dig(:input_html, :aria, :describedby), "validated-field-error-#{unique_id}", + "validated-field-hint-#{unique_id}", ], }, }, + hint_html: { + id: "validated-field-hint-#{unique_id}", + }, error_html: { id: "validated-field-error-#{unique_id}" }, ), ) %> diff --git a/app/controllers/account_reset/delete_account_controller.rb b/app/controllers/account_reset/delete_account_controller.rb index 8c305622d3d..6d1fbdcfe9f 100644 --- a/app/controllers/account_reset/delete_account_controller.rb +++ b/app/controllers/account_reset/delete_account_controller.rb @@ -18,6 +18,10 @@ def delete result = AccountReset::DeleteAccount.new(granted_token).call analytics.account_reset_delete(**result.to_h.except(:email)) + irs_attempts_api_tracker.account_reset_account_deleted( + success: result.success?, + failure_reason: result.errors, + ) if result.success? handle_successful_deletion(result) else diff --git a/app/controllers/account_reset/pending_controller.rb b/app/controllers/account_reset/pending_controller.rb index 54396478b2b..76d1c856900 100644 --- a/app/controllers/account_reset/pending_controller.rb +++ b/app/controllers/account_reset/pending_controller.rb @@ -14,6 +14,9 @@ def confirm; end def cancel analytics.pending_account_reset_cancelled + irs_attempts_api_tracker.account_reset_cancel_request( + success: true, + ) AccountReset::CancelRequestForUser.new(current_user).call end diff --git a/app/controllers/account_reset/request_controller.rb b/app/controllers/account_reset/request_controller.rb index 0ecc00c604e..03c07fb65cb 100644 --- a/app/controllers/account_reset/request_controller.rb +++ b/app/controllers/account_reset/request_controller.rb @@ -11,6 +11,7 @@ def show def create create_account_reset_request flash[:email] = current_user.email_addresses.take.email + redirect_to account_reset_confirm_request_url end @@ -18,6 +19,9 @@ def create def create_account_reset_request response = AccountReset::CreateRequest.new(current_user).call + irs_attempts_api_tracker.account_reset_request_submitted( + success: response.success?, + ) analytics.account_reset_request(**response.to_h, **analytics_attributes) end diff --git a/app/controllers/accounts/personal_keys_controller.rb b/app/controllers/accounts/personal_keys_controller.rb index 8af6905e87d..6c2f3285ce5 100644 --- a/app/controllers/accounts/personal_keys_controller.rb +++ b/app/controllers/accounts/personal_keys_controller.rb @@ -40,7 +40,7 @@ def send_new_personal_key_notifications end telephony_responses = MfaContext.new(current_user). - phone_configurations.map do |phone_configuration| + phone_configurations.map do |phone_configuration| phone = phone_configuration.phone Telephony.send_personal_key_regeneration_notice( to: phone, diff --git a/app/controllers/api/irs_attempts_api_controller.rb b/app/controllers/api/irs_attempts_api_controller.rb index c7eb932f8fd..3042abbae52 100644 --- a/app/controllers/api/irs_attempts_api_controller.rb +++ b/app/controllers/api/irs_attempts_api_controller.rb @@ -19,7 +19,12 @@ class IrsAttemptsApiController < ApplicationController def create if timestamp - render json: { sets: security_event_tokens } + result = encrypted_security_event_log_result + + headers['X-Payload-Key'] = Base64.strict_encode64(result.encrypted_key) + headers['X-Payload-IV'] = Base64.strict_encode64(result.iv) + send_data Base64.strict_encode64(result.encrypted_data), + disposition: "filename=#{result.filename}" else render json: { status: :unprocessable_entity, description: 'Invalid timestamp parameter' }, status: :unprocessable_entity @@ -30,7 +35,7 @@ def create private def authenticate_client - bearer, csp_id, token = request.authorization.split(' ', 3) + bearer, csp_id, token = request.authorization&.split(' ', 3) if bearer != 'Bearer' || !valid_auth_tokens.include?(token) || csp_id != IdentityConfig.store.irs_attempt_api_csp_id render json: { status: 401, description: 'Unauthorized' }, status: :unauthorized @@ -42,6 +47,16 @@ def security_event_tokens @security_event_tokens ||= redis_client.read_events(timestamp: timestamp) end + def encrypted_security_event_log_result + json = security_event_tokens.to_json + decoded_key_der = Base64.strict_decode64(IdentityConfig.store.irs_attempt_api_public_key) + pub_key = OpenSSL::PKey::RSA.new(decoded_key_der) + + IrsAttemptsApi::EnvelopeEncryptor.encrypt( + data: json, timestamp: timestamp, public_key: pub_key, + ) + end + def redis_client @redis_client ||= IrsAttemptsApi::RedisClient.new end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7a1333fb302..915ed4c9fa0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -86,7 +86,7 @@ def irs_attempts_api_tracker request: request, user: effective_user, sp: current_sp, - device_fingerprint: cookies[:device], + cookie_device_uuid: cookies[:device], sp_request_uri: decorated_session.request_url_params[:redirect_uri], enabled_for_session: irs_attempt_api_enabled_for_session?, ) @@ -317,15 +317,23 @@ def reauthn? def confirm_two_factor_authenticated(id = nil) return prompt_to_sign_in_with_request_id(id) if user_needs_new_session_with_request_id?(id) + authenticate_user!(force: true) - return prompt_to_setup_mfa unless two_factor_enabled? - return prompt_to_verify_mfa unless user_fully_authenticated? - return prompt_to_setup_mfa if service_provider_mfa_policy. - user_needs_sp_auth_method_setup? - return prompt_to_setup_non_restricted_mfa if two_factor_kantara_enabled? - return prompt_to_verify_sp_required_mfa if service_provider_mfa_policy. - user_needs_sp_auth_method_verification? + + if !two_factor_enabled? + return prompt_to_setup_mfa + elsif !user_fully_authenticated? + return prompt_to_verify_mfa + elsif service_provider_mfa_policy.user_needs_sp_auth_method_setup? + return prompt_to_setup_mfa + elsif two_factor_kantara_enabled? + return prompt_to_setup_non_restricted_mfa + elsif service_provider_mfa_policy.user_needs_sp_auth_method_verification? + return prompt_to_verify_sp_required_mfa + end + enforce_total_session_duration_timeout + true end diff --git a/app/controllers/concerns/two_factor_authenticatable_methods.rb b/app/controllers/concerns/two_factor_authenticatable_methods.rb index 9c70b576be4..e56a650379a 100644 --- a/app/controllers/concerns/two_factor_authenticatable_methods.rb +++ b/app/controllers/concerns/two_factor_authenticatable_methods.rb @@ -17,11 +17,19 @@ def authenticate_user authenticate_user!(force: true) end - def handle_second_factor_locked_user(type) + def handle_second_factor_locked_user(type:, context: nil) analytics.multi_factor_auth_max_attempts event = PushNotification::MfaLimitAccountLockedEvent.new(user: current_user) PushNotification::HttpPush.deliver(event) handle_max_attempts(type + '_login_attempts') + + if context + if UserSessionContext.authentication_context?(context) + irs_attempts_api_tracker.mfa_login_rate_limited(type: type) + elsif UserSessionContext.confirmation_context?(context) + irs_attempts_api_tracker.mfa_enroll_rate_limited(type: type) + end + end end def handle_too_many_otp_sends @@ -108,13 +116,13 @@ def two_factor_authentication_method # Method will be renamed in the next refactor. # You can pass in any "type" with a corresponding I18n key in # two_factor_authentication.invalid_#{type} - def handle_invalid_otp(type: 'otp') + def handle_invalid_otp(type:, context: nil) update_invalid_user flash.now[:error] = invalid_otp_error(type) if decorated_user.locked_out? - handle_second_factor_locked_user(type) + handle_second_factor_locked_user(context: context, type: type) else render_show_after_invalid end @@ -124,6 +132,8 @@ def invalid_otp_error(type) case type when 'otp' t('two_factor_authentication.invalid_otp') + when 'totp' + t('two_factor_authentication.invalid_otp') when 'personal_key' t('two_factor_authentication.invalid_personal_key') when 'piv_cac' diff --git a/app/controllers/concerns/unconfirmed_user_concern.rb b/app/controllers/concerns/unconfirmed_user_concern.rb index 6a969eaa8aa..96cf058fe4c 100644 --- a/app/controllers/concerns/unconfirmed_user_concern.rb +++ b/app/controllers/concerns/unconfirmed_user_concern.rb @@ -45,8 +45,9 @@ def stop_if_invalid_token end def email_confirmation_token_validator - @email_confirmation_token_validator ||= EmailConfirmationTokenValidator. - new(@email_address, current_user) + @email_confirmation_token_validator ||= begin + EmailConfirmationTokenValidator.new(@email_address, current_user) + end end def process_valid_confirmation_token diff --git a/app/controllers/concerns/verify_profile_concern.rb b/app/controllers/concerns/verify_profile_concern.rb index 6345dd54f04..e003dbe26e5 100644 --- a/app/controllers/concerns/verify_profile_concern.rb +++ b/app/controllers/concerns/verify_profile_concern.rb @@ -3,9 +3,24 @@ module VerifyProfileConcern def account_or_verify_profile_url return reactivate_account_url if user_needs_to_reactivate_account? - return account_url unless profile_needs_verification? - return idv_gpo_url if gpo_mail_bounced? - idv_gpo_verify_url + return idv_gpo_verify_url if profile_needs_verification? + return backup_code_reminder_url if user_needs_backup_code_reminder? + account_url + end + + def user_needs_backup_code_reminder? + return false unless IdentityConfig.store.backup_code_reminder_redirect + user_backup_codes_configured? && user_last_signed_in_more_than_5_months_ago? + end + + def user_backup_codes_configured? + MfaContext.new(current_user).backup_code_configurations.present? + end + + def user_last_signed_in_more_than_5_months_ago? + user = UserDecorator.new(current_user) + second_last_signed_in_at = user.second_last_signed_in_at + second_last_signed_in_at && second_last_signed_in_at < 5.months.ago end def profile_needs_verification? @@ -15,8 +30,4 @@ def profile_needs_verification? current_user.decorate.pending_profile_requires_verification? || user_needs_to_reactivate_account? end - - def gpo_mail_bounced? - current_user.decorate.gpo_mail_bounced? - end end diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb index e0ec439a53d..7d225b4ab30 100644 --- a/app/controllers/idv/doc_auth_controller.rb +++ b/app/controllers/idv/doc_auth_controller.rb @@ -1,7 +1,6 @@ module Idv class DocAuthController < ApplicationController before_action :confirm_two_factor_authenticated - before_action :redirect_if_mail_bounced before_action :redirect_if_pending_profile before_action :redirect_if_pending_in_person_enrollment before_action :extend_timeout_using_meta_refresh_for_select_paths @@ -31,10 +30,6 @@ def return_to_sp redirect_to return_to_sp_failure_to_proof_url(step: next_step, location: params[:location]) end - def redirect_if_mail_bounced - redirect_to idv_gpo_url if current_user.decorate.gpo_mail_bounced? - end - def redirect_if_pending_profile return if sp_session[:ial2_strict] && !IdentityConfig.store.gpo_allowed_for_strict_ial2 diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index 589957d68a1..39679384683 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -7,25 +7,12 @@ class GpoController < ApplicationController before_action :confirm_user_completed_idv_profile_step before_action :confirm_mail_not_spammed before_action :confirm_gpo_allowed_if_strict_ial2 - before_action :max_attempts_reached, only: [:update] def index @presenter = GpoPresenter.new(current_user, url_options) - current_async_state = async_state - - if current_async_state.none? - analytics.idv_gpo_address_visited( - letter_already_sent: @presenter.letter_already_sent?, - ) - render :index - elsif current_async_state.in_progress? - render :wait - elsif current_async_state.missing? - analytics.proofing_address_result_missing - render :index - elsif current_async_state.done? - async_state_done(current_async_state) - end + analytics.idv_gpo_address_visited( + letter_already_sent: @presenter.letter_already_sent?, + ) end def create @@ -42,12 +29,6 @@ def create end end - def update - result = idv_form.submit(profile_params) - enqueue_job if result.success? - redirect_to idv_gpo_path - end - def gpo_mail_service @gpo_mail_service ||= Idv::GpoMail.new(current_user) end @@ -65,52 +46,12 @@ def resend_requested? current_user.decorate.pending_profile_requires_verification? end - def failure - redirect_to idv_gpo_url unless performed? - end - def confirm_gpo_allowed_if_strict_ial2 return unless sp_session[:ial2_strict] return if IdentityConfig.store.gpo_allowed_for_strict_ial2 redirect_to idv_phone_url end - def pii(address_pii) - address_pii.dup.merge(non_address_pii) - end - - def non_address_pii - pii_to_h. - slice('first_name', 'middle_name', 'last_name', 'dob', 'phone', 'ssn'). - merge( - uuid: current_user.uuid, - uuid_prefix: ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id, - ) - end - - def pii_to_h - JSON.parse( - Pii::Cacher.new(current_user, user_session).fetch_string, - ) - end - - def resolution_success(hash) - idv_session_settings(hash).each { |key, value| user_session['idv'][key] = value } - resend_letter - redirect_to idv_review_url - end - - def idv_session_settings(hash) - { vendor_phone_confirmation: false, - user_phone_confirmation: false, - resolution_successful: 'phone', - address_verification_mechanism: 'gpo', - profile_confirmation: true, - params: hash, - applicant: hash, - uuid: current_user.uuid } - end - def confirm_mail_not_spammed redirect_to idv_review_url if idv_session.address_mechanism_chosen? && gpo_mail_service.mail_spammed? @@ -143,137 +84,12 @@ def confirmation_maker_perform confirmation_maker end - def idv_form - Idv::AddressForm.new( - user: current_user, - previous_params: idv_session.previous_profile_step_params, - ) - end - - def profile_params - params.require(:idv_form).permit(Idv::AddressForm::ATTRIBUTES) - end - - def form_response(result, success) - FormResponse.new( - success: success, - errors: result[:errors], - extra: { - pii_like_keypaths: [[:errors, :zipcode]], - }, - ) - end - - def idv_throttle_params - { - user: idv_session.current_user, - throttle_type: :proof_address, - } - end - - def idv_attempter_increment - Throttle.new(**idv_throttle_params).increment! - end - - def idv_attempter_throttled? - Throttle.new(**idv_throttle_params).throttled? - end - - def throttle_failure - idv_attempter_increment - flash_error - end - - def flash_error - flash[:error] = error_message - redirect_to idv_gpo_url - end - - def max_attempts_reached - if idv_attempter_throttled? - analytics.throttler_rate_limit_triggered( - throttle_type: :proof_address, - step_name: :gpo, - ) - flash_error - end - end - - def error_message - I18n.t('idv.failure.sessions.' + (idv_attempter_throttled? ? 'fail' : 'heading')) - end - def send_reminder current_user.confirmed_email_addresses.each do |email_address| UserMailer.letter_reminder(current_user, email_address.email).deliver_now_or_later end end - def enqueue_job - return if idv_session.idv_gpo_document_capture_session_uuid - idv_session.previous_gpo_step_params = profile_params.to_h - - document_capture_session = DocumentCaptureSession.create( - user_id: current_user.id, - issuer: sp_session[:issuer], - ial2_strict: sp_session[:ial2_strict], - requested_at: Time.zone.now, - ) - - document_capture_session.create_proofing_session - idv_session.idv_gpo_document_capture_session_uuid = document_capture_session.uuid - applicant = pii(profile_params.to_h) - Idv::Agent.new(applicant).proof_resolution( - document_capture_session, - should_proof_state_id: false, - trace_id: amzn_trace_id, - user_id: current_user.id, - threatmetrix_session_id: nil, - ) - end - - def async_state - dcs_uuid = idv_session.idv_gpo_document_capture_session_uuid - dcs = DocumentCaptureSession.find_by(uuid: dcs_uuid) - return ProofingSessionAsyncResult.none if dcs_uuid.nil? - return missing if dcs.nil? - - proofing_job_result = dcs.load_proofing_result - return missing if proofing_job_result.nil? - - proofing_job_result - end - - def async_state_done(async_state) - idv_result = async_state.result - success = idv_result[:success] - - throttle_failure unless success - result = form_response(idv_result, success) - - delete_async - - async_state_done_analytics(result) - applicant = pii(idv_session.previous_gpo_step_params) - result.success? ? resolution_success(applicant) : failure - end - - def async_state_done_analytics(result) - analytics.idv_gpo_address_submitted(**result.to_h) - Db::SpCost::AddSpCost.call(current_sp, 2, :lexis_nexis_resolution) - Db::ProofingCost::AddUserProofingCost.call(current_user.id, :lexis_nexis_resolution) - end - - def delete_async - idv_session.idv_gpo_document_capture_session_uuid = nil - end - - def missing - flash[:info] = I18n.t('idv.failure.timeout') - delete_async - ProofingSessionAsyncResult.missing - end - def pii_locked? !Pii::Cacher.new(current_user, user_session).exists_in_session? end diff --git a/app/controllers/idv/personal_key_controller.rb b/app/controllers/idv/personal_key_controller.rb index 8251bd612b2..d8057c5f2c8 100644 --- a/app/controllers/idv/personal_key_controller.rb +++ b/app/controllers/idv/personal_key_controller.rb @@ -14,6 +14,8 @@ def show add_proofing_component finish_idv_session + + @confirm = FeatureManagement.idv_personal_key_confirmation_enabled? ? 'modal' : 'skip' end def update diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index 90202919ff2..1c11eecca1a 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -45,9 +45,12 @@ def check_sp_handoff_bounced def confirm_user_is_authenticated_with_fresh_mfa bump_auth_count unless user_fully_authenticated? - return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? && - service_provider_mfa_policy. - auth_method_confirms_to_sp_request? + + unless user_fully_authenticated? && service_provider_mfa_policy. + auth_method_confirms_to_sp_request? + return confirm_two_factor_authenticated(request_id) + end + redirect_to user_two_factor_authentication_url if device_not_remembered? end @@ -82,7 +85,7 @@ def profile_or_identity_needs_verification? def track_authorize_analytics(result) analytics_attributes = result.to_h.except(:redirect_uri). - merge(user_fully_authenticated: user_fully_authenticated?) + merge(user_fully_authenticated: user_fully_authenticated?) analytics.openid_connect_request_authorization(**analytics_attributes) end diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 62f8b828fea..75fdf01102d 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -85,7 +85,7 @@ def confirm_user_is_authenticated_with_fresh_mfa bump_auth_count unless user_fully_authenticated? return confirm_two_factor_authenticated(request_id) unless user_fully_authenticated? && service_provider_mfa_policy. - auth_method_confirms_to_sp_request? + auth_method_confirms_to_sp_request? redirect_to user_two_factor_authentication_url if remember_device_expired_for_sp? end diff --git a/app/controllers/sign_up/passwords_controller.rb b/app/controllers/sign_up/passwords_controller.rb index 76a79838c10..6f8947118b4 100644 --- a/app/controllers/sign_up/passwords_controller.rb +++ b/app/controllers/sign_up/passwords_controller.rb @@ -14,6 +14,10 @@ def new def create result = password_form.submit(permitted_params) analytics.password_creation(**result.to_h) + irs_attempts_api_tracker.user_registration_password_submitted( + success: result.success?, + failure_reason: result.errors, + ) store_sp_metadata_in_session unless sp_request_id.empty? if result.success? 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 94bcbd38a26..9ec76045cea 100644 --- a/app/controllers/two_factor_authentication/backup_code_verification_controller.rb +++ b/app/controllers/two_factor_authentication/backup_code_verification_controller.rb @@ -20,7 +20,7 @@ def create @backup_code_form = BackupCodeVerificationForm.new(current_user) result = @backup_code_form.submit(backup_code_params) analytics.track_mfa_submit_event(result.to_h) - irs_attempts_api_tracker.mfa_verify_backup_code(success: result.success?) + irs_attempts_api_tracker.mfa_login_backup_code(success: result.success?) handle_result(result) end @@ -53,7 +53,7 @@ def handle_invalid_backup_code flash.now[:error] = t('two_factor_authentication.invalid_backup_code') if decorated_user.locked_out? - handle_second_factor_locked_user('backup_code') + handle_second_factor_locked_user(context: context, type: 'backup_code') else render_show_after_invalid end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 23949c74778..c505acfbffc 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -22,7 +22,7 @@ def create if result.success? handle_valid_otp else - handle_invalid_otp + handle_invalid_otp(context: context, type: 'otp') end end @@ -85,14 +85,14 @@ def post_analytics(result) analytics.track_mfa_submit_event(properties) - if UserSessionContext.authentication_context?(context) - irs_attempts_api_tracker.mfa_verify_phone_otp_submitted( - reauthentication: false, + if UserSessionContext.reauthentication_context?(context) + irs_attempts_api_tracker.mfa_login_phone_otp_submitted( + reauthentication: true, success: properties[:success], ) - elsif UserSessionContext.reauthentication_context?(context) - irs_attempts_api_tracker.mfa_verify_phone_otp_submitted( - reauthentication: true, + elsif UserSessionContext.authentication_context?(context) + irs_attempts_api_tracker.mfa_login_phone_otp_submitted( + reauthentication: false, success: properties[:success], ) elsif UserSessionContext.confirmation_context?(context) diff --git a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb index 6ec0fe51e4f..3f8dc40d29d 100644 --- a/app/controllers/two_factor_authentication/personal_key_verification_controller.rb +++ b/app/controllers/two_factor_authentication/personal_key_verification_controller.rb @@ -40,7 +40,7 @@ def handle_result(result) generate_new_personal_key_for_verified_users_otherwise_retire_the_key_and_ensure_two_mfa handle_valid_otp else - handle_invalid_otp(type: 'personal_key') + handle_invalid_otp(context: context, type: 'personal_key') end end diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index 5177c656e35..e36aae6a8b7 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -57,7 +57,7 @@ def handle_valid_piv_cac def handle_invalid_piv_cac clear_piv_cac_information - handle_invalid_otp(type: 'piv_cac') + handle_invalid_otp(context: context, type: 'piv_cac') end # This overrides the method in TwoFactorAuthenticatable so that we diff --git a/app/controllers/two_factor_authentication/totp_verification_controller.rb b/app/controllers/two_factor_authentication/totp_verification_controller.rb index a0c65f755ab..7a50fd5e33c 100644 --- a/app/controllers/two_factor_authentication/totp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/totp_verification_controller.rb @@ -19,12 +19,12 @@ def create result = TotpVerificationForm.new(current_user, params.require(:code).strip).submit analytics.track_mfa_submit_event(result.to_h) - irs_attempts_api_tracker.mfa_verify_totp(success: result.success?) + irs_attempts_api_tracker.mfa_login_totp(success: result.success?) if result.success? handle_valid_otp else - handle_invalid_otp + handle_invalid_otp(context: context, type: 'totp') 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 35997a12907..7b0116743b3 100644 --- a/app/controllers/two_factor_authentication/webauthn_verification_controller.rb +++ b/app/controllers/two_factor_authentication/webauthn_verification_controller.rb @@ -19,9 +19,9 @@ def confirm ) if analytics_properties[:multi_factor_auth_method] == 'webauthn_platform' - irs_attempts_api_tracker.mfa_verify_webauthn_platform(success: result.success?) + irs_attempts_api_tracker.mfa_login_webauthn_platform(success: result.success?) elsif analytics_properties[:multi_factor_auth_method] == 'webauthn' - irs_attempts_api_tracker.mfa_verify_webauthn_roaming(success: result.success?) + irs_attempts_api_tracker.mfa_login_webauthn_roaming(success: result.success?) end handle_webauthn_result(result) diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index 695338064f9..1fc037c7761 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -51,6 +51,10 @@ def delete redirect_to account_two_factor_authentication_path end + def reminder + flash.now[:success] = t('notices.authenticated_successfully') + end + private def track_backup_codes_created diff --git a/app/controllers/users/email_confirmations_controller.rb b/app/controllers/users/email_confirmations_controller.rb index 21815bd903b..59e1bd2ee33 100644 --- a/app/controllers/users/email_confirmations_controller.rb +++ b/app/controllers/users/email_confirmations_controller.rb @@ -17,8 +17,12 @@ def email_address end def email_confirmation_token_validator - @email_confirmation_token_validator ||= EmailConfirmationTokenValidator. - new(email_address, current_user) + @email_confirmation_token_validator ||= begin + EmailConfirmationTokenValidator.new( + email_address, + current_user, + ) + end end def email_address_already_confirmed? diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb index 943ef7e6cdf..9e8ec01ebdf 100644 --- a/app/controllers/users/reset_passwords_controller.rb +++ b/app/controllers/users/reset_passwords_controller.rb @@ -22,6 +22,10 @@ def edit result = PasswordResetTokenValidator.new(token_user).submit analytics.password_reset_token(**result.to_h) + irs_attempts_api_tracker.forgot_password_email_confirmed( + success: result.success?, + failure_reason: result.errors, + ) if result.success? @reset_password_form = ResetPasswordForm.new(build_user) @@ -39,6 +43,10 @@ def update result = @reset_password_form.submit(user_params) analytics.password_reset_password(**result.to_h) + irs_attempts_api_tracker.forgot_password_new_password_submitted( + success: result.success?, + failure_reason: result.errors, + ) if result.success? handle_successful_password_reset diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 352be381081..93983579a8d 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -211,15 +211,15 @@ def track_events(otp_delivery_preference:) success: @telephony_result.success?, ) - if UserSessionContext.authentication_context?(context) - irs_attempts_api_tracker.mfa_verify_phone_otp_sent( - reauthentication: false, + if UserSessionContext.reauthentication_context?(context) + irs_attempts_api_tracker.mfa_login_phone_otp_sent( + reauthentication: true, phone_number: parsed_phone.e164, success: @telephony_result.success?, ) - elsif UserSessionContext.reauthentication_context?(context) - irs_attempts_api_tracker.mfa_verify_phone_otp_sent( - reauthentication: true, + elsif UserSessionContext.authentication_context?(context) + irs_attempts_api_tracker.mfa_login_phone_otp_sent( + reauthentication: false, phone_number: parsed_phone.e164, success: @telephony_result.success?, ) diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 15f9273c1de..5a01f50e029 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -16,6 +16,10 @@ def index def create result = submit_form analytics.user_registration_2fa_setup(**result.to_h) + irs_attempts_api_tracker.mfa_enroll_options_selected( + success: result.success?, + mfa_device_types: @two_factor_options_form.selection, + ) if result.success? process_valid_form diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 30f9d286e81..749d845889e 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -25,6 +25,15 @@ def new analytics.webauthn_setup_visit(**result.to_h) save_challenge_in_session @exclude_credentials = exclude_credentials + + if !result.success? + if @platform_authenticator + irs_attempts_api_tracker.mfa_enroll_webauthn_platform(success: false) + else + irs_attempts_api_tracker.mfa_enroll_webauthn_roaming(success: false) + end + end + flash_error(result.errors) unless result.success? end @@ -143,12 +152,13 @@ def process_valid_webauthn(form) platform_authenticator: form.platform_authenticator?, enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, ) - Funnel::Registration::AddMfa.call(current_user.id, 'webauthn', analytics) mark_user_as_fully_authenticated handle_remember_device if form.platform_authenticator? + Funnel::Registration::AddMfa.call(current_user.id, 'webauthn_platform', analytics) flash[:success] = t('notices.webauthn_platform_configured') else + Funnel::Registration::AddMfa.call(current_user.id, 'webauthn', analytics) flash[:success] = t('notices.webauthn_configured') end user_session[:auth_method] = 'webauthn' diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index 7a5da36cd26..290389fcd34 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -26,7 +26,7 @@ def app_data cancel_url: idv_cancel_path, initial_values: initial_values, reset_password_url: forgot_password_url, - enabled_step_names: IdentityConfig.store.idv_api_enabled_steps, + enabled_step_names: enabled_steps, store_key: user_session[:idv_api_store_key], } end @@ -43,7 +43,11 @@ def first_step end def enabled_steps - IdentityConfig.store.idv_api_enabled_steps + steps = IdentityConfig.store.idv_api_enabled_steps + + return steps if FeatureManagement.idv_personal_key_confirmation_enabled? + + steps - ['personal_key_confirm'] end def step_enabled?(step) diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb index d3c4bf550bf..76689e43585 100644 --- a/app/decorators/user_decorator.rb +++ b/app/decorators/user_decorator.rb @@ -61,11 +61,6 @@ def identity_verified? user.active_profile.present? end - def gpo_mail_bounced? - return unless pending_profile - pending_profile&.gpo_confirmation_codes&.order(created_at: :desc)&.first&.bounced_at - end - def active_profile_newer_than_pending_profile? user.active_profile.activated_at >= pending_profile.created_at end @@ -99,7 +94,7 @@ def no_longer_locked_out? def recent_events events = Event.where(user_id: user.id).order('created_at DESC').limit(MAX_RECENT_EVENTS). - map(&:decorate) + map(&:decorate) (events + identity_events).sort_by(&:happened_at).reverse end @@ -116,6 +111,11 @@ def devices? !recent_devices.empty? end + def second_last_signed_in_at + user.events.where(event_type: 'sign_in_after_2fa'). + order(created_at: :desc).pluck(:created_at).second + end + def connected_apps user.identities.not_deleted.includes(:service_provider_record).order('created_at DESC') end diff --git a/app/forms/openid_connect_token_form.rb b/app/forms/openid_connect_token_form.rb index 4a0f8a99270..6bc151357b0 100644 --- a/app/forms/openid_connect_token_form.rb +++ b/app/forms/openid_connect_token_form.rb @@ -69,7 +69,7 @@ def find_identity_with_code return if code.blank? || code.include?("\x00") @identity = ServiceProviderIdentity.where(session_uuid: code). - order(updated_at: :desc).first + order(updated_at: :desc).first end def pkce? diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 666a7629468..39d2f12754e 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -64,13 +64,4 @@ def us_states_territories ['Wyoming', 'WY'], ] end - - private - - def validated_form_for(record, options = {}, &block) - options[:data] ||= {} - options[:data][:validate] = true - javascript_packs_tag_once('form-validation') - simple_form_for(record, options, &block) - end end diff --git a/app/helpers/script_helper.rb b/app/helpers/script_helper.rb index 4ab9708d705..1d2a7e8ac2d 100644 --- a/app/helpers/script_helper.rb +++ b/app/helpers/script_helper.rb @@ -23,7 +23,13 @@ def render_javascript_pack_once_tags(*names) [ javascript_assets_tag(*@scripts), javascript_polyfill_pack_tag, - javascript_include_tag(*sources, crossorigin: local_crossorigin_sources? ? true : nil), + *sources.map do |source| + javascript_include_tag( + source, + crossorigin: local_crossorigin_sources? ? true : nil, + integrity: AssetSources.get_integrity(source), + ) + end, ], ) end diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx similarity index 74% rename from app/javascript/packages/document-capture/components/acuant-capture.jsx rename to app/javascript/packages/document-capture/components/acuant-capture.tsx index 9fca4c40e51..36598517bb5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -10,6 +10,9 @@ import { import { useI18n } from '@18f/identity-react-i18n'; import { useIfStillMounted, useDidUpdateEffect } from '@18f/identity-react-hooks'; import { Button, FullScreen } from '@18f/identity-components'; +import type { FullScreenRefHandle } from '@18f/identity-components'; +import type { FocusTrap } from 'focus-trap'; +import type { ReactNode, MouseEvent, Ref } from 'react'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; import FailedCaptureAttemptsContext from '../context/failed-capture-attempts'; @@ -20,79 +23,105 @@ import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import useCounter from '../hooks/use-counter'; import useCookie from '../hooks/use-cookie'; +import type { + AcuantSuccessResponse, + AcuantDocumentType, + AcuantCaptureFailureError, +} from './acuant-camera'; -/** @typedef {import('react').ReactNode} ReactNode */ -/** @typedef {import('./acuant-camera').AcuantSuccessResponse} AcuantSuccessResponse */ -/** @typedef {import('./acuant-camera').AcuantDocumentType} AcuantDocumentType */ -/** @typedef {import('@18f/identity-components').FullScreenRefHandle} FullScreenRefHandle */ -/** @typedef {import('../context/acuant').AcuantGlobal} AcuantGlobal */ +type AcuantDocumentTypeLabel = 'id' | 'passport' | 'none'; +type AcuantImageAssessment = 'success' | 'glare' | 'blurry'; +type ImageSource = 'acuant' | 'upload'; -/** - * @typedef {"id"|"passport"|"none"} AcuantDocumentTypeLabel - */ - -/** - * @typedef {"success"|"glare"|"blurry"} AcuantImageAssessment - */ - -/** - * @typedef {"acuant"|"upload"} ImageSource - */ - -/** - * @typedef ImageAnalyticsPayload - * - * @prop {number?} width Image width, or null if unknown. - * @prop {number?} height Image height, or null if unknown. - * @prop {string?} mimeType Mime type, or null if unknown. - * @prop {ImageSource} source Method by which image was added. - * @prop {number=} attempt Total number of attempts at this point. - * @prop {number} size Size of image, in bytes. - */ - -/** - * @typedef _AcuantImageAnalyticsPayload - * - * @prop {AcuantDocumentTypeLabel} documentType - * @prop {number} dpi - * @prop {number} moire - * @prop {number} glare - * @prop {number} glareScoreThreshold - * @prop {boolean} isAssessedAsGlare - * @prop {number} sharpness - * @prop {number} sharpnessScoreThreshold - * @prop {boolean} isAssessedAsBlurry - * @prop {AcuantImageAssessment} assessment - */ +interface ImageAnalyticsPayload { + /** + * Image width, or null if unknown + */ + width: number | null; + /** + * Image height, or null if unknown + */ + height: number | null; + /** + * Mime type, or null if unknown + */ + mimeType: string | null; + /** + * Method by which the image was added + */ + source: ImageSource; + /** + * Total number of attempts at this point + */ + attempt?: number; + /** + * Size of the image in bytes + */ + size: number; +} -/** - * @typedef {ImageAnalyticsPayload & _AcuantImageAnalyticsPayload} AcuantImageAnalyticsPayload - */ +interface AcuantImageAnalyticsPayload extends ImageAnalyticsPayload { + documentType: AcuantDocumentTypeLabel; + dpi: number; + moire: number; + glare: number; + glareScoreThreshold: number; + isAssessedAsGlare: boolean; + sharpness: number; + sharpnessScoreThreshold: number; + isAssessedAsBlurry: boolean; + assessment: AcuantImageAssessment; +} -/** - * @typedef AcuantCaptureProps - * - * @prop {string} label Label associated with file input. - * @prop {string=} bannerText Optional banner text to show in file input. - * @prop {string|Blob|null|undefined} value Current value. - * @prop {( - * nextValue: string|Blob|null, - * metadata?: ImageAnalyticsPayload - * )=>void} onChange Callback receiving next value on change. - * @prop {()=>void=} onCameraAccessDeclined Camera permission declined callback. - * @prop {'user'|'environment'=} capture Facing mode of capture. If capture is not specified and a - * camera is supported, defaults to the Acuant environment camera capture. - * @prop {string=} className Optional additional class names. - * @prop {boolean=} allowUpload Whether to allow file upload. Defaults to `true`. - * @prop {ReactNode=} errorMessage Error to show. - * @prop {string} name Prefix to prepend to user action analytics labels. - */ +interface AcuantCaptureProps { + /** + * Label associated with file input + */ + label: string; + /** + * Optional banner text to show in file input + */ + bannerText: string; + /** + * Current value + */ + value: string | Blob | null | undefined; + /** + * Callback receiving next value on change + */ + onChange: (nextValue: string | Blob | null, metadata?: ImageAnalyticsPayload) => void; + /** + * Camera permission declined callback + */ + onCameraAccessDeclined?: () => void; + /** + * Facing mode of caopture. If capture is not + * specified and a camera is supported, defaults + * to the Acuant environment camera capture. + */ + capture: 'user' | 'environment'; + /** + * Optional additional class names + */ + className?: string; + /** + * Whether to allow file upload. Defaults + * to true. + */ + allowUpload?: boolean; + /** + * Error message to show + */ + errorMessage: ReactNode; + /** + * Prefix to prepend to user action analytics labels. + */ + name: string; +} /** * Non-breaking space (` `) represented as unicode escape sequence, which React will more * happily tolerate than an HTML entity. - * - * @type {string} */ const NBSP_UNICODE = '\u00A0'; @@ -104,21 +133,15 @@ const noop = () => {}; /** * Returns true if the given Acuant capture failure was caused by the user declining access to the * camera, or false otherwise. - * - * @param {import('./acuant-camera').AcuantCaptureFailureError} error - * - * @return {boolean} */ -export const isAcuantCameraAccessFailure = (error) => error instanceof Error; +export const isAcuantCameraAccessFailure = (error: AcuantCaptureFailureError): error is Error => + error instanceof Error; /** * Returns a human-readable document label corresponding to the given document type constant. * - * @param {AcuantDocumentType} documentType - * - * @return {AcuantDocumentTypeLabel} Human-readable document label. */ -function getDocumentTypeLabel(documentType) { +function getDocumentTypeLabel(documentType: AcuantDocumentType): AcuantDocumentTypeLabel { switch (documentType) { case 1: return 'id'; @@ -129,19 +152,15 @@ function getDocumentTypeLabel(documentType) { } } -/** - * @param {import('./acuant-camera').AcuantCaptureFailureError} error - * @param {string=} code - * - * @return {string} - */ -export function getNormalizedAcuantCaptureFailureMessage(error, code) { +export function getNormalizedAcuantCaptureFailureMessage( + error: AcuantCaptureFailureError, + code: string | undefined, +): string { if (isAcuantCameraAccessFailure(error)) { return 'User or system denied camera access'; } - const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = /** @type {AcuantGlobal} */ (window) - .AcuantJavascriptWebSdk; + const { REPEAT_FAIL_CODE, SEQUENCE_BREAK_CODE } = window.AcuantJavascriptWebSdk; switch (code) { case REPEAT_FAIL_CODE: @@ -168,15 +187,10 @@ export function getNormalizedAcuantCaptureFailureMessage(error, code) { } } -/** - * @param {File} file Image file. - * - * @return {Promise<{width: number?, height: number?}>} - */ -function getImageDimensions(file) { - let objectURL; +function getImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> { + let objectURL: string; return file.type.indexOf('image/') === 0 - ? new Promise((resolve) => { + ? new Promise<{ width: number | null; height: number | null }>((resolve) => { objectURL = window.URL.createObjectURL(file); const image = new window.Image(); image.onload = () => resolve({ width: image.width, height: image.height }); @@ -196,9 +210,8 @@ function getImageDimensions(file) { * tick, the focus trap's deactivation will be overridden to prevent any default focus return, in * order to avoid a race condition between the intended focus targets. * - * @param {import('focus-trap').FocusTrap} focusTrap */ -function suspendFocusTrapForAnticipatedFocus(focusTrap) { +function suspendFocusTrapForAnticipatedFocus(focusTrap: FocusTrap) { // Pause trap event listeners to prevent focus from being pulled back into the trap container in // response to programmatic focus transitions. focusTrap.pause(); @@ -225,7 +238,7 @@ function suspendFocusTrapForAnticipatedFocus(focusTrap) { }, 0); } -export function getDecodedBase64ByteSize(data) { +export function getDecodedBase64ByteSize(data: string) { let bytes = 0.75 * data.length; let i = data.length; @@ -239,8 +252,6 @@ export function getDecodedBase64ByteSize(data) { /** * Returns an element serving as an enhanced FileInput, supporting direct capture using Acuant SDK * in supported devices. - * - * @param {AcuantCaptureProps} props Props object. */ function AcuantCapture( { @@ -254,8 +265,8 @@ function AcuantCapture( allowUpload = true, errorMessage, name, - }, - ref, + }: AcuantCaptureProps, + ref: Ref, ) { const { isReady, @@ -268,12 +279,12 @@ function AcuantCapture( } = useContext(AcuantContext); const { isMockClient } = useContext(UploadContext); const { addPageAction } = useContext(AnalyticsContext); - const fullScreenRef = useRef(/** @type {FullScreenRefHandle?} */ (null)); - const inputRef = useRef(/** @type {?HTMLInputElement} */ (null)); + 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(/** @type {?string} */ (null)); + const [ownErrorMessage, setOwnErrorMessage] = useState(null); const [hasStartedCropping, setHasStartedCropping] = useState(false); const ifStillMounted = useIfStillMounted(); useMemo(() => setOwnErrorMessage(null), [value]); @@ -304,25 +315,21 @@ function AcuantCapture( /** * Calls onChange with next value and resets any errors which may be present. - * - * @param {Blob|string|null} nextValue Next value. - * @param {ImageAnalyticsPayload=} metadata Capture metadata. */ - function onChangeAndResetError(nextValue, metadata) { + function onChangeAndResetError( + nextValue: Blob | string | null, + metadata?: ImageAnalyticsPayload, + ) { setOwnErrorMessage(null); onChange(nextValue, metadata); } /** * Returns an analytics payload, decorated with common values. - * - * @template {ImageAnalyticsPayload|AcuantImageAnalyticsPayload} P - * - * @param {P} payload - * - * @return {P} */ - function getAddAttemptAnalyticsPayload(payload) { + function getAddAttemptAnalyticsPayload< + P extends ImageAnalyticsPayload | AcuantImageAnalyticsPayload, + >(payload: P): P { const enhancedPayload = { ...payload, attempt }; incrementAttempt(); return enhancedPayload; @@ -330,12 +337,9 @@ function AcuantCapture( /** * Handler for file input change events. - * - * @param {File?} nextValue Next value, if set. */ - async function onUpload(nextValue) { - /** @type {ImageAnalyticsPayload=} */ - let analyticsPayload; + async function onUpload(nextValue: File | null) { + let analyticsPayload: ImageAnalyticsPayload | undefined; if (nextValue) { const { width, height } = await getImageDimensions(nextValue); @@ -356,17 +360,10 @@ function AcuantCapture( /** * Given a click source, returns a higher-order function that, when called, will log an event * before calling the original function. - * - * @template {(...args: any[]) => any} T - * - * @param {string} source Click source. - * @param {{isDrop: boolean}=} metadata Additional payload metadata to log. - * - * @return {(fn: T) => (...args: Parameters) => ReturnType} */ - function withLoggedClick(source, metadata = { isDrop: false }) { - return (fn) => - (...args) => { + function withLoggedClick(source: string, metadata: { isDrop: boolean } = { isDrop: false }) { + return any>(fn: T) => + (...args: Parameters) => { if (!isSuppressingClickLogging.current) { addPageAction(`IdV: ${name} image clicked`, { source, ...metadata }); } @@ -378,9 +375,8 @@ function AcuantCapture( /** * Calls the given function, during which time any normal click logging will be suppressed. * - * @param {() => any} fn Function to call */ - function withoutClickLogging(fn) { + function withoutClickLogging(fn: () => any) { isSuppressingClickLogging.current = true; fn(); isSuppressingClickLogging.current = false; @@ -415,10 +411,8 @@ function AcuantCapture( * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or * another element which aims to trigger the prompt of the file input. - * - * @param {import('react').MouseEvent} event Click event. */ - function startCaptureOrTriggerUpload(event) { + function startCaptureOrTriggerUpload(event: MouseEvent) { if (event.target === inputRef.current) { if (forceNativeCamera) { addPageAction('IdV: Native camera forced after failed attempts', { @@ -438,7 +432,7 @@ function AcuantCapture( } if (shouldStartSelfieCapture) { - /** @type {AcuantGlobal} */ (window).AcuantPassiveLiveness.startSelfieCapture( + window.AcuantPassiveLiveness.startSelfieCapture( ifStillMounted((nextImageData) => { const dataURI = `data:image/jpeg;base64,${nextImageData}`; onChangeAndResetError(dataURI); @@ -454,17 +448,13 @@ function AcuantCapture( } } - /** - * @param {AcuantSuccessResponse} nextCapture - */ - function onAcuantImageCaptureSuccess(nextCapture) { + function onAcuantImageCaptureSuccess(nextCapture: AcuantSuccessResponse) { const { image, cardType, dpi, moire, glare, sharpness } = nextCapture; const isAssessedAsGlare = glare < glareThreshold; const isAssessedAsBlurry = sharpness < sharpnessThreshold; const { width, height, data } = image; - /** @type {AcuantImageAssessment} */ - let assessment; + let assessment: AcuantImageAssessment; if (isAssessedAsGlare) { setOwnErrorMessage(t('doc_auth.errors.glare.failed_short')); assessment = 'glare'; @@ -475,8 +465,7 @@ function AcuantCapture( assessment = 'success'; } - /** @type {AcuantImageAnalyticsPayload} */ - const analyticsPayload = getAddAttemptAnalyticsPayload({ + const analyticsPayload: AcuantImageAnalyticsPayload = getAddAttemptAnalyticsPayload({ width, height, mimeType: 'image/jpeg', // Acuant Web SDK currently encodes all images as JPEG @@ -513,8 +502,7 @@ function AcuantCapture( onCropStart={() => setHasStartedCropping(true)} onImageCaptureSuccess={onAcuantImageCaptureSuccess} onImageCaptureFailure={(error, code) => { - const { SEQUENCE_BREAK_CODE } = /** @type {AcuantGlobal} */ (window) - .AcuantJavascriptWebSdk; + const { SEQUENCE_BREAK_CODE } = window.AcuantJavascriptWebSdk; if (isAcuantCameraAccessFailure(error)) { if (fullScreenRef.current?.focusTrap) { suspendFocusTrapForAnticipatedFocus(fullScreenRef.current.focusTrap); diff --git a/app/javascript/packages/document-capture/components/in-person-troubleshooting-options.tsx b/app/javascript/packages/document-capture/components/in-person-troubleshooting-options.tsx index 0da747a9983..12a5b37c601 100644 --- a/app/javascript/packages/document-capture/components/in-person-troubleshooting-options.tsx +++ b/app/javascript/packages/document-capture/components/in-person-troubleshooting-options.tsx @@ -33,7 +33,7 @@ function InPersonTroubleshootingOptions({ { url: getHelpCenterURL({ category: 'verify-your-identity', - article: 'how-to-verify-in-person', + article: 'verify-your-identity-in-person', location, }), text: t('idv.troubleshooting.options.learn_more_verify_in_person'), diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx deleted file mode 100644 index 0ce5887c00e..00000000000 --- a/app/javascript/packages/document-capture/context/failed-capture-attempts.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import { createContext, useState } from 'react'; -import useCounter from '../hooks/use-counter'; - -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef CaptureAttemptMetadata - * - * @prop {boolean} isAssessedAsGlare - * @prop {boolean} isAssessedAsBlurry - */ - -/** - * @typedef FailedCaptureAttemptsContext - * - * @prop {number} failedCaptureAttempts Current number of failed attempts. - * @prop {(metadata: CaptureAttemptMetadata)=>void} onFailedCaptureAttempt Callback to trigger on - * attempt, to increment attempts. - * @prop {() => void} onResetFailedCaptureAttempts Callback to trigger a reset of attempts. - * @prop {number} maxFailedAttemptsBeforeTips Number of failed attempts before showing tips. - * @prop {number} maxAttemptsBeforeNativeCamera Number of attempts before forcing the use of the native camera (if available) - * @prop {CaptureAttemptMetadata} lastAttemptMetadata Metadata about the last attempt. - * @prop {boolean} forceNativeCamera Whether or not to force use of the native camera. Is set to true if the number of failedCaptureAttempts is equal to or greater than maxAttemptsBeforeNativeCamera - */ - -/** @type {CaptureAttemptMetadata} */ -const DEFAULT_LAST_ATTEMPT_METADATA = { - isAssessedAsGlare: false, - isAssessedAsBlurry: false, -}; - -const FailedCaptureAttemptsContext = createContext( - /** @type {FailedCaptureAttemptsContext} */ ({ - failedCaptureAttempts: 0, - onFailedCaptureAttempt: () => {}, - onResetFailedCaptureAttempts: () => {}, - maxAttemptsBeforeNativeCamera: Infinity, - maxFailedAttemptsBeforeTips: Infinity, - lastAttemptMetadata: DEFAULT_LAST_ATTEMPT_METADATA, - forceNativeCamera: false, - }), -); - -FailedCaptureAttemptsContext.displayName = 'FailedCaptureAttemptsContext'; - -/** - * @typedef FailedCaptureAttemptsContextProviderProps - * - * @prop {ReactNode} children - * @prop {number} maxFailedAttemptsBeforeTips - * @prop {number} maxAttemptsBeforeNativeCamera - */ - -/** - * @param {FailedCaptureAttemptsContextProviderProps} props - */ -function FailedCaptureAttemptsContextProvider({ - children, - maxFailedAttemptsBeforeTips, - maxAttemptsBeforeNativeCamera, -}) { - const [lastAttemptMetadata, setLastAttemptMetadata] = useState( - /** @type {CaptureAttemptMetadata} */ (DEFAULT_LAST_ATTEMPT_METADATA), - ); - const [failedCaptureAttempts, incrementFailedCaptureAttempts, onResetFailedCaptureAttempts] = - useCounter(); - - const forceNativeCamera = failedCaptureAttempts >= maxAttemptsBeforeNativeCamera; - - /** - * @param {CaptureAttemptMetadata} metadata - */ - function onFailedCaptureAttempt(metadata) { - incrementFailedCaptureAttempts(); - setLastAttemptMetadata(metadata); - } - - return ( - - {children} - - ); -} - -export default FailedCaptureAttemptsContext; -export { FailedCaptureAttemptsContextProvider as Provider }; diff --git a/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx new file mode 100644 index 00000000000..bc998e28c05 --- /dev/null +++ b/app/javascript/packages/document-capture/context/failed-capture-attempts.tsx @@ -0,0 +1,102 @@ +import { createContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import useCounter from '../hooks/use-counter'; + +interface CaptureAttemptMetadata { + isAssessedAsGlare: boolean; + isAssessedAsBlurry: boolean; +} + +interface FailedCaptureAttemptsContextInterface { + /** + * Current number of failed capture attempts + */ + failedCaptureAttempts: number; + /** + * Number of failed attempts before showing tips + */ + maxFailedAttemptsBeforeTips: number; + /** + * The maximum number of failed Acuant capture attempts + * before use of the native camera option is triggered + */ + maxAttemptsBeforeNativeCamera: number; + /** + * Callback triggered on attempt, to increment attempts + */ + onFailedCaptureAttempt: (metadata: CaptureAttemptMetadata) => void; + /** + * Callback to trigger a reset of attempts + */ + onResetFailedCaptureAttempts: () => void; + /** + * Metadata about the last attempt + */ + lastAttemptMetadata: CaptureAttemptMetadata; + /** + * Whether or not the native camera is currently being forced + * after maxAttemptsBeforeNativeCamera number of failed attempts + */ + forceNativeCamera: boolean; +} + +const DEFAULT_LAST_ATTEMPT_METADATA: CaptureAttemptMetadata = { + isAssessedAsGlare: false, + isAssessedAsBlurry: false, +}; + +const FailedCaptureAttemptsContext = createContext({ + failedCaptureAttempts: 0, + onFailedCaptureAttempt: () => {}, + onResetFailedCaptureAttempts: () => {}, + maxAttemptsBeforeNativeCamera: Infinity, + maxFailedAttemptsBeforeTips: Infinity, + lastAttemptMetadata: DEFAULT_LAST_ATTEMPT_METADATA, + forceNativeCamera: false, +}); + +FailedCaptureAttemptsContext.displayName = 'FailedCaptureAttemptsContext'; + +interface FailedCaptureAttemptsContextProviderProps { + children: ReactNode; + maxFailedAttemptsBeforeTips: number; + maxAttemptsBeforeNativeCamera: number; +} + +function FailedCaptureAttemptsContextProvider({ + children, + maxFailedAttemptsBeforeTips, + maxAttemptsBeforeNativeCamera, +}: FailedCaptureAttemptsContextProviderProps) { + const [lastAttemptMetadata, setLastAttemptMetadata] = useState( + DEFAULT_LAST_ATTEMPT_METADATA, + ); + const [failedCaptureAttempts, incrementFailedCaptureAttempts, onResetFailedCaptureAttempts] = + useCounter(); + + function onFailedCaptureAttempt(metadata: CaptureAttemptMetadata) { + incrementFailedCaptureAttempts(); + setLastAttemptMetadata(metadata); + } + + const forceNativeCamera = failedCaptureAttempts >= maxAttemptsBeforeNativeCamera; + + return ( + + {children} + + ); +} + +export default FailedCaptureAttemptsContext; +export { FailedCaptureAttemptsContextProvider as Provider }; diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index a562f057d76..dd96c348e90 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -5,6 +5,7 @@ import { waitFor } from '@testing-library/dom'; import sinon from 'sinon'; import { PageHeading } from '@18f/identity-components'; import * as analytics from '@18f/identity-analytics'; +import { t } from '@18f/identity-i18n'; import FormSteps, { FormStepComponentProps, getStepIndexByName } from './form-steps'; import FormError from './form-error'; import FormStepsContext from './form-steps-context'; @@ -390,6 +391,38 @@ describe('FormSteps', () => { expect(window.location.hash).to.equal('#second'); }); + it('retains errors from prior steps', async () => { + const errors = [ + { + field: 'nonExistentField1', + error: new FormError('abcde'), + }, + { + field: 'nonExistentField2', + error: new FormError('12345'), + }, + ]; + const { getByText, findByText } = render( + , + ); + + const checkFormHasExpectedErrors = () => + findByText('Errors:', { exact: false }).then((e) => + expect(e.parentElement?.textContent).contains('abcde,12345'), + ); + + await expect(checkFormHasExpectedErrors()).to.be.fulfilled(); + + await userEvent.click(getByText(t('forms.buttons.continue'))); + + await expect(findByText('Second Title')).to.be.fulfilled(); + await expect(checkFormHasExpectedErrors()).to.be.rejected(); + + window.history.back(); + + await expect(checkFormHasExpectedErrors()).to.be.fulfilled(); + }); + it('shifts focus to next heading on step change', async () => { const { getByText } = render(); diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index 72486e91b8c..87e6af1bd84 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -177,6 +177,10 @@ interface FormStepsProps { titleFormat?: string; } +interface PreviousStepErrorsLookup { + [stepName: string]: FormStepError>[] | undefined; +} + /** * React hook which sets page title for the current step. * @@ -246,6 +250,7 @@ function FormSteps({ const didSubmitWithErrors = useRef(false); const forceRender = useForceRender(); const ifStillMounted = useIfStillMounted(); + useEffect(() => { if (activeErrors.length && didSubmitWithErrors.current) { const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current); @@ -271,6 +276,17 @@ function FormSteps({ const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0); const step = steps[stepIndex] as FormStep | undefined; + // Preserve/restore non-blocking errors for each step regardless of field association + const [previousStepErrors, setPreviousStepErrors] = useState({}); + useEffect(() => { + if (step?.name) { + const prevErrs = previousStepErrors[step?.name]; + if (prevErrs && prevErrs.length > 0) { + setActiveErrors(prevErrs); + } + } + }, [step?.name, previousStepErrors]); + /** * After a change in content, maintain focus by resetting to the beginning of the new content. */ @@ -361,6 +377,10 @@ function FormSteps({ } const nextActiveErrors = getValidationErrors(); + setPreviousStepErrors((prev) => ({ + ...prev, + [stepName || steps[0].name]: activeErrors, + })); setActiveErrors(nextActiveErrors); if (nextActiveErrors.length) { didSubmitWithErrors.current = true; diff --git a/app/javascript/packages/submit-button/README.md b/app/javascript/packages/submit-button/README.md new file mode 100644 index 00000000000..8858d2e13a7 --- /dev/null +++ b/app/javascript/packages/submit-button/README.md @@ -0,0 +1,23 @@ +# `@18f/identity-submit-button` + +Custom element for a submit button component. + +## Usage + +### Custom Element + +Importing the element will register the `` custom element: + +```ts +import '@18f/identity-submit-button/submit-button-element'; +``` + +The custom element will implement the behavior to activate a button upon associated form submission, but all markup must already exist. + +```html +
+ + + +
+``` diff --git a/app/javascript/packages/submit-button/package.json b/app/javascript/packages/submit-button/package.json new file mode 100644 index 00000000000..a8ce01b62ae --- /dev/null +++ b/app/javascript/packages/submit-button/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-submit-button", + "version": "1.0.0", + "private": true +} diff --git a/app/javascript/packages/submit-button/submit-button-element.spec.ts b/app/javascript/packages/submit-button/submit-button-element.spec.ts new file mode 100644 index 00000000000..54e3dc11f4c --- /dev/null +++ b/app/javascript/packages/submit-button/submit-button-element.spec.ts @@ -0,0 +1,49 @@ +import { screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import './submit-button-element'; + +describe('SubmitButtonElement', () => { + it('gracefully ignores if there is no associated form', () => { + document.body.innerHTML = ` + + + `; + }); + + it('activates on form submit', async () => { + document.body.innerHTML = ` +
+ + + +
`; + + const button = screen.getByRole('button') as HTMLButtonElement; + const form = button.closest('form') as HTMLFormElement; + form.addEventListener('submit', (event) => event.preventDefault()); + + await userEvent.click(button); + + expect(button.disabled).to.be.true(); + expect(button.classList.contains('usa-button--active')).to.be.true(); + }); + + it('does not activate if form validation prevents submission', async () => { + document.body.innerHTML = ` +
+ + + + +
`; + + const button = screen.getByRole('button') as HTMLButtonElement; + const form = button.closest('form') as HTMLFormElement; + form.addEventListener('submit', (event) => event.preventDefault()); + + await userEvent.click(button); + + expect(button.disabled).to.be.false(); + expect(button.classList.contains('usa-button--active')).to.be.false(); + }); +}); diff --git a/app/javascript/packages/submit-button/submit-button-element.ts b/app/javascript/packages/submit-button/submit-button-element.ts new file mode 100644 index 00000000000..0a685687267 --- /dev/null +++ b/app/javascript/packages/submit-button/submit-button-element.ts @@ -0,0 +1,30 @@ +class SubmitButtonElement extends HTMLElement { + connectedCallback() { + this.form?.addEventListener('submit', () => this.activate()); + } + + get form(): HTMLFormElement | null { + return this.closest('form'); + } + + get button(): HTMLButtonElement { + return this.querySelector('button')!; + } + + activate() { + this.button.classList.add('usa-button--active'); + this.button.disabled = true; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lg-submit-button': SubmitButtonElement; + } +} + +if (!customElements.get('lg-submit-button')) { + customElements.define('lg-submit-button', SubmitButtonElement); +} + +export default SubmitButtonElement; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx index 64dcfe23067..57d5d935949 100644 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx +++ b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.spec.tsx @@ -111,9 +111,9 @@ describe('PasswordConfirmStep', () => { }); it('navigates to forgot password subpage', async () => { - const { getByRole } = render(); + const { getByText } = render(); - await userEvent.click(getByRole('link', { name: 'idv.forgot_password.link_text' })); + await userEvent.click(getByText('idv.forgot_password.link_text')); expect(window.location.pathname).to.equal('/password_confirm/forgot_password'); expect(analytics.trackEvent).to.have.been.calledWith('IdV: forgot password visited'); @@ -121,9 +121,9 @@ describe('PasswordConfirmStep', () => { }); it('navigates back from forgot password subpage', async () => { - const { getByRole } = render(); + const { getByRole, getByText } = render(); - await userEvent.click(getByRole('link', { name: 'idv.forgot_password.link_text' })); + await userEvent.click(getByText('idv.forgot_password.link_text')); await userEvent.click(getByRole('link', { name: 'idv.forgot_password.try_again' })); expect(window.location.pathname).to.equal('/password_confirm'); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx index 228a81a8f1f..d1edc50402e 100644 --- a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx @@ -92,18 +92,9 @@ function PasswordConfirmStep({ errors, registerField, onChange, value }: Passwor required />
- {formatHTML( - t('idv.forgot_password.link_html', { - link: ``, - }), - { - button: ({ children }) => ( - - {children} - - ), - }, - )} + + {t('idv.forgot_password.link_text')} +
diff --git a/app/javascript/packs/form-validation.ts b/app/javascript/packs/form-validation.ts deleted file mode 100644 index 518285b0bda..00000000000 --- a/app/javascript/packs/form-validation.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { t } from '@18f/identity-i18n'; - -/** - * Given a submit event, disables all submit buttons within the target form. - * - * @param event Submit event. - */ -function disableFormSubmit(event: Event) { - const form = event.target as HTMLFormElement; - Array.from(form.querySelectorAll(['button:not([type])', '[type="submit"]'].join())).forEach( - (element) => { - const submit = element as HTMLInputElement | HTMLButtonElement; - submit.disabled = true; - submit.classList.add('usa-button--active'); - }, - ); -} - -function resetInput(input) { - if (input.hasAttribute('data-form-validation-message')) { - input.setCustomValidity(''); - input.removeAttribute('data-form-validation-message'); - } - input.setAttribute('aria-invalid', 'false'); - input.classList.remove('usa-input--error'); -} - -/** - * Given an `input` or `invalid` event, updates custom validity of the given input. - * - * @param event Input or invalid event. - */ - -function checkInputValidity(event: Event) { - const input = event.target as HTMLInputElement; - resetInput(input); - - if (input.validity.valueMissing) { - input.setCustomValidity(t('simple_form.required.text')); - input.setAttribute('data-form-validation-message', ''); - } -} - -/** - * Binds validation to a given input. - * - * @param input Input element. - */ -function validateInput(input: HTMLInputElement) { - input.addEventListener('input', checkInputValidity); - input.addEventListener('invalid', checkInputValidity); -} - -/** - * Initializes validation on a form element. - * - * @param form Form to initialize. - */ -export function initialize(form: HTMLFormElement) { - const fields: HTMLInputElement[] = Array.from(form.querySelectorAll('[required]')); - fields.forEach(validateInput); - form.addEventListener('submit', disableFormSubmit); -} - -const forms: HTMLFormElement[] = Array.from(document.querySelectorAll('form[data-validate]')); -forms.forEach(initialize); diff --git a/app/javascript/packs/personal-key-page-controller.js b/app/javascript/packs/personal-key-page-controller.js index b626700daa4..6b1be48943c 100644 --- a/app/javascript/packs/personal-key-page-controller.js +++ b/app/javascript/packs/personal-key-page-controller.js @@ -69,7 +69,9 @@ function trackDownload() { trackEvent('IdV: download personal key'); } -modalTrigger.addEventListener('click', show); +if (modalTrigger) { + modalTrigger.addEventListener('click', show); +} modalDismiss.addEventListener('click', hide); input.addEventListener('input', validateInput); downloadLink.addEventListener('click', trackDownload); diff --git a/app/javascript/packs/pw-strength.js b/app/javascript/packs/pw-strength.js index 9964d2518da..d7f25bd0122 100644 --- a/app/javascript/packs/pw-strength.js +++ b/app/javascript/packs/pw-strength.js @@ -116,7 +116,7 @@ function analyzePw() { const pwCntnr = document.getElementById('pw-strength-cntnr'); const pwStrength = document.getElementById('pw-strength-txt'); const pwFeedback = document.getElementById('pw-strength-feedback'); - const submit = document.querySelector('input[type="submit"]'); + const submit = document.querySelector('[type="submit"]'); const forbiddenPasswordsElement = document.querySelector('[data-forbidden]'); const forbiddenPasswords = getForbiddenPasswords(forbiddenPasswordsElement); diff --git a/app/jobs/reports/doc_auth_drop_off_rates_per_sprint_report.rb b/app/jobs/reports/doc_auth_drop_off_rates_per_sprint_report.rb index 849ac97bf8e..9f60736b64c 100644 --- a/app/jobs/reports/doc_auth_drop_off_rates_per_sprint_report.rb +++ b/app/jobs/reports/doc_auth_drop_off_rates_per_sprint_report.rb @@ -33,7 +33,7 @@ def generate_sprints(ret, date, today_date) start = date finish = date.next_day(14) ret << Db::DocAuthLog::BlanketDropOffRatesAllSpsInRange.new. - call('Sprint', fmt(start), fmt(finish)) + call('Sprint', fmt(start), fmt(finish)) date = finish end end diff --git a/app/jobs/reports/doc_auth_drop_off_rates_report.rb b/app/jobs/reports/doc_auth_drop_off_rates_report.rb index 764274333a2..5b43d713e22 100644 --- a/app/jobs/reports/doc_auth_drop_off_rates_report.rb +++ b/app/jobs/reports/doc_auth_drop_off_rates_report.rb @@ -67,62 +67,62 @@ def generate_overall_report_all_sps(ret) def overall_drop_off_rates_all_sps_all_time(ret) ret << Db::DocAuthLog::OverallDropOffRatesAllSpsAllTime.new. - call('Overall drop off rates for all SPs all time') + call('Overall drop off rates for all SPs all time') end def overall_drop_off_rates_all_sps_last_24_hours(ret) ret << Db::DocAuthLog::OverallDropOffRatesAllSpsInRange.new. - call('Overall drop off rates for all SPs last 24 hours', Date.yesterday, today) + call('Overall drop off rates for all SPs last 24 hours', Date.yesterday, today) end def overall_drop_off_rates_all_sps_last_30_days(ret) ret << Db::DocAuthLog::OverallDropOffRatesAllSpsInRange.new. - call('Overall drop off rates for all SPs last 30 days', today - 30.days, today) + call('Overall drop off rates for all SPs last 30 days', today - 30.days, today) end def blanket_drop_off_rates_all_sps_all_time(ret) ret << Db::DocAuthLog::BlanketDropOffRatesAllSpsAllTime.new. - call('Blanket drop off rates for all SPs all time') + call('Blanket drop off rates for all SPs all time') end def blanket_drop_off_rates_all_sps_last_24_hours(ret) ret << Db::DocAuthLog::BlanketDropOffRatesAllSpsInRange.new. - call('Blanket drop off rates for all SPs last 24 hours', Date.yesterday, today) + call('Blanket drop off rates for all SPs last 24 hours', Date.yesterday, today) end def blanket_drop_off_rates_all_sps_last_30_days(ret) ret << Db::DocAuthLog::BlanketDropOffRatesAllSpsInRange.new. - call('Blanket drop off rates for all SPs last 30 days', today - 30.days, today) + call('Blanket drop off rates for all SPs last 30 days', today - 30.days, today) end def blanket_drop_off_rates_per_sp_all_time(ret, sp) ret << Db::DocAuthLog::BlanketDropOffRatesPerSpAllTime.new. - call('Blanket drop off rates per SP all time', sp.issuer) + call('Blanket drop off rates per SP all time', sp.issuer) end def blanket_drop_off_rates_per_sp_last_24_hours(ret, sp) ret << Db::DocAuthLog::BlanketDropOffRatesPerSpInRange.new. - call('Blanket drop off rates last 24 hours', sp.issuer, Date.yesterday, today) + call('Blanket drop off rates last 24 hours', sp.issuer, Date.yesterday, today) end def blanket_drop_off_rates_per_sp_last_30_days(ret, sp) ret << Db::DocAuthLog::BlanketDropOffRatesPerSpInRange.new. - call('Blanket drop off rates last 30 days', sp.issuer, today - 30.days, today) + call('Blanket drop off rates last 30 days', sp.issuer, today - 30.days, today) end def overall_drop_off_rates_per_sp_all_time(ret, sp) ret << Db::DocAuthLog::OverallDropOffRatesPerSpAllTime.new. - call('Overall drop off rates per SP all time', sp.issuer) + call('Overall drop off rates per SP all time', sp.issuer) end def overall_drop_off_rates_per_sp_last_24_hours(ret, sp) ret << Db::DocAuthLog::OverallDropOffRatesPerSpInRange.new. - call('Overall drop off rates last 24 hours', sp.issuer, Date.yesterday, today) + call('Overall drop off rates last 24 hours', sp.issuer, Date.yesterday, today) end def overall_drop_off_rates_per_sp_last_30_days(ret, sp) ret << Db::DocAuthLog::OverallDropOffRatesPerSpInRange.new. - call('Overall drop off rates last 30 days', sp.issuer, today - 30.days, today) + call('Overall drop off rates last 30 days', sp.issuer, today - 30.days, today) end def today diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb index 70199f0e444..9640739df23 100644 --- a/app/jobs/resolution_proofing_job.rb +++ b/app/jobs/resolution_proofing_job.rb @@ -13,7 +13,8 @@ class ResolutionProofingJob < ApplicationJob ) def perform(result_id:, encrypted_arguments:, trace_id:, should_proof_state_id:, - dob_year_only:, user_id: nil, threatmetrix_session_id: nil) + dob_year_only:, user_id: nil, threatmetrix_session_id: nil, + uuid_prefix: nil, request_ip: nil) timer = JobHelpers::Timer.new raise_stale_job! if stale_job?(enqueued_at) @@ -25,14 +26,15 @@ def perform(result_id:, encrypted_arguments:, trace_id:, should_proof_state_id:, applicant_pii = decrypted_args[:applicant_pii] - threatmetrix_result = nil - if use_lexisnexis_ddp_threatmetrix_before_rdp_instant_verify? - user = User.find_by(id: user_id) - threatmetrix_result = proof_lexisnexis_ddp_with_threatmetrix( - applicant_pii, user, threatmetrix_session_id - ) - log_threatmetrix_info(threatmetrix_result, user) - end + user = User.find_by(id: user_id) + + optional_threatmetrix_result = proof_lexisnexis_ddp_with_threatmetrix_if_needed( + applicant_pii: applicant_pii, + user: user, + threatmetrix_session_id: threatmetrix_session_id, + request_ip: request_ip, + uuid_prefix: uuid_prefix, + ) callback_log_data = if dob_year_only && should_proof_state_id proof_aamva_then_lexisnexis_dob_only( @@ -48,8 +50,11 @@ def perform(result_id:, encrypted_arguments:, trace_id:, should_proof_state_id:, ) end - if use_lexisnexis_ddp_threatmetrix_before_rdp_instant_verify? - add_threatmetrix_result_to_callback_result(callback_log_data.result, threatmetrix_result) + if optional_threatmetrix_result.present? + add_threatmetrix_result_to_callback_result( + callback_log_data.result, + optional_threatmetrix_result, + ) end document_capture_session = DocumentCaptureSession.new(result_id: result_id) @@ -84,12 +89,33 @@ def add_threatmetrix_result_to_callback_result(callback_log_data_result, threatm callback_log_data_result[:threatmetrix_request_id] = threatmetrix_result.transaction_id end - def proof_lexisnexis_ddp_with_threatmetrix(applicant_pii, user, threatmetrix_session_id) + def proof_lexisnexis_ddp_with_threatmetrix_if_needed( + applicant_pii:, + user:, + threatmetrix_session_id:, + request_ip:, + uuid_prefix: + ) + return unless IdentityConfig.store.lexisnexis_threatmetrix_enabled + + # The API call will fail without a session ID, so do not attempt to make + # it to avoid leaking data when not required. + return if threatmetrix_session_id.blank? + return unless applicant_pii + ddp_pii = applicant_pii.dup ddp_pii[:threatmetrix_session_id] = threatmetrix_session_id ddp_pii[:email] = user&.confirmed_email_addresses&.first&.email - lexisnexis_ddp_proofer.proof(ddp_pii) + ddp_pii[:input_ip_address] = request_ip + ddp_pii[:local_attrib_1] = uuid_prefix + + result = lexisnexis_ddp_proofer.proof(ddp_pii) + + log_threatmetrix_info(result, user) + add_threatmetrix_proofing_component(user.id, result) + + result end # @return [CallbackLogData] @@ -269,7 +295,10 @@ def state_id_proofer end end - def use_lexisnexis_ddp_threatmetrix_before_rdp_instant_verify? - IdentityConfig.store.lexisnexis_threatmetrix_enabled + def add_threatmetrix_proofing_component(user_id, threatmetrix_result) + ProofingComponent. + create_or_find_by(user_id: user_id). + update(threatmetrix: true, + threatmetrix_review_status: threatmetrix_result.review_status) end end diff --git a/app/models/gpo_confirmation_code.rb b/app/models/gpo_confirmation_code.rb index 0aeb61d3396..7c34fe6cec2 100644 --- a/app/models/gpo_confirmation_code.rb +++ b/app/models/gpo_confirmation_code.rb @@ -1,4 +1,6 @@ class GpoConfirmationCode < ApplicationRecord + self.ignored_columns = [:bounced_at] + self.table_name = 'usps_confirmation_codes' belongs_to :profile diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index b8010706ae0..d7841fc2ca5 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -24,11 +24,11 @@ class InPersonEnrollment < ApplicationRecord # Find enrollments that need a status check via the USPS API def self.needs_usps_status_check(check_interval) where(status: :pending). - and( - where(status_check_attempted_at: check_interval). - or(where(status_check_attempted_at: nil)), - ). - order(status_check_attempted_at: :asc) + and( + where(status_check_attempted_at: check_interval). + or(where(status_check_attempted_at: nil)), + ). + order(status_check_attempted_at: :asc) end # Does this enrollment need a status check via the USPS API? diff --git a/app/policies/service_provider_mfa_policy.rb b/app/policies/service_provider_mfa_policy.rb index dd48f146280..34457d07e6b 100644 --- a/app/policies/service_provider_mfa_policy.rb +++ b/app/policies/service_provider_mfa_policy.rb @@ -1,5 +1,5 @@ class ServiceProviderMfaPolicy - AAL3_METHODS = %w[webauthn piv_cac].freeze + AAL3_METHODS = %w[webauthn webauthn_platform piv_cac].freeze attr_reader :mfa_context, :auth_method, :service_provider diff --git a/app/presenters/idv/gpo_presenter.rb b/app/presenters/idv/gpo_presenter.rb index 7e793764d44..00260cd5977 100644 --- a/app/presenters/idv/gpo_presenter.rb +++ b/app/presenters/idv/gpo_presenter.rb @@ -13,14 +13,6 @@ def title letter_already_sent? ? I18n.t('idv.titles.mail.resend') : I18n.t('idv.titles.mail.verify') end - def byline - if gpo_mail_bounced? - I18n.t('idv.messages.gpo.new_address') - else - I18n.t('idv.messages.gpo.address_on_file') - end - end - def button letter_already_sent? ? I18n.t('idv.buttons.mail.resend') : I18n.t('idv.buttons.mail.send') end @@ -29,10 +21,6 @@ def fallback_back_path user_needs_address_otp_verification? ? idv_gpo_verify_path : idv_phone_path end - def gpo_mail_bounced? - current_user.decorate.gpo_mail_bounced? - end - def letter_already_sent? gpo_mail_service.any_mail_sent? end diff --git a/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb index 718de9e35b3..e6e06fef4fe 100644 --- a/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb +++ b/app/presenters/two_factor_auth_code/max_attempts_reached_presenter.rb @@ -19,6 +19,10 @@ def locked_reason t('two_factor_authentication.max_otp_login_attempts_reached') when 'otp_requests' t('two_factor_authentication.max_otp_requests_reached') + when 'totp_login_attempts' + t('two_factor_authentication.max_otp_login_attempts_reached') + when 'totp_requests' + t('two_factor_authentication.max_otp_requests_reached') when 'personal_key_login_attempts' t('two_factor_authentication.max_personal_key_login_attempts_reached') when 'piv_cac_login_attempts' diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 2606708ef70..00e537f945c 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -467,16 +467,12 @@ def idv_cancellation_visited(step:, request_came_from:, **extra) ) end - # @param [String] name the name to prepend to analytics events - # @param [Number] failed_attempts the number of failed document capture attempts so far # The number of acceptable failed attempts (maxFailedAttemptsBeforeNativeCamera) has been met # or exceeded, and the system has forced the use of the native camera, rather than Acuant's # camera, on mobile devices. - def idv_native_camera_forced(name:, failed_attempts:, **extra) + def idv_native_camera_forced(**extra) track_event( 'IdV: Native camera forced after failed attempts', - name: name, - failed_attempts: failed_attempts, **extra, ) end @@ -671,22 +667,6 @@ def idv_gpo_address_letter_requested(resend:, **extra) ) end - # @param [Boolean] success - # @param [Hash] errors - # GPO address submitted - def idv_gpo_address_submitted( - success:, - errors:, - **extra - ) - track_event( - 'IdV: USPS address submitted', - success: success, - errors: errors, - **extra, - ) - end - # @param [Boolean] letter_already_sent # GPO address visited def idv_gpo_address_visited( diff --git a/app/services/cloud_front_header_parser.rb b/app/services/cloud_front_header_parser.rb new file mode 100644 index 00000000000..11392f4dd13 --- /dev/null +++ b/app/services/cloud_front_header_parser.rb @@ -0,0 +1,15 @@ +class CloudFrontHeaderParser + def initialize(request) + @request = request + end + + def client_port + return nil unless viewer_address + viewer_address.split(':').last + end + + # Source IP and port for client connection to CloudFront + def viewer_address + @request.headers['CloudFront-Viewer-Address'] + end +end diff --git a/app/services/flow/base_flow.rb b/app/services/flow/base_flow.rb index b9370b8086a..dcd36ac0e7f 100644 --- a/app/services/flow/base_flow.rb +++ b/app/services/flow/base_flow.rb @@ -71,6 +71,6 @@ def successful_response end delegate :flash, :session, :current_user, :current_sp, :params, :request, - :poll_with_meta_refresh, :analytics, to: :@controller + :poll_with_meta_refresh, :analytics, :irs_attempts_api_tracker, to: :@controller end end diff --git a/app/services/idv/gpo_mail.rb b/app/services/idv/gpo_mail.rb index 9771144bf27..924fce74b76 100644 --- a/app/services/idv/gpo_mail.rb +++ b/app/services/idv/gpo_mail.rb @@ -22,9 +22,9 @@ def any_mail_sent? def user_mail_events @user_mail_events ||= current_user.events. - gpo_mail_sent. - order('updated_at DESC'). - limit(MAX_MAIL_EVENTS) + gpo_mail_sent. + order('updated_at DESC'). + limit(MAX_MAIL_EVENTS) end def max_events? diff --git a/app/services/idv/in_person_config.rb b/app/services/idv/in_person_config.rb index 9bc7a8a73db..151ca02f9d8 100644 --- a/app/services/idv/in_person_config.rb +++ b/app/services/idv/in_person_config.rb @@ -1,7 +1,17 @@ module Idv class InPersonConfig def self.enabled_for_issuer?(issuer) - enabled? && (issuer.nil? || enabled_issuers.include?(issuer)) + return false if !enabled? + + if issuer.nil? + enabled_without_issuer? + else + enabled_issuers.include?(issuer) + end + end + + def self.enabled_without_issuer? + !IdentityConfig.store.idv_sp_required end def self.enabled? diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index a6fcefd68e4..482d8b11b6e 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -5,13 +5,10 @@ class Session applicant go_back_path idv_phone_step_document_capture_session_uuid - idv_gpo_document_capture_session_uuid vendor_phone_confirmation user_phone_confirmation pii previous_phone_step_params - previous_profile_step_params - previous_gpo_step_params profile_confirmation profile_id profile_step_params diff --git a/app/services/idv/steps/ipp/ssn_step.rb b/app/services/idv/steps/ipp/ssn_step.rb index ee2d7b22701..a145989cc23 100644 --- a/app/services/idv/steps/ipp/ssn_step.rb +++ b/app/services/idv/steps/ipp/ssn_step.rb @@ -13,7 +13,6 @@ def call def extra_view_variables { updating_ssn: updating_ssn, - threatmetrix_session_id: generate_threatmetrix_session_id, } end @@ -26,13 +25,6 @@ def form_submit def updating_ssn flow_session.dig(:pii_from_user, :ssn).present? end - - def generate_threatmetrix_session_id - return unless IdentityConfig.store.proofing_device_profiling_collecting_enabled - - flow_session[:threatmetrix_session_id] = SecureRandom.uuid if !updating_ssn - flow_session[:threatmetrix_session_id] - end end end end diff --git a/app/services/idv/steps/send_link_step.rb b/app/services/idv/steps/send_link_step.rb index b6ac4286643..0e0edde24ee 100644 --- a/app/services/idv/steps/send_link_step.rb +++ b/app/services/idv/steps/send_link_step.rb @@ -8,6 +8,10 @@ class SendLinkStep < DocAuthBaseStep def call return throttled_failure if throttle.throttled_else_increment? telephony_result = send_link + @flow.irs_attempts_api_tracker.idv_phone_upload_link_sent( + success: telephony_result.success?, + phone_number: formatted_destination_phone, + ) return failure(telephony_result.error.friendly_message) unless telephony_result.success? end diff --git a/app/services/irs_attempts_api/attempt_event.rb b/app/services/irs_attempts_api/attempt_event.rb index 48471838428..34f5bbf9375 100644 --- a/app/services/irs_attempts_api/attempt_event.rb +++ b/app/services/irs_attempts_api/attempt_event.rb @@ -24,6 +24,7 @@ def to_jwe event_data_encryption_key, typ: 'secevent+jwe', zip: 'DEF', + enc: 'A256GCM', ) end diff --git a/app/services/irs_attempts_api/envelope_encryptor.rb b/app/services/irs_attempts_api/envelope_encryptor.rb new file mode 100644 index 00000000000..d46790ac776 --- /dev/null +++ b/app/services/irs_attempts_api/envelope_encryptor.rb @@ -0,0 +1,43 @@ +module IrsAttemptsApi + class EnvelopeEncryptor + Result = Struct.new(:filename, :iv, :encrypted_key, :encrypted_data, keyword_init: true) + + # A new key is generated for each encryption. This key is encrypted with the public_key + # provided so that only the owner of the private key may decrypt this data. + def self.encrypt(data:, timestamp:, public_key:) + compressed_data = Zlib.gzip(data) + cipher = OpenSSL::Cipher.new('aes-128-cbc') + cipher.encrypt + key = cipher.random_key + iv = cipher.random_iv + encrypted_data = cipher.update(compressed_data) + cipher.final + digest = Digest::SHA256.hexdigest(encrypted_data) + encrypted_key = public_key.public_encrypt(key) + formatted_time = formatted_timestamp(timestamp) + + filename = + "FCI-#{IdentityConfig.store.irs_attempt_api_csp_id}_#{formatted_time}_#{digest}.json.gz" + + Result.new( + filename: filename, + iv: iv, + encrypted_key: encrypted_key, + encrypted_data: encrypted_data, + ) + end + + def self.formatted_timestamp(timestamp) + timestamp.strftime('%Y%m%dT%HZ') + end + + def self.decrypt(encrypted_data:, key:, iv:) + cipher = OpenSSL::Cipher.new('aes-128-cbc') + cipher.decrypt + cipher.key = key + cipher.iv = iv + decrypted = cipher.update(encrypted_data) + cipher.final + + Zlib.gunzip(decrypted) + end + end +end diff --git a/app/services/irs_attempts_api/tracker.rb b/app/services/irs_attempts_api/tracker.rb index 193087dff41..0e2f0ce2da0 100644 --- a/app/services/irs_attempts_api/tracker.rb +++ b/app/services/irs_attempts_api/tracker.rb @@ -1,15 +1,15 @@ module IrsAttemptsApi class Tracker - attr_reader :session_id, :enabled_for_session, :request, :user, :sp, :device_fingerprint, + attr_reader :session_id, :enabled_for_session, :request, :user, :sp, :cookie_device_uuid, :sp_request_uri - def initialize(session_id:, request:, user:, sp:, device_fingerprint:, + def initialize(session_id:, request:, user:, sp:, cookie_device_uuid:, sp_request_uri:, enabled_for_session:) @session_id = session_id # IRS session ID @request = request @user = user @sp = sp - @device_fingerprint = device_fingerprint + @cookie_device_uuid = cookie_device_uuid @sp_request_uri = sp_request_uri @enabled_for_session = enabled_for_session end @@ -21,9 +21,10 @@ def track_event(event_type, metadata = {}) user_agent: request&.user_agent, unique_session_id: hashed_session_id, user_uuid: AgencyIdentityLinker.for(user: user, service_provider: sp)&.uuid, - device_fingerprint: device_fingerprint, + device_fingerprint: hashed_cookie_device_uuid, user_ip_address: request&.remote_ip, irs_application_url: sp_request_uri, + client_port: CloudFrontHeaderParser.new(request).client_port, }.merge(metadata) event = AttemptEvent.new( @@ -51,6 +52,11 @@ def hashed_session_id Digest::SHA1.hexdigest(user&.unique_session_id) end + def hashed_cookie_device_uuid + return nil unless cookie_device_uuid + Digest::SHA1.hexdigest(cookie_device_uuid) + end + def enabled? IdentityConfig.store.irs_attempt_api_enabled && @enabled_for_session end diff --git a/app/services/irs_attempts_api/tracker_events.rb b/app/services/irs_attempts_api/tracker_events.rb index e6b06115cd0..950e5c9bfe0 100644 --- a/app/services/irs_attempts_api/tracker_events.rb +++ b/app/services/irs_attempts_api/tracker_events.rb @@ -1,5 +1,37 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ModuleLength module IrsAttemptsApi module TrackerEvents + # param [Boolean] success True if account reset request is cancelled + # A user cancels the request to delete their account before 24 hour period + def account_reset_cancel_request(success:) + track_event( + :account_reset_cancel_request, + success: success, + ) + end + + # @param [Boolean] success True if Account Reset Deletion submitted successful + # account Reset Deletion Requested + def account_reset_request_submitted(success:) + track_event( + :account_reset_request_submitted, + success: success, + ) + end + + # param [Boolean] success True if Account Successfully Deleted + # param [Hash>] failure_reason displays why account deletion failed + # A User confirms and deletes their Login.gov account after 24 hour period + def account_reset_account_deleted(success:, failure_reason:) + track_event( + :account_reset_account_deleted, + success: success, + failure_reason: failure_reason, + ) + end + # @param [String] email The submitted email address # @param [Boolean] success True if the email and password matched # A user has submitted an email address and password for authentication @@ -11,6 +43,30 @@ def email_and_password_auth(email:, success:) ) end + # @param [Boolean] success + # @param [Hash>] failure_reason + def forgot_password_email_confirmed(success:, failure_reason: nil) + track_event( + :forgot_password_email_confirmed, + success: success, + failure_reason: failure_reason, + ) + end + + # @param [Boolean] success + # @param [String] phone_number + # The phone upload link was sent during the IDV process + def idv_phone_upload_link_sent( + success:, + phone_number: + ) + track_event( + :idv_phone_upload_link_sent, + success: success, + phone_number: phone_number, + ) + end + # @param [Boolean] success True if the email and password matched # A user has initiated a logout event def logout_initiated(success:) @@ -20,6 +76,16 @@ def logout_initiated(success:) ) end + # @param [Boolean] success + # @param [Hash>] failure_reason + def forgot_password_new_password_submitted(success:, failure_reason: nil) + track_event( + :forgot_password_new_password_submitted, + success: success, + failure_reason: failure_reason, + ) + end + # Tracks when the user has attempted to enroll the Backup Codes MFA method to their account # @param [Boolean] success def mfa_enroll_backup_code(success:) @@ -29,6 +95,17 @@ def mfa_enroll_backup_code(success:) ) end + # @param [Boolean] success True if selection was valid + # @param [Array] mfa_device_types List of MFA options users selected on account creation + # A user has selected MFA options + def mfa_enroll_options_selected(success:, mfa_device_types:) + track_event( + :mfa_enroll_options_selected, + success: success, + mfa_device_types: mfa_device_types, + ) + end + # @param [String] phone_number - The user's phone_number used for multi-factor authentication # @param [Boolean] success - True if the OTP Verification was sent # Relevant only when the user is enrolling a phone as their MFA. @@ -51,6 +128,33 @@ def mfa_enroll_phone_otp_submitted(success:) ) end + # Tracks when the user has attempted to enroll the piv cac MFA method to their account + # @param [String] subject_dn + # @param [Boolean] success + # @param [Hash>] failure_reason + def mfa_enroll_piv_cac( + success:, + subject_dn: nil, + failure_reason: nil + ) + track_event( + :mfa_enroll_piv_cac, + success: success, + subject_dn: subject_dn, + failure_reason: failure_reason, + ) + end + + # @param [String] type - the type of multi-factor authentication used + # The user has exceeded the rate limit during enrollment + # and account has been locked + def mfa_enroll_rate_limited(type:) + track_event( + :mfa_enroll_rate_limited, + type: type, + ) + end + # Tracks when the user has attempted to enroll the TOTP MFA method to their account # @param [Boolean] success def mfa_enroll_totp(success:) @@ -78,11 +182,11 @@ def mfa_enroll_webauthn_roaming(success:) ) end - # Tracks when the user has attempted to verify the Backup Codes MFA method to their account + # Tracks when the user has attempted to log in with the Backup Codes MFA method to their account # @param [Boolean] success - def mfa_verify_backup_code(success:) + def mfa_login_backup_code(success:) track_event( - :mfa_verify_backup_code, + :mfa_login_backup_code, success: success, ) end @@ -91,9 +195,9 @@ def mfa_verify_backup_code(success:) # @param [String] phone_number - The user's phone_number used for multi-factor authentication # @param [Boolean] success - True if the OTP Verification was sent # During a login attempt, an OTP code has been sent via SMS. - def mfa_verify_phone_otp_sent(reauthentication:, phone_number:, success:) + def mfa_login_phone_otp_sent(reauthentication:, phone_number:, success:) track_event( - :mfa_verify_phone_otp_sent, + :mfa_login_phone_otp_sent, reauthentication: reauthentication, phone_number: phone_number, success: success, @@ -103,72 +207,65 @@ def mfa_verify_phone_otp_sent(reauthentication:, phone_number:, success:) # @param [Boolean] success - True if the sms otp submitted matched what was sent # During a login attempt, the user, having previously been sent an OTP code via SMS # has entered an OTP code. - def mfa_verify_phone_otp_submitted(reauthentication:, success:) + def mfa_login_phone_otp_submitted(reauthentication:, success:) track_event( - :mfa_verify_phone_otp_submitted, + :mfa_login_phone_otp_submitted, reauthentication: reauthentication, success: success, ) end - # Tracks when the user has attempted to verify via the TOTP MFA method to access their account + # Tracks when the user has attempted to log in with the piv cac MFA method to their account # @param [Boolean] success - def mfa_verify_totp(success:) + # @param [String] subject_dn + # @param [Hash>] failure_reason + def mfa_login_piv_cac( + success:, + subject_dn: nil, + failure_reason: nil + ) track_event( - :mfa_verify_totp, + :mfa_login_piv_cac, success: success, + subject_dn: subject_dn, + failure_reason: failure_reason, ) end - # Tracks when user has attempted to verify via the WebAuthn-Platform MFA method to their account - # @param [Boolean] success - def mfa_verify_webauthn_platform(success:) + # @param [String] type - the type of multi-factor authentication used + # The user has exceeded the rate limit during verification + # and account has been locked + def mfa_login_rate_limited(type:) track_event( - :mfa_verify_webauthn_platform, - success: success, + :mfa_login_rate_limited, + type: type, ) end - # Tracks when the user has attempted to verify via the WebAuthn MFA method to their account + # Tracks when the user has attempted to log in with the TOTP MFA method to access their account # @param [Boolean] success - def mfa_verify_webauthn_roaming(success:) + def mfa_login_totp(success:) track_event( - :mfa_verify_webauthn_roaming, + :mfa_login_totp, success: success, ) end - # Tracks when the user has attempted to enroll the piv cac MFA method to their account - # @param [String] subject_dn + # Tracks when user has attempted to log in with WebAuthn-Platform MFA method to their account # @param [Boolean] success - # @param [Hash>] failure_reason - def mfa_enroll_piv_cac( - success:, - subject_dn: nil, - failure_reason: nil - ) + def mfa_login_webauthn_platform(success:) track_event( - :mfa_enroll_piv_cac, + :mfa_login_webauthn_platform, success: success, - subject_dn: subject_dn, - failure_reason: failure_reason, ) end - # Tracks when the user has attempted to verify the piv cac MFA method to their account - # @param [String] subject_dn + # Tracks when the user has attempted to log in with the WebAuthn MFA method to their account # @param [Boolean] success - # @param [Hash>] failure_reason - def mfa_login_piv_cac( - success:, - subject_dn: nil, - failure_reason: nil - ) + def mfa_login_webauthn_roaming(success:) track_event( - :mfa_login_piv_cac, + :mfa_login_webauthn_roaming, success: success, - subject_dn: subject_dn, - failure_reason: failure_reason, ) end @@ -205,5 +302,20 @@ def user_registration_email_submitted( failure_reason: failure_reason, ) end + + # Tracks when user submits registration password + # @param [Boolean] success + # @param [Hash>] failure_reason + def user_registration_password_submitted( + success:, + failure_reason: nil + ) + track_event( + :user_registration_password_submitted, + success: success, + failure_reason: failure_reason, + ) + end end end +# rubocop:enable Metrics/ModuleLength diff --git a/app/services/marketing_site.rb b/app/services/marketing_site.rb index 11dad835cdc..58faf797208 100644 --- a/app/services/marketing_site.rb +++ b/app/services/marketing_site.rb @@ -9,7 +9,7 @@ class MarketingSite signing-in/what-is-a-hardware-security-key verify-your-identity/accepted-state-issued-identification verify-your-identity/how-to-add-images-of-your-state-issued-id - verify-your-identity/how-to-verify-in-person + verify-your-identity/verify-your-identity-in-person verify-your-identity/phone-number-and-phone-plan-in-your-name verify-your-identity/verify-your-address-by-mail get-started/authentication-options diff --git a/app/services/proofing/lexis_nexis/ddp/proofer.rb b/app/services/proofing/lexis_nexis/ddp/proofer.rb index f3f31230127..4843bde36d8 100644 --- a/app/services/proofing/lexis_nexis/ddp/proofer.rb +++ b/app/services/proofing/lexis_nexis/ddp/proofer.rb @@ -14,7 +14,9 @@ class Proofer < LexisNexis::Proofer :address1, :city, :state, - :zipcode + :zipcode, + :request_ip, + :uuid_prefix optional_attributes :address2, :phone, :email @@ -40,6 +42,7 @@ def process_response(response, result) result.transaction_id = body['request_id'] request_result = body['request_result'] review_status = body['review_status'] + result.review_status = review_status result.add_error(:request_result, request_result) unless request_result == 'success' result.add_error(:review_status, review_status) unless review_status == 'pass' end diff --git a/app/services/proofing/lexis_nexis/ddp/verification_request.rb b/app/services/proofing/lexis_nexis/ddp/verification_request.rb index 0a19aa65323..e5c94f96a7e 100644 --- a/app/services/proofing/lexis_nexis/ddp/verification_request.rb +++ b/app/services/proofing/lexis_nexis/ddp/verification_request.rb @@ -27,6 +27,8 @@ def build_request_body service_type: 'all', session_id: applicant[:threatmetrix_session_id], ssn_hash: OpenSSL::Digest::SHA256.hexdigest(applicant[:ssn].gsub(/\D/, '')), + input_ip_address: applicant[:request_ip], + local_attrib_1: applicant[:uuid_prefix], }.to_json end diff --git a/app/services/proofing/mock/ddp_mock_client.rb b/app/services/proofing/mock/ddp_mock_client.rb index acf18b3d8e2..285e14ea6cb 100644 --- a/app/services/proofing/mock/ddp_mock_client.rb +++ b/app/services/proofing/mock/ddp_mock_client.rb @@ -20,8 +20,27 @@ class DdpMockClient < Proofing::Base TRANSACTION_ID = 'ddp-mock-transaction-id-123' + # Trigger the "REJECT" status + REJECT_STATUS_SSN = '666-77-8888' + + # Trigger the "REVIEW" status + REVIEW_STATUS_SSN = '666-77-9999' + + # Trigger a nil status + NIL_STATUS_SSN = '666-77-0000' + proof do |applicant, result| result.transaction_id = TRANSACTION_ID + result.review_status = case SsnFormatter.format(applicant[:ssn]) + when REJECT_STATUS_SSN + 'reject' + when REVIEW_STATUS_SSN + 'review' + when NIL_STATUS_SSN + nil + else + 'pass' + end end end end diff --git a/app/services/proofing/result.rb b/app/services/proofing/result.rb index 2193212f7fe..644083c5e4b 100644 --- a/app/services/proofing/result.rb +++ b/app/services/proofing/result.rb @@ -1,7 +1,7 @@ module Proofing class Result attr_reader :exception - attr_accessor :context, :transaction_id, :reference + attr_accessor :context, :transaction_id, :reference, :review_status def initialize( errors: {}, diff --git a/app/views/account_reset/recovery_options/show.html.erb b/app/views/account_reset/recovery_options/show.html.erb index f8dd70d3687..8b3fb591aaa 100644 --- a/app/views/account_reset/recovery_options/show.html.erb +++ b/app/views/account_reset/recovery_options/show.html.erb @@ -23,6 +23,6 @@ <%= link_to( t('account_reset.request.yes_continue'), - account_reset_delete_account_path, + account_reset_request_path, class: 'usa-button usa-button--unstyled', ) %> \ No newline at end of file diff --git a/app/views/accounts/_pending_profile_bounced_gpo.html.erb b/app/views/accounts/_pending_profile_bounced_gpo.html.erb deleted file mode 100644 index 256c67db641..00000000000 --- a/app/views/accounts/_pending_profile_bounced_gpo.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%= render AlertComponent.new(type: :warning, text_tag: 'div') do %> -

- <%= t('account.index.verification.bounced') %> -

-

- <%= link_to t('account.index.verification.update_address'), idv_gpo_path %> -

-<% end %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 167acb1d46f..8c941c5ce2b 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -11,11 +11,7 @@ <% end %> <% if @presenter.show_gpo_partial? %> - <% if @presenter.decorated_user.gpo_mail_bounced? %> - <%= render 'accounts/pending_profile_bounced_gpo' %> - <% else %> - <%= render 'accounts/pending_profile_gpo' %> - <% end %> + <%= render 'accounts/pending_profile_gpo' %> <% end %> <% if @presenter.show_service_provider_continue_partial? %> diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index 40f9917e95a..abbc687ab36 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -4,7 +4,7 @@

<%= t('instructions.password.password_key') %>

-<%= validated_form_for( +<%= simple_form_for( @reset_password_form, url: user_password_path, html: { autocomplete: 'off', @@ -18,7 +18,7 @@ required: true, ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> - <%= f.button :submit, t('forms.passwords.edit.buttons.submit'), class: 'usa-button--big margin-bottom-4' %> + <%= f.submit t('forms.passwords.edit.buttons.submit'), class: 'margin-bottom-4' %> <% end %> <%= render 'shared/password_accordion' %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 8dad65addca..50a540f6f98 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -9,7 +9,7 @@ <%= t('instructions.password.forgot') %>

-<%= validated_form_for( +<%= simple_form_for( @password_reset_email_form, url: user_password_path, html: { autocomplete: 'off', method: :post }, @@ -19,11 +19,7 @@ input_html: { autocorrect: 'off', aria: { invalid: false, describedby: 'email-description' } } %> <%= f.input :request_id, as: :hidden, input_html: { value: request_id } %> - <%= f.button( - :submit, - t('forms.buttons.continue'), - class: 'display-block usa-button--big usa-button--wide margin-y-5', - ) %> + <%= f.submit t('forms.buttons.continue'), class: 'display-block margin-y-5' %> <% end %> <%= render(PageFooterComponent.new) do %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index a15034192f6..820be8f9529 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -13,23 +13,28 @@ <% end %> <%= render 'shared/sp_alert' %> -<%= validated_form_for( +<%= simple_form_for( resource, as: resource_name, url: session_path(resource_name), html: { autocomplete: 'off' }, ) do |f| %> - <%= f.input :email, - label: t('account.index.email'), - required: true, - input_html: { class: 'margin-bottom-6', - autocorrect: 'off', - aria: { invalid: false } } %> - <%= render PasswordToggleComponent.new(form: f, required: true) %> + <%= render ValidatedFieldComponent.new( + form: f, + name: :email, + label: t('account.index.email'), + required: true, + input_html: { autocorrect: 'off' }, + ) %> + <%= render PasswordToggleComponent.new( + form: f, + required: true, + wrapper_html: { class: 'margin-top-6' }, + ) %> <%= f.input :request_id, as: :hidden, input_html: { value: @request_id } %>
- <%= submit_tag t('links.next'), class: 'usa-button usa-button--primary usa-button--full-width margin-bottom-2' %> + <%= f.submit t('links.next'), full_width: true, big: false, wide: false, class: 'margin-bottom-2' %> <%= link_to( t('links.create_account'), sign_up_email_url(request_id: @request_id), diff --git a/app/views/event_disavowal/new.html.erb b/app/views/event_disavowal/new.html.erb index 95355152ba0..7c1456793ce 100644 --- a/app/views/event_disavowal/new.html.erb +++ b/app/views/event_disavowal/new.html.erb @@ -2,7 +2,7 @@ <%= render PageHeadingComponent.new.with_content(t('headings.passwords.change')) %> -<%= validated_form_for( +<%= simple_form_for( @password_reset_from_disavowal_form, url: events_disavowal_url, html: { autocomplete: 'off', @@ -16,7 +16,7 @@ required: true, ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> - <%= f.button :submit, t('forms.passwords.edit.buttons.submit'), class: 'usa-button--big usa-button--wide margin-bottom-4' %> + <%= f.submit t('forms.passwords.edit.buttons.submit'), class: 'margin-bottom-4' %> <% end %> <%= render 'shared/password_accordion' %> diff --git a/app/views/forgot_password/show.html.erb b/app/views/forgot_password/show.html.erb index 2897f91a9a6..2af37e556ee 100644 --- a/app/views/forgot_password/show.html.erb +++ b/app/views/forgot_password/show.html.erb @@ -16,9 +16,9 @@
-<%= validated_form_for @password_reset_email_form, - html: { autocomplete: 'off', method: :post, class: 'margin-bottom-2' }, - url: user_password_path do |f| %> +<%= simple_form_for @password_reset_email_form, + html: { autocomplete: 'off', method: :post, class: 'margin-bottom-2' }, + url: user_password_path do |f| %> <%= f.input :email, as: :hidden %> <%= f.input :resend, as: :hidden %> diff --git a/app/views/idv/address/new.html.erb b/app/views/idv/address/new.html.erb index 7739d880393..f362d8571e4 100644 --- a/app/views/idv/address/new.html.erb +++ b/app/views/idv/address/new.html.erb @@ -64,9 +64,7 @@ }, ) %> - <%= render ButtonComponent.new(big: true, wide: true, class: 'display-block margin-y-5') do %> - <%= t('forms.buttons.submit.update') %> - <% end %> + <%= f.submit t('forms.buttons.submit.update'), class: 'display-block margin-y-5' %> <% end %> <%= render 'idv/shared/back', step: 'verify' %> diff --git a/app/views/idv/doc_auth/agreement.html.erb b/app/views/idv/doc_auth/agreement.html.erb index 90becb555ed..4be4222496d 100644 --- a/app/views/idv/doc_auth/agreement.html.erb +++ b/app/views/idv/doc_auth/agreement.html.erb @@ -36,12 +36,7 @@ <% end, required: true, ) %> - <%= f.button( - :button, - t('doc_auth.buttons.continue'), - type: :submit, - class: 'usa-button--big usa-button--wide margin-top-4', - ) %> + <%= f.submit t('doc_auth.buttons.continue'), class: 'margin-top-4' %> <% end %> <%= render 'idv/doc_auth/cancel', step: 'agreement' %> diff --git a/app/views/idv/doc_auth/send_link.html.erb b/app/views/idv/doc_auth/send_link.html.erb index be1365ce6ce..2ad9512e160 100644 --- a/app/views/idv/doc_auth/send_link.html.erb +++ b/app/views/idv/doc_auth/send_link.html.erb @@ -15,7 +15,7 @@

<%= t('doc_auth.info.camera_required') %>

<%= t('doc_auth.instructions.send_sms') %>

-<%= validated_form_for( +<%= simple_form_for( :doc_auth, url: url_for, method: 'PUT', @@ -27,9 +27,6 @@ delivery_methods: [:sms], class: 'margin-bottom-4', ) %> - - + <%= f.submit t('forms.buttons.continue'), class: 'margin-top-4' %> <% end %> <%= render 'idv/shared/back', action: 'cancel_send_link' %> diff --git a/app/views/idv/doc_auth/upload.html.erb b/app/views/idv/doc_auth/upload.html.erb index 5354cc1781c..87da877c1a5 100644 --- a/app/views/idv/doc_auth/upload.html.erb +++ b/app/views/idv/doc_auth/upload.html.erb @@ -46,15 +46,13 @@ <% end %> <%= t('doc_auth.info.upload_from_phone') %> - <%= validated_form_for( + <%= simple_form_for( :doc_auth, url: url_for(type: :mobile), method: 'PUT', html: { autocomplete: 'off', class: 'margin-top-2' }, - ) do %> - + ) do |f| %> + <%= f.submit t('doc_auth.buttons.use_phone'), wide: false, class: 'margin-top-05' %> <% end %> @@ -63,15 +61,13 @@
<%= t('doc_auth.info.upload_from_computer') %>  - <%= validated_form_for( + <%= simple_form_for( :doc_auth, url: url_for(type: :desktop), method: 'PUT', html: { autocomplete: 'off', class: 'display-inline' }, - ) do %> - + ) do |f| %> + <%= f.submit t('doc_auth.info.upload_computer_link'), unstyled: true, big: false, wide: false %> <% end %>
diff --git a/app/views/idv/doc_auth/welcome.html.erb b/app/views/idv/doc_auth/welcome.html.erb index 7b726f72ca5..ea5b1d78c11 100644 --- a/app/views/idv/doc_auth/welcome.html.erb +++ b/app/views/idv/doc_auth/welcome.html.erb @@ -28,14 +28,11 @@ <% end %> <% end %> - <%= validated_form_for :doc_auth, - url: url_for, - method: 'put', - html: { autocomplete: 'off', class: 'margin-y-5 js-consent-continue-form' } do |f| %> - <%= f.button :button, - t('doc_auth.buttons.continue'), - type: :submit, - class: 'usa-button--big usa-button--wide' %> + <%= simple_form_for :doc_auth, + url: url_for, + method: 'put', + html: { autocomplete: 'off', class: 'margin-y-5 js-consent-continue-form' } do |f| %> + <%= f.submit t('doc_auth.buttons.continue') %> <% end %> <%= render( diff --git a/app/views/idv/gpo/_new_address.html.erb b/app/views/idv/gpo/_new_address.html.erb deleted file mode 100644 index 1273a0253e9..00000000000 --- a/app/views/idv/gpo/_new_address.html.erb +++ /dev/null @@ -1,55 +0,0 @@ -<%= simple_form_for( - :idv_form, - url: idv_gpo_path, - method: 'POST', - html: { autocomplete: 'off' }, - ) do |f| %> - <%= render ValidatedFieldComponent.new( - form: f, - name: :address1, - label: t('idv.form.address1'), - required: true, - maxlength: 255, - ) %> - <%= render ValidatedFieldComponent.new( - form: f, - name: :address2, - label: t('idv.form.address2'), - required: false, - maxlength: 255, - ) %> - <%= render ValidatedFieldComponent.new( - form: f, - name: :city, - label: t('idv.form.city'), - required: true, - maxlength: 255, - ) %> - <%= render ValidatedFieldComponent.new( - form: f, - name: :state, - collection: us_states_territories, - label: t('idv.form.state'), - required: true, - ) %> -
- <%# using :tel for mobile numeric keypad %> - <%= render ValidatedFieldComponent.new( - form: f, - name: :zipcode, - as: :tel, - label: t('idv.form.zipcode'), - required: true, - pattern: '(\d{5}([\-]\d{4})?)', - input_html: { class: 'zipcode' }, - error_messages: { - patternMismatch: t('idv.errors.pattern_mismatch.zipcode'), - }, - ) %> -
- <%= render ButtonComponent.new(big: true, wide: true, class: 'display-block margin-y-5') do %> - <%= t('idv.buttons.mail.resend') %> - <% end %> -<% end %> - -<%= javascript_packs_tag_once('formatted-fields') %> diff --git a/app/views/idv/gpo/index.html.erb b/app/views/idv/gpo/index.html.erb index f5a372195ea..38a077dcdfb 100644 --- a/app/views/idv/gpo/index.html.erb +++ b/app/views/idv/gpo/index.html.erb @@ -12,16 +12,15 @@ <%= render PageHeadingComponent.new.with_content(@presenter.title) %>

- <%= @presenter.byline %> - <%= t('idv.messages.gpo.timeframe') %> + <%= t('idv.messages.gpo.address_on_file_html') %> + <%= t('idv.messages.gpo.timeframe_html') %>

- <% if @presenter.gpo_mail_bounced? %> - <%= render 'idv/gpo/new_address', presenter: @presenter %> - <% else %> - <%= render 'idv/gpo/address_on_file', presenter: @presenter %> - <% end %> + <%= button_to @presenter.button, + idv_gpo_path, + method: 'put', + class: 'usa-button usa-button--big usa-button--wide' %>
<%= render( diff --git a/app/views/idv/gpo_verify/index.html.erb b/app/views/idv/gpo_verify/index.html.erb index 34cd249dc98..be009c21be5 100644 --- a/app/views/idv/gpo_verify/index.html.erb +++ b/app/views/idv/gpo_verify/index.html.erb @@ -9,9 +9,12 @@ } %> <% end %> -<% title t('titles.verify_profile') %> +<% title t('forms.verify_profile.title') %> -<%= render PageHeadingComponent.new.with_content(t('forms.verify_profile.title')) %> +<%= render PageHeadingComponent.new.with_content(t('forms.verify_profile.welcome_back')) %> +<%= sanitize t('forms.verify_profile.welcome_back_description'), tags: %i[p strong] %> +
+

<%= t('forms.verify_profile.title') %>

<%= t('forms.verify_profile.instructions') %> @@ -35,9 +38,7 @@ }, label: t('forms.verify_profile.name'), ) %> - <%= f.button :submit, - t('forms.verify_profile.submit'), - class: 'usa-button--big usa-button--full-width display-block margin-top-5' %> + <%= f.submit t('forms.verify_profile.submit'), full_width: true, wide: false, class: 'display-block margin-top-5' %>

<% end %> diff --git a/app/views/idv/in_person/address.html.erb b/app/views/idv/in_person/address.html.erb index 0aea9ce4067..085e1e10a2d 100644 --- a/app/views/idv/in_person/address.html.erb +++ b/app/views/idv/in_person/address.html.erb @@ -12,7 +12,7 @@ t('in_person_proofing.body.address.learn_more'), MarketingSite.help_center_article_url( category: 'verify-your-identity', - article: 'how-to-verify-in-person', + article: 'verify-your-identity-in-person', ), ) %>

@@ -88,7 +88,7 @@ wrapper: :uswds_radio_buttons, ) %> - <%= render ButtonComponent.new(big: true, wide: true, class: 'margin-top-1') do %> + <%= f.submit class: 'margin-top-1' do %> <% if updating_address %> <%= t('forms.buttons.submit.update') %> <% else %> 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 20d83074778..71ff6114e88 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 @@ -64,7 +64,7 @@ t('in_person_proofing.body.barcode.learn_more'), MarketingSite.help_center_article_url( category: 'verify-your-identity', - article: 'how-to-verify-in-person', + article: 'verify-your-identity-in-person', ), ) %>

diff --git a/app/views/idv/in_person/ssn.html.erb b/app/views/idv/in_person/ssn.html.erb index 558e4ce0128..5bb75490e02 100644 --- a/app/views/idv/in_person/ssn.html.erb +++ b/app/views/idv/in_person/ssn.html.erb @@ -1 +1 @@ -<%= render 'idv/shared/ssn', flow_session: flow_session, success_alert_enabled: false, updating_ssn: updating_ssn, threatmetrix_session_id: threatmetrix_session_id %> +<%= render 'idv/shared/ssn', flow_session: flow_session, success_alert_enabled: false, updating_ssn: updating_ssn %> diff --git a/app/views/idv/in_person/state_id.html.erb b/app/views/idv/in_person/state_id.html.erb index e809569ea76..2defb0033b1 100644 --- a/app/views/idv/in_person/state_id.html.erb +++ b/app/views/idv/in_person/state_id.html.erb @@ -10,10 +10,10 @@ <%= t('in_person_proofing.body.state_id.info_html') %>

-<%= validated_form_for :doc_auth, - url: url_for, - method: 'put', - html: { autocomplete: 'off', class: 'margin-y-5' } do |f| %> +<%= simple_form_for :doc_auth, + url: url_for, + method: 'put', + html: { autocomplete: 'off', class: 'margin-y-5' } do |f| %>
<%= render ValidatedFieldComponent.new( @@ -81,7 +81,7 @@ ) %>
- <%= render ButtonComponent.new(big: true, wide: true) do %> + <%= f.submit do %> <% if updating_state_id %> <%= t('forms.buttons.submit.update') %> <% else %> diff --git a/app/views/idv/phone/new.html.erb b/app/views/idv/phone/new.html.erb index 59f6718d106..dfc825161b1 100644 --- a/app/views/idv/phone/new.html.erb +++ b/app/views/idv/phone/new.html.erb @@ -39,7 +39,7 @@ <%= t('idv.messages.phone.final_note_html') %>

-<%= validated_form_for( +<%= simple_form_for( @idv_form, url: idv_phone_path, data: { diff --git a/app/views/idv/review/new.html.erb b/app/views/idv/review/new.html.erb index 99a7140a09e..26266a254ef 100644 --- a/app/views/idv/review/new.html.erb +++ b/app/views/idv/review/new.html.erb @@ -32,10 +32,7 @@ wrapper_html: { class: 'margin-bottom-0' }, ) %>
- <%= t( - 'idv.forgot_password.link_html', - link: link_to(t('idv.forgot_password.link_text'), idv_forgot_password_url, class: 'margin-left-1'), - ) %> + <%= link_to(t('idv.forgot_password.link_text'), idv_forgot_password_url, class: 'margin-left-1') %>
<%= render AccordionComponent.new do |c| %> <% c.header { t('idv.messages.review.intro') } %> @@ -43,11 +40,7 @@ phone: PhoneFormatter.format(@applicant[:phone]) %> <% end %> - <%= f.button( - :submit, - t('forms.buttons.continue'), - class: 'usa-button--big usa-button--wide margin-top-5', - ) %> + <%= f.submit t('forms.buttons.continue'), class: 'margin-top-5' %> <% end %> <%= render 'idv/doc_auth/cancel', step: 'review' %> diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 1bdb3657c0c..ec016b4a726 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -41,7 +41,7 @@ idv_in_person_url: Idv::InPersonConfig.enabled_for_issuer?(decorated_session.sp_issuer) ? idv_in_person_url : nil, security_and_privacy_how_it_works_url: MarketingSite.security_and_privacy_how_it_works_url, } %> - <%= validated_form_for( + <%= simple_form_for( :doc_auth, url: url_for, method: 'PUT', diff --git a/app/views/idv/shared/_ssn.html.erb b/app/views/idv/shared/_ssn.html.erb index 9dd29c89813..a936994cd99 100644 --- a/app/views/idv/shared/_ssn.html.erb +++ b/app/views/idv/shared/_ssn.html.erb @@ -30,11 +30,13 @@ locals: <% if IdentityConfig.store.proofing_device_profiling_collecting_enabled %> <% unless IdentityConfig.store.lexisnexis_threatmetrix_org_id.empty? %> - <%= javascript_include_tag "https://h.online-metrix.net/fp/tags.js?org_id=#{IdentityConfig.store.lexisnexis_threatmetrix_org_id}&session_id=#{flow_session[:threatmetrix_session_id]}", nonce: true %> - + <% if flow_session[:threatmetrix_session_id].present? %> + <%= javascript_include_tag "https://h.online-metrix.net/fp/tags.js?org_id=#{IdentityConfig.store.lexisnexis_threatmetrix_org_id}&session_id=#{flow_session[:threatmetrix_session_id]}", nonce: true %> + + <% end %> <% end %> <% end %> @@ -60,13 +62,13 @@ locals:

<%= flow_session[:error_message] %>

- + <% end %> <% end %> <% if updating_ssn %> diff --git a/app/views/mfa_confirmation/new.html.erb b/app/views/mfa_confirmation/new.html.erb index 8301994ffa2..a0aa93a3dba 100644 --- a/app/views/mfa_confirmation/new.html.erb +++ b/app/views/mfa_confirmation/new.html.erb @@ -8,12 +8,12 @@ t('help_text.change_factor', factor: user_session[:factor_to_change]) %>

-<%= validated_form_for( +<%= simple_form_for( current_user, url: reauthn_user_password_path, html: { autocomplete: 'off', method: 'post', class: 'margin-top-6' }, ) do |f| %> <%= render PasswordToggleComponent.new(form: f, required: true) %> - <%= f.button :submit, t('forms.buttons.continue'), class: 'display-block margin-y-5 usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.continue'), class: 'display-block margin-y-5' %> <% end %> <%= render 'shared/cancel', link: account_path %> diff --git a/app/views/password_capture/new.html.erb b/app/views/password_capture/new.html.erb index a3926cd5d21..f7e6016a4b7 100644 --- a/app/views/password_capture/new.html.erb +++ b/app/views/password_capture/new.html.erb @@ -2,7 +2,7 @@ <%= render PageHeadingComponent.new.with_content(password_header) %> -<%= validated_form_for( +<%= simple_form_for( current_user, as: :user, url: capture_password_url, @@ -14,7 +14,7 @@ label: t('account.index.password'), required: true, ) %> - <%= f.button :submit, t('forms.buttons.submit.default'), class: 'display-block margin-y-5 usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.submit.default'), class: 'display-block margin-y-5' %> <% end %> <%= render PageFooterComponent.new do %> diff --git a/app/views/shared/_email_languages.html.erb b/app/views/shared/_email_languages.html.erb index 8e75b731816..ed6262521dd 100644 --- a/app/views/shared/_email_languages.html.erb +++ b/app/views/shared/_email_languages.html.erb @@ -1,6 +1,6 @@ <%# locals: -* f: from validated_form_for +* f: from simple_form_for * selection: the current language selection * hint: optional hint override %> diff --git a/app/views/shared/_personal_key.html.erb b/app/views/shared/_personal_key.html.erb index 668f4493b7c..42dcdb62ea0 100644 --- a/app/views/shared/_personal_key.html.erb +++ b/app/views/shared/_personal_key.html.erb @@ -45,7 +45,7 @@ t('forms.buttons.continue'), update_path, class: 'display-block usa-button usa-button--big usa-button--wide personal-key-continue margin-top-5', - 'data-toggle': 'modal', + 'data-toggle': @confirm, ) %> <%= render 'shared/personal_key_confirmation_modal', code: code, update_path: update_path %> <%== javascript_packs_tag_once 'personal-key-page-controller' %> diff --git a/app/views/sign_up/completions/show.html.erb b/app/views/sign_up/completions/show.html.erb index cb35e28fba2..ce7d5fbc550 100644 --- a/app/views/sign_up/completions/show.html.erb +++ b/app/views/sign_up/completions/show.html.erb @@ -34,7 +34,7 @@ <% end %> <% end %>

- <%= validated_form_for(:idv_form, url: sign_up_completed_path) do %> - <%= submit_tag t('sign_up.agree_and_continue'), class: 'usa-button usa-button--big usa-button--wide' %> + <%= simple_form_for(:idv_form, url: sign_up_completed_path) do |f| %> + <%= f.submit t('sign_up.agree_and_continue') %> <% end %>

diff --git a/app/views/sign_up/email_resend/new.html.erb b/app/views/sign_up/email_resend/new.html.erb index 8aa88252171..645c72c5cde 100644 --- a/app/views/sign_up/email_resend/new.html.erb +++ b/app/views/sign_up/email_resend/new.html.erb @@ -1,7 +1,7 @@ <% title t('titles.confirmations.new') %> <%= render PageHeadingComponent.new.with_content(t('headings.confirmations.new')) %> -<%= validated_form_for( +<%= simple_form_for( @resend_email_confirmation_form, url: sign_up_register_path, html: { autocomplete: 'off', method: :post }, @@ -11,5 +11,5 @@ required: true, input_html: { aria: { invalid: false } } %> <%= f.input :request_id, as: :hidden %> - <%= f.button :submit, t('forms.buttons.resend_confirmation'), class: 'usa-button--big usa-button--wide margin-top-2 margin-bottom-1' %> + <%= f.submit t('forms.buttons.resend_confirmation'), class: 'margin-top-2 margin-bottom-1' %> <% end %> diff --git a/app/views/sign_up/emails/show.html.erb b/app/views/sign_up/emails/show.html.erb index bea5f9e71c8..e9ac6282215 100644 --- a/app/views/sign_up/emails/show.html.erb +++ b/app/views/sign_up/emails/show.html.erb @@ -19,9 +19,9 @@
-<%= validated_form_for @resend_email_confirmation_form, - html: { class: 'margin-bottom-2' }, - url: sign_up_register_path do |f| %> +<%= simple_form_for @resend_email_confirmation_form, + html: { class: 'margin-bottom-2' }, + url: sign_up_register_path do |f| %> <%= f.input :email, as: :hidden %> <%= f.input :resend, as: :hidden %> <%= f.input :request_id, as: :hidden %> diff --git a/app/views/sign_up/passwords/new.html.erb b/app/views/sign_up/passwords/new.html.erb index 94c32bdc1e5..3b11dedb889 100644 --- a/app/views/sign_up/passwords/new.html.erb +++ b/app/views/sign_up/passwords/new.html.erb @@ -5,7 +5,7 @@

<%= t('instructions.password.info.lead', min_length: Devise.password_length.first) %>

-<%= validated_form_for( +<%= simple_form_for( @password_form, url: sign_up_create_password_path, method: :post, @@ -20,7 +20,7 @@ <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= hidden_field_tag :confirmation_token, @confirmation_token, id: 'confirmation_token' %> <%= f.input :request_id, as: :hidden, input_html: { value: params[:request_id] || request_id } %> - <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide margin-bottom-5' %> + <%= f.submit t('forms.buttons.continue'), class: 'margin-bottom-5' %> <% end %> <%= render 'shared/password_accordion' %> diff --git a/app/views/sign_up/registrations/new.html.erb b/app/views/sign_up/registrations/new.html.erb index 32be4fa05c8..3434586fbd5 100644 --- a/app/views/sign_up/registrations/new.html.erb +++ b/app/views/sign_up/registrations/new.html.erb @@ -34,12 +34,7 @@ ) %> <%= f.input :request_id, as: :hidden, input_html: { value: params[:request_id] || request_id } %> - <%= f.button( - :button, - t('forms.buttons.submit.default'), - type: :submit, - class: 'display-block usa-button usa-button--big usa-button--wide margin-y-5', - ) %> + <%= f.submit t('forms.buttons.submit.default'), class: 'display-block margin-y-5' %> <% end %> <%= render 'shared/cancel', link: decorated_session.cancel_link_url %> diff --git a/app/views/two_factor_authentication/backup_code_verification/show.html.erb b/app/views/two_factor_authentication/backup_code_verification/show.html.erb index d915264d88e..e69f593ded8 100644 --- a/app/views/two_factor_authentication/backup_code_verification/show.html.erb +++ b/app/views/two_factor_authentication/backup_code_verification/show.html.erb @@ -6,13 +6,13 @@ <%= t('two_factor_authentication.backup_code_prompt') %>

-<%= validated_form_for( +<%= simple_form_for( @backup_code_form, url: login_two_factor_backup_code_path, html: { autocomplete: 'off', method: :post }, ) do |f| %> <%= render 'partials/backup_code/entry_fields', f: f, attribute_name: :backup_code %> - <%= f.button :submit, t('forms.buttons.submit.default'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.submit.default') %> <% end %> <%= render 'shared/fallback_links', presenter: @presenter %> diff --git a/app/views/two_factor_authentication/options/index.html.erb b/app/views/two_factor_authentication/options/index.html.erb index c716689a087..918008a8ba1 100644 --- a/app/views/two_factor_authentication/options/index.html.erb +++ b/app/views/two_factor_authentication/options/index.html.erb @@ -8,7 +8,7 @@ <%= @presenter.info %>

-<%= validated_form_for( +<%= simple_form_for( @two_factor_options_form, html: { autocomplete: 'off' }, method: :post, @@ -37,11 +37,7 @@ <% end %> - <%= f.button( - :submit, - t('forms.buttons.continue'), - class: 'display-block margin-y-5 usa-button--big usa-button--wide', - ) %> + <%= f.submit t('forms.buttons.continue'), class: 'display-block margin-y-5' %> <% end %>

diff --git a/app/views/two_factor_authentication/otp_verification/show.html.erb b/app/views/two_factor_authentication/otp_verification/show.html.erb index 0f51179021a..26e341d41cd 100644 --- a/app/views/two_factor_authentication/otp_verification/show.html.erb +++ b/app/views/two_factor_authentication/otp_verification/show.html.erb @@ -32,10 +32,7 @@ checked: @presenter.remember_device_box_checked?, }, ) %> - <%= submit_tag( - t('forms.buttons.submit.default'), - class: 'usa-button usa-button--big usa-button--wide display-block margin-y-5', - ) %> + <%= f.submit t('forms.buttons.submit.default'), class: 'display-block margin-y-5' %> <%= hidden_field_tag 'otp_make_default_number', @presenter.otp_make_default_number %> <%= render ButtonComponent.new( diff --git a/app/views/two_factor_authentication/personal_key_verification/show.html.erb b/app/views/two_factor_authentication/personal_key_verification/show.html.erb index 3af2e772fa5..6219ebae45d 100644 --- a/app/views/two_factor_authentication/personal_key_verification/show.html.erb +++ b/app/views/two_factor_authentication/personal_key_verification/show.html.erb @@ -6,12 +6,12 @@ <%= t('two_factor_authentication.personal_key_prompt') %>

-<%= validated_form_for( +<%= simple_form_for( @personal_key_form, url: login_two_factor_personal_key_path, html: { autocomplete: 'off', method: :post } ) do |f| %> <%= render 'partials/personal_key/entry_fields', f: f, attribute_name: :personal_key %> - <%= f.button :submit, t('forms.buttons.submit.default'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.submit.default') %> <% end %> <%= render 'shared/fallback_links', presenter: @presenter %> diff --git a/app/views/two_factor_authentication/totp_verification/show.html.erb b/app/views/two_factor_authentication/totp_verification/show.html.erb index 545adebe2da..4559cbffe9c 100644 --- a/app/views/two_factor_authentication/totp_verification/show.html.erb +++ b/app/views/two_factor_authentication/totp_verification/show.html.erb @@ -29,10 +29,7 @@ checked: @presenter.remember_device_box_checked?, }, ) %> - <%= submit_tag( - t('forms.buttons.submit.default'), - class: 'display-block usa-button usa-button--wide usa-button--big margin-top-5', - ) %> + <%= f.submit t('forms.buttons.submit.default'), class: 'display-block margin-top-5' %> <% end %> <%= render 'shared/fallback_links', presenter: @presenter %> diff --git a/app/views/two_factor_authentication/webauthn_verification/show.html.erb b/app/views/two_factor_authentication/webauthn_verification/show.html.erb index d8b494d88d7..125a3957c16 100644 --- a/app/views/two_factor_authentication/webauthn_verification/show.html.erb +++ b/app/views/two_factor_authentication/webauthn_verification/show.html.erb @@ -77,8 +77,7 @@ checked: @presenter.remember_device_box_checked?, }, ) %> - <%= submit_tag t('forms.buttons.continue'), - class: 'display-block usa-button usa-button--big usa-button--wide margin-y-4' %> + <%= f.submit t('forms.buttons.continue'), class: 'display-block margin-y-4' %> <% end %> <%= render 'shared/cancel', link: @presenter.cancel_link %> diff --git a/app/views/user_mailer/in_person_ready_to_verify.html.erb b/app/views/user_mailer/in_person_ready_to_verify.html.erb index 4fa023a4c67..8c986695cb3 100644 --- a/app/views/user_mailer/in_person_ready_to_verify.html.erb +++ b/app/views/user_mailer/in_person_ready_to_verify.html.erb @@ -67,7 +67,7 @@ t('in_person_proofing.body.barcode.learn_more'), MarketingSite.help_center_article_url( category: 'verify-your-identity', - article: 'how-to-verify-in-person', + article: 'verify-your-identity-in-person', ), ) %>

diff --git a/app/views/users/backup_code_setup/reminder.html.erb b/app/views/users/backup_code_setup/reminder.html.erb new file mode 100644 index 00000000000..5c42dda3ec2 --- /dev/null +++ b/app/views/users/backup_code_setup/reminder.html.erb @@ -0,0 +1,19 @@ +<% title t('forms.backup_code.title') %> + +<%= image_tag asset_url('user-signup-ial1.svg'), width: 107, height: 119, alt: '', class: 'margin-bottom-4' %> + +<%= render PageHeadingComponent.new.with_content(t('forms.backup_code_reminder.heading')) %> + +

+ <%= t('forms.backup_code_reminder.body_info') %> +

+ +<%= button_to( + account_path, + method: :get, + class: 'usa-button usa-button--wide usa-button--big margin-bottom-3', + ) do %> + <%= t('forms.backup_code_reminder.have_codes') %> +<% end %> + +<%= link_to t('forms.backup_code_reminder.need_new_codes'), backup_code_regenerate_path %> diff --git a/app/views/users/delete/show.html.erb b/app/views/users/delete/show.html.erb index 78fec448400..d4424f8cb30 100644 --- a/app/views/users/delete/show.html.erb +++ b/app/views/users/delete/show.html.erb @@ -29,11 +29,7 @@ required: true, ) %> - <%= f.button( - :submit, - t('users.delete.actions.delete'), - class: 'usa-button--big usa-button--wide usa-button--danger margin-top-1 margin-bottom-2', - ) %> + <%= f.submit t('users.delete.actions.delete'), danger: true, class: 'margin-top-1 margin-bottom-2' %> <% end %> <%= link_to( diff --git a/app/views/users/edit_phone/edit.html.erb b/app/views/users/edit_phone/edit.html.erb index ca090b91330..5ea8f5e099a 100644 --- a/app/views/users/edit_phone/edit.html.erb +++ b/app/views/users/edit_phone/edit.html.erb @@ -1,7 +1,7 @@ <% title t('titles.edit_info.phone') %> <%= render PageHeadingComponent.new.with_content(t('headings.edit_info.phone')) %> -<%= validated_form_for( +<%= simple_form_for( @edit_phone_form, html: { autocomplete: 'off', method: :put }, url: manage_phone_path(id: @phone_configuration.id), @@ -15,7 +15,7 @@ <%= render 'delivery_preference_selection' %> <%= render 'make_default_number' %> - <%= f.button :submit, t('forms.buttons.submit.confirm_change'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.submit.confirm_change') %> <% end %> <%= render 'remove_phone' %> diff --git a/app/views/users/email_language/show.html.erb b/app/views/users/email_language/show.html.erb index 80923d9a0fb..db864d361b8 100644 --- a/app/views/users/email_language/show.html.erb +++ b/app/views/users/email_language/show.html.erb @@ -12,8 +12,8 @@ ) %>

-<%= validated_form_for(current_user, url: account_email_language_path, method: 'PATCH') do |f| %> +<%= simple_form_for(current_user, url: account_email_language_path, method: 'PATCH') do |f| %> <%= render partial: 'shared/email_languages', locals: { f: f, hint: false, selection: current_user.email_language } %> - <%= f.button :submit, t('forms.buttons.submit.default'), class: 'usa-button--big usa-button--wide grid-col-8 tablet:grid-col-6' %> + <%= f.submit t('forms.buttons.submit.default'), class: 'grid-col-8 tablet:grid-col-6' %> <% end %> diff --git a/app/views/users/emails/show.html.erb b/app/views/users/emails/show.html.erb index 4ad32da3381..2702d88a964 100644 --- a/app/views/users/emails/show.html.erb +++ b/app/views/users/emails/show.html.erb @@ -5,14 +5,14 @@ <%= render PageHeadingComponent.new.with_content(t('headings.add_email')) %>
- <%= validated_form_for( + <%= simple_form_for( @add_user_email_form, html: { autocomplete: 'off' }, url: add_email_path, ) do |f| %> <%= f.input :email, label: t('forms.registration.labels.email'), required: true, input_html: { aria: { invalid: false } } %> - <%= f.button :submit, t('forms.buttons.submit.default'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.submit.default') %> <% end %>
diff --git a/app/views/users/mfa_selection/index.html.erb b/app/views/users/mfa_selection/index.html.erb index 345b19fea6f..a7a952c2496 100644 --- a/app/views/users/mfa_selection/index.html.erb +++ b/app/views/users/mfa_selection/index.html.erb @@ -4,10 +4,10 @@

<%= @presenter.intro %>

-<%= validated_form_for @two_factor_options_form, - html: { autocomplete: 'off' }, - method: :patch, - url: second_mfa_setup_path do |f| %> +<%= simple_form_for @two_factor_options_form, + html: { autocomplete: 'off' }, + method: :patch, + url: second_mfa_setup_path do |f| %>
<%= @presenter.intro %> @@ -21,7 +21,7 @@
- <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide margin-bottom-1' %> + <%= f.submit t('forms.buttons.continue'), class: 'margin-bottom-1' %> <% end %> <%= render 'shared/cancel', link: @after_setup_path %> diff --git a/app/views/users/passwords/edit.html.erb b/app/views/users/passwords/edit.html.erb index 93f4d845129..943b2130cab 100644 --- a/app/views/users/passwords/edit.html.erb +++ b/app/views/users/passwords/edit.html.erb @@ -6,7 +6,7 @@ <%= t('instructions.password.info.lead', min_length: Devise.password_length.first) %>

-<%= validated_form_for( +<%= simple_form_for( @update_user_password_form, url: manage_password_path, html: { autocomplete: 'off', method: :patch } ) do |f| %> @@ -19,7 +19,7 @@ input_html: { aria: { describedby: 'password-description' } }, ) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> - <%= f.button :submit, t('forms.buttons.submit.update'), class: 'usa-button--big usa-button--wide margin-top-2 margin-bottom-4' %> + <%= f.submit t('forms.buttons.submit.update'), class: 'margin-top-2 margin-bottom-4' %> <% end %> <%= render 'shared/password_accordion' %> diff --git a/app/views/users/phone_setup/index.html.erb b/app/views/users/phone_setup/index.html.erb index bda7e37c2af..07dd7570202 100644 --- a/app/views/users/phone_setup/index.html.erb +++ b/app/views/users/phone_setup/index.html.erb @@ -16,7 +16,7 @@ <% end %>

-<%= validated_form_for( +<%= simple_form_for( @new_phone_form, html: { autocomplete: 'off', method: :patch }, data: { international_phone_form: true }, @@ -36,7 +36,7 @@ <%= render 'users/shared/otp_make_default_number', form_obj: @new_phone_form %> <% end %> - <%= f.button :submit, t('forms.buttons.send_security_code'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.send_security_code') %> <% end %> <%= render 'shared/cancel_or_back_to_options' %> diff --git a/app/views/users/phones/add.html.erb b/app/views/users/phones/add.html.erb index 4810e907400..e926c51f698 100644 --- a/app/views/users/phones/add.html.erb +++ b/app/views/users/phones/add.html.erb @@ -14,7 +14,7 @@ <% end %>

-<%= validated_form_for( +<%= simple_form_for( @new_phone_form, html: { autocomplete: 'off', method: :post }, data: { international_phone_form: true }, @@ -29,7 +29,7 @@ <%= render 'users/shared/otp_make_default_number', form_obj: @new_phone_form %> <% end %> - <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.continue') %> <% end %> <%= render 'shared/cancel', link: account_path %> diff --git a/app/views/users/piv_cac_authentication_setup/new.html.erb b/app/views/users/piv_cac_authentication_setup/new.html.erb index 10e57c82dfb..5c8ae1e349e 100644 --- a/app/views/users/piv_cac_authentication_setup/new.html.erb +++ b/app/views/users/piv_cac_authentication_setup/new.html.erb @@ -31,10 +31,7 @@ <% end %> <% end %> - <%= f.submit( - t('forms.piv_cac_setup.submit'), - class: 'display-block usa-button usa-button--wide usa-button--big margin-y-5', - ) %> + <%= f.submit t('forms.piv_cac_setup.submit'), class: 'display-block margin-y-5' %> <% end %> diff --git a/app/views/users/piv_cac_setup_from_sign_in/prompt.html.erb b/app/views/users/piv_cac_setup_from_sign_in/prompt.html.erb index f3bbfbfd7c4..348b068e81f 100644 --- a/app/views/users/piv_cac_setup_from_sign_in/prompt.html.erb +++ b/app/views/users/piv_cac_setup_from_sign_in/prompt.html.erb @@ -23,7 +23,7 @@ }, ) %>
- <%= submit_tag t('forms.piv_cac_setup.submit'), class: 'usa-button usa-button--wide usa-button--big' %> + <%= f.submit t('forms.piv_cac_setup.submit') %>
<%= link_to t('forms.piv_cac_setup.no_thanks'), diff --git a/app/views/users/rules_of_use/new.html.erb b/app/views/users/rules_of_use/new.html.erb index 19a4eaa5efc..3e7a0b8c56a 100644 --- a/app/views/users/rules_of_use/new.html.erb +++ b/app/views/users/rules_of_use/new.html.erb @@ -29,12 +29,7 @@ required: true, ) %> - <%= f.button( - :button, - t('forms.buttons.continue'), - type: :submit, - class: 'usa-button--big usa-button--wide margin-y-5', - ) %> + <%= f.submit t('forms.buttons.continue'), class: 'margin-y-5' %> <% end %> <%= render 'shared/cancel', link: decorated_session.cancel_link_url %> diff --git a/app/views/users/totp_setup/new.html.erb b/app/views/users/totp_setup/new.html.erb index c527e40b11a..6a0c46fb1e7 100644 --- a/app/views/users/totp_setup/new.html.erb +++ b/app/views/users/totp_setup/new.html.erb @@ -59,10 +59,7 @@ checked: @presenter.remember_device_box_checked?, }, ) %> - <%= submit_tag( - t('forms.buttons.submit.default'), - class: 'display-block usa-button usa-button--big usa-button--wide margin-y-5', - ) %> + <%= f.submit t('forms.buttons.submit.default'), class: 'display-block margin-y-5' %> <% end %> <%= render 'shared/cancel_or_back_to_options' %> diff --git a/app/views/users/two_factor_authentication_setup/index.html.erb b/app/views/users/two_factor_authentication_setup/index.html.erb index b14e8220df1..53c51676462 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.erb +++ b/app/views/users/two_factor_authentication_setup/index.html.erb @@ -14,10 +14,10 @@

<%= @presenter.intro %>

-<%= validated_form_for @two_factor_options_form, - html: { autocomplete: 'off' }, - method: :patch, - url: authentication_methods_setup_path do |f| %> +<%= simple_form_for @two_factor_options_form, + html: { autocomplete: 'off' }, + method: :patch, + url: authentication_methods_setup_path do |f| %>
<%= @presenter.intro %> @@ -54,7 +54,7 @@
- <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide margin-bottom-1' %> + <%= f.submit t('forms.buttons.continue'), class: 'margin-bottom-1' %> <% end %> <%= render 'shared/cancel', link: destroy_user_session_path %> diff --git a/app/views/users/verify_password/new.html.erb b/app/views/users/verify_password/new.html.erb index a5f8b8932a3..414ac55d7f6 100644 --- a/app/views/users/verify_password/new.html.erb +++ b/app/views/users/verify_password/new.html.erb @@ -6,7 +6,7 @@ <%= t('idv.messages.sessions.review_message', app_name: APP_NAME) %>

-<%= validated_form_for( +<%= simple_form_for( current_user, url: update_verify_password_path, html: { autocomplete: 'off', method: :put } ) do |f| %> @@ -16,7 +16,7 @@ label: t('idv.form.password'), required: true, ) %> - <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide' %> + <%= f.submit t('forms.buttons.continue') %> <% end %>
diff --git a/config/application.yml.default b/config/application.yml.default index 413ca9371d7..139be4992b6 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -51,6 +51,7 @@ aws_logo_bucket: '' aws_region: 'us-west-2' aws_kms_multi_region_enabled: false backup_code_cost: '2000$8$1$' +backup_code_reminder_redirect: false broken_personal_key_window_start: '2021-07-29T00:00:00Z' broken_personal_key_window_finish: '2021-09-22T00:00:00Z' country_phone_number_overrides: '{}' @@ -100,6 +101,7 @@ idv_api_enabled_steps: '[]' idv_attempt_window_in_hours: 6 idv_max_attempts: 5 idv_min_age_years: 13 +idv_personal_key_confirmation_enabled: true idv_public_key: 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBS3p4d25rbUxqeGx1NmhsRlQ2d2JreUlweHNtYkMyaApjYW5TMGhuWm1DRGIrTEhaME5zQTdHWURpZkMxQlRBMHRuRFo0Zm9HNTRmYjNzYk9ubGpGWXVNQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=' idv_private_key: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBS3p4d25rbUxqeGx1NmhsRlQ2d2JreUlweHNtYkMyaGNhblMwaG5abUNEYitMSFowTnNBCjdHWURpZkMxQlRBMHRuRFo0Zm9HNTRmYjNzYk9ubGpGWXVNQ0F3RUFBUUpCQUp6TUMvOSs2RWlHQzkrZTFlWWkKVzc0ejN4MjBkanZndFlhOHh4UDh2ZnA3TjdKQXMvaGNUbjVLOCtDM2swaXUyR2RNb21qSlp2ckxwT0IyTWh4RQo3QkVDSVFEVERhbVRCMHhKSlVpV0ljNk15Y0dFa2J4SEZ3eEtURVNCaHhzREFISDZEUUloQU5IR2NwVUs5dmVSCkdrZlZTOS9MSVNZQlk2YzRUZk1NUFJZU21KVHFNRVN2QWlBZFdiY05aV1JzZjZ6YWhCVVBhemRvVWtRV3R0UFUKdVVxRm9ONVd5b2NQT1FJZ1FrUjlaK1haMUtVcTl5eERWc1FWaWFzQXJ3K1RXRWN5ZU9tUTkrSHZNNU1DSUcrMQpxVldqNW9PL0FBSU1QbXZVZmp5L0JnMnhEQVRiOEp6alFrQ3dLSnNwCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==' idv_send_link_attempt_window_in_minutes: 10 @@ -112,7 +114,7 @@ in_person_results_delay_in_hours: 1 include_slo_in_saml_metadata: false irs_attempt_api_audience: 'https://irs.gov' irs_attempt_api_auth_tokens: '' -irs_attempt_api_csp_id: 'Login.gov' +irs_attempt_api_csp_id: 'LOGIN.gov' irs_attempt_api_enabled: false irs_attempt_api_event_ttl_seconds: 86400 irs_attempt_api_event_count_default: 1000 @@ -319,12 +321,13 @@ development: hmac_fingerprinter_key: a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c hmac_fingerprinter_key_queue: '["11111111111111111111111111111111", "22222222222222222222222222222222"]' identity_pki_local_dev: true - idv_api_enabled_steps: '["password_confirm", "personal_key","personal_key_confirm"]' in_person_proofing_enabled: true kantara_2fa_phone_restricted: true kantara_2fa_phone_existing_user_restriction: true kantara_restriction_enforcement_date: '2022-07-01' irs_attempt_api_public_key: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyut9Uio5XxsIUVrXARqCoHvcMVYT0p6WyU1BnbhxLRW4Q60p+4Bn32vVOt9nzeih7qvauYM5M0PZdKEmwOHflqPP+ABfKhL+6jxBhykN5P5UY375wTFBJZ20Fx8jOJbRhJD02oUQ49YKlDu3MG5Y0ApyD4ER4WKgxuB2OdyQKd9vg2ZZa+P2pw1HkFPEin0h8KBUFBeLGDZni8PIJdHBP6dA+xbayGBxSM/8xQC0JIg6KlGTcLql37QJIhP2oSv0nAJNb6idFPAz0uMCQDQWKKWV5FUDCsFVH7VuQz8xUCwnPn/SdaratB+29bwUpVhgHXrHdJ0i8vjBEX7smD7pI8CcFHuVgACt86NMlBnNCVkwumQgZNAAxe2mJoYcotEWOnhCuMc6MwSj985bj8XEdFlbf4ny9QO9rETd5aYcwXBiV/T6vd637uvHb0KenghNmlb1Tv9LMj2b9ZwNc9C6oeCnbN2YAfxSDrb8Ik+yq4hRewOvIK7f0CcpZYDXK25aHXnHm306Uu53KIwMGf1mha5T5LWTNaYy5XFoMWHJ9E+AnU/MUJSrwCAITH/S0JFcna5Oatn70aTE9pISATsqB5Iz1c46MvdrxD8hPoDjT7x6/EO316DZrxQfJhjbWsCB+R0QxYLkXPHczhB2Z0HPna9xB6RbJHzph7ifDizhZoMCAwEAAQ== + irs_attempt_api_enabled: true + irs_attempt_api_auth_tokens: 'abc123' liveness_checking_enabled: true logins_per_ip_limit: 5 logo_upload_enabled: true diff --git a/config/environments/test.rb b/config/environments/test.rb index 711fe6db86a..0ae75ffc808 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -36,10 +36,6 @@ config.middleware.use RackSessionAccess::Middleware - # Disable lograge when computing coverage and not in CircleCI, where lograge is required. - # This enables scanning for view test coverage with `rake test:scan_log_for_view_coverage` - config.lograge.enabled = !ENV['COVERAGE'] || ENV['CI'] - config.after_initialize do # Having bullet enabled in the test environment causes issues with unit # tests that may not make user of eager loaded values. We disable it by diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index f60177a61a2..bdfd10bee85 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -33,7 +33,7 @@ if auth.env['action_dispatch.cookies'] expected_cookie_value = "#{user.class}-#{user.id}" actual_cookie_value = auth.env['action_dispatch.cookies']. - signed[TwoFactorAuthenticatable::REMEMBER_2FA_COOKIE] + signed[TwoFactorAuthenticatable::REMEMBER_2FA_COOKIE] bypass_by_cookie = actual_cookie_value == expected_cookie_value end diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb index 7990dcc33cd..52098335a39 100644 --- a/config/initializers/simple_form.rb +++ b/config/initializers/simple_form.rb @@ -1,6 +1,7 @@ # rubocop:disable Metrics/BlockLength SimpleForm.setup do |config| require Rails.root.join('lib', 'extensions', 'simple_form', 'error_notification') + require Rails.root.join('lib', 'extensions', 'simple_form', 'components', 'submit_component') config.button_class = 'usa-button' config.boolean_label_class = nil diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index eb707b2e7f1..f5f5c075df3 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -53,11 +53,9 @@ en: totp_confirm_delete: Yes, remove authentication app unknown_location: unknown location verification: - bounced: The postal service could not deliver the letter to your address. instructions: Your account requires a confirmation code to be verified. reactivate_button: Enter the code you received via US mail success: Your account has been verified. - update_address: Please update your address to be verified. webauthn: Security key webauthn_add: Add security key webauthn_confirm_delete: Yes, remove key diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index ad4e43f3db8..30f02a3ca05 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -54,11 +54,9 @@ es: totp_confirm_delete: Sí, eliminar la aplicación de autenticación unknown_location: ubicación desconocida verification: - bounced: El servicio postal no pudo entregar la carta a su dirección. instructions: Su cuenta requiere un código de confirmación para ser verificado. reactivate_button: Ingrese el código que recibió por correo postal. success: Su cuenta ha sido verificada. - update_address: Actualice su dirección para ser verificada. webauthn: Clave de seguridad webauthn_add: Añadir clave de seguridad webauthn_confirm_delete: Si quitar la llave diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index c438c393d60..bd0884fed08 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -57,11 +57,9 @@ fr: totp_confirm_delete: Oui, supprimez l’application d’authentification unknown_location: lieu inconnu verification: - bounced: Le service postal n’a pas pu envoyer la lettre à votre adresse. instructions: Votre compte nécessite un code de confirmation pour être vérifié. reactivate_button: Entrez le code que vous avez reçu par la poste success: Votre compte a été vérifié. - update_address: Veuillez mettre à jour votre adresse pour être vérifiée. webauthn: Clé de sécurité webauthn_add: Ajouter une clé de sécurité webauthn_confirm_delete: Oui, supprimer la clé diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml index 77c0c3db60e..4478278956a 100644 --- a/config/locales/forms/en.yml +++ b/config/locales/forms/en.yml @@ -27,6 +27,12 @@ en: caution: If you regenerate your backup codes you will receive a new set of backup codes. Your original backup codes will no longer be valid. confirm: Are you sure you want to regenerate your backup codes? + backup_code_reminder: + body_info: If you ever lose access to your primary authentication method, you + can use backup codes to regain access to your account. + have_codes: I have my codes + heading: Do you still have your backup codes? + need_new_codes: I need a new set of backup codes buttons: back: Back cancel: Yes, cancel @@ -108,10 +114,15 @@ en: validation: required_checkbox: Please check this box to continue verify_profile: - instructions: Enter the ten-character code in the letter we sent you. + instructions: Enter the 10-character code from the letter you received. name: Confirmation code submit: Confirm account title: Confirm your account + welcome_back: Welcome back + welcome_back_description: '

If you have received your letter, please enter + your confirmation code below.





If your letter hasn’t arrived + yet, please be patient as letters typically take 3 to 7 business + days to arrive. Thank you for your patience.

' webauthn_delete: caution: If you remove your security key you won’t be able to use it to access your %{app_name} account. diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml index e8792640ece..4d449c235a5 100644 --- a/config/locales/forms/es.yml +++ b/config/locales/forms/es.yml @@ -32,6 +32,12 @@ es: no serán válidos. confirm: '¿Está seguro de que desea volver a generar sus códigos de copia de seguridad?' + backup_code_reminder: + body_info: Si por alguna razón no puede acceder a su método de autenticación + principal, puede usar códigos de recuperación para ingresar a su cuenta. + have_codes: Tengo mis códigos + heading: '¿Todavía tiene sus códigos de recuperación?' + need_new_codes: Necesito un nuevo conjunto de códigos de recuperación buttons: back: Atrás cancel: Sí, cancelar @@ -115,10 +121,15 @@ es: validation: required_checkbox: Marque esta casilla para continuar verify_profile: - instructions: Ingrese el código de 10 caracteres que le enviamos en la carta. + instructions: Introduzca el código de 10 caracteres de la carta que ha recibido. name: Código de confirmación submit: Confirmar cuenta title: Confirme su cuenta + welcome_back: Bienvenido de nuevo + welcome_back_description: '

Si ha recibido su carta, introduzca su código de + confirmación a continuación.

Si su carta aún no ha llegado, tenga + paciencia, ya que las cartas suelen tardar de 3 a 7 días hábiles + en llegar. Gracias por su paciencia.

' webauthn_delete: caution: Si elimina su clave de seguridad, no podrá usarla para acceder a su cuenta %{app_name}. diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml index 84eb16b2492..97cb4bd26ff 100644 --- a/config/locales/forms/fr.yml +++ b/config/locales/forms/fr.yml @@ -32,6 +32,13 @@ fr: ensemble de codes de sauvegarde. Vos codes de sauvegarde d’origine ne seront plus valides. confirm: Êtes-vous sûr de vouloir régénérer vos codes de sauvegarde? + backup_code_reminder: + body_info: Si vous perdez l’accès à votre méthode d’authentification principale, + vous pouvez utiliser des codes de sauvegarde pour accéder à nouveau à + votre compte. + have_codes: J’ai mes codes + heading: Avez-vous toujours vos codes de sauvegarde? + need_new_codes: J’ai besoin d’un nouvel ensemble de codes de sauvegarde buttons: back: Retour cancel: Oui, annuler @@ -115,11 +122,17 @@ fr: validation: required_checkbox: Veuillez cocher cette case pour continuer verify_profile: - instructions: Entrez le code à dix caractères qui se trouve dans la lettre que - nous vous avons envoyée. + instructions: Entrez le code à 10 caractères figurant sur la lettre que vous + avez reçue. name: Code de confirmation submit: Confirmer le compte title: Confirmez votre compte + welcome_back: Content de vous revoir + welcome_back_description: '

Si vous avez reçu votre lettre, veuillez entrer + votre code de confirmation ci-dessous.

Si votre lettre n’est pas + encore arrivée, veuillez être patient car les lettres prennent + généralement entre trois à sept jours ouvrables pour arriver. + Nous vous remercions de votre patience.

' webauthn_delete: caution: Si vous supprimez votre clé de sécurité, vous ne pourrez plus l’utiliser pour accéder à votre compte %{app_name}. diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 395fd16ae26..13df5213250 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -85,8 +85,7 @@ en: timeout: We are experiencing higher than usual wait time processing your request. Please try again. forgot_password: - link_html: Forgot password? %{link} - link_text: Follow these instructions + link_text: Forgot password? modal_header: Are you sure you can’t remember your password? reset_password: Reset password try_again: Try again @@ -124,13 +123,11 @@ en: come_back_later_sp_html: You can return to %{sp} for now. confirm: You have encrypted your verified data gpo: - address_on_file: We will mail a letter with a confirmation code to the address - that you provided on the previous step. - new_address: We will mail a letter with a confirmation code to the address you - specify. + address_on_file_html: We will mail a letter with a confirmation + code to the address that you provided on the previous step. resend: Send me another letter - timeframe: Letters are sent the next business day via USPS First Class Mail and - typically take 3 to 7 business days. + timeframe_html: Letters are sent the next business day via USPS First Class Mail + and typically take 3 to 7 business days to arrive. mail_sent: Your letter is on its way otp_delivery_method: phone_number_html: We’ll send a code to %{phone} to verify that diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 74460ccaee2..cde86dbcfac 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -91,8 +91,7 @@ es: timeout: Estamos experimentando un tiempo de espera superior al habitual al procesar su solicitud. Inténtalo de nuevo. forgot_password: - link_html: '¿Se te olvidó tu contraseña? %{link}' - link_text: Siga estas instrucciones + link_text: '¿Se te olvidó tu contraseña?' modal_header: '¿Estás seguro de que no puedes recordar tu contraseña?' reset_password: Restablecer la contraseña try_again: Inténtalo de nuevo @@ -129,13 +128,13 @@ es: come_back_later_sp_html: Ahora puedes volver a %{sp}. confirm: Usted ha encriptado sus datos verificados. gpo: - address_on_file: Le enviaremos una carta con un código de confirmación a la - dirección que nos facilitó en el paso anterior. - new_address: Le enviaremos una carta con un código de confirmación a la - dirección que especifique. + address_on_file_html: Le enviaremos una carta con un código de + confirmación a la dirección que nos facilitó en el paso + anterior. resend: Envíeme otra carta - timeframe: Las correspondencias se envían al día siguiente por correo de primera - clase de USPS y por lo general tardan entre 3 y 7 días laborables. + timeframe_html: Las cartas se envían al día siguiente por First Class Mail de + USPS y suelen tardar entre 3 y 7 días hábiles en + llegar. mail_sent: Su carta está en camino otp_delivery_method: phone_number_html: Enviaremos un código a %{phone} para diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 1ab7443d94c..797b8094fee 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -95,8 +95,7 @@ fr: timeout: Le temps d’attente pour le traitement de votre demande est plus long que d’habitude Veuillez réessayer. forgot_password: - link_html: Mot de passe oublié? %{link} - link_text: Suivez ces instructions + link_text: Mot de passe oublié? modal_header: Êtes-vous sûr de ne pas pouvoir vous souvenir de votre mot de passe? reset_password: Réinitialiser le mot de passe try_again: Réessayer @@ -136,14 +135,13 @@ fr: come_back_later_sp_html: Vous pouvez revenir à %{sp} pour le moment. confirm: Vous avez crypté vos données vérifiées gpo: - address_on_file: Nous enverrons une lettre avec un code de confirmation à - l’adresse que vous avez indiquée à l’étape précédente. - new_address: Nous vous enverrons une lettre avec un code de confirmation à - l’adresse que vous spécifiez. + address_on_file_html: Nous enverrons une lettre avec un code de + confirmation à l’adresse que vous avez indiquée à l’étape + précédente. resend: Envoyez-moi une autre lettre - timeframe: Les lettres sont envoyées le jour ouvrable suivant par courrier de - première classe USPS et prennent généralement de 3 à 7 jours - ouvrables. + timeframe_html: Les lettres sont envoyées les jours ouvrables par courriel de + première classe de USPS et prennent généralement entre trois à + sept jours ouvrables pour être reçues. mail_sent: Votre lettre est en route otp_delivery_method: phone_number_html: Nous enverrons un code à %{phone} pour diff --git a/config/locales/notices/en.yml b/config/locales/notices/en.yml index 7c8abe7065b..eb8f8ee4e82 100644 --- a/config/locales/notices/en.yml +++ b/config/locales/notices/en.yml @@ -2,6 +2,7 @@ en: notices: account_reactivation: Great! You have your personal key. + authenticated_successfully: Authenticated successfully. backup_codes_configured: Backup codes were added to your account. backup_codes_deleted: Your backup codes were deleted from your account. dap_participation: We participate in the US government’s analytics program. See diff --git a/config/locales/notices/es.yml b/config/locales/notices/es.yml index ca61e20e78e..04550a8c2b3 100644 --- a/config/locales/notices/es.yml +++ b/config/locales/notices/es.yml @@ -2,6 +2,7 @@ es: notices: account_reactivation: '¡Estupendo! Tiene su clave personal.' + authenticated_successfully: Autenticado con éxito. backup_codes_configured: Códigos de respaldo fueron agregados a tu cuenta. backup_codes_deleted: Tus códigos de respaldo fueron eliminados de tu cuenta. dap_participation: Participamos en el programa analítico del Gobierno de Estados diff --git a/config/locales/notices/fr.yml b/config/locales/notices/fr.yml index 4bcad803be3..30fa3da0fea 100644 --- a/config/locales/notices/fr.yml +++ b/config/locales/notices/fr.yml @@ -2,6 +2,7 @@ fr: notices: account_reactivation: Excellent! Vous avez votre clé personnelle. + authenticated_successfully: Authentifié avec succès. backup_codes_configured: Les codes de sauvegarde ont été ajoutés à votre compte. backup_codes_deleted: Vos codes de sauvegarde ont été supprimés de votre compte. dap_participation: Nous participons au programme d’analytique du gouvernement diff --git a/config/routes.rb b/config/routes.rb index 5ddf2cace0d..a35df3bdf84 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -241,6 +241,7 @@ get '/users/two_factor_authentication' => 'users/two_factor_authentication#show', as: :user_two_factor_authentication # route name is used by two_factor_authentication gem get '/backup_code_refreshed' => 'users/backup_code_setup#refreshed' + get '/backup_code_reminder' => 'users/backup_code_setup#reminder' get '/backup_code_setup' => 'users/backup_code_setup#index' patch '/backup_code_setup' => 'users/backup_code_setup#create', as: :backup_code_create patch '/backup_code_continue' => 'users/backup_code_setup#continue' diff --git a/lib/asset_sources.rb b/lib/asset_sources.rb index 067d81f748f..1e2579d4922 100644 --- a/lib/asset_sources.rb +++ b/lib/asset_sources.rb @@ -9,7 +9,7 @@ def get_sources(*names) # See: app/javascript/packages/rails-i18n-webpack-plugin/extract-keys-webpack-plugin.js regexp_locale_suffix = %r{\.(#{I18n.available_locales.join('|')})\.js$} - load_manifest if !manifest || !cache_manifest + load_manifest_if_needed locale_sources, sources = names.flat_map do |name| manifest&.dig('entrypoints', name, 'assets', 'js') @@ -22,13 +22,19 @@ def get_sources(*names) end def get_assets(*names) - load_manifest if !manifest || !cache_manifest + load_manifest_if_needed names.flat_map do |name| manifest&.dig('entrypoints', name, 'assets')&.except('js')&.values&.flatten end.uniq.compact end + def get_integrity(path) + load_manifest_if_needed + + manifest&.dig('integrity', path) + end + def load_manifest self.manifest = begin JSON.parse(File.read(manifest_path)) @@ -36,5 +42,11 @@ def load_manifest nil end end + + private + + def load_manifest_if_needed + load_manifest if !manifest || !cache_manifest + end end end diff --git a/lib/extensions/simple_form/components/submit_component.rb b/lib/extensions/simple_form/components/submit_component.rb new file mode 100644 index 00000000000..2c69eb999d0 --- /dev/null +++ b/lib/extensions/simple_form/components/submit_component.rb @@ -0,0 +1,10 @@ +module SubmitComponent + def submit(*args, &block) + options = args.extract_options! + content = args.first + content = template.capture { yield(content) } if block + template.render SubmitButtonComponent.new(**options, &block).with_content(content) + end +end + +SimpleForm::FormBuilder.send :include, SubmitComponent diff --git a/lib/feature_management.rb b/lib/feature_management.rb index 91b2dfd50c5..c572397d91b 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -116,6 +116,10 @@ def self.idv_api_enabled? IdentityConfig.store.idv_api_enabled_steps.present? end + def self.idv_personal_key_confirmation_enabled? + IdentityConfig.store.idv_personal_key_confirmation_enabled + end + # Manual allowlist for VOIPs, should only include known VOIPs that we use for smoke tests # @return [Set] set of phone numbers normalized to e164 def self.voip_allowed_phones diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 8055f92d158..10189484a0c 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -113,6 +113,7 @@ def self.build_store(config_map) config.add(:aws_logo_bucket, type: :string) config.add(:aws_region, type: :string) config.add(:backup_code_cost, type: :string) + config.add(:backup_code_reminder_redirect, type: :boolean) config.add(:broken_personal_key_window_start, type: :timestamp) config.add(:broken_personal_key_window_finish, type: :timestamp) config.add(:country_phone_number_overrides, type: :json) @@ -179,6 +180,7 @@ def self.build_store(config_map) config.add(:idv_attempt_window_in_hours, type: :integer) config.add(:idv_max_attempts, type: :integer) config.add(:idv_min_age_years, type: :integer) + config.add(:idv_personal_key_confirmation_enabled, type: :boolean) config.add(:idv_private_key, type: :string) config.add(:idv_public_key, type: :string) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) diff --git a/lib/tasks/attempts.rake b/lib/tasks/attempts.rake index 93dcd1ca13d..3cc6571b324 100644 --- a/lib/tasks/attempts.rake +++ b/lib/tasks/attempts.rake @@ -11,17 +11,23 @@ namespace :attempts do resp = conn.post('/api/irs_attempts_api/security_events', body) do |req| req.headers['Authorization'] = "Bearer #{IdentityConfig.store.irs_attempt_api_csp_id} #{auth_token}" - end.body - - events = JSON.parse(resp) + end + encrypted_data = Base64.strict_decode64(resp.body) + iv = Base64.strict_decode64(resp.headers['x-payload-iv']) + encrypted_key = Base64.strict_decode64(resp.headers['x-payload-key']) + private_key = OpenSSL::PKey::RSA.new(File.read(private_key_path)) + key = private_key.private_decrypt(encrypted_key) + decrypted = IrsAttemptsApi::EnvelopeEncryptor.decrypt( + encrypted_data: encrypted_data, key: key, iv: iv, + ) + events = JSON.parse(decrypted) if File.exist?(private_key_path) - puts events['sets'].any? ? 'Decrypted events:' : 'No events returned.' + puts events.any? ? 'Decrypted events:' : 'No events returned.' - key = OpenSSL::PKey::RSA.new(File.read(private_key_path)) - events['sets'].each do |_jti, event| + events.each do |_jti, event| begin - pp JSON.parse(JWE.decrypt(event, key)) + pp JSON.parse(JWE.decrypt(event, private_key)) rescue puts 'Failed to parse/decrypt event!' end diff --git a/lib/telephony/pinpoint/sms_sender.rb b/lib/telephony/pinpoint/sms_sender.rb index 5b916710448..a4f77d31932 100644 --- a/lib/telephony/pinpoint/sms_sender.rb +++ b/lib/telephony/pinpoint/sms_sender.rb @@ -94,7 +94,7 @@ def phone_info(phone_number) ) break if response rescue Seahorse::Client::NetworkingError, - Aws::Pinpoint::Errors::InternalServerErrorException => error + Aws::Pinpoint::Errors::ServiceError => error PinpointHelper.notify_pinpoint_failover( error: error, region: sms_config.region, diff --git a/spec/components/button_component_spec.rb b/spec/components/button_component_spec.rb index f25760d3e64..21cd20d14e2 100644 --- a/spec/components/button_component_spec.rb +++ b/spec/components/button_component_spec.rb @@ -43,6 +43,30 @@ end end + context 'as full width' do + let(:options) { { full_width: true } } + + it 'renders with design system classes' do + expect(rendered).to have_css('button.usa-button.usa-button--full-width') + end + end + + context 'as unstyled' do + let(:options) { { unstyled: true } } + + it 'renders with design system classes' do + expect(rendered).to have_css('button.usa-button.usa-button--unstyled') + end + end + + context 'as dangerous' do + let(:options) { { danger: true } } + + it 'renders with design system classes' do + expect(rendered).to have_css('button.usa-button.usa-button--danger') + end + end + context 'with tag options' do it 'renders as attributes' do rendered = render_inline ButtonComponent.new( diff --git a/spec/components/submit_button_component_spec.rb b/spec/components/submit_button_component_spec.rb new file mode 100644 index 00000000000..19ebfc7e986 --- /dev/null +++ b/spec/components/submit_button_component_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe SubmitButtonComponent, type: :component do + let(:options) { {} } + let(:content) { 'Button' } + + subject(:rendered) do + render_inline described_class.new(**options).with_content(content) + end + + it 'renders the submit button custom element' do + expect(rendered).to have_css('lg-submit-button') + expect(rendered).to have_css('button.usa-button') + expect(rendered).to have_content(content) + end + + it 'renders as big, wide by default' do + expect(rendered).to have_css('.usa-button.usa-button--big.usa-button--wide') + end + + context 'with explicit big, wide options' do + let(:options) { { big: false, wide: false } } + + it 'renders respecting big, wide options' do + expect(rendered).to have_css('.usa-button:not(.usa-button--big):not(.usa-button--wide)') + end + end + + context 'with additional options' do + let(:options) { { unstyled: true, data: { foo: 'bar' } } } + + it 'passes additional options through to ButtonComponent' do + expect(rendered).to have_css('.usa-button.usa-button--unstyled[data-foo="bar"]') + end + end +end diff --git a/spec/components/validated_field_component_spec.rb b/spec/components/validated_field_component_spec.rb index 7d9e890620d..3ad4581a286 100644 --- a/spec/components/validated_field_component_spec.rb +++ b/spec/components/validated_field_component_spec.rb @@ -28,7 +28,10 @@ it 'renders aria-describedby to establish connection between input and error message' do field = rendered.at_css('input') - expect(field.attr('aria-describedby')).to start_with('validated-field-error-') + expect(field.attr('aria-describedby').split(' ')).to include( + start_with('validated-field-hint-'), + start_with('validated-field-error-'), + ) end describe 'error message strings' do @@ -73,7 +76,8 @@ it 'merges aria-describedby with the one applied by the field' do field = rendered.at_css('input') - expect(field.attr('aria-describedby')).to start_with('foo validated-field-error-') + expect(field.attr('aria-describedby')).to include('validated-field-error-') + expect(field.attr('aria-describedby')).to include('foo') end end end diff --git a/spec/controllers/account_reset/delete_account_controller_spec.rb b/spec/controllers/account_reset/delete_account_controller_spec.rb index d69e47237b1..4afa58afa03 100644 --- a/spec/controllers/account_reset/delete_account_controller_spec.rb +++ b/spec/controllers/account_reset/delete_account_controller_spec.rb @@ -6,12 +6,12 @@ describe '#delete' do it 'logs a good token to the analytics' do user = create(:user, :signed_up, :with_backup_code) - create(:phone_configuration, user: user, phone: '+1 703-555-1214') + create(:phone_configuration, user: user, phone: Faker::PhoneNumber.cell_phone) create_list(:webauthn_configuration, 2, user: user) create_account_reset_request_for(user) grant_request(user) - session[:granted_token] = AccountResetRequest.all[0].granted_token + session[:granted_token] = AccountResetRequest.first.granted_token stub_analytics properties = { user_id: user.uuid, @@ -29,6 +29,26 @@ expect(response).to redirect_to account_reset_confirm_delete_account_url end + it 'logs a good token to the attempts api' do + user = create(:user, :signed_up, :with_backup_code) + create(:phone_configuration, user: user, phone: Faker::PhoneNumber.cell_phone) + create_list(:webauthn_configuration, 2, user: user) + create_account_reset_request_for(user) + grant_request(user) + + session[:granted_token] = AccountResetRequest.first.granted_token + stub_attempts_tracker + + expect(@irs_attempts_api_tracker).to receive(:account_reset_account_deleted).with( + success: true, + failure_reason: {}, + ) + + delete :delete + + expect(response).to redirect_to account_reset_confirm_delete_account_url + end + it 'redirects to root if the token does not match one in the DB' do session[:granted_token] = 'foo' stub_analytics @@ -53,6 +73,23 @@ ) end + it 'logs an error in irs attempts tracker' do + session[:granted_token] = 'foo' + stub_attempts_tracker + properties = { + success: false, + failure_reason: { token: [t( + 'errors.account_reset.granted_token_invalid', + app_name: APP_NAME, + )] }, + } + expect(@irs_attempts_api_tracker).to receive(:account_reset_account_deleted).with( + properties, + ) + + delete :delete + end + it 'displays a flash and redirects to root if the token is missing' do stub_analytics properties = { @@ -95,7 +132,7 @@ expect(@analytics).to receive(:track_event).with('Account Reset: delete', properties) travel_to(Time.zone.now + 2.days) do - session[:granted_token] = AccountResetRequest.all[0].granted_token + session[:granted_token] = AccountResetRequest.first.granted_token delete :delete end @@ -146,7 +183,7 @@ with('Account Reset: granted token validation', properties) travel_to(Time.zone.now + 2.days) do - get :show, params: { token: AccountResetRequest.all[0].granted_token } + get :show, params: { token: AccountResetRequest.first.granted_token } end expect(response).to redirect_to(root_url) diff --git a/spec/controllers/account_reset/pending_controller_spec.rb b/spec/controllers/account_reset/pending_controller_spec.rb index 63e97171a32..d296969afb7 100644 --- a/spec/controllers/account_reset/pending_controller_spec.rb +++ b/spec/controllers/account_reset/pending_controller_spec.rb @@ -26,6 +26,19 @@ expect(account_reset_request.reload.cancelled_at).to_not be_nil end + it 'logs the cancellation in attempts api' do + stub_attempts_tracker + + account_reset_request = AccountResetRequest.create(user: user, requested_at: 1.hour.ago) + + expect(@irs_attempts_api_tracker).to receive(:track_event). + with(:account_reset_cancel_request, success: true) + + post :cancel + + expect(account_reset_request.reload.cancelled_at).to_not be_nil + end + context 'when the account reset request does not exist' do it 'renders a 404' do post :cancel diff --git a/spec/controllers/account_reset/request_controller_spec.rb b/spec/controllers/account_reset/request_controller_spec.rb index cf4cd94dd66..cc43803f51a 100644 --- a/spec/controllers/account_reset/request_controller_spec.rb +++ b/spec/controllers/account_reset/request_controller_spec.rb @@ -90,6 +90,17 @@ post :create end + it 'logs the visit to attempts api' do + user = create(:user, :with_piv_or_cac, :with_backup_code) + stub_sign_in_before_2fa(user) + stub_attempts_tracker + + expect(@irs_attempts_api_tracker).to receive(:track_event). + with(:account_reset_request_submitted, success: true) + + get :create + end + it 'redirects to root if user not signed in' do post :create diff --git a/spec/controllers/api/irs_attempts_api_controller_spec.rb b/spec/controllers/api/irs_attempts_api_controller_spec.rb index a5e0f9205b4..ac512f6198b 100644 --- a/spec/controllers/api/irs_attempts_api_controller_spec.rb +++ b/spec/controllers/api/irs_attempts_api_controller_spec.rb @@ -85,20 +85,25 @@ post :create, params: { timestamp: time.iso8601 } expect(response.status).to eq(401) + + request.headers['Authorization'] = nil + + post :create, params: { timestamp: time.iso8601 } + + expect(response.status).to eq(401) end - it 'renders new events' do + it 'renders encrypted events' do post :create, params: { timestamp: time.iso8601 } expect(response).to be_ok + expect(Base64.strict_decode64(response.headers['X-Payload-IV'])).to be_present + expect(Base64.strict_decode64(response.headers['X-Payload-Key'])).to be_present + expect(Base64.strict_decode64(response.body)).to be_present - expected_response = { - 'sets' => existing_events.to_h, - } - expect(JSON.parse(response.body)).to eq(expected_response) expect(@analytics).to have_logged_event( 'IRS Attempt API: Events submitted', - rendered_event_count: 3, + rendered_event_count: existing_events.count, success: true, timestamp: time.iso8601, ) diff --git a/spec/controllers/concerns/idv/threat_metrix_concern_spec.rb b/spec/controllers/concerns/idv/threat_metrix_concern_spec.rb index 6386089ccbc..a6ea567e454 100644 --- a/spec/controllers/concerns/idv/threat_metrix_concern_spec.rb +++ b/spec/controllers/concerns/idv/threat_metrix_concern_spec.rb @@ -16,7 +16,7 @@ def index; end before do allow(IdentityConfig.store).to receive(:proofing_device_profiling_collecting_enabled). - and_return(ff_enabled) + and_return(ff_enabled) end context 'ff is set' do diff --git a/spec/controllers/frontend_log_controller_spec.rb b/spec/controllers/frontend_log_controller_spec.rb index 56c10df36b1..42e74701541 100644 --- a/spec/controllers/frontend_log_controller_spec.rb +++ b/spec/controllers/frontend_log_controller_spec.rb @@ -7,8 +7,8 @@ let(:fake_analytics) { FakeAnalytics.new } let(:user) { create(:user, :with_phone, with: { phone: '+1 (202) 555-1212' }) } let(:event) { 'Custom Event' } - let(:payload) { { message: 'To be logged...' } } - let(:params) { { event: event, payload: payload } } + let(:payload) { { 'message' => 'To be logged...' } } + let(:params) { { 'event' => event, 'payload' => payload } } let(:json) { JSON.parse(response.body, symbolize_names: true) } context 'user is signed in' do @@ -92,7 +92,7 @@ it 'rejects a request without specifying event' do expect(fake_analytics).not_to receive(:track_event) - params.delete(:event) + params.delete('event') action expect(response).to have_http_status(:bad_request) @@ -102,13 +102,34 @@ it 'rejects a request without specifying payload' do expect(fake_analytics).not_to receive(:track_event) - params.delete(:payload) + params.delete('payload') action expect(response).to have_http_status(:bad_request) expect(json[:success]).to eq(false) end end + + context 'for a named analytics method' do + let(:payload) { { 'field' => 'front', 'failed_attempts' => 0 } } + let(:params) do + { + 'event' => 'IdV: Native camera forced after failed attempts', + 'payload' => payload, + } + end + + it 'logs the analytics event without the prefix' do + expect(fake_analytics).to receive(:track_event).with( + 'IdV: Native camera forced after failed attempts', payload + ) + + action + + expect(response).to have_http_status(:ok) + expect(json[:success]).to eq(true) + end + end end context 'user is not signed in' do diff --git a/spec/controllers/idv/gpo_controller_spec.rb b/spec/controllers/idv/gpo_controller_spec.rb index bd75da3c548..8e15a7240cc 100644 --- a/spec/controllers/idv/gpo_controller_spec.rb +++ b/spec/controllers/idv/gpo_controller_spec.rb @@ -51,28 +51,6 @@ expect(response).to be_ok end - it 'renders wait page while job is in progress' do - allow(controller).to receive(:async_state).and_return( - ProofingSessionAsyncResult.new( - status: ProofingSessionAsyncResult::IN_PROGRESS, - ), - ) - get :index - - expect(response).to render_template :wait - end - - it 'logs an event when there is a timeout' do - allow(controller).to receive(:async_state).and_return( - ProofingSessionAsyncResult.new( - status: ProofingSessionAsyncResult::MISSING, - ), - ) - - get :index - expect(@analytics).to have_logged_event('Proofing Address Result Missing', {}) - end - context 'with letter already sent' do before do allow_any_instance_of(Idv::GpoPresenter).to receive(:letter_already_sent?).and_return(true) diff --git a/spec/controllers/sign_up/passwords_controller_spec.rb b/spec/controllers/sign_up/passwords_controller_spec.rb index 2d552fcc77b..4fa5333069e 100644 --- a/spec/controllers/sign_up/passwords_controller_spec.rb +++ b/spec/controllers/sign_up/passwords_controller_spec.rb @@ -7,6 +7,9 @@ user = create(:user, :unconfirmed, confirmation_token: token) stub_analytics + stub_attempts_tracker + + allow(@irs_attempts_api_tracker).to receive(:track_event) analytics_hash = { success: true, @@ -20,12 +23,6 @@ 'User Registration: Email Confirmation', { errors: {}, error_details: nil, success: true, user_id: user.uuid }, ) - expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( - :user_registration_email_confirmation, - email: user.email_addresses.first.email, - success: true, - failure_reason: nil, - ) expect(@analytics).to receive(:track_event). with('Password Creation', analytics_hash) @@ -35,6 +32,18 @@ } user.reload + + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :user_registration_password_submitted, + success: true, + failure_reason: {}, + ) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :user_registration_email_confirmation, + email: user.email_addresses.first.email, + success: true, + failure_reason: nil, + ) expect(user.valid_password?('NewVal!dPassw0rd')).to eq true expect(user.confirmed?).to eq true end @@ -70,6 +79,9 @@ user = create(:user, :unconfirmed, confirmation_token: token) stub_analytics + stub_attempts_tracker + + allow(@irs_attempts_api_tracker).to receive(:track_event) analytics_hash = { success: false, @@ -89,18 +101,22 @@ 'User Registration: Email Confirmation', { errors: {}, error_details: nil, success: true, user_id: user.uuid }, ) + expect(@analytics).to receive(:track_event). + with('Password Creation', analytics_hash) - expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( + post :create, params: { password_form: { password: 'NewVal' }, confirmation_token: token } + + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :user_registration_password_submitted, + success: false, + failure_reason: { password: ['This password is too short (minimum is 12 characters)'] }, + ) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( :user_registration_email_confirmation, email: user.email_addresses.first.email, success: true, failure_reason: nil, ) - - expect(@analytics).to receive(:track_event). - with('Password Creation', analytics_hash) - - post :create, params: { password_form: { password: 'NewVal' }, confirmation_token: token } end end diff --git a/spec/controllers/two_factor_authentication/backup_code_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/backup_code_verification_controller_spec.rb index f49749aa63f..673f1867c2c 100644 --- a/spec/controllers/two_factor_authentication/backup_code_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/backup_code_verification_controller_spec.rb @@ -38,7 +38,7 @@ with(analytics_hash) expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_backup_code, success: true) + with(:mfa_login_backup_code, success: true) post :create, params: payload end @@ -64,7 +64,7 @@ with(analytics_hash) expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_backup_code, success: true) + with(:mfa_login_backup_code, success: true) expect(@analytics).to receive(:track_event). with('User marked authenticated', authentication_type: :valid_2fa) @@ -91,7 +91,7 @@ it 'renders the show page' do stub_attempts_tracker expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_backup_code, success: false) + with(:mfa_login_backup_code, success: false) post :create, params: payload expect(response).to render_template(:show) expect(flash[:error]).to eq t('two_factor_authentication.invalid_backup_code') @@ -115,7 +115,7 @@ it 're-renders the backup code entry screen' do stub_attempts_tracker expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_backup_code, success: false) + with(:mfa_login_backup_code, success: false) post :create, params: payload expect(response).to render_template(:show) @@ -137,10 +137,14 @@ with(properties) expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_backup_code, success: false) + with(:mfa_login_backup_code, success: false) expect(@analytics).to receive(:track_event). - with('Multi-Factor Authentication: max attempts reached') + with('Multi-Factor Authentication: max attempts reached') + + expect(@irs_attempts_api_tracker).to receive(:mfa_login_rate_limited). + with(type: 'backup_code') + expect(PushNotification::HttpPush).to receive(:deliver). with(PushNotification::MfaLimitAccountLockedEvent.new(user: subject.current_user)) diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index dd0fcf9b9c4..7591213069b 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -114,7 +114,7 @@ expect(@analytics).to receive(:track_mfa_submit_event). with(properties) - expect(@irs_attempts_api_tracker).to receive(:mfa_verify_phone_otp_submitted). + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_submitted). with({ reauthentication: false, success: false }) post :create, params: @@ -172,13 +172,16 @@ with(properties) expect(@analytics).to receive(:track_event). - with('Multi-Factor Authentication: max attempts reached') + with('Multi-Factor Authentication: max attempts reached') expect(PushNotification::HttpPush).to receive(:deliver). with(PushNotification::MfaLimitAccountLockedEvent.new(user: subject.current_user)) - expect(@irs_attempts_api_tracker).to receive(:mfa_verify_phone_otp_submitted). + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_submitted). with({ reauthentication: false, success: false }) + expect(@irs_attempts_api_tracker).to receive(:mfa_login_rate_limited). + with(type: 'otp') + post :create, params: { code: '12345', otp_delivery_preference: 'sms' } @@ -234,8 +237,22 @@ expect(@analytics).to receive(:track_event). with('User marked authenticated', authentication_type: :valid_2fa) - expect(@irs_attempts_api_tracker).to receive(:mfa_verify_phone_otp_submitted). - with({ reauthentication: false, success: true }) + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_submitted). + with(reauthentication: false, success: true) + + post :create, params: { + code: subject.current_user.reload.direct_otp, + otp_delivery_preference: 'sms', + } + end + + it 'tracks the attempt event with reauthentication parameter true' do + stub_attempts_tracker + + subject.user_session[:context] = 'reauthentication' + + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_submitted). + with(reauthentication: true, success: true) post :create, params: { code: subject.current_user.reload.direct_otp, @@ -291,7 +308,7 @@ before do stub_attempts_tracker - expect(@irs_attempts_api_tracker).to receive(:mfa_verify_phone_otp_submitted). + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_submitted). with({ reauthentication: false, success: false }) post :create, params: { code: '12345', otp_delivery_preference: 'sms' } end @@ -309,7 +326,7 @@ before do stub_attempts_tracker - expect(@irs_attempts_api_tracker).to receive(:mfa_verify_phone_otp_submitted). + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_submitted). with({ reauthentication: false, success: true }) post :create, params: { code: subject.current_user.direct_otp, @@ -380,7 +397,7 @@ controller.user_session[:phone_id] = phone_id expect(@irs_attempts_api_tracker).to receive(:mfa_enroll_phone_otp_submitted). - with({ success: true }) + with(success: true) post( :create, @@ -410,7 +427,7 @@ context 'user enters an invalid code' do before do expect(@irs_attempts_api_tracker).to receive(:mfa_enroll_phone_otp_submitted). - with({ success: false }) + with(success: false) post( :create, @@ -461,6 +478,19 @@ expect(@analytics).to have_received(:track_event). with('Multi-Factor Authentication Setup', properties) end + + context 'user has exceeded the maximum number of attempts' do + it 'tracks the attempt event' do + allow_any_instance_of(User).to receive(:max_login_attempts?).and_return(true) + sign_in_before_2fa + + stub_attempts_tracker + expect(@irs_attempts_api_tracker).to receive(:mfa_enroll_rate_limited). + with(type: 'otp') + + post :create, params: { code: '12345', otp_delivery_preference: 'sms' } + end + end end context 'user does not include a code parameter' do diff --git a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb index 5ec4561b2f3..8e81fa14877 100644 --- a/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb @@ -152,11 +152,16 @@ } stub_analytics + stub_attempts_tracker expect(@analytics).to receive(:track_mfa_submit_event). with(properties) expect(@analytics).to receive(:track_event). - with('Multi-Factor Authentication: max attempts reached') + with('Multi-Factor Authentication: max attempts reached') + + expect(@irs_attempts_api_tracker).to receive(:mfa_login_rate_limited). + with(type: 'personal_key') + expect(PushNotification::HttpPush).to receive(:deliver). with(PushNotification::MfaLimitAccountLockedEvent.new(user: subject.current_user)) diff --git a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb index 62506060e7b..a0cb3ccbf6a 100644 --- a/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb @@ -192,6 +192,9 @@ expect(@analytics).to receive(:track_event). with('Multi-Factor Authentication: enter PIV CAC visited', attributes) + expect(@irs_attempts_api_tracker).to receive(:mfa_login_rate_limited). + with(type: 'piv_cac') + submit_attributes = { success: false, errors: { type: 'user.piv_cac_mismatch' }, @@ -211,7 +214,7 @@ ) expect(@analytics).to receive(:track_event). - with('Multi-Factor Authentication: max attempts reached') + with('Multi-Factor Authentication: max attempts reached') expect(PushNotification::HttpPush).to receive(:deliver). with(PushNotification::MfaLimitAccountLockedEvent.new(user: subject.current_user)) diff --git a/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb index ececb339549..4765db3cde2 100644 --- a/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb @@ -48,7 +48,7 @@ expect(@analytics).to receive(:track_event). with('User marked authenticated', authentication_type: :valid_2fa) expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_totp, success: true) + with(:mfa_login_totp, success: true) post :create, params: { code: generate_totp_code(@secret) } end @@ -94,9 +94,13 @@ expect(@analytics).to receive(:track_mfa_submit_event). with(attributes) expect(@analytics).to receive(:track_event). - with('Multi-Factor Authentication: max attempts reached') + with('Multi-Factor Authentication: max attempts reached') expect(@irs_attempts_api_tracker).to receive(:track_event). - with(:mfa_verify_totp, success: false) + with(:mfa_login_totp, success: false) + + expect(@irs_attempts_api_tracker).to receive(:mfa_login_rate_limited). + with(type: 'totp') + expect(PushNotification::HttpPush).to receive(:deliver). with(PushNotification::MfaLimitAccountLockedEvent.new(user: subject.current_user)) diff --git a/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb index 66ca69b9fed..abf127d8994 100644 --- a/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb @@ -83,7 +83,7 @@ with('User marked authenticated', authentication_type: :valid_2fa) expect(@irs_attempts_api_tracker).to receive(:track_event).with( - :mfa_verify_webauthn_roaming, + :mfa_login_webauthn_roaming, success: true, ) @@ -108,7 +108,7 @@ with('User marked authenticated', authentication_type: :valid_2fa) expect(@irs_attempts_api_tracker).to receive(:track_event).with( - :mfa_verify_webauthn_platform, + :mfa_login_webauthn_platform, success: true, ) @@ -151,8 +151,8 @@ let(:view_context) { ActionController::Base.new.view_context } before do allow_any_instance_of(TwoFactorAuthCode::WebauthnAuthenticationPresenter). - to receive(:multiple_factors_enabled?). - and_return(true) + to receive(:multiple_factors_enabled?). + and_return(true) end it 'redirects to webauthn show page' do @@ -175,8 +175,8 @@ context 'User only has webauthn as an MFA method' do before do allow_any_instance_of(TwoFactorAuthCode::WebauthnAuthenticationPresenter). - to receive(:multiple_factors_enabled?). - and_return(false) + to receive(:multiple_factors_enabled?). + and_return(false) end it 'redirects to webauthn error page ' do diff --git a/spec/controllers/users/additional_mfa_required_controller_spec.rb b/spec/controllers/users/additional_mfa_required_controller_spec.rb index a7d0ee4d59d..0da826ce04d 100644 --- a/spec/controllers/users/additional_mfa_required_controller_spec.rb +++ b/spec/controllers/users/additional_mfa_required_controller_spec.rb @@ -28,7 +28,7 @@ let(:enforcement_date) { Time.zone.today + 6.days } before do allow(IdentityConfig.store).to receive(:kantara_restriction_enforcement_date). - and_return(enforcement_date) + and_return(enforcement_date) end context 'before enforcement date' do @@ -55,7 +55,7 @@ user.reload expect(user.non_restricted_mfa_required_prompt_skip_date). - to eq Time.zone.today + to eq Time.zone.today end it 'does not allow unauthenticated users' do diff --git a/spec/controllers/users/email_confirmations_controller_spec.rb b/spec/controllers/users/email_confirmations_controller_spec.rb index 8ae61c069e4..35abc25a95f 100644 --- a/spec/controllers/users/email_confirmations_controller_spec.rb +++ b/spec/controllers/users/email_confirmations_controller_spec.rb @@ -14,9 +14,9 @@ )).ordered expect(PushNotification::HttpPush).to receive(:deliver).once. - with(PushNotification::RecoveryInformationChangedEvent.new( - user: user, - )).ordered + with(PushNotification::RecoveryInformationChangedEvent.new( + user: user, + )).ordered add_email_form = AddUserEmailForm.new add_email_form.submit(user, email: new_email) diff --git a/spec/controllers/users/reset_passwords_controller_spec.rb b/spec/controllers/users/reset_passwords_controller_spec.rb index 467d5e4ae30..c956301c219 100644 --- a/spec/controllers/users/reset_passwords_controller_spec.rb +++ b/spec/controllers/users/reset_passwords_controller_spec.rb @@ -8,7 +8,9 @@ context 'no user matches token' do it 'redirects to page where user enters email for password reset token' do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) get :edit, params: { reset_password_token: 'foo' } @@ -21,6 +23,11 @@ expect(@analytics).to have_received(:track_event). with('Password Reset: Token Submitted', analytics_hash) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_email_confirmed, + success: false, + failure_reason: { user: ['invalid_token'] }, + ) expect(response).to redirect_to new_user_password_path expect(flash[:error]).to eq t('devise.passwords.invalid_token') @@ -30,7 +37,9 @@ context 'token expired' do it 'redirects to page where user enters email for password reset token' do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) user = instance_double('User', uuid: '123') allow(User).to receive(:with_reset_password_token).with('foo').and_return(user) @@ -47,7 +56,11 @@ expect(@analytics).to have_received(:track_event). with('Password Reset: Token Submitted', analytics_hash) - + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_email_confirmed, + success: false, + failure_reason: { user: ['token_expired'] }, + ) expect(response).to redirect_to new_user_password_path expect(flash[:error]).to eq t('devise.passwords.token_expired') end @@ -58,6 +71,8 @@ it 'displays the form to enter a new password and disallows indexing' do stub_analytics + stub_attempts_tracker + allow(@irs_attempts_api_tracker).to receive(:track_event) user = instance_double('User', uuid: '123') email_address = instance_double('EmailAddress') @@ -75,15 +90,29 @@ expect(response).to render_template :edit expect(flash.keys).to be_empty expect(response.body).to match('') + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_email_confirmed, + success: true, + failure_reason: {}, + ) end end end describe '#update' do context 'user submits new password after token expires' do + let(:irs_tracker_failure_reason) do + { + password: [password_error_message], + reset_password_token: ['token_expired'], + } + end + it 'redirects to page where user enters email for password reset token' do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) raw_reset_token, db_confirmation_token = Devise.token_generator.generate(User, :reset_password_token) @@ -116,14 +145,26 @@ expect(@analytics).to have_received(:track_event). with('Password Reset: Password Submitted', analytics_hash) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_new_password_submitted, + success: false, + failure_reason: irs_tracker_failure_reason, + ) + expect(response).to redirect_to new_user_password_path expect(flash[:error]).to eq t('devise.passwords.token_expired') end end context 'user submits invalid new password' do + let(:irs_tracker_failure_reason) do + { password: [password_error_message] } + end + it 'renders edit' do stub_analytics + stub_attempts_tracker + allow(@irs_attempts_api_tracker).to receive(:track_event) raw_reset_token, db_confirmation_token = Devise.token_generator.generate(User, :reset_password_token) @@ -153,6 +194,11 @@ expect(assigns(:forbidden_passwords)).to all(be_a(String)) expect(response).to render_template(:edit) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_new_password_submitted, + success: false, + failure_reason: irs_tracker_failure_reason, + ) end end @@ -179,7 +225,9 @@ context 'IAL1 user submits valid new password' do it 'redirects to sign in page' do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) raw_reset_token, db_confirmation_token = Devise.token_generator.generate(User, :reset_password_token) @@ -214,7 +262,11 @@ expect(@analytics).to have_received(:track_event). with('Password Reset: Password Submitted', analytics_hash) - + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_new_password_submitted, + success: true, + failure_reason: {}, + ) expect(user.events.password_changed.size).to be 1 expect(response).to redirect_to new_user_session_path @@ -227,7 +279,9 @@ context 'ial2 user submits valid new password' do it 'deactivates the active profile and redirects' do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) raw_reset_token, db_confirmation_token = Devise.token_generator.generate(User, :reset_password_token) @@ -258,6 +312,11 @@ expect(@analytics).to have_received(:track_event). with('Password Reset: Password Submitted', analytics_hash) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_new_password_submitted, + success: true, + failure_reason: {}, + ) expect(user.active_profile.present?).to eq false @@ -268,7 +327,9 @@ context 'unconfirmed user submits valid new password' do it 'confirms the user' do stub_analytics + stub_attempts_tracker allow(@analytics).to receive(:track_event) + allow(@irs_attempts_api_tracker).to receive(:track_event) raw_reset_token, db_confirmation_token = Devise.token_generator.generate(User, :reset_password_token) @@ -300,6 +361,11 @@ expect(@analytics).to have_received(:track_event). with('Password Reset: Password Submitted', analytics_hash) + expect(@irs_attempts_api_tracker).to have_received(:track_event).with( + :forgot_password_new_password_submitted, + success: true, + failure_reason: {}, + ) expect(user.reload.confirmed?).to eq true diff --git a/spec/controllers/users/rules_of_use_controller_spec.rb b/spec/controllers/users/rules_of_use_controller_spec.rb index 6b20923ba62..9a903e3ab15 100644 --- a/spec/controllers/users/rules_of_use_controller_spec.rb +++ b/spec/controllers/users/rules_of_use_controller_spec.rb @@ -147,7 +147,7 @@ it 'logs a failure analytics event' do stub_analytics expect(@analytics).to receive(:track_event). - with('Rules of Use Submitted', hash_including(success: false)) + with('Rules of Use Submitted', hash_including(success: false)) action end diff --git a/spec/controllers/users/service_provider_revoke_controller_spec.rb b/spec/controllers/users/service_provider_revoke_controller_spec.rb index e72982aef8a..b8da2b470cc 100644 --- a/spec/controllers/users/service_provider_revoke_controller_spec.rb +++ b/spec/controllers/users/service_provider_revoke_controller_spec.rb @@ -67,7 +67,7 @@ subject end end.to change { @identity.reload.deleted_at&.to_i }. - from(nil).to(now.to_i) + from(nil).to(now.to_i) expect(response).to redirect_to(account_connected_accounts_path) end diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index dfeac6aa2f3..4cd745abb16 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -326,13 +326,24 @@ def index it 'tracks the verification attempt event' do stub_attempts_tracker - expect(@irs_attempts_api_tracker).to receive(:mfa_verify_phone_otp_sent). + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_sent). with(phone_number: '+12025551212', reauthentication: false, success: true) get :send_code, params: { otp_delivery_selection_form: { otp_delivery_preference: 'sms' } } end + it 'tracks the attempt event when user session context is reauthentication' do + stub_attempts_tracker + subject.user_session[:context] = 'reauthentication' + + expect(@irs_attempts_api_tracker).to receive(:mfa_login_phone_otp_sent). + with(phone_number: '+12025551212', reauthentication: true, success: true) + + get :send_code, params: { otp_delivery_selection_form: + { otp_delivery_preference: 'sms' } } + end + it 'calls OtpRateLimiter#exceeded_otp_send_limit? and #increment' do otp_rate_limiter = instance_double(OtpRateLimiter) allow(OtpRateLimiter).to receive(:new). diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index 366a5b1d62c..866761b0f63 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -73,7 +73,7 @@ expect(form).to receive(:submit). with(params.require(:two_factor_options_form).permit(:selection)). and_return(response) - expect(form).to receive(:selection).and_return(['voice']) + expect(form).to receive(:selection).twice.and_return(['voice']) patch :create, params: voice_params @@ -102,6 +102,21 @@ } end + it 'tracks IRS attempts event' do + stub_sign_in_before_2fa + stub_attempts_tracker + + expect(@irs_attempts_api_tracker).to receive(:track_event). + with(:mfa_enroll_options_selected, success: true, + mfa_device_types: ['voice', 'auth_app']) + + patch :create, params: { + two_factor_options_form: { + selection: ['voice', 'auth_app'], + }, + } + end + context 'when the selection is only phone and multi mfa is enabled' do before do allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index 79707620de0..c5b85d9ef3a 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -43,6 +43,7 @@ it 'tracks page visit' do stub_sign_in stub_analytics + stub_attempts_tracker expect(@analytics).to receive(:track_event). with( @@ -53,6 +54,8 @@ success: true, ) + expect(@irs_attempts_api_tracker).not_to receive(:track_event) + get :new end end @@ -243,6 +246,11 @@ } end it 'should log expected events' do + expect(@analytics).to receive(:track_event).with( + 'User Registration: User Fully Registered', + { mfa_method: 'webauthn_platform' }, + ) + expect(@analytics).to receive(:track_event).with( 'Multi-Factor Authentication Setup', { @@ -269,6 +277,51 @@ :mfa_enroll_webauthn_platform, success: true ) + registration_log = Funnel::Registration::Create.call(user.id) + patch :confirm, params: params + + expect(registration_log.reload.first_mfa).to eq 'webauthn_platform' + end + end + + context 'with attestation response error' do + let(:mfa_selections) { ['webauthn_platform'] } + let(:params) do + { + attestation_object: attestation_object, + client_data_json: setup_client_data_json, + name: 'mykey', + platform_authenticator: 'true', + } + end + it 'should log expected events' do + allow(IdentityConfig.store).to receive(:domain_name).and_return('localhost:3000') + allow(WebAuthn::AttestationStatement).to receive(:from).and_raise(StandardError) + + expect(@analytics).to receive(:track_event).with( + 'Multi-Factor Authentication Setup', + { + enabled_mfa_methods_count: 0, + errors: { name: [I18n.t( + 'errors.webauthn_platform_setup.attestation_error', + link: MarketingSite.contact_url, + )] }, + error_details: { name: [I18n.t( + 'errors.webauthn_platform_setup.attestation_error', + link: MarketingSite.contact_url, + )] }, + in_multi_mfa_selection_flow: true, + mfa_method_counts: {}, + multi_factor_auth_method: 'webauthn_platform', + pii_like_keypaths: [[:mfa_method_counts, :phone]], + success: false, + }, + ) + + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :mfa_enroll_webauthn_platform, success: false + ) + patch :confirm, params: params end end diff --git a/spec/features/account/backup_codes_spec.rb b/spec/features/account/backup_codes_spec.rb index 7f149813fc3..9bffd3ffbf1 100644 --- a/spec/features/account/backup_codes_spec.rb +++ b/spec/features/account/backup_codes_spec.rb @@ -55,8 +55,8 @@ click_continue generated_at = user.backup_code_configurations. - order(created_at: :asc).first.created_at. - in_time_zone('UTC') + order(created_at: :asc).first.created_at. + in_time_zone('UTC') formatted_generated_at = l(generated_at, format: t('time.formats.event_timestamp')) expected_message = "#{t('account.index.backup_codes_exist')} #{formatted_generated_at}" diff --git a/spec/features/idv/doc_auth/send_link_step_spec.rb b/spec/features/idv/doc_auth/send_link_step_spec.rb index 0120c24dba5..2cf07639ed2 100644 --- a/spec/features/idv/doc_auth/send_link_step_spec.rb +++ b/spec/features/idv/doc_auth/send_link_step_spec.rb @@ -27,6 +27,11 @@ end it 'proceeds to the next page with valid info' do + expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( + :idv_phone_upload_link_sent, + success: true, + phone_number: '+1 415-555-0199', + ) expect(Telephony).to receive(:send_doc_auth_link). with(hash_including(to: '+1 415-555-0199')). and_call_original @@ -44,7 +49,11 @@ impl.call(**config) end - + expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( + :idv_phone_upload_link_sent, + success: true, + phone_number: '+1 415-555-0199', + ) fill_in :doc_auth_phone, with: '415-555-0199' click_idv_continue @@ -59,6 +68,11 @@ end it 'does not proceed if Telephony raises an error' do + expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( + :idv_phone_upload_link_sent, + success: false, + phone_number: '+1 225-555-1000', + ) fill_in :doc_auth_phone, with: '225-555-1000' click_idv_continue @@ -127,7 +141,11 @@ allow_any_instance_of(Flow::BaseFlow).to receive(:flow_session).and_return( document_capture_session_uuid: document_capture_session.uuid, ) - + expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( + :idv_phone_upload_link_sent, + success: true, + phone_number: '+1 415-555-0199', + ) 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).to eq('document-capture-session' => document_capture_session.uuid) diff --git a/spec/features/idv/steps/confirmation_step_spec.rb b/spec/features/idv/steps/confirmation_step_spec.rb index 5392fd077c5..f306c3050ea 100644 --- a/spec/features/idv/steps/confirmation_step_spec.rb +++ b/spec/features/idv/steps/confirmation_step_spec.rb @@ -4,11 +4,14 @@ include IdvStepHelper let(:idv_api_enabled_steps) { [] } + let(:idv_personal_key_confirmation_enabled) { true } let(:sp) { nil } let(:address_verification_mechanism) { :phone } before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return(idv_api_enabled_steps) + allow(IdentityConfig.store).to receive(:idv_personal_key_confirmation_enabled). + and_return(idv_personal_key_confirmation_enabled) start_idv_from_sp(sp) complete_idv_steps_before_confirmation_step(address_verification_mechanism) end @@ -47,6 +50,21 @@ acknowledge_and_confirm_personal_key expect(page).to have_current_path(account_path) end + + context 'with personal key confirmation disabled' do + let(:idv_personal_key_confirmation_enabled) { false } + + before do + click_continue if javascript_enabled? + end + + it 'does not display modal content. and continues to the account page' do + expect(page).not_to have_content t('forms.personal_key.title') + expect(page).not_to have_content t('forms.personal_key.instructions') + expect(current_path).to eq(account_path) + expect(page).to have_content t('headings.account.verified_account') + end + end end context 'verifying by gpo' do @@ -79,5 +97,19 @@ expect(current_url).to start_with('http://localhost:7654/auth/result') end + + context 'with personal key confirmation disabled' do + let(:idv_personal_key_confirmation_enabled) { false } + + it 'redirects to the completions page and then to the SP' do + click_acknowledge_personal_key + + expect(page).to have_current_path(sign_up_completed_path) + + click_agree_and_continue + + expect(current_url).to start_with('http://localhost:7654/auth/result') + end + end end end diff --git a/spec/features/irs_attempts_api/event_tracking_spec.rb b/spec/features/irs_attempts_api/event_tracking_spec.rb index 64e9a474027..fa4354b2a87 100644 --- a/spec/features/irs_attempts_api/event_tracking_spec.rb +++ b/spec/features/irs_attempts_api/event_tracking_spec.rb @@ -31,12 +31,18 @@ sign_in_user(user) events = irs_attempts_api_tracked_events(timestamp: Time.zone.now) - expected_event_types = ['email-and-password-auth', 'mfa-verify-phone-otp-sent'] + expected_event_types = %w[email-and-password-auth mfa-login-phone-otp-sent] received_event_types = events.map(&:event_type) - expect(events.count).to be > 0 - expect(received_event_types.sort).to eq(expected_event_types.sort) + expect(events.count).to eq received_event_types.count + expect(received_event_types).to match_array(expected_event_types) + + metadata = events.first.event_metadata + expect(metadata[:user_ip_address]).to eq '127.0.0.1' + expect(metadata[:irs_application_url]).to eq 'http://localhost:7654/auth/result' + expect(metadata[:unique_session_id]).to be_a(String) + expect(metadata[:success]).to be_truthy end end diff --git a/spec/features/saml/ial2_sso_spec.rb b/spec/features/saml/ial2_sso_spec.rb index 830bfbbe9b2..78c1f714f6a 100644 --- a/spec/features/saml/ial2_sso_spec.rb +++ b/spec/features/saml/ial2_sso_spec.rb @@ -40,10 +40,6 @@ def expected_gpo_return_to_sp_url ).to_s end - def mock_gpo_mail_bounced - allow_any_instance_of(UserDecorator).to receive(:gpo_mail_bounced?).and_return(true) - end - def update_mailing_address click_on t('idv.buttons.mail.resend') fill_in t('idv.form.password'), with: user.password @@ -138,55 +134,6 @@ def sign_out_user expect(current_path).to eq(idv_come_back_later_path) end end - - context 'provides an option to update address if undeliverable' do - it 'allows the user to update the address' do - user = create(:user, :signed_up) - - perform_id_verification_with_gpo_without_confirming_code(user) - - expect(current_url).to eq expected_gpo_return_to_sp_url - - visit account_path - - mock_gpo_mail_bounced - visit account_path - click_link(t('account.index.verification.update_address')) - - expect(current_path).to eq idv_gpo_path - - fill_out_address_form_fail - click_on t('idv.buttons.mail.resend') - - fill_out_address_form_ok - update_mailing_address - end - - it 'throttles resolution' do - user = create(:user, :signed_up) - - perform_id_verification_with_gpo_without_confirming_code(user) - - expect(current_url).to eq expected_gpo_return_to_sp_url - - visit account_path - - mock_gpo_mail_bounced - visit account_path - click_link(t('account.index.verification.update_address')) - - expect(current_path).to eq idv_gpo_path - fill_out_address_form_resolution_fail - click_on t('idv.buttons.mail.resend') - expect(current_path).to eq idv_gpo_path - expect(page).to have_content(t('idv.failure.sessions.heading')) - - fill_out_address_form_resolution_fail - click_on t('idv.buttons.mail.resend') - expect(current_path).to eq idv_gpo_path - expect(page).to have_content(strip_tags(t('idv.failure.sessions.heading'))) - end - end end end diff --git a/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb b/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb index 4a93d6cb15c..779c49f8fd4 100644 --- a/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb +++ b/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb @@ -74,6 +74,37 @@ expect(current_url).to start_with('http://localhost:7654/auth/result') end + context 'when the user needs a backup code reminder' do + let!(:user) { create(:user, :signed_up, :with_authentication_app, :with_backup_code) } + let!(:event) do + create(:event, user: user, event_type: :sign_in_after_2fa, created_at: 9.months.ago) + end + + context 'without feature flag on (IdentityConfig.store.backup_code_reminder_redirect)' do + it 'redirects the user to the account url' do + sign_in_user(user) + fill_in_code_with_last_totp(user) + click_submit_default + + expect(current_path).to eq account_path + end + end + + context 'with the feature flag turned on' do + before do + allow(IdentityConfig.store).to receive(:backup_code_reminder_redirect).and_return(true) + end + + it 'redirects the user to the backup code reminder url' do + sign_in_user(user) + fill_in_code_with_last_totp(user) + click_submit_default + + expect(current_path).to eq backup_code_reminder_path + end + end + end + def sign_out_user first(:link, t('links.sign_out')).click end diff --git a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb index 099ca99e5b4..edd7b0e58b8 100644 --- a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb +++ b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb @@ -150,7 +150,7 @@ scenario 'redirects to the two_factor path with an error and phone option selected' do expect(page). - to have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) + to have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) expect( URI.parse(current_url).path + '#' + URI.parse(current_url).fragment, ).to eq authentication_methods_setup_path(anchor: 'select_phone') @@ -159,7 +159,7 @@ scenario 'clears the error when another mfa method is selected' do click_2fa_option('backup_code') expect(page). - to_not have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) + to_not have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) end scenario 'clears the error when phone mfa method is unselected' do diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 6f34f75befe..1ad82115b2d 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -954,7 +954,7 @@ expect(page).to have_content(user.email) agree_and_continue_button = find_button(t('sign_up.agree_and_continue')) - action_url = agree_and_continue_button.find(:xpath, '..')[:action] + action_url = agree_and_continue_button.ancestor('form')[:action] agree_and_continue_button.click expect(current_url).to start_with('http://localhost:7654/auth/result') diff --git a/spec/fixtures/proofing/lexis_nexis/ddp/request.json b/spec/fixtures/proofing/lexis_nexis/ddp/request.json index b43d46754e8..f633a9e040f 100644 --- a/spec/fixtures/proofing/lexis_nexis/ddp/request.json +++ b/spec/fixtures/proofing/lexis_nexis/ddp/request.json @@ -17,5 +17,8 @@ "policy": "test-policy", "service_type": "all", "session_id": "UNIQUE_SESSION_ID", - "ssn_hash": "15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225" + "ssn_hash": "15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225", + "input_ip_address": "127.0.0.1", + "local_attrib_1": "ABCD" + } diff --git a/spec/forms/delete_user_email_form_spec.rb b/spec/forms/delete_user_email_form_spec.rb index f7972952e8e..36b6cc79483 100644 --- a/spec/forms/delete_user_email_form_spec.rb +++ b/spec/forms/delete_user_email_form_spec.rb @@ -54,9 +54,9 @@ email: email_address.email, )).ordered expect(PushNotification::HttpPush).to receive(:deliver).once. - with(PushNotification::RecoveryInformationChangedEvent.new( - user: user, - )).ordered + with(PushNotification::RecoveryInformationChangedEvent.new( + user: user, + )).ordered submit end diff --git a/spec/helpers/script_helper_spec.rb b/spec/helpers/script_helper_spec.rb index 1483415be93..ed1137d6f02 100644 --- a/spec/helpers/script_helper_spec.rb +++ b/spec/helpers/script_helper_spec.rb @@ -86,6 +86,25 @@ ) end + context 'with script integrity available' do + before do + allow(AssetSources).to receive(:get_integrity).and_return(nil) + allow(AssetSources).to receive(:get_integrity).with('/application.js'). + and_return('sha256-aztp/wpATyjXXpigZtP8ZP/9mUCHDMaL7OKFRbmnUIazQ9ehNmg4CD5Ljzym/TyA') + end + + it 'adds integrity attribute' do + output = render_javascript_pack_once_tags + + expect(output).to have_css( + "script[src^='/polyfill.js']:not([integrity]) ~ \ + script[src^='/application.js'][integrity^='sha256-']", + count: 1, + visible: :all, + ) + end + end + context 'local development crossorigin sources' do let(:webpack_port) { '3035' } diff --git a/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx b/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx index 392c4e731c7..f472bcc1972 100644 --- a/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/failed-capture-attempts-spec.jsx @@ -119,7 +119,7 @@ describe('maxAttemptsBeforeNativeCamera logging tests', () => { */ it('calls analytics with native camera message when failed attempts is greater than or equal to 0', async function () { const addPageAction = sinon.spy(); - const acuantCaptureComponent = ; + const acuantCaptureComponent = ; function TestComponent({ children }) { return ( @@ -142,6 +142,7 @@ describe('maxAttemptsBeforeNativeCamera logging tests', () => { expect(addPageAction).to.have.been.called(); expect(addPageAction).to.have.been.calledWith( 'IdV: Native camera forced after failed attempts', + { field: 'example', failed_attempts: 0 }, ); }); diff --git a/spec/javascripts/packs/form-validation-spec.js b/spec/javascripts/packs/form-validation-spec.js deleted file mode 100644 index acb6805ab23..00000000000 --- a/spec/javascripts/packs/form-validation-spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { screen } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; -import { initialize } from '../../../app/javascript/packs/form-validation'; - -describe('form-validation', () => { - const onSubmit = (event) => event.preventDefault(); - - beforeEach(() => { - window.addEventListener('submit', onSubmit); - }); - - afterEach(() => { - window.removeEventListener('submit', onSubmit); - }); - - it('adds active, disabled effect to submit buttons on submit', () => { - document.body.innerHTML = ` -
- - - -
- - `; - - const submit1 = screen.getByText('Submit1'); - const submit2 = screen.getByText('Submit2'); - const submit3 = screen.getByText('Submit3'); - const button1 = screen.getByText('Button1'); - const input1 = screen.getByDisplayValue('Input1'); - - const form = submit1.closest('form'); - initialize(form); - submit1.click(); - - expect(submit1.disabled).to.be.true(); - expect(submit1.classList.contains('usa-button--active')).to.be.true(); - expect(submit2.disabled).to.be.true(); - expect(submit2.classList.contains('usa-button--active')).to.be.true(); - expect(submit3.disabled).to.be.true(); - expect(submit3.classList.contains('usa-button--active')).to.be.true(); - expect(button1.disabled).to.be.false(); - expect(button1.classList.contains('usa-button--active')).to.be.false(); - expect(input1.disabled).to.be.false(); - expect(input1.classList.contains('usa-button--active')).to.be.false(); - }); - - it('checks validity of inputs', async () => { - document.body.innerHTML = ` -
- - -
`; - - initialize(document.querySelector('form')); - - const notRequiredField = screen.getByLabelText('not required field'); - await userEvent.type(notRequiredField, 'a{Backspace}'); - expect(notRequiredField.validationMessage).to.be.empty(); - - const requiredField = screen.getByLabelText('required field'); - await userEvent.type(requiredField, 'a{Backspace}'); - expect(requiredField.validationMessage).to.equal('simple_form.required.text'); - await userEvent.type(requiredField, 'a'); - expect(notRequiredField.validationMessage).to.be.empty(); - }); - - it('resets its own custom validity message on input', async () => { - document.body.innerHTML = ` -
- - -
`; - - const form = document.querySelector('form'); - initialize(form); - - form.checkValidity(); - - const input = screen.getByLabelText('required field'); - await userEvent.type(input, 'a'); - - expect(input.validity.customError).to.be.false(); - }); - - it('does not reset external custom validity message on input', async () => { - document.body.innerHTML = ` -
- - -
`; - - const form = document.querySelector('form'); - initialize(form); - - form.checkValidity(); - - /** @type {HTMLInputElement} */ - const input = screen.getByLabelText('field'); - input.setCustomValidity('custom error'); - - await userEvent.type(input, 'a'); - - expect(input.validity.customError).to.be.true(); - }); -}); diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb index d7f9dc0503d..fd3c863fc30 100644 --- a/spec/jobs/resolution_proofing_job_spec.rb +++ b/spec/jobs/resolution_proofing_job_spec.rb @@ -39,9 +39,11 @@ instance_double(Proofing::Aamva::Proofer, class: Proofing::Aamva::Proofer) end let(:trace_id) { SecureRandom.uuid } - let(:user) { build(:user, :signed_up) } + let(:user) { create(:user, :signed_up) } let(:threatmetrix_session_id) { SecureRandom.uuid } let(:threatmetrix_request_id) { Proofing::Mock::DdpMockClient::TRANSACTION_ID } + let(:request_ip) { '127.0.0.1' } + let(:uuid_prefix) { 'ABC' } describe '.perform_later' do it 'stores results' do @@ -53,6 +55,8 @@ trace_id: trace_id, user_id: user.id, threatmetrix_session_id: threatmetrix_session_id, + request_ip: request_ip, + uuid_prefix: uuid_prefix, ) result = document_capture_session.load_proofing_result[:result] @@ -72,6 +76,8 @@ trace_id: trace_id, user_id: user.id, threatmetrix_session_id: threatmetrix_session_id, + request_ip: request_ip, + uuid_prefix: uuid_prefix, ) end @@ -117,7 +123,7 @@ let(:dob_year_only) { false } - it 'returns results' do + it 'returns results and adds threatmetrix proofing components' do perform result = document_capture_session.load_proofing_result[:result] @@ -156,6 +162,9 @@ threatmetrix_success: true, threatmetrix_request_id: threatmetrix_request_id, ) + proofing_component = user.proofing_component + expect(proofing_component.threatmetrix).to equal(true) + expect(proofing_component.threatmetrix_review_status).to eq('pass') end context 'dob_year_only, failed response from lexisnexis' do @@ -236,6 +245,15 @@ ) end end + + context 'no threatmetrix_session_id' do + let(:threatmetrix_session_id) { nil } + it 'does not attempt to create a ddp proofer' do + perform + + expect(instance).not_to receive(:lexisnexis_ddp_proofer) + end + end end context 'stubbing vendors' do @@ -258,7 +276,7 @@ expect(instance).to receive(:logger_info_hash).ordered.with( hash_including( name: 'ThreatMetrix', - user_id: nil, + user_id: user.uuid, threatmetrix_request_id: Proofing::Mock::DdpMockClient::TRANSACTION_ID, threatmetrix_success: true, ), @@ -273,6 +291,10 @@ ) perform + + proofing_component = user.proofing_component + expect(proofing_component.threatmetrix).to equal(true) + expect(proofing_component.threatmetrix_review_status).to eq('pass') end end diff --git a/spec/lib/asset_sources_spec.rb b/spec/lib/asset_sources_spec.rb index 70c369625fc..9c640345e2b 100644 --- a/spec/lib/asset_sources_spec.rb +++ b/spec/lib/asset_sources_spec.rb @@ -41,6 +41,9 @@ ] } } + }, + "integrity": { + "vendor.js": "sha256-aztp/wpATyjXXpigZtP8ZP/9mUCHDMaL7OKFRbmnUIazQ9ehNmg4CD5Ljzym/TyA" } } STR @@ -136,6 +139,23 @@ end end + describe '.get_integrity' do + let(:path) { 'vendor.js' } + subject(:integrity) { AssetSources.get_integrity(path) } + + it 'returns the integrity hash' do + expect(integrity).to start_with('sha256-') + end + + context 'a path which does not exist in the manifest' do + let(:path) { 'missing.js' } + + it 'returns nil' do + expect(integrity).to be_nil + end + end + end + describe '.load_manifest' do it 'sets the manifest' do AssetSources.load_manifest diff --git a/spec/lib/telephony/pinpoint/voice_sender_spec.rb b/spec/lib/telephony/pinpoint/voice_sender_spec.rb index e440cd5f7a7..cd88cd99cc1 100644 --- a/spec/lib/telephony/pinpoint/voice_sender_spec.rb +++ b/spec/lib/telephony/pinpoint/voice_sender_spec.rb @@ -13,12 +13,12 @@ def mock_build_client allow(voice_sender). - to receive(:build_client).with(voice_config).and_return(pinpoint_client) + to receive(:build_client).with(voice_config).and_return(pinpoint_client) end def mock_build_backup_client allow(voice_sender). - to receive(:build_client).with(backup_voice_config).and_return(backup_pinpoint_client) + to receive(:build_client).with(backup_voice_config).and_return(backup_pinpoint_client) end describe '#send' do diff --git a/spec/lib/telephony/telephony_spec.rb b/spec/lib/telephony/telephony_spec.rb index 0c0e88855a7..1c71c6005ac 100644 --- a/spec/lib/telephony/telephony_spec.rb +++ b/spec/lib/telephony/telephony_spec.rb @@ -62,7 +62,7 @@ random_double_character = Telephony::GSM_DOUBLE_CHARACTERS.to_a.sample expect(Telephony.sms_character_length("abc\n¥ΔΦΓΛΩΠΨΣΘΞ#{random_double_character}")). - to eq 17 + to eq 17 end it 'calculates correct length of messages containing non-GSM characters' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bea5bb3e68c..c50d5e9145f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,12 +14,12 @@ it { is_expected.to have_many(:in_person_enrollments).dependent(:destroy) } it { is_expected.to have_one(:pending_in_person_enrollment). - conditions(status: :pending). - order(created_at: :desc). - class_name('InPersonEnrollment'). - with_foreign_key(:user_id). - inverse_of(:user). - dependent(:destroy) + conditions(status: :pending). + order(created_at: :desc). + class_name('InPersonEnrollment'). + with_foreign_key(:user_id). + inverse_of(:user). + dependent(:destroy) } end diff --git a/spec/presenters/additional_mfa_required_presenter_spec.rb b/spec/presenters/additional_mfa_required_presenter_spec.rb index 4b8461bdf74..2cda8a0ac0e 100644 --- a/spec/presenters/additional_mfa_required_presenter_spec.rb +++ b/spec/presenters/additional_mfa_required_presenter_spec.rb @@ -87,7 +87,7 @@ it 'should return false' do expect(presenter.cant_skip_anymore?). - to be_falsey + to be_falsey end end @@ -100,7 +100,7 @@ it 'should return true' do expect(presenter.cant_skip_anymore?). - to be_truthy + to be_truthy end end end diff --git a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb index 3853ac75c71..425dc3f25d2 100644 --- a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb @@ -63,12 +63,12 @@ it 'includes a note to select an additional mfa method on first setup' do expect(presenter_without_mfa.info). - to eq(t('two_factor_authentication.two_factor_choice_options.phone_info_html')) + to eq(t('two_factor_authentication.two_factor_choice_options.phone_info_html')) end it 'does not include a note to select an additional mfa on additional setup' do expect(presenter_with_mfa.info). - to eq(t('two_factor_authentication.two_factor_choice_options.phone_info')) + to eq(t('two_factor_authentication.two_factor_choice_options.phone_info')) end end end diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index dc02d7e9c97..76d64af4250 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -78,7 +78,7 @@ } expect(ahoy).to receive(:track). - with('Trackable Event', analytics_hash.merge(request_attributes)) + with('Trackable Event', analytics_hash.merge(request_attributes)) analytics.track_event('Trackable Event', { success: false }) end diff --git a/spec/services/cloud_front_header_parser_spec.rb b/spec/services/cloud_front_header_parser_spec.rb new file mode 100644 index 00000000000..159bd2df848 --- /dev/null +++ b/spec/services/cloud_front_header_parser_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe CloudFrontHeaderParser do + let(:req) { ActionDispatch::TestRequest.new({}) } + let(:port) { '1234' } + + subject { described_class.new(req) } + + context 'with an IPv4 address' do + let(:ip) { '192.0.2.1' } + + before do + req.headers['CloudFront-Viewer-Address'] = "#{ip}:#{port}" + end + + describe '#client_port' do + it 'returns the client port number' do + expect(subject.client_port).to eq port + end + end + end + + context 'with an IPv6 address' do + let(:ip) { '[2001:DB8::1]' } + + before do + req.headers['CloudFront-Viewer-Address'] = "#{ip}:#{port}" + end + + describe '#client_port' do + it 'returns the client port number' do + expect(subject.client_port).to eq port + end + end + end + + context 'with no CloudFront header sent' do + let(:ip) { '192.0.2.1' } + + describe '#client_port' do + it 'returns nil' do + expect(subject.client_port).to eq nil + end + end + end +end diff --git a/spec/services/doc_auth/acuant/acuant_client_spec.rb b/spec/services/doc_auth/acuant/acuant_client_spec.rb index 838368865ae..b9f2e856e8b 100644 --- a/spec/services/doc_auth/acuant/acuant_client_spec.rb +++ b/spec/services/doc_auth/acuant/acuant_client_spec.rb @@ -258,11 +258,11 @@ context 'when the result is a pass' do it 'sends the requests and returns success' do get_face_stub = stub_request(:get, get_face_image_url). - to_return(body: AcuantFixtures.get_face_image_response) + to_return(body: AcuantFixtures.get_face_image_response) facial_match_stub = stub_request(:post, full_facial_match_url). - to_return(body: AcuantFixtures.facial_match_response_success) + to_return(body: AcuantFixtures.facial_match_response_success) liveness_stub = stub_request(:post, full_liveness_url). - to_return(body: AcuantFixtures.liveness_response_success) + to_return(body: AcuantFixtures.liveness_response_success) result = subject.post_selfie( instance_id: instance_id, diff --git a/spec/services/doc_auth/acuant/requests/facial_match_request_spec.rb b/spec/services/doc_auth/acuant/requests/facial_match_request_spec.rb index 8d3190e95df..bf3be974311 100644 --- a/spec/services/doc_auth/acuant/requests/facial_match_request_spec.rb +++ b/spec/services/doc_auth/acuant/requests/facial_match_request_spec.rb @@ -32,8 +32,8 @@ it 'returns a successful response' do request_stub = stub_request(:post, url). - with(body: request_body). - to_return(body: response_body) + with(body: request_body). + to_return(body: response_body) response = described_class.new( config: config, @@ -53,8 +53,8 @@ it 'returns an unsuccessful response' do request_stub = stub_request(:post, url). - with(body: request_body). - to_return(body: response_body) + with(body: request_body). + to_return(body: response_body) response = described_class.new( config: config, diff --git a/spec/services/doc_auth/acuant/requests/liveness_request_spec.rb b/spec/services/doc_auth/acuant/requests/liveness_request_spec.rb index 0b7925e884b..d5c76f65f2e 100644 --- a/spec/services/doc_auth/acuant/requests/liveness_request_spec.rb +++ b/spec/services/doc_auth/acuant/requests/liveness_request_spec.rb @@ -34,8 +34,8 @@ it 'returns a successful response' do request_stub = stub_request(:post, url). - with(body: request_body). - to_return(body: response_body) + with(body: request_body). + to_return(body: response_body) response = request.fetch @@ -51,8 +51,8 @@ it 'returns an unsuccessful response' do request_stub = stub_request(:post, url). - with(body: request_body). - to_return(body: response_body) + with(body: request_body). + to_return(body: response_body) response = request.fetch diff --git a/spec/services/id_token_builder_spec.rb b/spec/services/id_token_builder_spec.rb index 43224eb36e4..d917a4383ba 100644 --- a/spec/services/id_token_builder_spec.rb +++ b/spec/services/id_token_builder_spec.rb @@ -95,7 +95,7 @@ it 'sets the code hash correctly' do leftmost_128_bits = Digest::SHA256.digest(code). - byteslice(0, IdTokenBuilder::NUM_BYTES_FIRST_128_BITS) + byteslice(0, IdTokenBuilder::NUM_BYTES_FIRST_128_BITS) expected_hash = Base64.urlsafe_encode64(leftmost_128_bits, padding: false) expect(decoded_payload[:c_hash]).to eq(expected_hash) diff --git a/spec/services/identity_linker_spec.rb b/spec/services/identity_linker_spec.rb index 725835281fb..346b73cb011 100644 --- a/spec/services/identity_linker_spec.rb +++ b/spec/services/identity_linker_spec.rb @@ -18,8 +18,8 @@ } identity_attributes = last_identity.attributes.symbolize_keys. - except(:created_at, :updated_at, :id, :session_uuid, - :last_authenticated_at, :nonce) + except(:created_at, :updated_at, :id, :session_uuid, + :last_authenticated_at, :nonce) expect(last_identity.session_uuid).to match(/.{8}-.{4}-.{4}-.{4}-.{12}/) expect(last_identity.last_authenticated_at).to be_present diff --git a/spec/services/idv/in_person_config_spec.rb b/spec/services/idv/in_person_config_spec.rb index 8abaefe0bd0..69c8a0d036a 100644 --- a/spec/services/idv/in_person_config_spec.rb +++ b/spec/services/idv/in_person_config_spec.rb @@ -2,6 +2,7 @@ describe Idv::InPersonConfig do let(:in_person_proofing_enabled) { false } + let(:idv_sp_required) { false } let(:in_person_proofing_enabled_issuers) { [] } before do @@ -9,6 +10,7 @@ and_return(in_person_proofing_enabled) allow(IdentityConfig.store).to receive(:in_person_proofing_enabled_issuers). and_return(in_person_proofing_enabled_issuers) + allow(IdentityConfig.store).to receive(:idv_sp_required).and_return(idv_sp_required) end describe '.enabled_for_issuer?' do @@ -22,6 +24,12 @@ it { expect(enabled_for_issuer).to eq true } + context 'with idv sp required' do + let(:idv_sp_required) { true } + + it { expect(enabled_for_issuer).to eq false } + end + context 'with issuer argument' do let(:issuer) { 'example-issuer' } @@ -48,6 +56,18 @@ end end + describe '.enabled_without_issuer?' do + subject(:enabled_without_issuer) { described_class.enabled_without_issuer? } + + it { expect(enabled_without_issuer).to eq true } + + context 'with idv sp required' do + let(:idv_sp_required) { true } + + it { expect(enabled_without_issuer).to eq false } + end + end + describe '.enabled_issuers' do subject(:enabled_issuers) { described_class.enabled_issuers } diff --git a/spec/services/idv/steps/ipp/ssn_step_spec.rb b/spec/services/idv/steps/ipp/ssn_step_spec.rb index 9221e8ae202..5ad89face08 100644 --- a/spec/services/idv/steps/ipp/ssn_step_spec.rb +++ b/spec/services/idv/steps/ipp/ssn_step_spec.rb @@ -46,13 +46,13 @@ end context 'with proofing device profiling collecting enabled' do - it 'adds a session id to flow session' do + it 'does not add a threatmetrix session id to flow session' do allow(IdentityConfig.store). to receive(:proofing_device_profiling_collecting_enabled). and_return(true) step.extra_view_variables - expect(flow.flow_session[:threatmetrix_session_id]).to_not eq(nil) + expect(flow.flow_session[:threatmetrix_session_id]).to eq(nil) end it 'does not change threatmetrix_session_id when updating ssn' do diff --git a/spec/services/irs_attempts_api/attempt_event_spec.rb b/spec/services/irs_attempts_api/attempt_event_spec.rb index b1753f6747c..547219e62fc 100644 --- a/spec/services/irs_attempts_api/attempt_event_spec.rb +++ b/spec/services/irs_attempts_api/attempt_event_spec.rb @@ -7,7 +7,7 @@ before do encoded_public_key = Base64.strict_encode64(irs_attempt_api_public_key.to_der) allow(IdentityConfig.store).to receive(:irs_attempt_api_public_key). - and_return(encoded_public_key) + and_return(encoded_public_key) end let(:jti) { 'test-unique-id' } diff --git a/spec/services/irs_attempts_api/envelope_encryptor_spec.rb b/spec/services/irs_attempts_api/envelope_encryptor_spec.rb new file mode 100644 index 00000000000..8b7cba7c1da --- /dev/null +++ b/spec/services/irs_attempts_api/envelope_encryptor_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' +RSpec.describe IrsAttemptsApi::EnvelopeEncryptor do + let(:private_key) { OpenSSL::PKey::RSA.new(4096) } + let(:public_key) { private_key.public_key } + describe '.encrypt' do + it 'returns encrypted result' do + text = 'test' + time = Time.zone.now + result = IrsAttemptsApi::EnvelopeEncryptor.encrypt( + data: text, timestamp: time, public_key: public_key, + ) + + expect(result.encrypted_data).to_not eq text + end + + it 'filename includes digest and truncated timestamp' do + text = 'test' + time = Time.zone.now + result = IrsAttemptsApi::EnvelopeEncryptor.encrypt( + data: text, timestamp: time, + public_key: public_key + ) + digest = Digest::SHA256.hexdigest(result.encrypted_data) + + expect(result.filename).to include( + IrsAttemptsApi::EnvelopeEncryptor.formatted_timestamp(time), + ) + expect(result.filename).to include(digest) + end + end + + describe '.decrypt' do + it 'returns decrypted text' do + text = 'test' + time = Time.zone.now + result = IrsAttemptsApi::EnvelopeEncryptor.encrypt( + data: text, timestamp: time, + public_key: public_key + ) + key = private_key.private_decrypt(result.encrypted_key) + + expect( + IrsAttemptsApi::EnvelopeEncryptor.decrypt( + encrypted_data: result.encrypted_data, + key: key, + iv: result.iv, + ), + ).to eq(text) + end + end + + describe '.formatted_timestamp' do + it 'formats according to the specification' do + timestamp = Time.new(2022, 1, 1, 11, 1, 1, 'UTC') + result = IrsAttemptsApi::EnvelopeEncryptor.formatted_timestamp(timestamp) + + expect(result).to eq '20220101T11Z' + end + end +end diff --git a/spec/services/irs_attempts_api/tracker_spec.rb b/spec/services/irs_attempts_api/tracker_spec.rb index 8b03cfaaf49..e3826510a16 100644 --- a/spec/services/irs_attempts_api/tracker_spec.rb +++ b/spec/services/irs_attempts_api/tracker_spec.rb @@ -7,6 +7,9 @@ ) allow(request).to receive(:user_agent).and_return('example/1.0') allow(request).to receive(:remote_ip).and_return('192.0.2.1') + allow(request).to receive(:headers).and_return( + { 'CloudFront-Viewer-Address' => '192.0.2.1:1234' }, + ) end let(:irs_attempt_api_enabled) { true } @@ -14,7 +17,7 @@ let(:enabled_for_session) { true } let(:request) { instance_double(ActionDispatch::Request) } let(:service_provider) { create(:service_provider) } - let(:device_fingerprint) { 'device_id' } + let(:cookie_device_uuid) { 'device_id' } let(:sp_request_uri) { 'https://example.com/auth_page' } let(:user) { create(:user) } @@ -24,7 +27,7 @@ request: request, user: user, sp: service_provider, - device_fingerprint: device_fingerprint, + cookie_device_uuid: cookie_device_uuid, sp_request_uri: sp_request_uri, enabled_for_session: enabled_for_session, ) diff --git a/spec/services/marketing_site_spec.rb b/spec/services/marketing_site_spec.rb index 503e4c78035..b77196acfc0 100644 --- a/spec/services/marketing_site_spec.rb +++ b/spec/services/marketing_site_spec.rb @@ -50,7 +50,7 @@ describe '.rules_of_use_url' do it 'points to the rules of use page' do expect(MarketingSite.rules_of_use_url). - to eq('https://www.login.gov/policy/rules-of-use/') + to eq('https://www.login.gov/policy/rules-of-use/') end context 'when the user has set their locale to :es' do @@ -58,7 +58,7 @@ it 'points to the rules of use page with the locale appended' do expect(MarketingSite.rules_of_use_url). - to eq('https://www.login.gov/es/policy/rules-of-use/') + to eq('https://www.login.gov/es/policy/rules-of-use/') end end end diff --git a/spec/services/proofing/aamva/request/security_token_request_spec.rb b/spec/services/proofing/aamva/request/security_token_request_spec.rb index c3e4ee61def..d2fd8923951 100644 --- a/spec/services/proofing/aamva/request/security_token_request_spec.rb +++ b/spec/services/proofing/aamva/request/security_token_request_spec.rb @@ -31,9 +31,9 @@ expect(signature.text).to_not be_empty body_without_sig = security_token_request.body. - gsub(public_key.text, ''). - gsub(signature.text, ''). - gsub(key_identifier.text, '') + gsub(public_key.text, ''). + gsub(signature.text, ''). + gsub(key_identifier.text, '') expect(body_without_sig).to eq(AamvaFixtures.security_token_request) end diff --git a/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb b/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb index 0b5e2588cf7..66a55915298 100644 --- a/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb +++ b/spec/services/proofing/lexis_nexis/ddp/proofing_spec.rb @@ -16,6 +16,8 @@ threatmetrix_session_id: '123456', phone: '5551231234', email: 'test@example.com', + request_ip: '127.0.0.1', + uuid_prefix: 'ABCD', } end let(:verification_request) do diff --git a/spec/services/proofing/lexis_nexis/ddp/verification_request_spec.rb b/spec/services/proofing/lexis_nexis/ddp/verification_request_spec.rb index ec7c7d71c91..9b09b30e77b 100644 --- a/spec/services/proofing/lexis_nexis/ddp/verification_request_spec.rb +++ b/spec/services/proofing/lexis_nexis/ddp/verification_request_spec.rb @@ -17,6 +17,8 @@ threatmetrix_session_id: 'UNIQUE_SESSION_ID', phone: '5551231234', email: 'test@example.com', + request_ip: '127.0.0.1', + uuid_prefix: 'ABCD', } end diff --git a/spec/services/proofing/mock/ddp_mock_client_spec.rb b/spec/services/proofing/mock/ddp_mock_client_spec.rb new file mode 100644 index 00000000000..ee5ae3a21e5 --- /dev/null +++ b/spec/services/proofing/mock/ddp_mock_client_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe Proofing::Mock::DdpMockClient do + let(:applicant) { + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge(threatmetrix_session_id: 'ABCD-1234') + } + + subject(:instance) { described_class.new } + + describe '#proof' do + subject(:result) { instance.proof(applicant) } + + it 'passes by default' do + expect(result.review_status).to eq('pass') + end + + context 'with magic "reject" SSN' do + let(:applicant) { super().merge(ssn: '666-77-8888') } + it 'fails' do + expect(result.review_status).to eq('reject') + end + end + + context 'with magic "review" SSN' do + let(:applicant) { super().merge(ssn: '666-77-9999') } + it 'fails' do + expect(result.review_status).to eq('review') + end + end + + context 'with magic "nil" SSN' do + let(:applicant) { super().merge(ssn: '666-77-0000') } + it 'fails' do + expect(result.review_status).to be_nil + end + end + end +end diff --git a/spec/simplecov_helper.rb b/spec/simplecov_helper.rb index 77bea2b9ed3..3a327d3a971 100644 --- a/spec/simplecov_helper.rb +++ b/spec/simplecov_helper.rb @@ -60,9 +60,6 @@ def self.configured_formatters if ENV['GITLAB_CI'] # GitLab CI uses Cobertura formatter to display diffs in pull requests formatters << SimpleCov::Formatter::CoberturaFormatter - elsif ENV['CIRCLE_CI'] - # CircleCI uses JSON formatting for CodeClimate - formatters << SimpleCov::Formatter::JSONFormatter end formatters diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index 6ee1590361f..bbe859febbd 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -70,10 +70,6 @@ def stub_decorated_user_with_pending_profile(user) decorated_user end - def stub_gpo_mail_bounced(decorated_user) - allow(decorated_user).to receive(:gpo_mail_bounced?).and_return(true) - end - def stub_identity(user, params) ServiceProviderIdentity.new(params.merge(user: user)).save end diff --git a/spec/support/shared_examples_for_email_validation.rb b/spec/support/shared_examples_for_email_validation.rb index 32e2919b461..9f287cd4aa9 100644 --- a/spec/support/shared_examples_for_email_validation.rb +++ b/spec/support/shared_examples_for_email_validation.rb @@ -1,7 +1,7 @@ shared_examples 'email validation' do it 'uses the valid_email gem with mx and ban_disposable options' do email_validator = subject._validators.values.flatten. - detect { |v| v.instance_of?(EmailValidator) } + detect { |v| v.instance_of?(EmailValidator) } expect(email_validator.options). to eq(mx_with_fallback: true, ban_disposable_email: true) diff --git a/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb b/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb index 6dead571ecb..7024b083bc3 100644 --- a/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb +++ b/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb @@ -22,7 +22,7 @@ render expect(rendered).to have_link(t('account.index.auth_app_add'), href: authenticator_setup_url) - expect(rendered).not_to have_xpath("//input[@value='Disable']") + expect(rendered).not_to have_link(t('forms.buttons.disable')) end end @@ -43,7 +43,10 @@ it 'contains link to disable TOTP' do render - expect(rendered).to have_link(t('forms.buttons.disable', href: auth_app_delete_path)) + expect(rendered).to have_link( + t('forms.buttons.disable'), + href: auth_app_delete_path(id: user.auth_app_configurations.first.id), + ) end end @@ -95,7 +98,7 @@ it 'disables delete buttons for the last non restricted mfa method with phone configured' do render - expect(rendered).to_not have_link(t('forms.buttons.disable', href: auth_app_delete_path)) + expect(rendered).to_not have_link(t('forms.buttons.disable')) end end end diff --git a/spec/views/idv/gpo/index.html.erb_spec.rb b/spec/views/idv/gpo/index.html.erb_spec.rb index 2188c648e35..a627f5c332d 100644 --- a/spec/views/idv/gpo/index.html.erb_spec.rb +++ b/spec/views/idv/gpo/index.html.erb_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe 'idv/gpo/index.html.erb' do - let(:gpo_mail_bounced) { false } let(:letter_already_sent) { false } let(:user_needs_address_otp_verification) { false } let(:go_back_path) { nil } @@ -13,7 +12,6 @@ before do allow(view).to receive(:go_back_path).and_return(go_back_path) - allow(presenter).to receive(:gpo_mail_bounced?).and_return(gpo_mail_bounced) allow(presenter).to receive(:letter_already_sent?).and_return(letter_already_sent) allow(presenter).to receive(:user_needs_address_otp_verification?). and_return(user_needs_address_otp_verification) @@ -39,16 +37,6 @@ end end - context 'gpo mail bounced' do - let(:gpo_mail_bounced) { true } - - it 'renders address form to resend letter' do - expect(rendered).to have_content(I18n.t('idv.messages.gpo.new_address')) - expect(rendered).to have_field(t('idv.form.address1')) - expect(rendered).to have_button(I18n.t('idv.buttons.mail.resend')) - end - end - context 'letter already sent' do let(:letter_already_sent) { true } diff --git a/spec/views/idv/shared/_ssn.html.erb_spec.rb b/spec/views/idv/shared/_ssn.html.erb_spec.rb index 80aa8b701be..c57ae368127 100644 --- a/spec/views/idv/shared/_ssn.html.erb_spec.rb +++ b/spec/views/idv/shared/_ssn.html.erb_spec.rb @@ -51,6 +51,17 @@ expect_noscript_tag_rendered end end + + context 'session id not specified' do + let(:session_id) { nil } + + it 'does not render