diff --git a/Gemfile b/Gemfile index 02cfd82138a..f88a95012bf 100644 --- a/Gemfile +++ b/Gemfile @@ -54,7 +54,7 @@ gem 'rqrcode' gem 'ruby-progressbar' gem 'ruby-saml' gem 'safe_target_blank', '>= 1.0.2' -gem 'saml_idp', github: '18F/saml_idp', tag: '0.16.0-18f' +gem 'saml_idp', github: '18F/saml_idp', tag: '0.17.0-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' gem 'stringex', require: false @@ -87,6 +87,7 @@ group :development, :test do gem 'aws-sdk-cloudwatchlogs', require: false gem 'brakeman', require: false gem 'bullet', '>= 6.0.2' + gem 'capybara-webmock', git: 'https://github.com/hashrocket/capybara-webmock.git', ref: '63d790a0' gem 'data_uri', require: false gem 'erb_lint', '~> 0.1.0', require: false gem 'i18n-tasks', '>= 0.9.31' diff --git a/Gemfile.lock b/Gemfile.lock index a6abfc25d43..e7c0e56fc76 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,10 +25,10 @@ GIT GIT remote: https://github.com/18F/saml_idp.git - revision: f82bd9cd1682d1645abe98e1fe5e6261f53a8279 - tag: 0.16.0-18f + revision: f10c8ba1b4e10ba983a79b1d0fd39cadca95a728 + tag: 0.17.0-18f specs: - saml_idp (0.16.0.pre.18f) + saml_idp (0.17.0.pre.18f) activesupport builder faraday @@ -36,6 +36,19 @@ GIT pkcs11 uuid +GIT + remote: https://github.com/hashrocket/capybara-webmock.git + revision: 63d790a0b6c779b9700634bfc153e25ccdeb3688 + ref: 63d790a0 + specs: + capybara-webmock (0.6.0) + capybara (>= 2.4, < 4) + rack (>= 1.4) + rack-proxy (>= 0.6.0) + rexml (>= 3.2) + selenium-webdriver (>= 4.0) + webrick (>= 1.7) + GEM remote: https://rubygems.org/ specs: @@ -408,7 +421,7 @@ GEM pg_query (2.1.3) google-protobuf (>= 3.19.2) phonelib (0.6.54) - pkcs11 (0.3.3) + pkcs11 (0.3.4) premailer (1.15.0) addressable css_parser (>= 1.6.0) @@ -442,6 +455,8 @@ GEM rack-headers_filter (0.0.1) rack-mini-profiler (2.3.3) rack (>= 1.2.0) + rack-proxy (0.7.2) + rack rack-test (1.1.0) rack (>= 1.0, < 3) rack-timeout (0.6.0) @@ -700,6 +715,7 @@ DEPENDENCIES bullet (>= 6.0.2) bundler-audit capybara-selenium (>= 0.0.6) + capybara-webmock! connection_pool cssbundling-rails data_uri diff --git a/app/assets/stylesheets/components/_validated-checkbox.scss b/app/assets/stylesheets/components/_validated-checkbox.scss new file mode 100644 index 00000000000..7bb8efb9024 --- /dev/null +++ b/app/assets/stylesheets/components/_validated-checkbox.scss @@ -0,0 +1,7 @@ +.mfa-selection { + .usa-checkbox__input--tile:checked + + label.checkbox__invalid.usa-checkbox__label.usa-checkbox__label--illustrated { + border-color: color('secondary'); + border-width: 2px; + } +} diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index 1a0947e5b58..435850e3922 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -24,4 +24,5 @@ @import 'spinner-dots'; @import 'step-indicator'; @import 'troubleshooting-options'; +@import 'validated-checkbox'; @import 'i18n-dropdown'; diff --git a/app/controllers/api/verify/complete_controller.rb b/app/controllers/api/verify/complete_controller.rb index 8e7e08b0b3e..0cfd610a172 100644 --- a/app/controllers/api/verify/complete_controller.rb +++ b/app/controllers/api/verify/complete_controller.rb @@ -4,7 +4,7 @@ class CompleteController < Api::BaseController def create result, personal_key = Api::ProfileCreationForm.new( password: verify_params[:password], - jwt: verify_params[:details], + jwt: verify_params[:user_bundle_token], user_session: user_session, service_provider: current_sp, ).submit @@ -23,7 +23,7 @@ def create private def verify_params - params.permit(:password, :details) + params.permit(:password, :user_bundle_token) end def add_proofing_component(user) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 85bb625cd77..f9c8c89bf71 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -428,16 +428,6 @@ def analytics_exception_info(exception) } end - def add_sp_cost(token) - Db::SpCost::AddSpCost.call( - current_sp, - sp_session_ial, - token, - transaction_id: nil, - user: current_user, - ) - end - def mobile? BrowserCache.parse(request.user_agent).mobile? end diff --git a/app/controllers/concerns/billable_event_trackable.rb b/app/controllers/concerns/billable_event_trackable.rb index 1b6798c4ea1..0f08e90debf 100644 --- a/app/controllers/concerns/billable_event_trackable.rb +++ b/app/controllers/concerns/billable_event_trackable.rb @@ -6,7 +6,6 @@ def track_billing_events increment_sp_monthly_auths create_sp_return_log(billable: true) mark_current_session_billed - add_sp_cost(:authentication) end end @@ -21,14 +20,14 @@ def increment_sp_monthly_auths end def create_sp_return_log(billable:) - ial_context = IalContext.new( - ial: sp_session_ial, service_provider: current_sp, user: current_user, + user_ial_context = IalContext.new( + ial: ial_context.ial, service_provider: current_sp, user: current_user, ) Db::SpReturnLog.create_return( request_id: request_id, user_id: current_user.id, billable: billable, - ial: ial_context.bill_for_ial_1_or_2, + ial: user_ial_context.bill_for_ial_1_or_2, issuer: current_sp.issuer, requested_at: session[:session_started_at], ) diff --git a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb index 69965896c25..5fcbd7fbbc9 100644 --- a/app/controllers/concerns/idv/phone_otp_rate_limitable.rb +++ b/app/controllers/concerns/idv/phone_otp_rate_limitable.rb @@ -10,7 +10,7 @@ module PhoneOtpRateLimitable def handle_locked_out_user reset_attempt_count_if_user_no_longer_locked_out return unless decorated_user.locked_out? - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT) + analytics.idv_phone_confirmation_otp_rate_limit_locked_out handle_too_many_otp_attempts false end @@ -28,12 +28,12 @@ def reset_attempt_count_if_user_no_longer_locked_out end def handle_too_many_otp_sends - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS) + analytics.idv_phone_confirmation_otp_rate_limit_sends handle_max_attempts('otp_requests') end def handle_too_many_otp_attempts - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS) + analytics.idv_phone_confirmation_otp_rate_limit_attempts handle_max_attempts('otp_login_attempts') end diff --git a/app/controllers/concerns/saml_idp_auth_concern.rb b/app/controllers/concerns/saml_idp_auth_concern.rb index 10c7bcf0a9b..adf162a5af9 100644 --- a/app/controllers/concerns/saml_idp_auth_concern.rb +++ b/app/controllers/concerns/saml_idp_auth_concern.rb @@ -122,6 +122,7 @@ def ial_context @ial_context ||= IalContext.new( ial: requested_ial_authn_context, service_provider: saml_request_service_provider, + authn_context_comparison: saml_request.requested_authn_context_comparison, ) end diff --git a/app/controllers/concerns/verify_profile_concern.rb b/app/controllers/concerns/verify_profile_concern.rb index d49791c025f..6345dd54f04 100644 --- a/app/controllers/concerns/verify_profile_concern.rb +++ b/app/controllers/concerns/verify_profile_concern.rb @@ -10,6 +10,8 @@ def account_or_verify_profile_url def profile_needs_verification? return false if current_user.blank? + return false if sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2 current_user.decorate.pending_profile_requires_verification? || user_needs_to_reactivate_account? end diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb index d22e70162d1..45a937fc904 100644 --- a/app/controllers/idv/doc_auth_controller.rb +++ b/app/controllers/idv/doc_auth_controller.rb @@ -31,6 +31,8 @@ def redirect_if_mail_bounced end def redirect_if_pending_profile + return if sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2 redirect_to idv_gpo_verify_url if current_user.decorate.pending_profile_requires_verification? end diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index ef23b2b2cc5..c7df8e8ae70 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -6,6 +6,7 @@ class GpoController < ApplicationController before_action :confirm_idv_needed 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 @@ -32,7 +33,9 @@ def create update_tracking idv_session.address_verification_mechanism = :gpo - if current_user.decorate.pending_profile_requires_verification? + if current_user.decorate.pending_profile_requires_verification? && pii_locked? + redirect_to capture_password_url + elsif current_user.decorate.pending_profile_requires_verification? resend_letter redirect_to idv_come_back_later_url else @@ -63,6 +66,12 @@ 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 @@ -256,5 +265,9 @@ def missing delete_async ProofingSessionAsyncResult.missing end + + def pii_locked? + !Pii::Cacher.new(current_user, user_session).exists_in_session? + end end end diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index ec19f9dae32..108d87d1416 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -48,7 +48,7 @@ def render_new_with_error_message def send_phone_confirmation_otp_and_handle_result save_delivery_preference result = send_phone_confirmation_otp - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_SENT, result.to_h) + analytics.idv_phone_confirmation_otp_sent(**result.to_h) if result.success? redirect_to idv_otp_verification_url else @@ -79,8 +79,11 @@ def otp_delivery_selection_form end def gpo_letter_available + return @gpo_letter_available if defined?(@gpo_letter_available) @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? && - !Idv::GpoMail.new(current_user).mail_spammed? + !Idv::GpoMail.new(current_user).mail_spammed? && + !(sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2) end end end diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index fdb6d6d5d83..af0e5131b00 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -140,8 +140,11 @@ def new_phone_added? end def gpo_letter_available + return @gpo_letter_available if defined?(@gpo_letter_available) @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? && - !Idv::GpoMail.new(current_user).mail_spammed? + !Idv::GpoMail.new(current_user).mail_spammed? && + !(sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2) end end end diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb index cb2d87d0a73..636c5598ae0 100644 --- a/app/controllers/idv/phone_errors_controller.rb +++ b/app/controllers/idv/phone_errors_controller.rb @@ -4,6 +4,7 @@ class PhoneErrorsController < ApplicationController before_action :confirm_two_factor_authenticated before_action :confirm_idv_phone_step_needed + before_action :set_gpo_letter_available def warning @remaining_attempts = throttle.remaining_count @@ -45,5 +46,15 @@ def track_event(type:) analytics.idv_phone_error_visited(**attributes) end + + # rubocop:disable Naming/MemoizedInstanceVariableName + def set_gpo_letter_available + return @gpo_letter_available if defined?(@gpo_letter_available) + @gpo_letter_available ||= FeatureManagement.enable_gpo_verification? && + !Idv::GpoMail.new(current_user).mail_spammed? && + !(sp_session[:ial2_strict] && + !IdentityConfig.store.gpo_allowed_for_strict_ial2) + end + # rubocop:enable Naming/MemoizedInstanceVariableName end end diff --git a/app/controllers/idv/resend_otp_controller.rb b/app/controllers/idv/resend_otp_controller.rb index f6e85139b54..917f12d3a2b 100644 --- a/app/controllers/idv/resend_otp_controller.rb +++ b/app/controllers/idv/resend_otp_controller.rb @@ -10,7 +10,7 @@ class ResendOtpController < ApplicationController def create result = send_phone_confirmation_otp - analytics.track_event(Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, result.to_h) + analytics.idv_phone_confirmation_otp_resent(**result.to_h) if result.success? redirect_to idv_otp_verification_url else diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 81c13175fd3..eb304dde471 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -46,7 +46,7 @@ def create user_session[:need_personal_key_confirmation] = true redirect_to next_step analytics.track_event(Analytics::IDV_REVIEW_COMPLETE) - analytics.track_event(Analytics::IDV_FINAL, success: true) + analytics.idv_final(success: true) return unless FeatureManagement.reveal_gpo_code? session[:last_gpo_confirmation_code] = idv_session.gpo_otp @@ -115,7 +115,7 @@ def need_personal_key_confirmation? def next_step if idv_api_personal_key_step_enabled? - idv_app_root_url + idv_app_url else idv_personal_key_url end @@ -123,7 +123,7 @@ def next_step def idv_api_personal_key_step_enabled? return false if idv_session.address_verification_mechanism == 'gpo' - IdentityConfig.store.idv_api_enabled_steps.include?(:personal_key) + IdentityConfig.store.idv_api_enabled_steps.include?('personal_key') end end end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index a6ab0006e53..adcc1f1836d 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -58,6 +58,10 @@ def link_identity_to_service_provider @authorize_form.link_identity_to_service_provider(current_user, session.id) end + def ial_context + @authorize_form.ial_context + end + def handle_successful_handoff track_events SpHandoffBounce::AddHandoffTimeToSession.call(sp_session) diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 361fb05b158..d5c5cd090a5 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -100,10 +100,14 @@ def redirect_to_verification_url end def profile_or_identity_needs_verification_or_decryption? - return false unless ial_context.ial2_or_greater? + return false unless ial_context.ial2_or_greater? || ialmax_requested_with_ial2_user? profile_needs_verification? || identity_needs_verification? || identity_needs_decryption? end + def ialmax_requested_with_ial2_user? + ial_context.ialmax_requested? && identity_needs_decryption? + end + def identity_needs_decryption? UserDecorator.new(current_user).identity_verified? && !Pii::Cacher.new(current_user, user_session).exists_in_session? @@ -114,7 +118,7 @@ def capture_analytics endpoint: remap_auth_post_path(request.env['PATH_INFO']), idv: identity_needs_verification?, finish_profile: profile_needs_verification?, - requested_ial: saml_request&.requested_ial_authn_context || 'none', + requested_ial: requested_ial, ) analytics.track_event(Analytics::SAML_AUTH, analytics_payload) end @@ -123,11 +127,17 @@ def log_external_saml_auth_request return unless external_saml_request? analytics.saml_auth_request( - requested_ial: saml_request&.requested_ial_authn_context || 'none', + requested_ial: requested_ial, service_provider: saml_request&.issuer, ) end + def requested_ial + return 'ialmax' if ial_context.ialmax_requested? + + saml_request&.requested_ial_authn_context || 'none' + end + def handle_successful_handoff track_events delete_branded_experience @@ -152,7 +162,7 @@ def render_template_for(message, action_url, type) end def track_events - analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: sp_session_ial) + analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: ial_context.ial) track_billing_events end end diff --git a/app/controllers/users/mfa_selection_controller.rb b/app/controllers/users/mfa_selection_controller.rb new file mode 100644 index 00000000000..1afe3d1758d --- /dev/null +++ b/app/controllers/users/mfa_selection_controller.rb @@ -0,0 +1,49 @@ +module Users + class MfaSelectionController < ApplicationController + include UserAuthenticator + include MfaSetupConcern + + def index + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @presenter = two_factor_options_presenter + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP_VISIT) + end + + def update + result = submit_form + analytics.track_event(Analytics::USER_REGISTRATION_2FA_SETUP, result.to_h) + + if result.success? + process_valid_form + else + @presenter = two_factor_options_presenter + render :index + end + end + + private + + def submit_form + @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + @two_factor_options_form.submit(two_factor_options_form_params) + end + + def two_factor_options_presenter + TwoFactorOptionsPresenter.new( + user_agent: request.user_agent, + user: current_user, + aal3_required: service_provider_mfa_policy.aal3_required?, + piv_cac_required: service_provider_mfa_policy.piv_cac_required?, + ) + end + + def process_valid_form + user_session[:selected_mfa_options] = @two_factor_options_form.selection + redirect_to confirmation_path(user_session[:selected_mfa_options].first) + end + + def two_factor_options_form_params + params.require(:two_factor_options_form).permit(:selection, selection: []) + end + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 03d582431f4..d409eac260a 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -127,7 +127,6 @@ def process_locked_out_user def handle_valid_authentication sign_in(resource_name, resource) cache_active_profile(auth_params[:password]) - add_sp_cost(:digest) create_user_event(:sign_in_before_2fa) EmailAddress.update_last_sign_in_at_on_user_id_and_email( user_id: current_user.id, diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index 5fb042de916..d888257ff86 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -180,7 +180,7 @@ def handle_valid_otp_params(method, default = nil) end def handle_telephony_result(method:, default:) - track_events(method) + track_events if @telephony_result.success? redirect_to login_two_factor_url( otp_delivery_preference: method, @@ -197,9 +197,8 @@ def handle_telephony_result(method:, default:) end end - def track_events(method) + def track_events analytics.track_event(Analytics::TELEPHONY_OTP_SENT, @telephony_result.to_h) - add_sp_cost(method) if @telephony_result.success? end def exceeded_otp_send_limit? diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 9543960b3cd..10f0ade0850 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -6,7 +6,6 @@ class TwoFactorAuthenticationSetupController < ApplicationController before_action :authenticate_user before_action :confirm_user_authenticated_for_2fa_setup before_action :confirm_user_needs_2fa_setup - before_action :handle_empty_selection, only: :create def index @two_factor_options_form = TwoFactorOptionsForm.new(current_user) @@ -20,10 +19,16 @@ def create if result.success? process_valid_form + elsif result.errors[:selection].include? 'phone' + flash[:phone_error] = t('errors.two_factor_auth_setup.must_select_additional_option') + redirect_to two_factor_options_path(anchor: 'select_phone') else @presenter = two_factor_options_presenter render :index end + rescue ActionController::ParameterMissing + flash[:error] = t('errors.two_factor_auth_setup.must_select_option') + redirect_back(fallback_location: two_factor_options_path, allow_other_host: false) end private @@ -47,16 +52,8 @@ def process_valid_form redirect_to confirmation_path(user_session[:selected_mfa_options].first) end - def handle_empty_selection - return if params[:two_factor_options_form].present? - - flash[:error] = t('errors.two_factor_auth_setup.must_select_option') - redirect_back(fallback_location: two_factor_options_path, allow_other_host: false) - end - def confirm_user_needs_2fa_setup return unless mfa_policy.two_factor_enabled? - return if params.has_key?(:multiple_mfa_setup) return if service_provider_mfa_policy.user_needs_sp_auth_method_setup? redirect_to after_mfa_setup_path end diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index 194be97f393..1f5e12e6632 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -2,11 +2,13 @@ class VerifyController < ApplicationController include RenderConditionConcern include IdvSession + check_or_render_not_found -> { FeatureManagement.idv_api_enabled? }, only: [:show] + + before_action :redirect_root_path_to_first_step + before_action :validate_step before_action :confirm_two_factor_authenticated before_action :confirm_idv_vendor_session_started - before_action :confirm_profile_has_been_created - - check_or_render_not_found -> { FeatureManagement.idv_api_enabled? }, only: [:show] + before_action :confirm_profile_has_been_created, if: :first_step_is_personal_key? def show @app_data = app_data @@ -14,16 +16,56 @@ def show private + def redirect_root_path_to_first_step + redirect_to idv_app_path(step: first_step) if params[:step].blank? + end + + def validate_step + render_not_found if !enabled_steps.include?(params[:step]) + end + def app_data + user_session[:idv_api_store_key] ||= Base64.strict_encode64(random_encryption_key) + { - base_path: idv_app_root_path, + base_path: idv_app_path, app_name: APP_NAME, completion_url: completion_url, - initial_values: { 'personalKey' => personal_key }, + initial_values: initial_values, enabled_step_names: IdentityConfig.store.idv_api_enabled_steps, + store_key: user_session[:idv_api_store_key], } end + def initial_values + case first_step + when 'password_confirm' + { 'userBundleToken' => user_bundle_token } + when 'personal_key' + { 'personalKey' => personal_key } + end + end + + def first_step + enabled_steps.detect { |step| step_enabled?(step) } + end + + def first_step_is_personal_key? + first_step == 'personal_key' + end + + def enabled_steps + IdentityConfig.store.idv_api_enabled_steps + end + + def step_enabled?(step) + enabled_steps.include?(step) + end + + def random_encryption_key + Encryption::AesCipher.encryption_cipher.random_key + end + def confirm_profile_has_been_created redirect_to account_url if idv_session.profile.blank? end @@ -44,4 +86,11 @@ def completion_url after_sign_in_path_for(current_user) end end + + def user_bundle_token + Idv::UserBundleTokenizer.new( + user: current_user, + idv_session: idv_session, + ).token + end end diff --git a/app/forms/idv/api_document_verification_form.rb b/app/forms/idv/api_document_verification_form.rb index b2054c90767..1b1f57b8c1d 100644 --- a/app/forms/idv/api_document_verification_form.rb +++ b/app/forms/idv/api_document_verification_form.rb @@ -31,9 +31,8 @@ def submit }, ) - @analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - response.to_h, + @analytics.idv_doc_auth_submitted_image_upload_form( + **response.to_h, ) response diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 0f2c3d3f48c..9a047c4ae86 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -57,10 +57,7 @@ def validate_form extra: extra_attributes, ) - track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - response.to_h, - ) + analytics.idv_doc_auth_submitted_image_upload_form(**response.to_h) response end @@ -88,10 +85,8 @@ def validate_pii_from_doc(client_response) response = Idv::DocPiiForm.new(client_response.pii_from_doc).submit response.extra.merge!(extra_attributes) - track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - response.to_h, - ) + analytics.idv_doc_auth_submitted_pii_validation(**response.to_h) + store_pii(client_response) if client_response.success? && response.success? response @@ -219,9 +214,8 @@ def track_event(event, attributes = {}) def update_analytics(client_response) add_costs(client_response) update_funnel(client_response) - track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - client_response.to_h.merge( + analytics.idv_doc_auth_submitted_image_upload_vendor( + **client_response.to_h.merge( client_image_metrics: image_metadata, async: false, flow_path: params[:flow_path], diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index 27d789e3263..f4aa6076e90 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -95,6 +95,10 @@ def aal_values acr_values.filter { |acr| %r{/aal/}.match? acr } end + def ial_context + @ial_context ||= IalContext.new(ial: ial, service_provider: service_provider) + end + def_delegators :ial_context, :ial2_or_greater?, :ial2_requested?, @@ -104,10 +108,6 @@ def aal_values attr_reader :identity, :success - def ial_context - @ial_context ||= IalContext.new(ial: ial, service_provider: service_provider) - end - def check_for_unauthorized_scope(params) param_value = params[:scope] return false if ial2_or_greater? || param_value.blank? diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index e25258d8676..addd67d4ee6 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -7,6 +7,10 @@ class TwoFactorOptionsForm validates :selection, inclusion: { in: %w[phone sms voice auth_app piv_cac webauthn webauthn_platform backup_code] } + validates :selection, length: { minimum: 2, message: 'phone' }, if: [ + :multiple_mfa_options_enabled?, + :phone_selected?, + ] def initialize(user) self.user = user @@ -17,7 +21,6 @@ def submit(params) success = valid? update_otp_delivery_preference_for_user if success && user_needs_updating? - FormResponse.new(success: success, errors: errors, extra: extra_analytics_attributes) end @@ -42,4 +45,12 @@ def update_otp_delivery_preference_for_user selection.find { |element| %w[voice sms].include?(element) } } UpdateUser.new(user: user, attributes: user_attributes).call end + + def multiple_mfa_options_enabled? + IdentityConfig.store.select_multiple_mfa_options + end + + def phone_selected? + selection.include?('phone') || selection.include?('voice') || selection.include?('sms') + end end diff --git a/app/javascript/packages/components/alert.jsx b/app/javascript/packages/components/alert.jsx deleted file mode 100644 index 1d6279de599..00000000000 --- a/app/javascript/packages/components/alert.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { forwardRef } from 'react'; - -/** @typedef {import('react').ReactNode} ReactNode */ - -/** - * @typedef {"success"|"warning"|"error"|"info"|"other"} AlertType - */ - -/** - * @typedef AlertProps - * - * @prop {AlertType=} type Alert type. Defaults to "other". - * @prop {string=} className Optional additional class names to add to element. - * @prop {boolean=} isFocusable Optional, whether rendered element should be focusable, as in the - * case where focus should be shifted programmatically to a new alert. - * @prop {ReactNode} children Child elements. - */ - -/** - * @param {AlertProps} props Props object. - * @param {import('react').ForwardedRef} ref - */ -function Alert({ type = 'other', className, isFocusable, children }, ref) { - const classes = [`usa-alert usa-alert--${type}`, className].filter(Boolean).join(' '); - - return ( -
-
-

{children}

-
-
- ); -} - -export default forwardRef(Alert); diff --git a/app/javascript/packages/components/alert.spec.tsx b/app/javascript/packages/components/alert.spec.tsx new file mode 100644 index 00000000000..2d11f4f9698 --- /dev/null +++ b/app/javascript/packages/components/alert.spec.tsx @@ -0,0 +1,56 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import Alert from './alert'; +import type { AlertType } from './alert'; + +describe('Alert', () => { + describe('role', () => { + ( + [ + ['success', 'status'], + ['warning', 'status'], + ['error', 'alert'], + ['info', 'status'], + ['other', 'status'], + ] as [AlertType, 'alert' | 'status'][] + ).forEach(([type, role]) => { + context(`with ${type} type`, () => { + it(`should apply ${role} role`, () => { + const { getByRole } = render(); + + const alert = getByRole(role); + + expect(alert).to.be.ok(); + }); + }); + }); + }); + + it('accepts additional class names', () => { + const { getByRole } = render( + + Uh oh! + , + ); + + const alert = getByRole('status'); + + expect(alert.classList.contains('my-class')).to.be.true(); + }); + + it('is optionally focusable', () => { + const { getByRole } = render(); + + const alert = getByRole('status'); + alert.focus(); + + expect(document.activeElement).to.equal(alert); + }); + + it('forwards ref', () => { + const ref = createRef(); + const { container } = render(); + + expect(ref.current).to.equal(container.firstChild); + }); +}); diff --git a/app/javascript/packages/components/alert.tsx b/app/javascript/packages/components/alert.tsx new file mode 100644 index 00000000000..0ea1b2fd612 --- /dev/null +++ b/app/javascript/packages/components/alert.tsx @@ -0,0 +1,44 @@ +import { forwardRef } from 'react'; +import type { ReactNode, ForwardedRef } from 'react'; + +export type AlertType = 'success' | 'warning' | 'error' | 'info' | 'other'; + +interface AlertProps { + /** + * Alert type. Defaults to "other". + */ + type?: AlertType; + + /** + * Optional additional class names to add to element. + */ + className?: string; + + /** + * Optional, whether rendered element should be focusable, as in the case where focus should be shifted programmatically to a new alert. + */ + isFocusable?: boolean; + + /** + * Child elements. + */ + children?: ReactNode; +} + +function Alert( + { type = 'other', className, isFocusable, children }: AlertProps, + ref: ForwardedRef, +) { + const classes = [`usa-alert usa-alert--${type}`, className].filter(Boolean).join(' '); + const role = type === 'error' ? 'alert' : 'status'; + + return ( +
+
+

{children}

+
+
+ ); +} + +export default forwardRef(Alert); diff --git a/app/javascript/packages/countdown-element/index.spec.ts b/app/javascript/packages/countdown-element/index.spec.ts index 5666ce65051..09dcfc0e748 100644 --- a/app/javascript/packages/countdown-element/index.spec.ts +++ b/app/javascript/packages/countdown-element/index.spec.ts @@ -1,6 +1,6 @@ import sinon from 'sinon'; import { i18n } from '@18f/identity-i18n'; -import { usePropertyValue } from '@18f/identity-test-helpers'; +import { usePropertyValue, useSandbox } from '@18f/identity-test-helpers'; import { CountdownElement } from './index'; const DEFAULT_DATASET = { @@ -10,7 +10,7 @@ const DEFAULT_DATASET = { }; describe('CountdownElement', () => { - let clock: sinon.SinonFakeTimers; + const { clock } = useSandbox({ useFakeTimers: true }); usePropertyValue(i18n, 'strings', { 'datetime.dotiw.seconds': { one: 'one second', other: '%{count} seconds' }, @@ -22,12 +22,6 @@ describe('CountdownElement', () => { if (!customElements.get('lg-countdown')) { customElements.define('lg-countdown', CountdownElement); } - - clock = sinon.useFakeTimers(); - }); - - after(() => { - clock.restore(); }); function createElement(dataset = {}) { diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 65a3ed57523..7e9aa5aaba5 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -342,10 +342,7 @@ function AcuantCapture( size: nextValue.size, }); - addPageAction({ - label: `IdV: ${name} image added`, - payload: analyticsPayload, - }); + addPageAction(`IdV: ${name} image added`, analyticsPayload); } onChangeAndResetError(nextValue, analyticsPayload); @@ -366,10 +363,7 @@ function AcuantCapture( return (fn) => (...args) => { if (!isSuppressingClickLogging.current) { - addPageAction({ - label: `IdV: ${name} image clicked`, - payload: { source, ...metadata }, - }); + addPageAction(`IdV: ${name} image clicked`, { source, ...metadata }); } return fn(...args); @@ -488,11 +482,7 @@ function AcuantCapture( size: getDecodedBase64ByteSize(nextCapture.image.data), }); - addPageAction({ - key: 'documentCapture.acuantWebSDKResult', - label: `IdV: ${name} image added`, - payload: analyticsPayload, - }); + addPageAction(`IdV: ${name} image added`, analyticsPayload); if (assessment === 'success') { onChangeAndResetError(data, analyticsPayload); @@ -538,12 +528,9 @@ function AcuantCapture( } setIsCapturingEnvironment(false); - addPageAction({ - label: 'IdV: Image capture failed', - payload: { - field: name, - error: getNormalizedAcuantCaptureFailureMessage(error, code), - }, + addPageAction('IdV: Image capture failed', { + field: name, + error: getNormalizedAcuantCaptureFailureMessage(error, code), }); }} > diff --git a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx index a5c301a4879..3477e50bfba 100644 --- a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx +++ b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx @@ -28,16 +28,13 @@ function CaptureTroubleshooting({ children }) { const { isAssessedAsGlare, isAssessedAsBlurry } = lastAttemptMetadata; function onCaptureTipsShown() { - addPageAction({ - label: 'IdV: Capture troubleshooting shown', - payload: lastAttemptMetadata, - }); + addPageAction('IdV: Capture troubleshooting shown', lastAttemptMetadata); onPageTransition(); } function onCaptureTipsDismissed() { - addPageAction({ label: 'IdV: Capture troubleshooting dismissed' }); + addPageAction('IdV: Capture troubleshooting dismissed'); setDidShowTroubleshooting(true); } diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx index ac7e83baddc..19f82eeda6d 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -77,7 +77,7 @@ function ReviewIssuesStep({ useDidUpdateEffect(onPageTransition, [hasDismissed]); function onWarningPageDismissed() { - addPageAction({ label: 'IdV: Capture troubleshooting dismissed' }); + addPageAction('IdV: Capture troubleshooting dismissed'); setHasDismissed(true); } diff --git a/app/javascript/packages/document-capture/components/warning.jsx b/app/javascript/packages/document-capture/components/warning.jsx index b8f7e63d250..c9c7cf02087 100644 --- a/app/javascript/packages/document-capture/components/warning.jsx +++ b/app/javascript/packages/document-capture/components/warning.jsx @@ -33,10 +33,7 @@ function Warning({ const { addPageAction } = useContext(AnalyticsContext); const { t } = useI18n(); useEffect(() => { - addPageAction({ - label: 'IdV: warning shown', - payload: { location, remaining_attempts: remainingAttempts }, - }); + addPageAction('IdV: warning shown', { location, remaining_attempts: remainingAttempts }); }, []); return ( @@ -56,7 +53,7 @@ function Warning({ type="button" className="usa-button usa-button--big usa-button--wide" onClick={() => { - addPageAction({ label: 'IdV: warning action triggered', payload: { location } }); + addPageAction('IdV: warning action triggered', { location }); actionOnClick(); }} > diff --git a/app/javascript/packages/document-capture/context/acuant.jsx b/app/javascript/packages/document-capture/context/acuant.jsx index 8a0a3f49e9d..02e2d50d6d8 100644 --- a/app/javascript/packages/document-capture/context/acuant.jsx +++ b/app/javascript/packages/document-capture/context/acuant.jsx @@ -205,9 +205,9 @@ function AcuantContextProvider({ window ).AcuantCamera; - addPageAction({ - label: 'IdV: Acuant SDK loaded', - payload: { success: true, isCameraSupported: nextIsCameraSupported }, + addPageAction('IdV: Acuant SDK loaded', { + success: true, + isCameraSupported: nextIsCameraSupported, }); setIsCameraSupported(nextIsCameraSupported); @@ -216,13 +216,10 @@ function AcuantContextProvider({ }); }, onFail(code, description) { - addPageAction({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: false, - code, - description, - }, + addPageAction('IdV: Acuant SDK loaded', { + success: false, + code, + description, }); setIsError(true); diff --git a/app/javascript/packages/document-capture/context/analytics.jsx b/app/javascript/packages/document-capture/context/analytics.jsx index 909ce78f1df..93bb4bf3ea4 100644 --- a/app/javascript/packages/document-capture/context/analytics.jsx +++ b/app/javascript/packages/document-capture/context/analytics.jsx @@ -1,5 +1,6 @@ import { createContext } from 'react'; +/** @typedef {import('@18f/identity-analytics').trackEvent} TrackEvent */ /** @typedef {Record} Payload */ /** @@ -10,10 +11,6 @@ import { createContext } from 'react'; * @property {Payload=} payload Additional payload arguments to log with action. */ -/** - * @typedef {(action: PageAction)=>void} AddPageAction - */ - /** * @typedef {(error: Error)=>void} NoticeError */ @@ -21,13 +18,13 @@ import { createContext } from 'react'; /** * @typedef AnalyticsContext * - * @prop {AddPageAction} addPageAction Log an action with optional payload. + * @prop {TrackEvent} addPageAction Log an action with optional payload. * @prop {NoticeError} noticeError Log an error without affecting application behavior. */ const AnalyticsContext = createContext( /** @type {AnalyticsContext} */ ({ - addPageAction: () => {}, + addPageAction: () => Promise.resolve(), noticeError: () => {}, }), ); diff --git a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx index d6f35a1855d..8fa6d73a8f3 100644 --- a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx +++ b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx @@ -102,24 +102,14 @@ const withBackgroundEncryptedUpload = (Component) => { value, ) .catch((error) => { - addPageAction({ - label: 'IdV: document capture async upload encryption', - payload: { - success: false, - }, - }); + addPageAction('IdV: document capture async upload encryption', { success: false }); noticeError(error); // Rethrow error to skip upload and proceed from next `catch` block. throw error; }) .then((encryptedValue) => { - addPageAction({ - label: 'IdV: document capture async upload encryption', - payload: { - success: true, - }, - }); + addPageAction('IdV: document capture async upload encryption', { success: true }); return window.fetch(url, { method: 'PUT', @@ -129,14 +119,10 @@ const withBackgroundEncryptedUpload = (Component) => { }) .then((response) => { const traceId = response.headers.get('X-Amzn-Trace-Id'); - addPageAction({ - key: 'documentCapture.asyncUpload', - label: 'IdV: document capture async upload submitted', - payload: { - success: response.ok, - trace_id: traceId, - status_code: response.status, - }, + addPageAction('IdV: document capture async upload submitted', { + success: response.ok, + trace_id: traceId, + status_code: response.status, }); if (!response.ok) { diff --git a/app/javascript/packages/secret-session-storage/index.spec.ts b/app/javascript/packages/secret-session-storage/index.spec.ts new file mode 100644 index 00000000000..43093f0c664 --- /dev/null +++ b/app/javascript/packages/secret-session-storage/index.spec.ts @@ -0,0 +1,81 @@ +import sinon from 'sinon'; +import SecretSessionStorage from './index'; + +describe('SecretSessionStorage', () => { + const STORAGE_KEY = 'test'; + + const sandbox = sinon.createSandbox(); + + let key: CryptoKey; + before(async () => { + key = await window.crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ); + }); + + function createStorage() { + const storage = new SecretSessionStorage(STORAGE_KEY); + storage.key = key; + return storage; + } + + afterEach(() => { + sessionStorage.removeItem(STORAGE_KEY); + sandbox.restore(); + }); + + it('writes to session storage', async () => { + sandbox.spy(Storage.prototype, 'setItem'); + + const storage = createStorage(); + await storage.setItem('foo', 'bar'); + + expect(Storage.prototype.setItem).to.have.been.calledWith( + STORAGE_KEY, + sinon.match( + (value: string) => + /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), + ), + ); + }); + + it('loads from previous written storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('foo')).to.equal('bar'); + }); + + it('returns undefined for value not yet loaded from storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + + expect(storage2.getItem('foo')).to.be.undefined(); + }); + + it('returns undefined for value not in loaded storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('baz')).to.be.undefined(); + }); + + it('silently ignores invalid written storage', async () => { + sessionStorage.setItem(STORAGE_KEY, 'nonsense'); + const storage = createStorage(); + await storage.load(); + }); +}); diff --git a/app/javascript/packages/secret-session-storage/index.ts b/app/javascript/packages/secret-session-storage/index.ts new file mode 100644 index 00000000000..453f7a34ff7 --- /dev/null +++ b/app/javascript/packages/secret-session-storage/index.ts @@ -0,0 +1,104 @@ +/** + * Serializable JSON value. + */ +type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; + +/** + * Convert an ArrayBuffer to an equivalent string. + */ +export const ab2s = (buffer: Uint8Array) => String.fromCharCode.apply(null, new Uint8Array(buffer)); + +/** + * Convert a string to an equivalent ArrayBuffer. + */ +export const s2ab = (string: string) => Uint8Array.from(string, (c) => c.charCodeAt(0)); + +class SecretSessionStorage> { + /** + * Web storage key. + */ + storageKey: string; + + /** + * In-memory reflection of unencrypted web storage payload. + */ + storage: S = {} as S; + + /** + * Encryption key. + */ + key: CryptoKey; + + /** + * Constructs a new session store. + * + * @param storageKey Web storage key. + * @param key Encryption key. + */ + constructor(storageKey: string) { + this.storageKey = storageKey; + } + + /** + * Reads and decrypts storage object into in-memory reflection, if available. + */ + async load() { + const storage = await this.#readStorage(); + if (storage) { + this.storage = storage; + } + } + + /** + * Sets a value into storage. + * + * @param key Storage object key. + * @param value Storage object value. + */ + async setItem(key: keyof S, value: S[typeof key]) { + this.storage[key] = value; + await this.#writeStorage(); + } + + /** + * Gets a value from the in-memory storage. + * + * @param key Storage object key. + */ + getItem(key: keyof S) { + return this.storage[key]; + } + + /** + * Reads and decrypts storage object, if available. + */ + async #readStorage() { + try { + const storageData = sessionStorage.getItem(this.storageKey)!; + const [encryptedData, iv] = (JSON.parse(storageData) as [string, string]).map(s2ab); + const data = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + this.key, + encryptedData, + ); + + return JSON.parse(ab2s(data)); + } catch {} + } + + /** + * Encrypts and writes current in-memory reflection of storage object to web storage. + */ + async #writeStorage() { + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const encryptedData = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + this.key, + s2ab(JSON.stringify(this.storage)), + ); + + sessionStorage.setItem(this.storageKey, JSON.stringify([encryptedData, iv].map(ab2s))); + } +} + +export default SecretSessionStorage; diff --git a/app/javascript/packages/secret-session-storage/package.json b/app/javascript/packages/secret-session-storage/package.json new file mode 100644 index 00000000000..a57a3b1a085 --- /dev/null +++ b/app/javascript/packages/secret-session-storage/package.json @@ -0,0 +1,5 @@ +{ + "name": "@18f/identity-secret-session-storage", + "private": true, + "version": "1.0.0" +} diff --git a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts index 1854bd03df5..c91fdff2d7f 100644 --- a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts +++ b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts @@ -1,12 +1,12 @@ -import sinon from 'sinon'; import baseUserEvent from '@testing-library/user-event'; import { getByRole, fireEvent, screen } from '@testing-library/dom'; +import { useSandbox } from '@18f/identity-test-helpers'; import './spinner-button-element'; import type { SpinnerButtonElement } from './spinner-button-element'; describe('SpinnerButtonElement', () => { - let clock: sinon.SinonFakeTimers; - const userEvent = baseUserEvent.setup({ advanceTimers: (ms: number) => clock.tick(ms) }); + const { clock } = useSandbox({ useFakeTimers: true }); + const userEvent = baseUserEvent.setup({ advanceTimers: clock.tick }); const longWaitDurationMs = 1000; @@ -45,14 +45,6 @@ describe('SpinnerButtonElement', () => { return document.body.firstElementChild as SpinnerButtonElement; } - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - it('shows spinner on click', async () => { const wrapper = createWrapper(); const button = screen.getByRole('link', { name: 'Click Me' }); diff --git a/app/javascript/packages/spinner-button/spinner-button.spec.tsx b/app/javascript/packages/spinner-button/spinner-button.spec.tsx index 7eed3392d74..266414bf25f 100644 --- a/app/javascript/packages/spinner-button/spinner-button.spec.tsx +++ b/app/javascript/packages/spinner-button/spinner-button.spec.tsx @@ -1,21 +1,13 @@ -import sinon from 'sinon'; import baseUserEvent from '@testing-library/user-event'; import { render } from '@testing-library/react'; import { createRef } from 'react'; +import { useSandbox } from '@18f/identity-test-helpers'; import { SpinnerButtonElement } from './spinner-button-element'; import SpinnerButton from './spinner-button'; describe('SpinnerButton', () => { - const sandbox = sinon.createSandbox(); - const userEvent = baseUserEvent.setup({ advanceTimers: (ms: number) => sandbox.clock.tick(ms) }); - - beforeEach(() => { - sandbox.useFakeTimers(); - }); - - afterEach(() => { - sandbox.restore(); - }); + const { clock } = useSandbox({ useFakeTimers: true }); + const userEvent = baseUserEvent.setup({ advanceTimers: clock.tick }); it('renders a SpinnerButton', async () => { const { getByRole } = render(Spin!); @@ -50,7 +42,7 @@ describe('SpinnerButton', () => { expect(status.textContent).not.to.be.empty(); expect(status.classList.contains('usa-sr-only')).to.be.true(); - sandbox.clock.tick(1); + clock.tick(1); expect(status.textContent).not.to.be.empty(); expect(status.classList.contains('usa-sr-only')).to.be.false(); @@ -70,7 +62,7 @@ describe('SpinnerButton', () => { expect(spinner.classList.contains('spinner-button--spinner-active')).to.be.false(); spinner.toggleSpinner(true); - sandbox.clock.tick(1); + clock.tick(1); expect(status.classList.contains('usa-sr-only')).to.be.false(); }); diff --git a/app/javascript/packages/test-helpers/index.ts b/app/javascript/packages/test-helpers/index.ts index c835adac397..4467543c15b 100644 --- a/app/javascript/packages/test-helpers/index.ts +++ b/app/javascript/packages/test-helpers/index.ts @@ -1,2 +1,3 @@ export { default as useDefineProperty } from './use-define-property'; export { default as usePropertyValue } from './use-property-value'; +export { default as useSandbox } from './use-sandbox'; diff --git a/app/javascript/packages/test-helpers/package.json b/app/javascript/packages/test-helpers/package.json index 39a34107089..d34e1ae3b98 100644 --- a/app/javascript/packages/test-helpers/package.json +++ b/app/javascript/packages/test-helpers/package.json @@ -1,5 +1,8 @@ { "name": "@18f/identity-test-helpers", "private": true, - "version": "1.0.0" + "version": "1.0.0", + "peerDependencies": { + "sinon": "^9.2.2" + } } diff --git a/app/javascript/packages/test-helpers/use-sandbox.spec.ts b/app/javascript/packages/test-helpers/use-sandbox.spec.ts new file mode 100644 index 00000000000..39c71beea37 --- /dev/null +++ b/app/javascript/packages/test-helpers/use-sandbox.spec.ts @@ -0,0 +1,36 @@ +import useSandbox from './use-sandbox'; + +describe('useSandbox', () => { + const sandbox = useSandbox(); + + const object = { fn: () => 0 }; + + afterEach(() => { + expect(object.fn()).to.equal(0); + }); + + it('cleans up after itself', () => { + sandbox.stub(object, 'fn').callsFake(() => 1); + + expect(object.fn()).to.equal(1); + // See `afterEach` for clean-up assertions + }); + + context('with fake timers', () => { + const { clock } = useSandbox({ useFakeTimers: true }); + + expect(clock.tick).to.be.a('function'); + + it('supports invoking against a destructured clock', () => { + clock.tick(0); + }); + + it('advances the clock', () => { + const MAX_SAFE_32_BIT_INT = 2147483647; + return new Promise((resolve) => { + setTimeout(resolve, MAX_SAFE_32_BIT_INT); + clock.tick(MAX_SAFE_32_BIT_INT); + }); + }); + }); +}); diff --git a/app/javascript/packages/test-helpers/use-sandbox.ts b/app/javascript/packages/test-helpers/use-sandbox.ts new file mode 100644 index 00000000000..8899a8cac84 --- /dev/null +++ b/app/javascript/packages/test-helpers/use-sandbox.ts @@ -0,0 +1,45 @@ +import sinon from 'sinon'; +import type { SinonSandboxConfig, SinonFakeTimers } from 'sinon'; + +/** + * Returns an instance of a Sinon sandbox, and automatically restores all stubbed methods after each + * test case. + */ +function useSandbox(config?: Partial) { + const { useFakeTimers = false, ...remainingConfig } = config ?? {}; + const sandbox = sinon.createSandbox(remainingConfig); + + // To support destructuring the result of the sandbox while still waiting for `beforeEach` to + // initialize the fake timers, create a proxy to pass through to the underlying implementation. + const clockImpl = {}; + if (useFakeTimers) { + sandbox.clock = Object.fromEntries( + Object.entries(sinon.useFakeTimers()).map(([key, value]) => [ + key, + key === 'restore' ? value : (...args: any[]) => clockImpl[key](...args), + ]), + ) as SinonFakeTimers; + sandbox.clock.restore(); + } + + beforeEach(() => { + // useFakeTimers overrides global timer functions as soon as sandbox is created, thus leaking + // across tests. Instead, wait until tests start to initialize. + if (useFakeTimers) { + Object.assign(clockImpl, sandbox.useFakeTimers()); + } + }); + + afterEach(() => { + sandbox.reset(); + sandbox.restore(); + + if (useFakeTimers) { + sandbox.clock.restore(); + } + }); + + return sandbox; +} + +export default useSandbox; diff --git a/app/javascript/packages/verify-flow/context/secrets-context.tsx b/app/javascript/packages/verify-flow/context/secrets-context.tsx new file mode 100644 index 00000000000..aeae06ca481 --- /dev/null +++ b/app/javascript/packages/verify-flow/context/secrets-context.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useEffect, useCallback, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import SecretSessionStorage from '@18f/identity-secret-session-storage'; +import { useIfStillMounted } from '@18f/identity-react-hooks'; +import { VerifyFlowValues } from '../verify-flow'; + +type SecretValues = Partial; + +type SetItem = typeof SecretSessionStorage.prototype.setItem; + +interface SecretsContextProviderProps { + /** + * Encryption key. + */ + storeKey: Uint8Array; + + /** + * Context provider children. + */ + children?: ReactNode; +} + +/** + * Web storage key. + */ +const STORAGE_KEY = 'verify'; + +const SecretsContext = createContext({ + storage: new SecretSessionStorage(STORAGE_KEY), + setItem: (async () => {}) as SetItem, +}); + +export function SecretsContextProvider({ storeKey, children }: SecretsContextProviderProps) { + const ifStillMounted = useIfStillMounted(); + const storage = useMemo(() => new SecretSessionStorage(STORAGE_KEY), []); + const [value, setValue] = useState({ storage, setItem: storage.setItem }); + const onChange = useCallback(() => { + setValue({ + storage, + async setItem(...args) { + await storage.setItem(...args); + onChange(); + }, + }); + }, []); + + useEffect(() => { + crypto.subtle + .importKey('raw', storeKey, 'AES-GCM', true, ['encrypt', 'decrypt']) + .then((cryptoKey) => { + storage.key = cryptoKey; + storage.load().then(ifStillMounted(onChange)); + }); + }, []); + + return {children}; +} + +export function useSecretValue( + key: K, +): [SecretValues[K], (nextValue: SecretValues[K]) => void] { + const { storage, setItem } = useContext(SecretsContext); + + const setValue = (nextValue: SecretValues[K]) => setItem(key, nextValue); + + return [storage.getItem(key), setValue]; +} + +export default SecretsContext; diff --git a/app/javascript/packages/verify-flow/index.ts b/app/javascript/packages/verify-flow/index.ts new file mode 100644 index 00000000000..a412251b0cd --- /dev/null +++ b/app/javascript/packages/verify-flow/index.ts @@ -0,0 +1,4 @@ +export { SecretsContextProvider } from './context/secrets-context'; +export { default as VerifyFlow } from './verify-flow'; + +export type { VerifyFlowValues } from './verify-flow'; diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts b/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts index ae9018bdaab..6ae5e247e74 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/index.ts @@ -1,6 +1,6 @@ import { t } from '@18f/identity-i18n'; import type { FormStep } from '@18f/identity-form-steps'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; import form from './personal-key-confirm-step'; export default { diff --git a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx index 1db11d16133..cebcd6b942d 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key-confirm/personal-key-confirm-step.tsx @@ -7,7 +7,7 @@ import { getAssetPath } from '@18f/identity-assets'; import { trackEvent } from '@18f/identity-analytics'; import PersonalKeyStep from '../personal-key/personal-key-step'; import PersonalKeyInput from './personal-key-input'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; interface PersonalKeyConfirmStepProps extends FormStepComponentProps {} diff --git a/app/javascript/packages/verify-flow/steps/personal-key/index.ts b/app/javascript/packages/verify-flow/steps/personal-key/index.ts index 87e56dfb267..73534cda563 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key/index.ts +++ b/app/javascript/packages/verify-flow/steps/personal-key/index.ts @@ -1,6 +1,6 @@ import { t } from '@18f/identity-i18n'; import type { FormStep } from '@18f/identity-form-steps'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; import form from './personal-key-step'; export default { diff --git a/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx b/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx index d9106c1c25b..567e50c5a74 100644 --- a/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx +++ b/app/javascript/packages/verify-flow/steps/personal-key/personal-key-step.tsx @@ -7,7 +7,7 @@ import { FormStepsButton } from '@18f/identity-form-steps'; import type { FormStepComponentProps } from '@18f/identity-form-steps'; import { getAssetPath } from '@18f/identity-assets'; import { trackEvent } from '@18f/identity-analytics'; -import type { VerifyFlowValues } from '../..'; +import type { VerifyFlowValues } from '../../verify-flow'; import DownloadButton from './download-button'; interface PersonalKeyStepProps extends FormStepComponentProps {} diff --git a/app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx b/app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx new file mode 100644 index 00000000000..2e29e25af29 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-alert.spec.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react'; +import VerifyFlowAlert from './verify-flow-alert'; + +describe('VerifyFlowAlert', () => { + context('step with a status message', () => { + [ + ['personal_key', 'idv.messages.confirm'], + ['personal_key_confirm', 'idv.messages.confirm'], + ].forEach(([step, expected]) => { + it('renders status message', () => { + const { getByRole } = render(); + + expect(getByRole('status').textContent).equal(expected); + }); + }); + }); + + context('step without a status message', () => { + it('renders nothing', () => { + const { container } = render(); + + expect(container.innerHTML).to.be.empty(); + }); + }); +}); diff --git a/app/javascript/packages/verify-flow/verify-flow-alert.tsx b/app/javascript/packages/verify-flow/verify-flow-alert.tsx new file mode 100644 index 00000000000..29a8f670d8f --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-alert.tsx @@ -0,0 +1,35 @@ +import { t } from '@18f/identity-i18n'; +import { Alert } from '@18f/identity-components'; + +interface VerifyFlowAlertProps { + /** + * Current step name. + */ + currentStep: string; +} + +/** + * Returns the status message to show for a given step, if applicable. + * + * @param stepName Step name. + */ +function getStepMessage(stepName: string): string | undefined { + if (stepName === 'personal_key' || stepName === 'personal_key_confirm') { + return t('idv.messages.confirm'); + } +} + +function VerifyFlowAlert({ currentStep }: VerifyFlowAlertProps) { + const message = getStepMessage(currentStep); + if (!message) { + return null; + } + + return ( + + {message} + + ); +} + +export default VerifyFlowAlert; diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx new file mode 100644 index 00000000000..7c2086a2257 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.spec.tsx @@ -0,0 +1,35 @@ +import { render } from '@testing-library/react'; +import { StepStatus } from '@18f/identity-step-indicator'; +import VerifyFlowStepIndicator, { getStepStatus } from './verify-flow-step-indicator'; + +describe('getStepStatus', () => { + it('returns incomplete if step is after current step', () => { + const result = getStepStatus(1, 0); + + expect(result).to.equal(StepStatus.INCOMPLETE); + }); + + it('returns current if step is current step', () => { + const result = getStepStatus(1, 1); + + expect(result).to.equal(StepStatus.CURRENT); + }); + + it('returns complete if step is before current step', () => { + const result = getStepStatus(0, 1); + + expect(result).to.equal(StepStatus.COMPLETE); + }); +}); + +describe('VerifyFlowStepIndicator', () => { + it('renders step indicator for the current step', () => { + const { getByText } = render(); + + const current = getByText('step_indicator.flows.idv.secure_account'); + expect(current.closest('.step-indicator__step--current')).to.exist(); + + const previous = getByText('step_indicator.flows.idv.verify_phone_or_address'); + expect(previous.closest('.step-indicator__step--complete')).to.exist(); + }); +}); diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx new file mode 100644 index 00000000000..8b134f51db5 --- /dev/null +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx @@ -0,0 +1,80 @@ +import { StepIndicator, StepIndicatorStep, StepStatus } from '@18f/identity-step-indicator'; +import { t } from '@18f/identity-i18n'; + +// i18n-tasks-use t('step_indicator.flows.idv.getting_started') +// i18n-tasks-use t('step_indicator.flows.idv.verify_id') +// i18n-tasks-use t('step_indicator.flows.idv.verify_info') +// i18n-tasks-use t('step_indicator.flows.idv.verify_phone_or_address') +// i18n-tasks-use t('step_indicator.flows.idv.secure_account') + +type VerifyFlowStepIndicatorStep = + | 'getting_started' + | 'verify_id' + | 'verify_info' + | 'verify_phone_or_address' + | 'secure_account'; + +/** + * Mapping of flow form steps to corresponding step indicator step. + */ +const FLOW_STEP_STEP_MAPPING: Record = { + personal_key: 'secure_account', + personal_key_confirm: 'secure_account', +}; + +/** + * Sequence of step indicator steps. + */ +const STEP_INDICATOR_STEPS: VerifyFlowStepIndicatorStep[] = [ + 'getting_started', + 'verify_id', + 'verify_info', + 'verify_phone_or_address', + 'secure_account', +]; + +interface VerifyFlowStepIndicatorProps { + /** + * Current step name. + */ + currentStep: string; +} + +/** + * Given an index of a step and the current step index, returns the status of the step relative to + * the current step. + * + * @param index Index of step against which to compare current step. + * @param currentStepIndex Index of current step. + * + * @return Step status. + */ +export function getStepStatus(index, currentStepIndex): StepStatus { + if (index === currentStepIndex) { + return StepStatus.CURRENT; + } + + if (index < currentStepIndex) { + return StepStatus.COMPLETE; + } + + return StepStatus.INCOMPLETE; +} + +function VerifyFlowStepIndicator({ currentStep }: VerifyFlowStepIndicatorProps) { + const currentStepIndex = STEP_INDICATOR_STEPS.indexOf(FLOW_STEP_STEP_MAPPING[currentStep]); + + return ( + + {STEP_INDICATOR_STEPS.map((step, index) => ( + + ))} + + ); +} + +export default VerifyFlowStepIndicator; diff --git a/app/javascript/packages/verify-flow/index.spec.tsx b/app/javascript/packages/verify-flow/verify-flow.spec.tsx similarity index 89% rename from app/javascript/packages/verify-flow/index.spec.tsx rename to app/javascript/packages/verify-flow/verify-flow.spec.tsx index c5252cdd08f..423112dadb1 100644 --- a/app/javascript/packages/verify-flow/index.spec.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.spec.tsx @@ -2,7 +2,7 @@ import sinon from 'sinon'; import { render } from '@testing-library/react'; import * as analytics from '@18f/identity-analytics'; import userEvent from '@testing-library/user-event'; -import { VerifyFlow } from './index'; +import VerifyFlow from './verify-flow'; describe('VerifyFlow', () => { const sandbox = sinon.createSandbox(); @@ -23,7 +23,12 @@ describe('VerifyFlow', () => { , ); + // Personal key + expect(getByText('idv.messages.confirm')).to.be.ok(); await userEvent.click(getByText('forms.buttons.continue')); + + // Personal key confirm + expect(getByText('idv.messages.confirm')).to.be.ok(); await userEvent.type(getByLabelText('forms.personal_key.confirmation_label'), personalKey); await userEvent.keyboard('{Enter}'); diff --git a/app/javascript/packages/verify-flow/index.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx similarity index 64% rename from app/javascript/packages/verify-flow/index.tsx rename to app/javascript/packages/verify-flow/verify-flow.tsx index 6fce4686195..f4097973926 100644 --- a/app/javascript/packages/verify-flow/index.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -1,15 +1,34 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { FormSteps } from '@18f/identity-form-steps'; -import { StepIndicator, StepIndicatorStep, StepStatus } from '@18f/identity-step-indicator'; -import { t } from '@18f/identity-i18n'; -import { Alert } from '@18f/identity-components'; import { trackEvent } from '@18f/identity-analytics'; import { STEPS } from './steps'; +import VerifyFlowStepIndicator from './verify-flow-step-indicator'; +import VerifyFlowAlert from './verify-flow-alert'; export interface VerifyFlowValues { + userBundleToken?: string; + personalKey?: string; personalKeyConfirm?: string; + + firstName?: string; + + lastName?: string; + + address1?: string; + + address2?: string; + + city?: string; + + state?: string; + + zipcode?: string; + + phone?: string; + + ssn?: string; } interface VerifyFlowProps { @@ -60,16 +79,17 @@ const logStepVisited = (stepName: string) => const logStepSubmitted = (stepName: string) => trackEvent(`IdV: ${getEventStepName(stepName)} submitted`); -export function VerifyFlow({ +function VerifyFlow({ initialValues = {}, enabledStepNames, basePath, appName, onComplete, }: VerifyFlowProps) { + const [currentStep, setCurrentStep] = useState(STEPS[0].name); useEffect(() => { - logStepVisited(STEPS[0].name); - }, []); + logStepVisited(currentStep); + }, [currentStep]); let steps = STEPS; if (enabledStepNames) { @@ -78,16 +98,8 @@ export function VerifyFlow({ return ( <> - - - - - - - - - {t('idv.messages.confirm')} - + + ); } + +export default VerifyFlow; diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index eeafa061367..317377fb4ba 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -20,7 +20,6 @@ import { trackEvent } from '@18f/identity-analytics'; /** * @typedef NewRelicAgent * - * @prop {(name:string,attributes:object)=>void} addPageAction Log page action to New Relic. * @prop {(error:Error)=>void} noticeError Log an error without affecting application behavior. */ @@ -103,17 +102,10 @@ const device = { isMobile: isCameraCapableMobile(), }; -/** @type {import('@18f/identity-document-capture/context/analytics').AddPageAction} */ -function addPageAction(action) { +/** @type {import('@18f/identity-analytics').trackEvent} */ +function addPageAction(event, payload) { const { flowPath } = appRoot.dataset; - const payload = { ...action.payload, flow_path: flowPath }; - - const { newrelic } = /** @type {DocumentCaptureGlobal} */ (window); - if (action.key && newrelic) { - newrelic.addPageAction(action.key, payload); - } - - trackEvent(action.label, payload); + return trackEvent(event, { ...payload, flow_path: flowPath }); } /** @type {import('@18f/identity-document-capture/context/analytics').NoticeError} */ diff --git a/app/javascript/packs/mfa_selection_component.ts b/app/javascript/packs/mfa_selection_component.ts new file mode 100644 index 00000000000..3f25909de9d --- /dev/null +++ b/app/javascript/packs/mfa_selection_component.ts @@ -0,0 +1,17 @@ +function clearPhoneSelectionError() { + const error = document.getElementById('phone_error'); + const invalid = document.querySelector('label.checkbox__invalid'); + if (error) { + error.style.display = 'none'; + } + if (invalid) { + invalid.classList.remove('checkbox__invalid'); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const checkboxes = document.getElementsByName('two_factor_options_form[selection][]'); + checkboxes.forEach((checkbox) => { + checkbox.onchange = clearPhoneSelectionError; + }); +}); diff --git a/app/javascript/packs/verify-flow.tsx b/app/javascript/packs/verify-flow.tsx index 62e768496d0..99c8e9c4576 100644 --- a/app/javascript/packs/verify-flow.tsx +++ b/app/javascript/packs/verify-flow.tsx @@ -1,5 +1,7 @@ import { render } from 'react-dom'; -import { VerifyFlow } from '@18f/identity-verify-flow'; +import { VerifyFlow, SecretsContextProvider } from '@18f/identity-verify-flow'; +import { s2ab } from '@18f/identity-secret-session-storage'; +import type { VerifyFlowValues } from '@18f/identity-verify-flow'; interface AppRootValues { /** @@ -26,6 +28,16 @@ interface AppRootValues { * URL to which user should be redirected after completing the form. */ completionUrl: string; + + /** + * Base64-encoded encryption key for secret session store. + */ + storeKey: string; + + /** + * Signed JWT containing user data. + */ + userBundleToken: string; } interface AppRootElement extends HTMLElement { @@ -39,22 +51,36 @@ const { basePath, appName, completionUrl: completionURL, + storeKey: storeKeyBase64, } = appRoot.dataset; - -const initialValues = JSON.parse(initialValuesJSON); +const storeKey = s2ab(atob(storeKeyBase64)); +const initialValues: Partial = JSON.parse(initialValuesJSON); const enabledStepNames = JSON.parse(enabledStepNamesJSON) as string[]; +const camelCase = (string: string) => + string.replace(/[^a-z]([a-z])/gi, (_match, nextLetter) => nextLetter.toUpperCase()); + +if (initialValues.userBundleToken) { + const jwtData = JSON.parse(atob(initialValues.userBundleToken.split('.')[1])); + const pii = Object.fromEntries( + Object.entries(jwtData.pii).map(([key, value]) => [camelCase(key), value]), + ); + Object.assign(initialValues, pii); +} + function onComplete() { window.location.href = completionURL; } render( - , + + + , appRoot, ); diff --git a/app/jobs/document_proofing_job.rb b/app/jobs/document_proofing_job.rb index 4bdbc3d6091..0d7d68d5e96 100644 --- a/app/jobs/document_proofing_job.rb +++ b/app/jobs/document_proofing_job.rb @@ -72,9 +72,8 @@ def perform( throttle = Throttle.for(user: user, throttle_type: :idv_doc_auth) - analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - proofer_result.to_h.merge( + analytics.idv_doc_auth_submitted_image_upload_vendor( + **proofer_result.to_h.merge( state: proofer_result.pii_from_doc[:state], state_id_type: proofer_result.pii_from_doc[:state_id_type], async: true, diff --git a/app/models/profile.rb b/app/models/profile.rb index 111e6b262ef..8eddfeeed34 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -93,7 +93,7 @@ def includes_phone_check? def strict_ial2_proofed? return false unless active return false unless includes_liveness_check? - return true if IdentityConfig.store.usps_upload_allowed_for_strict_ial2 + return true if IdentityConfig.store.gpo_allowed_for_strict_ial2 includes_phone_check? end diff --git a/app/presenters/saml_request_presenter.rb b/app/presenters/saml_request_presenter.rb index 1535af95c85..ff6e2231c65 100644 --- a/app/presenters/saml_request_presenter.rb +++ b/app/presenters/saml_request_presenter.rb @@ -3,7 +3,6 @@ class SamlRequestPresenter email: :email, all_emails: :all_emails, first_name: :given_name, - middle_name: :name, last_name: :family_name, dob: :birthdate, ssn: :social_security_number, @@ -27,6 +26,7 @@ def requested_attributes bundle.map { |attr| ATTRIBUTE_TO_FRIENDLY_NAME_MAP[attr] }.compact.uniq else attrs = [:email] + attrs << :all_emails if bundle.include?(:all_emails) attrs << :verified_at if bundle.include?(:verified_at) attrs end @@ -37,7 +37,7 @@ def requested_attributes attr_reader :request, :service_provider def ial2_authn_context? - (Saml::Idp::Constants::IAL2_AUTHN_CONTEXTS & authn_context).present? + ial_context.ial2_requested? end def ial2_strict_authn_context? @@ -45,13 +45,29 @@ def ial2_strict_authn_context? end def ialmax_authn_context? - authn_context.include? Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF + ial_context.ialmax_requested? end def authn_context request.requested_authn_contexts end + def ial_context + @ial_context ||= IalContext.new( + ial: request.requested_ial_authn_context || default_ial_context, + service_provider: service_provider, + authn_context_comparison: request.requested_authn_context_comparison, + ) + end + + def default_ial_context + if service_provider&.ial + Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[service_provider.ial] + else + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + end + end + def bundle @bundle ||= ( authn_request_bundle || service_provider&.attribute_bundle || [] diff --git a/app/presenters/two_factor_authentication/phone_selection_presenter.rb b/app/presenters/two_factor_authentication/phone_selection_presenter.rb index 064a8c83952..dc5f67c9292 100644 --- a/app/presenters/two_factor_authentication/phone_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/phone_selection_presenter.rb @@ -13,21 +13,9 @@ def type end def info - if configuration.present? - t( - 'two_factor_authentication.login_options.phone_info_html', - phone: configuration.masked_phone, - ) - else - voip_note = if IdentityConfig.store.voip_block - t('two_factor_authentication.two_factor_choice_options.phone_info_no_voip') - end - - safe_join( - [t('two_factor_authentication.two_factor_choice_options.phone_info'), *voip_note], - ' ', - ) - end + IdentityConfig.store.select_multiple_mfa_options ? + t('two_factor_authentication.two_factor_choice_options.phone_info_html') : + t('two_factor_authentication.two_factor_choice_options.phone_info') end def security_level diff --git a/app/presenters/two_factor_authentication/selection_presenter.rb b/app/presenters/two_factor_authentication/selection_presenter.rb index fa21d2913fb..e03675b1445 100644 --- a/app/presenters/two_factor_authentication/selection_presenter.rb +++ b/app/presenters/two_factor_authentication/selection_presenter.rb @@ -96,10 +96,6 @@ def login_info(type) t('two_factor_authentication.login_options.personal_key_info') when 'piv_cac' t('two_factor_authentication.login_options.piv_cac_info') - when 'sms' - t('two_factor_authentication.login_options.sms_info_html') - when 'voice' - t('two_factor_authentication.login_options.voice_info_html') when 'webauthn' t('two_factor_authentication.login_options.webauthn_info') when 'webauthn_platform' @@ -116,7 +112,9 @@ def setup_info(type) when 'backup_code' t('two_factor_authentication.two_factor_choice_options.backup_code_info') when 'phone' - t('two_factor_authentication.two_factor_choice_options.phone_info') + IdentityConfig.store.select_multiple_mfa_options ? + t('two_factor_authentication.two_factor_choice_options.phone_info_html') : + t('two_factor_authentication.two_factor_choice_options.phone_info') when 'piv_cac' t('two_factor_authentication.two_factor_choice_options.piv_cac_info') when 'sms' diff --git a/app/presenters/two_factor_authentication/sms_selection_presenter.rb b/app/presenters/two_factor_authentication/sms_selection_presenter.rb index 3b0c122c0f7..081efd9d847 100644 --- a/app/presenters/two_factor_authentication/sms_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/sms_selection_presenter.rb @@ -4,6 +4,17 @@ def method :sms end + def info + if configuration.present? + t( + 'two_factor_authentication.login_options.sms_info_html', + phone: configuration.masked_phone, + ) + else + super + end + end + def disabled? VendorStatus.new.vendor_outage?(:sms) end diff --git a/app/presenters/two_factor_authentication/voice_selection_presenter.rb b/app/presenters/two_factor_authentication/voice_selection_presenter.rb index 6144f27f023..b2e4a061785 100644 --- a/app/presenters/two_factor_authentication/voice_selection_presenter.rb +++ b/app/presenters/two_factor_authentication/voice_selection_presenter.rb @@ -4,6 +4,17 @@ def method :voice end + def info + if configuration.present? + t( + 'two_factor_authentication.login_options.voice_info_html', + phone: configuration.masked_phone, + ) + else + super + end + end + def disabled? VendorStatus.new.vendor_outage?(:voice) end diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 91e68dbd22b..fc65bba6b40 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -138,21 +138,11 @@ def session_started_at # rubocop:disable Layout/LineLength DOC_AUTH = 'Doc Auth' # visited or submitted is appended - IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM = 'IdV: doc auth image upload form submitted' - IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR = 'IdV: doc auth image upload vendor submitted' - IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION = 'IdV: doc auth image upload vendor pii validation' - IDV_DOC_AUTH_WARNING_VISITED = 'IdV: doc auth warning visited' - IDV_FINAL = 'IdV: final resolution' IDV_FORGOT_PASSWORD = 'IdV: forgot password visited' IDV_FORGOT_PASSWORD_CONFIRMED = 'IdV: forgot password confirmed' IDV_INTRO_VISIT = 'IdV: intro visited' IDV_JURISDICTION_VISIT = 'IdV: jurisdiction visited' IDV_JURISDICTION_FORM = 'IdV: jurisdiction form submitted' - IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_ATTEMPTS = 'Idv: Phone OTP attempts rate limited' - IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_LOCKED_OUT = 'Idv: Phone OTP rate limited user' - IDV_PHONE_CONFIRMATION_OTP_RATE_LIMIT_SENDS = 'Idv: Phone OTP sends rate limited' - IDV_PHONE_CONFIRMATION_OTP_RESENT = 'IdV: phone confirmation otp resent' - IDV_PHONE_CONFIRMATION_OTP_SENT = 'IdV: phone confirmation otp sent' IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT = 'IdV: Phone OTP delivery Selection Visited' IDV_PHONE_USE_DIFFERENT = 'IdV: use different phone number' IDV_PHONE_RECORD_VISIT = 'IdV: phone of record visited' diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index a6ca71ee53b..e739bae42b6 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # rubocop:disable Metrics/ModuleLength module AnalyticsEvents # @identity.idp.previous_event_name Account Reset @@ -465,6 +467,136 @@ def idv_doc_auth_exception_visited(step_name:, remaining_attempts:, **extra) ) end + # @param [Boolean] success + # @param [Hash] errors + # @param [Integer] attempts + # @param [Integer] remaining_attempts + # @param [String] user_id + # @param [String] flow_path + # The document capture image uploaded was locally validated during the IDV process + def idv_doc_auth_submitted_image_upload_form( + success:, + errors:, + remaining_attempts:, + flow_path:, + attempts: nil, + user_id: nil, + **extra + ) + track_event( + 'IdV: doc auth image upload form submitted', + success: success, + errors: errors, + attempts: attempts, + remaining_attempts: remaining_attempts, + user_id: user_id, + flow_path: flow_path, + **extra, + ) + end + + # @param [Boolean] success + # @param [Hash] errors + # @param [String] exception + # @param [Boolean] billed + # @param [String] doc_auth_result + # @param [String] state + # @param [String] state_id_type + # @param [Boolean] async + # @param [Integer] attempts + # @param [Integer] remaining_attempts + # @param [Hash] client_image_metrics + # @param [String] flow_path + # The document capture image was uploaded to vendor during the IDV process + def idv_doc_auth_submitted_image_upload_vendor( + success:, + errors:, + exception:, + state:, + state_id_type:, + async:, attempts:, + remaining_attempts:, + client_image_metrics:, + flow_path:, + billed: nil, + doc_auth_result: nil, + **extra + ) + track_event( + 'IdV: doc auth image upload vendor submitted', + success: success, + errors: errors, + exception: exception, + billed: billed, + doc_auth_result: doc_auth_result, + state: state, + state_id_type: state_id_type, + async: async, + attempts: attempts, + remaining_attempts: remaining_attempts, + client_image_metrics: client_image_metrics, + flow_path: flow_path, + **extra, + ) + end + + # @param [Boolean] success + # @param [Hash] errors + # @param [String] user_id + # @param [Integer] remaining_attempts + # @param [Hash] pii_like_keypaths + # @param [String] flow_path + # The PII that came back from the document capture vendor was validated + def idv_doc_auth_submitted_pii_validation( + success:, + errors:, + remaining_attempts:, + pii_like_keypaths:, + flow_path:, + user_id: nil, + **extra + ) + track_event( + 'IdV: doc auth image upload vendor pii validation', + success: success, + errors: errors, + user_id: user_id, + remaining_attempts: remaining_attempts, + pii_like_keypaths: pii_like_keypaths, + flow_path: flow_path, + **extra, + ) + end + + # @param [String] step_name + # @param [Integer] remaining_attempts + # The user was sent to a warning page during the IDV flow + def idv_doc_auth_warning_visited( + step_name:, + remaining_attempts:, + **extra + ) + track_event( + 'IdV: doc auth warning visited', + step_name: step_name, + remaining_attempts: remaining_attempts, + **extra, + ) + end + + # @param [Boolean] success + # Tracks the last step of IDV, indicates the user successfully prooved + def idv_final( + success:, + **extra + ) + track_event( + 'IdV: final resolution', + success: success, + **extra, + ) + end + # User visited IDV personal key page def idv_personal_key_visited track_event('IdV: personal key visited') @@ -509,6 +641,83 @@ def idv_phone_confirmation_form_submitted( ) end + # The user was rate limited for submitting too many OTPs during the IDV phone step + def idv_phone_confirmation_otp_rate_limit_attempts + track_event('Idv: Phone OTP attempts rate limited') + end + + # The user was locked out for hitting the phone OTP rate limit during IDV + def idv_phone_confirmation_otp_rate_limit_locked_out + track_event('Idv: Phone OTP rate limited user') + end + + # The user was rate limited for requesting too many OTPs during the IDV phone step + def idv_phone_confirmation_otp_rate_limit_sends + track_event('Idv: Phone OTP sends rate limited') + end + + # @param [Boolean] success + # @param [Hash] errors + # @param ["sms","voice"] otp_delivery_preference which chaennel the OTP was delivered by + # @param [String] country_code country code of phone number + # @param [String] area_code area code of phone number + # @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt + # @param [Hash] telephony_response response from Telephony gem + # The user resent an OTP during the IDV phone step + def idv_phone_confirmation_otp_resent( + success:, + errors:, + otp_delivery_preference:, + country_code:, + area_code:, + rate_limit_exceeded:, + telephony_response:, + **extra + ) + track_event( + 'IdV: phone confirmation otp resent', + success: success, + errors: errors, + otp_delivery_preference: otp_delivery_preference, + country_code: country_code, + area_code: area_code, + rate_limit_exceeded: rate_limit_exceeded, + telephony_response: telephony_response, + **extra, + ) + end + + # @param [Boolean] success + # @param [Hash] errors + # @param ["sms","voice"] otp_delivery_preference which chaennel the OTP was delivered by + # @param [String] country_code country code of phone number + # @param [String] area_code area code of phone number + # @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt + # @param [Hash] telephony_response response from Telephony gem + # The user requested an OTP to confirm their phone during the IDV phone step + def idv_phone_confirmation_otp_sent( + success:, + errors:, + otp_delivery_preference:, + country_code:, + area_code:, + rate_limit_exceeded:, + telephony_response:, + **extra + ) + track_event( + 'IdV: phone confirmation otp sent', + success: success, + errors: errors, + otp_delivery_preference: otp_delivery_preference, + country_code: country_code, + area_code: area_code, + rate_limit_exceeded: rate_limit_exceeded, + telephony_response: telephony_response, + **extra, + ) + end + # @param [Boolean] success # @param [Hash] errors # The vendor finished the process of confirming the users phone diff --git a/app/services/attribute_asserter.rb b/app/services/attribute_asserter.rb index b7cf33006ea..e4b4737a01f 100644 --- a/app/services/attribute_asserter.rb +++ b/app/services/attribute_asserter.rb @@ -52,7 +52,12 @@ def build :user_session def ial_context - @ial_context ||= IalContext.new(ial: authn_context, service_provider: service_provider) + @ial_context ||= IalContext.new( + ial: authn_context, + service_provider: service_provider, + user: user, + authn_context_comparison: authn_request&.requested_authn_context_comparison, + ) end def default_attrs @@ -120,11 +125,19 @@ def add_aal(attrs) end def add_ial(attrs) - context = authn_request.requested_ial_authn_context - context ||= Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[service_provider.ial] + requested_context = authn_request.requested_ial_authn_context + context = if ial_context.ialmax_requested? && ial_context.ial2_requested? + sp_ial # IAL2 since IALMAX only works for IAL2 SPs + else + requested_context.presence || sp_ial + end attrs[:ial] = { getter: ial_getter_function(context) } if context end + def sp_ial + Saml::Idp::Constants::AUTHN_CONTEXT_IAL_TO_CLASSREF[service_provider.ial] + end + def add_x509(attrs) attrs[:x509_subject] = { getter: ->(_principal) { x509_data.subject } } attrs[:x509_issuer] = { getter: ->(_principal) { x509_data.issuer } } diff --git a/app/services/db/sp_cost/add_sp_cost.rb b/app/services/db/sp_cost/add_sp_cost.rb index f7f4fe606aa..6c4ef015e18 100644 --- a/app/services/db/sp_cost/add_sp_cost.rb +++ b/app/services/db/sp_cost/add_sp_cost.rb @@ -9,15 +9,9 @@ class SpCostTypeError < StandardError; end acuant_back_image acuant_result acuant_selfie - authentication - digest lexis_nexis_resolution lexis_nexis_address gpo_letter - phone_otp - sms - user_added - voice ].freeze def self.call(service_provider, ial, token, transaction_id: nil, user: nil) diff --git a/app/services/encryption/aes_cipher.rb b/app/services/encryption/aes_cipher.rb index 9022acdfcfa..c020e9d723b 100644 --- a/app/services/encryption/aes_cipher.rb +++ b/app/services/encryption/aes_cipher.rb @@ -3,8 +3,7 @@ class AesCipher include Encodable def encrypt(plaintext, cek) - self.cipher = OpenSSL::Cipher.new 'aes-256-gcm' - cipher.encrypt + self.cipher = self.class.encryption_cipher # The key length for the AES-256-GCM cipher is fixed at 128 bits, or 32 # characters. Starting with Ruby 2.4, an expection is thrown if you try to # set a key longer than 32 characters, which is what we have been doing @@ -20,6 +19,10 @@ def decrypt(payload, cek) decipher(payload) end + def self.encryption_cipher + OpenSSL::Cipher.new('aes-256-gcm').encrypt + end + private attr_accessor :cipher diff --git a/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb b/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb index 6fdb86017d3..f873533b01e 100644 --- a/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb +++ b/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb @@ -4,7 +4,7 @@ class RegisterStepFromAnalyticsViewEvent ANALYTICS_EVENT_TO_DOC_AUTH_LOG_TOKEN = { Analytics::IDV_PHONE_RECORD_VISIT => :verify_phone, Analytics::IDV_REVIEW_VISIT => :encrypt, - Analytics::IDV_FINAL => :verified, + 'IdV: final resolution' => :verified, Analytics::IDV_GPO_ADDRESS_VISITED => :usps_address, }.freeze diff --git a/app/services/ial_context.rb b/app/services/ial_context.rb index fd1b9dd11b5..e1af6e3d6d5 100644 --- a/app/services/ial_context.rb +++ b/app/services/ial_context.rb @@ -1,14 +1,15 @@ # Wraps up logic for querying the IAL level of an authorization request class IalContext - attr_reader :ial, :service_provider, :user + attr_reader :ial, :service_provider, :user, :authn_context_comparison # @param ial [String, Integer] IAL level as either an integer (see ::Idp::Constants::IAL2, etc) # or a string see Saml::Idp::Constants contexts # @param service_provider [ServiceProvider, nil] - def initialize(ial:, service_provider:, user: nil) - @ial = int_ial(ial) + def initialize(ial:, service_provider:, user: nil, authn_context_comparison: nil) + @authn_context_comparison = authn_context_comparison @service_provider = service_provider @user = user + @ial = int_ial(ial) end def ial2_service_provider? @@ -46,6 +47,19 @@ def ial2_strict_requested? private def int_ial(input) + return 0 if saml_ialmax?(input) + + convert_ial_to_int(input) + end + + def saml_ialmax?(input) + int_ial_from_request = convert_ial_to_int(input) + return false unless int_ial_from_request.present? + + service_provider&.ial == 2 && authn_context_comparison == 'minimum' && int_ial_from_request < 2 + end + + def convert_ial_to_int(input) Integer(input) rescue TypeError # input was nil nil diff --git a/app/services/identity_linker.rb b/app/services/identity_linker.rb index 254f0326ec8..fa52e198fc4 100644 --- a/app/services/identity_linker.rb +++ b/app/services/identity_linker.rb @@ -45,7 +45,6 @@ def identity def find_or_create_identity_with_costing identity_record = identity_relation.first return identity_record if identity_record - Db::SpCost::AddSpCost.call(service_provider, @ial, :user_added) user.identities.create(service_provider: service_provider.issuer) end diff --git a/app/services/idv/actions/verify_document_status_action.rb b/app/services/idv/actions/verify_document_status_action.rb index 2a4a02097e8..7bb3cb28de7 100644 --- a/app/services/idv/actions/verify_document_status_action.rb +++ b/app/services/idv/actions/verify_document_status_action.rb @@ -43,9 +43,8 @@ def process_async_state(current_async_state) def async_state_done(async_result) doc_pii_form_result = Idv::DocPiiForm.new(async_result.pii).submit - @flow.analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - doc_pii_form_result.to_h.merge( + @flow.analytics.idv_doc_auth_submitted_pii_validation( + **doc_pii_form_result.to_h.merge( remaining_attempts: remaining_attempts, flow_path: flow_path, ), diff --git a/app/services/idv/send_phone_confirmation_otp.rb b/app/services/idv/send_phone_confirmation_otp.rb index 0a960e51f6a..abc9e8bc235 100644 --- a/app/services/idv/send_phone_confirmation_otp.rb +++ b/app/services/idv/send_phone_confirmation_otp.rb @@ -73,7 +73,6 @@ def otp_sent_response def add_cost Db::ProofingCost::AddUserProofingCost.call(user.id, :phone_otp) - Db::SpCost::AddSpCost.call(idv_session.service_provider, 2, :phone_otp) end def extra_analytics_attributes diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index 65aa437f4fe..693e18c088f 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -30,8 +30,7 @@ def idv_failure(result) ) redirect_to idv_session_errors_exception_url else - @flow.analytics.track_event( - Analytics::IDV_DOC_AUTH_WARNING_VISITED, + @flow.analytics.idv_doc_auth_warning_visited( step_name: self.class.name, remaining_attempts: throttle.remaining_count, ) diff --git a/app/services/idv/user_bundle_tokenizer.rb b/app/services/idv/user_bundle_tokenizer.rb new file mode 100644 index 00000000000..c077232f137 --- /dev/null +++ b/app/services/idv/user_bundle_tokenizer.rb @@ -0,0 +1,40 @@ +module Idv + class UserBundleTokenizer + def initialize(user:, idv_session:) + @user = user + @idv_session = idv_session + end + + def token + JWT.encode( + { + # for now, load whatever pii is saved in the session + pii: idv_session.applicant, + metadata: metadata, + }, + private_key, + 'RS256', + sub: user.uuid, + ) + end + + private + + attr_reader :user, :idv_session + + def private_key + OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_private_key)) + end + + def metadata + # populate with anything from the session we'll need later on + data = {} + + data[:address_verification_mechanism] = idv_session.address_verification_mechanism + data[:user_phone_confirmation] = idv_session.user_phone_confirmation + data[:vendor_phone_confirmation] = idv_session.vendor_phone_confirmation + + data + end + end +end diff --git a/app/services/reactivate_account_session.rb b/app/services/reactivate_account_session.rb index ab68fe00faf..2729264d465 100644 --- a/app/services/reactivate_account_session.rb +++ b/app/services/reactivate_account_session.rb @@ -27,23 +27,21 @@ def suspend # Stores PII as a string in the session # @param [Pii::Attributes] def store_decrypted_pii(pii) - reactivate_account_session[:personal_key] = true reactivate_account_session[:validated_personal_key] = true pii_json = pii.to_json - reactivate_account_session[:pii] = pii_json Pii::Cacher.new(@user, session).save_decrypted_pii_json(pii_json) nil end def validated_personal_key? - reactivate_account_session[:personal_key] + reactivate_account_session[:validated_personal_key] end # Parses string into PII struct # @return [Pii::Attributes, nil] def decrypted_pii - json_str = reactivate_account_session[:pii] - Pii::Attributes.new_from_json(json_str) if json_str + return unless validated_personal_key? + Pii::Cacher.new(@user, session).fetch end private @@ -53,9 +51,7 @@ def decrypted_pii def generate_session { active: false, - personal_key: false, validated_personal_key: false, - pii: nil, x509: nil, } end diff --git a/app/services/saml_endpoint.rb b/app/services/saml_endpoint.rb index 2e4c0062bf8..5dd5da315c7 100644 --- a/app/services/saml_endpoint.rb +++ b/app/services/saml_endpoint.rb @@ -34,8 +34,15 @@ def x509_certificate def saml_metadata config = SamlIdp.config.dup - config.single_service_post_location = config.single_service_post_location + suffix - config.single_logout_service_post_location = config.single_logout_service_post_location + suffix + config.single_service_post_location += suffix + if IdentityConfig.store.include_slo_in_saml_metadata + config.single_logout_service_post_location += suffix + config.remote_logout_service_post_location += suffix + else + config.single_logout_service_post_location = nil + config.remote_logout_service_post_location = nil + end + SamlIdp::MetadataBuilder.new( config, x509_certificate, diff --git a/app/views/idv/phone_errors/_warning.html.erb b/app/views/idv/phone_errors/_warning.html.erb index ccb0822efc8..9a722d0ecef 100644 --- a/app/views/idv/phone_errors/_warning.html.erb +++ b/app/views/idv/phone_errors/_warning.html.erb @@ -19,7 +19,7 @@ locals: text: t('idv.troubleshooting.options.contact_support', app_name: APP_NAME), new_tab: true, }, - FeatureManagement.enable_gpo_verification? && { + @gpo_letter_available && { text: t('idv.troubleshooting.options.verify_by_mail'), url: idv_gpo_path, }, diff --git a/app/views/idv/phone_errors/failure.html.erb b/app/views/idv/phone_errors/failure.html.erb index bd75ad9db9c..adde1cf968d 100644 --- a/app/views/idv/phone_errors/failure.html.erb +++ b/app/views/idv/phone_errors/failure.html.erb @@ -3,7 +3,7 @@ title: t('titles.failure.phone_verification'), heading: t('idv.failure.phone.heading'), options: [ - FeatureManagement.enable_gpo_verification? && { + @gpo_letter_available && { text: t('idv.troubleshooting.options.verify_by_mail'), url: idv_gpo_path, }, diff --git a/app/views/partials/multi_factor_authentication/_mfa_selection.html.erb b/app/views/partials/multi_factor_authentication/_mfa_selection.html.erb new file mode 100644 index 00000000000..1e4909f7556 --- /dev/null +++ b/app/views/partials/multi_factor_authentication/_mfa_selection.html.erb @@ -0,0 +1,31 @@ +
+ <%= check_box_tag( + 'two_factor_options_form[selection][]', + option.type, + option.type == 'phone' && flash[:phone_error].present?, + disabled: option.disabled?, + class: 'usa-checkbox__input usa-checkbox__input--tile', + id: "two_factor_options_form_selection_#{option.type}", + ) %> + <%= label_tag( + "two_factor_options_form_selection_#{option.type}", + class: [ + option.type == 'phone' && flash[:phone_error] && 'checkbox__invalid', + 'usa-checkbox__label', + 'usa-checkbox__label--illustrated', + ].select(&:present?).join(' '), + ) do %> + <%= image_tag(asset_url("mfa-options/#{option.type}.svg"), alt: "#{option.label} icon", class: 'usa-checkbox__image') %> +
<%= option.label %> + + <%= option.info %> + +
+ <% end %> + <% if option.type == "phone" && flash[:phone_error] %> + + <% end %> +
+<%= javascript_packs_tag_once('mfa_selection_component') %> diff --git a/app/views/sign_up/completions/show.html.erb b/app/views/sign_up/completions/show.html.erb index 81b1a968500..88117f51b57 100644 --- a/app/views/sign_up/completions/show.html.erb +++ b/app/views/sign_up/completions/show.html.erb @@ -28,7 +28,7 @@ <%= render(AlertComponent.new(type: :warning, class: 'margin-bottom-4')) do %> <%= link_to( t('mfa.second_method_warning.link'), - two_factor_options_url(multiple_mfa_setup: ''), + mfa_setup_path, ) %> <%= t('mfa.second_method_warning.text') %> <% end %> diff --git a/app/views/test/saml_test/decode_response.html.erb b/app/views/test/saml_test/decode_response.html.erb index 23272b31a57..ec426e239e2 100644 --- a/app/views/test/saml_test/decode_response.html.erb +++ b/app/views/test/saml_test/decode_response.html.erb @@ -26,6 +26,7 @@ <%= link_to 'Open in New Window', "data:text/xml;charset=utf-8;base64,#{xml_doc}", target: '_blank', rel: 'noopener noreferrer' %> + <%= hidden_field_tag '', xml_doc, id: 'SAMLResponse' %> diff --git a/app/views/users/mfa_selection/index.html.erb b/app/views/users/mfa_selection/index.html.erb new file mode 100644 index 00000000000..639bfcd6f73 --- /dev/null +++ b/app/views/users/mfa_selection/index.html.erb @@ -0,0 +1,33 @@ +<%= title t('two_factor_authentication.two_factor_choice') %> + +<%= render PageHeadingComponent.new.with_content(t('two_factor_authentication.two_factor_choice')) %> + +<% if IdentityConfig.store.select_multiple_mfa_options %> + <%= render AlertComponent.new(type: :info, class: 'margin-bottom-4') do %> + <%= t('mfa.info') %> + <% end %> +<% end %> + +<%= validated_form_for @two_factor_options_form, + html: { autocomplete: 'off' }, + method: :patch, + url: mfa_setup_path do |f| %> +
+
+ <%= @presenter.intro %> + <% @presenter.options.each do |option| %> +
" class="<%= option.html_class %>"> + <%= render partial: 'partials/multi_factor_authentication/mfa_selection', + locals: { form: f, option: option } %> +
+ <% end %> +
+
+ + <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide margin-bottom-1' %> +<% end %> + +<%= render 'shared/cancel', link: destroy_user_session_path %> + +<%= javascript_packs_tag_once('webauthn-unhide') %> + 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 b5c060a52be..9881592b8a8 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.erb +++ b/app/views/users/two_factor_authentication_setup/index.html.erb @@ -14,8 +14,6 @@

<%= @presenter.intro %>

-<%# If it is decided that there will be an info banner on this page for MFA setup, the text will need Gengo translations %> - <% if IdentityConfig.store.select_multiple_mfa_options %> <%= render AlertComponent.new(type: :info, class: 'margin-bottom-4') do %> <%= t('mfa.info') %> @@ -32,25 +30,8 @@ <% @presenter.options.each do |option| %>
" class="<%= option.html_class %>"> <% if IdentityConfig.store.select_multiple_mfa_options %> - <%= check_box_tag( - 'two_factor_options_form[selection][]', - option.type, - false, - disabled: option.disabled?, - class: 'usa-checkbox__input usa-checkbox__input--tile', - id: "two_factor_options_form_selection_#{option.type}", - ) %> - <%= label_tag( - "two_factor_options_form_selection_#{option.type}", - class: 'usa-checkbox__label usa-checkbox__label--illustrated', - ) do %> - <%= image_tag(asset_url("mfa-options/#{option.type}.svg"), alt: "#{option.label} icon", class: 'usa-checkbox__image') %> -
<%= option.label %> - - <%= option.info %> - -
- <% end %> + <%= render partial: 'partials/multi_factor_authentication/mfa_selection', + locals: { form: f, option: option } %> <% else %> <%= radio_button_tag( 'two_factor_options_form[selection]', diff --git a/config/application.yml.default b/config/application.yml.default index 7a9d3309f7c..a16558208ad 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -102,6 +102,7 @@ idv_private_key: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBS3 idv_send_link_attempt_window_in_minutes: 10 idv_send_link_max_attempts: 5 in_person_proofing_enabled: true +include_slo_in_saml_metadata: false liveness_checking_enabled: false logins_per_ip_track_only_mode: false # LexisNexis ##################################################### @@ -245,7 +246,7 @@ usps_ipp_root_url: '' usps_ipp_request_timeout: 10 usps_ipp_sponsor_id: '' usps_ipp_username: '' -usps_upload_allowed_for_strict_ial2: true +gpo_allowed_for_strict_ial2: true voice_otp_pause_time: '0.5s' voice_otp_speech_rate: 'slow' voip_check: true @@ -281,6 +282,7 @@ development: hmac_fingerprinter_key: a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c hmac_fingerprinter_key_queue: '["11111111111111111111111111111111", "22222222222222222222222222222222"]' identity_pki_local_dev: true + idv_api_enabled_steps: '["personal_key","personal_key_confirm"]' liveness_checking_enabled: true logins_per_ip_limit: 5 logo_upload_enabled: true diff --git a/config/initializers/saml_idp.rb b/config/initializers/saml_idp.rb index eb23021e5d4..af52ccd80a9 100644 --- a/config/initializers/saml_idp.rb +++ b/config/initializers/saml_idp.rb @@ -19,6 +19,7 @@ config.attribute_service_location = "#{api_base}/saml/attributes" config.single_service_post_location = "#{api_base}/saml/auth" config.single_logout_service_post_location = "#{api_base}/saml/logout" + config.remote_logout_service_post_location = "#{api_base}/saml/remotelogout" # Name ID config.name_id.formats = diff --git a/config/locales/errors/en.yml b/config/locales/errors/en.yml index 049bee7e87a..78b14ac0fcb 100644 --- a/config/locales/errors/en.yml +++ b/config/locales/errors/en.yml @@ -107,6 +107,7 @@ en: sign_in: bad_password_limit: You have exceeeded the maximum sign in attempts. two_factor_auth_setup: + must_select_additional_option: Select an additional authentication method. must_select_option: Select an authentication method. verify_personal_key: throttled: You tried too many times, please try again in %{timeout}. diff --git a/config/locales/errors/es.yml b/config/locales/errors/es.yml index 0674e94c139..0d6f56777f5 100644 --- a/config/locales/errors/es.yml +++ b/config/locales/errors/es.yml @@ -112,6 +112,7 @@ es: sign_in: bad_password_limit: Has superado el número máximo de intentos de inicio de sesión. two_factor_auth_setup: + must_select_additional_option: Seleccione un método de autenticación adicional. must_select_option: Seleccione un método de autenticación. verify_personal_key: throttled: Lo intentaste muchas veces, vuelve a intentarlo en %{timeout}. diff --git a/config/locales/errors/fr.yml b/config/locales/errors/fr.yml index a173700b69a..3384668a7f6 100644 --- a/config/locales/errors/fr.yml +++ b/config/locales/errors/fr.yml @@ -121,6 +121,7 @@ fr: sign_in: bad_password_limit: Vous avez dépassé le nombre maximal de tentatives de connexion. two_factor_auth_setup: + must_select_additional_option: Sélectionnez une méthode d’authentification supplémentaire. must_select_option: Sélectionnez une méthode d’authentification. verify_personal_key: throttled: Vous avez essayé plusieurs fois, essayez à nouveau dans %{timeout}. diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index f936b0ea7f6..d57527b7d9f 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -35,8 +35,6 @@ en: backup_code_info: Use a backup code from your list of backup codes to sign in. personal_key: Personal Key personal_key_info: Use the 16 character personal key you received at account creation. - phone_info_html: Get security code via text/SMS or phone call to - %{phone}. piv_cac: Government employee ID piv_cac_info: Use your PIV/CAC card instead of a security code. sms: Text message @@ -135,7 +133,9 @@ en: less_secure_label: Less secure more_secure_label: More Secure phone: Text or Voice Message - phone_info: Receive a secure code by (SMS) text or phone call to your device. + phone_info: Receive a secure code by (SMS) text or phone call. + phone_info_html: Receive a secure code by (SMS) text or phone call. You + need to select another method in addition to this one. phone_info_no_voip: Do not use web-based (VOIP) phone services or premium rate (toll) phone numbers. piv_cac: Government Employee ID diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index f588ca274d5..8568b851fe2 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -37,8 +37,6 @@ es: personal_key: Clave personal personal_key_info: Use la clave personal de 16 caracteres que usó en la creación de la cuenta. - phone_info_html: Obtenga su código de seguridad a través de mensajes de texto / - SMS o de una llamada telefónica a %{phone}. piv_cac: Empleados del Gobierno piv_cac_info: Use su tarjeta PIV / CAC para asegurar su cuenta. sms: Mensaje de texto / SMS @@ -146,8 +144,11 @@ es: less_secure_label: Menos seguro more_secure_label: Más seguro phone: Mensaje de texto o de voz - phone_info: Reciba un código de seguridad a través de un mensaje de texto (SMS) - o una llamada telefónica a su dispositivo. + phone_info: Recibir un código seguro por medio de un mensaje de texto (SMS) o + una llamada telefónica. + phone_info_html: Recibir un código seguro por medio de un mensaje de texto (SMS) + o una llamada telefónica. Tienes que elegir otro método además + de este. phone_info_no_voip: Se prohíbe el uso de servicios telefónicos basados en la web (VOIP) o de números de teléfono de tarificación adicional (de pago). piv_cac: Identificación de empleado gubernamental diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index ae1ccda9495..0509626e2a5 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -39,8 +39,6 @@ fr: personal_key: Clé personnelle personal_key_info: Utilisez la clé personnelle de 16 caractères que vous avez utilisée lors de la création du compte. - phone_info_html: Obtenez votre code de sécurité par SMS ou Obtenez votre code de - sécurité par SMS à %{phone}. piv_cac: Employés du gouvernement piv_cac_info: Utilisez votre carte PIV / CAC pour sécuriser votre compte. sms: SMS @@ -150,8 +148,10 @@ fr: less_secure_label: Moins sécurisé more_secure_label: Plus sécurisé phone: Message texte ou vocal - phone_info: Recevez un code sécurisé par message texte ou appel téléphonique sur - votre appareil. + phone_info: Recevoir un code de sécurité par texto (SMS) ou appel téléphonique. + phone_info_html: Recevoir un code de sécurité par texto (SMS) ou appel + téléphonique. Vous devez sélectionner une autre méthode en plus + de celle-ci. phone_info_no_voip: N’utilisez pas de services téléphoniques basés sur le Web ( Voix sur IP ) ou de numéros de téléphone à tarif majoré ( péage ). piv_cac: Carte d’identification des employés du gouvernement diff --git a/config/routes.rb b/config/routes.rb index 47f926bc0bb..fa3dabceb29 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,7 @@ SamlEndpoint.suffixes.each do |suffix| get "/api/saml/metadata#{suffix}" => 'saml_idp#metadata', format: false match "/api/saml/logout#{suffix}" => 'saml_idp#logout', via: %i[get post delete] - match "/api/saml/remotelogout#{suffix}" => 'saml_idp#remotelogout', via: %i[get post delete] + post "/api/saml/remotelogout#{suffix}" => 'saml_idp#remotelogout' # JS-driven POST redirect route to preserve existing session post "/api/saml/auth#{suffix}" => 'saml_post#auth' # actual SAML handling POST route @@ -220,6 +220,8 @@ get '/otp/send' => 'users/two_factor_authentication#send_code' get '/two_factor_options' => 'users/two_factor_authentication_setup#index' patch '/two_factor_options' => 'users/two_factor_authentication_setup#create' + get '/mfa_setup' => 'users/mfa_selection#index' + patch '/mfa_setup' => 'users/mfa_selection#update' get '/phone_setup' => 'users/phone_setup#index' patch '/phone_setup' => 'users/phone_setup#create' get '/aal3_required' => 'users/aal3#show' @@ -324,13 +326,7 @@ post '/confirmations' => 'personal_key#update' end - scope '/verify/v2' do - get '/' => 'verify#show', as: :idv_app_root - %w[ - /personal_key - /personal_key_confirm - ].each { |step_path| get step_path => 'verify#show' } - end + get '/verify/v2(/:step)' => 'verify#show', as: :idv_app namespace :api do post '/verify/complete' => 'verify/complete#create' diff --git a/db/schema.rb b/db/schema.rb index afa061e2de7..ac3ccd694cd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -171,8 +171,8 @@ t.string "state" t.boolean "aamva" t.datetime "verify_submit_at" - t.datetime "verify_phone_submit_at" t.integer "verify_phone_submit_count", default: 0 + t.datetime "verify_phone_submit_at" t.datetime "document_capture_submit_at" t.index ["issuer"], name: "index_doc_auth_logs_on_issuer" t.index ["user_id"], name: "index_doc_auth_logs_on_user_id", unique: true @@ -577,11 +577,6 @@ t.string "reset_password_token", limit: 255 t.datetime "reset_password_sent_at" t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0, null: false - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip", limit: 255 - t.string "last_sign_in_ip", limit: 255 t.datetime "created_at" t.datetime "updated_at" t.string "confirmation_token", limit: 255 diff --git a/docs/frontend.md b/docs/frontend.md index 409ba62589d..48631b28c14 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -44,12 +44,12 @@ identify issues in your code as you write. ### At a Glance -- Site should work if JS is off (and have enhanced features if JS is on). -- Uses AirBnB's ESLint config, alongside [Prettier](https://prettier.io/). -- JS modules are installed & managed via `yarn` (see `package.json`). -- JS is transpiled, bundled, and minified via [Webpack](https://webpack.js.org/) and [Babel](https://babeljs.io/). -- Reusable code is organized using - [Yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/). +- All new code is expected to be written using [TypeScript](https://www.typescriptlang.org/) (`.ts` or `.tsx` file extension) +- The site should be functional even when JavaScript is disabled, with a few specific exceptions (identity proofing) +- The code follows [TTS JavaScript standards](https://engineering.18f.gov/javascript/), using a [custom ESLint configuration](https://github.com/18F/identity-idp/tree/main/app/javascript/packages/eslint-plugin) +- Code styling is formatted automatically using [Prettier](https://prettier.io/) +- Packages are managed with [Yarn](https://classic.yarnpkg.com/), organized using [Yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/) +- JavaScript is transpiled, bundled, and minified via [Webpack](https://webpack.js.org/) and [Babel](https://babeljs.io/) ### Prettier diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 9d2a086c74b..33001e01f9d 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -176,6 +176,7 @@ def self.build_store(config_map) config.add(:idv_send_link_attempt_window_in_minutes, type: :integer) config.add(:idv_send_link_max_attempts, type: :integer) config.add(:in_person_proofing_enabled, type: :boolean) + config.add(:include_slo_in_saml_metadata, type: :boolean) config.add(:lexisnexis_base_url, type: :string) config.add(:lexisnexis_request_mode, type: :string) config.add(:lexisnexis_account_id, type: :string) @@ -327,7 +328,7 @@ def self.build_store(config_map) config.add(:usps_ipp_username, type: :string) config.add(:usps_ipp_request_timeout, type: :integer) config.add(:usps_upload_enabled, type: :boolean) - config.add(:usps_upload_allowed_for_strict_ial2, type: :boolean) + config.add(:gpo_allowed_for_strict_ial2, type: :boolean) config.add(:usps_upload_sftp_directory, type: :string) config.add(:usps_upload_sftp_host, type: :string) config.add(:usps_upload_sftp_password, type: :string) diff --git a/spec/controllers/api/verify/complete_controller_spec.rb b/spec/controllers/api/verify/complete_controller_spec.rb index e5dd15f0900..4d5cecbc6e8 100644 --- a/spec/controllers/api/verify/complete_controller_spec.rb +++ b/spec/controllers/api/verify/complete_controller_spec.rb @@ -31,7 +31,7 @@ def stub_idv_session let(:jwt) { JWT.encode({ pii: pii, metadata: {} }, key, 'RS256', sub: user.uuid) } before do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return([:personal_key]) + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return(['personal_key']) end describe 'before_actions' do @@ -46,7 +46,7 @@ def stub_idv_session describe '#create' do context 'when the user is not signed in and submits the password' do it 'does not create a profile or return a key' do - post :create, params: { password: 'iambatman', details: jwt } + post :create, params: { password: 'iambatman', user_bundle_token: jwt } expect(JSON.parse(response.body)['personal_key']).to be_nil expect(response.status).to eq 401 expect(JSON.parse(response.body)['error']).to eq 'user is not fully authenticated' @@ -59,13 +59,13 @@ def stub_idv_session end it 'creates a profile and returns a key' do - post :create, params: { password: 'iambatman', details: jwt } + post :create, params: { password: 'iambatman', user_bundle_token: jwt } expect(JSON.parse(response.body)['personal_key']).not_to be_nil expect(response.status).to eq 200 end it 'does not create a profile and return a key when it has the wrong password' do - post :create, params: { password: 'iamnotbatman', details: jwt } + post :create, params: { password: 'iamnotbatman', user_bundle_token: jwt } expect(JSON.parse(response.body)['personal_key']).to be_nil expect(response.status).to eq 400 end @@ -77,7 +77,7 @@ def stub_idv_session end it 'responds with not found' do - post :create, params: { password: 'iambatman', details: jwt }, as: :json + post :create, params: { password: 'iambatman', user_bundle_token: jwt }, as: :json expect(response.status).to eq 404 expect(JSON.parse(response.body)['error']). to eq "The page you were looking for doesn't exist" diff --git a/spec/controllers/idv/gpo_controller_spec.rb b/spec/controllers/idv/gpo_controller_spec.rb index 85d9d714b34..74cfe38ce1e 100644 --- a/spec/controllers/idv/gpo_controller_spec.rb +++ b/spec/controllers/idv/gpo_controller_spec.rb @@ -123,6 +123,17 @@ allow(FeatureManagement).to receive(:reveal_gpo_code?).and_return(true) expect_resend_letter_to_send_letter_and_redirect(otp: true) end + + it 'redirects to capture password if pii is locked' do + pii_cacher = instance_double(Pii::Cacher) + allow(pii_cacher).to receive(:fetch).and_return(nil) + allow(pii_cacher).to receive(:exists_in_session?).and_return(false) + allow(Pii::Cacher).to receive(:new).and_return(pii_cacher) + + put :create + + expect(response).to redirect_to capture_password_path + end end end @@ -130,6 +141,7 @@ def expect_resend_letter_to_send_letter_and_redirect(otp:) pii = { first_name: 'Samuel', last_name: 'Sampson' } pii_cacher = instance_double(Pii::Cacher) allow(pii_cacher).to receive(:fetch).and_return(pii) + allow(pii_cacher).to receive(:exists_in_session?).and_return(true) allow(Pii::Cacher).to receive(:new).and_return(pii_cacher) service_provider = create(:service_provider, issuer: '123abc') diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 46276eab9e1..94291a0cc5b 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -40,7 +40,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted,', success: false, errors: { front: ['Please fill in this field.'], @@ -53,10 +53,10 @@ remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, pii_like_keypaths: [[:pii]], flow_path: 'standard', - ) + ).exactly(0).times expect(@analytics).not_to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', any_args, ) @@ -95,7 +95,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: false, errors: { front: [I18n.t('doc_auth.errors.not_a_file')], @@ -111,7 +111,8 @@ ) expect(@analytics).not_to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', + # Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, any_args, ) @@ -196,7 +197,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: false, errors: { limit: [I18n.t('errors.doc_auth.throttled_heading')], @@ -212,7 +213,7 @@ ) expect(@analytics).not_to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', any_args, ) @@ -235,7 +236,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -246,7 +247,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -267,7 +268,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: true, errors: {}, user_id: user.uuid, @@ -314,7 +315,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -325,7 +326,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -346,7 +347,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: false, errors: { pii: [I18n.t('doc_auth.errors.alerts.full_name_check')], @@ -372,7 +373,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -383,7 +384,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -404,7 +405,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: false, errors: { pii: [I18n.t('doc_auth.errors.general.no_liveness')], @@ -430,7 +431,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -441,7 +442,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, async: false, @@ -462,7 +463,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: false, errors: { pii: [I18n.t('doc_auth.errors.alerts.birth_date_checks')], @@ -512,7 +513,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -523,13 +524,14 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: false, errors: { front: [I18n.t('doc_auth.errors.general.multiple_front_id_failures')], }, user_id: user.uuid, attempts: 1, + billed: nil, remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, state: nil, state_id_type: nil, @@ -539,6 +541,7 @@ front: { glare: 99.99 }, back: { glare: 99.99 }, }, + doc_auth_result: nil, pii_like_keypaths: [[:pii]], flow_path: 'standard', ) @@ -571,7 +574,7 @@ stub_analytics expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, user_id: user.uuid, @@ -582,7 +585,7 @@ ) expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: false, errors: { general: [I18n.t('doc_auth.errors.alerts.barcode_content_check')], diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index d759f5c2d97..51253c2ed35 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -215,7 +215,7 @@ 'IdV: Phone OTP Delivery Selection Submitted', hash_including(success: true) ) expect(@analytics).to receive(:track_event).ordered.with( - Analytics::IDV_PHONE_CONFIRMATION_OTP_SENT, + 'IdV: phone confirmation otp sent', hash_including( success: false, telephony_response: telephony_response, diff --git a/spec/controllers/idv/resend_otp_controller_spec.rb b/spec/controllers/idv/resend_otp_controller_spec.rb index a7b78443955..ea6eada9999 100644 --- a/spec/controllers/idv/resend_otp_controller_spec.rb +++ b/spec/controllers/idv/resend_otp_controller_spec.rb @@ -63,7 +63,7 @@ } expect(@analytics).to have_received(:track_event).with( - Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, + 'IdV: phone confirmation otp resent', expected_result, ) end @@ -95,7 +95,7 @@ it 'tracks an analytics events' do expect(@analytics).to receive(:track_event).ordered.with( - Analytics::IDV_PHONE_CONFIRMATION_OTP_RESENT, + 'IdV: phone confirmation otp resent', hash_including( success: false, telephony_response: telephony_response, diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 72b754f8f72..cd866e877b7 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -297,7 +297,10 @@ def show put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } expect(@analytics).to have_received(:track_event).with(Analytics::IDV_REVIEW_COMPLETE) - expect(@analytics).to have_received(:track_event).with(Analytics::IDV_FINAL, success: true) + expect(@analytics).to have_received(:track_event).with( + 'IdV: final resolution', + success: true, + ) expect(response).to redirect_to idv_personal_key_path end @@ -349,13 +352,13 @@ def show context 'with idv app personal key step enabled' do before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return([:personal_key]) + and_return(['personal_key']) end it 'redirects to idv app personal key path' do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } - expect(response).to redirect_to idv_app_root_url + expect(response).to redirect_to idv_app_url end end end @@ -377,7 +380,7 @@ def show context 'with idv api personal key step enabled' do before do allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return([:personal_key]) + and_return(['personal_key']) end it 'redirects to personal key path' do diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 5ca2a1a6045..096413627e1 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -111,7 +111,7 @@ result = { service_provider: nil, saml_request_valid: false } expect(@analytics).to receive(:track_event).with('Remote Logout initiated', result) - delete :remotelogout, params: { SAMLRequest: 'foo' } + post :remotelogout, params: { SAMLRequest: 'foo' } end let(:agency) { create(:agency) } @@ -240,7 +240,7 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_ok expect(session_accessor.load).to be_empty @@ -277,7 +277,7 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_bad_request end @@ -308,7 +308,7 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_bad_request end @@ -339,13 +339,13 @@ ), ).to eq(true) - delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) + post :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature)) expect(response).to be_bad_request end it 'rejects requests from a wrong cert' do - delete :remotelogout, params: UriService.params( + post :remotelogout, params: UriService.params( OneLogin::RubySaml::Logoutrequest.new.create(wrong_cert_settings), ) @@ -441,6 +441,15 @@ def name_id_version(format_urn) }, ) end + let(:ialmax_settings) do + saml_settings( + overrides: { + issuer: sp1_issuer, + authn_context: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + authn_context_comparison: 'minimum', + }, + ) + end shared_examples 'a verified identity' do |authn_context, ial| let(:ial2_settings) do @@ -604,6 +613,117 @@ def name_id_version(format_urn) end end + context 'with IALMAX and the identity is already verified' do + let(:user) { create(:profile, :active, :verified).user } + let(:pii) do + Pii::Attributes.new_from_hash( + first_name: 'Some', + last_name: 'One', + ssn: '666666666', + zipcode: '12345', + ) + end + let(:this_authn_request) do + ialmax_authnrequest = saml_authn_request_url( + overrides: { + issuer: sp1_issuer, + authn_context: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + authn_context_comparison: 'minimum', + }, + ) + raw_req = CGI.unescape ialmax_authnrequest.split('SAMLRequest').last + SamlIdp::Request.from_deflated_request(raw_req) + end + let(:asserter) do + AttributeAsserter.new( + user: user, + service_provider: ServiceProvider.find_by(issuer: sp1_issuer), + authn_request: this_authn_request, + name_id_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + decrypted_pii: pii, + user_session: {}, + ) + end + + before do + stub_sign_in(user) + IdentityLinker.new(user, ServiceProvider.find_by(issuer: sp1_issuer)).link_identity(ial: 2) + user.identities.last.update!( + verified_attributes: %w[email given_name family_name social_security_number address], + ) + allow(subject).to receive(:attribute_asserter) { asserter } + + controller.user_session[:decrypted_pii] = pii + end + + it 'calls AttributeAsserter#build' do + expect(asserter).to receive(:build).at_least(:once).and_call_original + + saml_get_auth(ialmax_settings) + end + + it 'sets identity ial to 0' do + saml_get_auth(ialmax_settings) + expect(user.identities.last.ial).to eq(0) + end + + it 'does not redirect the user to the IdV URL' do + saml_get_auth(ialmax_settings) + + expect(response).to_not be_redirect + end + + it 'contains verified attributes' do + saml_get_auth(ialmax_settings) + + expect(xmldoc.attribute_node_for('address1')).to be_nil + + %w[first_name last_name ssn zipcode].each do |attr| + node_value = xmldoc.attribute_value_for(attr) + expect(node_value).to eq(pii[attr]) + end + + expect(xmldoc.attribute_value_for('verified_at')).to eq( + user.active_profile.verified_at.iso8601, + ) + end + + it 'tracks IAL2 authentication events' do + stub_analytics + expect(@analytics).to receive(:track_event). + with('SAML Auth Request', + requested_ial: 'ialmax', + service_provider: sp1_issuer) + expect(@analytics).to receive(:track_event). + with(Analytics::SAML_AUTH, + success: true, + errors: {}, + nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + authn_context: ['http://idmanagement.gov/ns/assurance/ial/1'], + authn_context_comparison: 'minimum', + requested_ial: 'ialmax', + service_provider: sp1_issuer, + endpoint: '/api/saml/auth2022', + idv: false, + finish_profile: false) + expect(@analytics).to receive(:track_event). + with(Analytics::SP_REDIRECT_INITIATED, + ial: 0) + + allow(controller).to receive(:identity_needs_verification?).and_return(false) + saml_get_auth(ialmax_settings) + end + + context 'profile is not in session' do + let(:pii) { nil } + + it 'redirects to password capture if profile is verified but not in session' do + saml_get_auth(ialmax_settings) + expect(response).to redirect_to capture_password_url + end + end + end + context 'authn_context is invalid' do it 'renders an error page' do stub_analytics @@ -1753,8 +1873,6 @@ def stub_requested_attributes ial: 1) generate_saml_response(user) - - expect_sp_authentication_cost end end @@ -1789,8 +1907,6 @@ def stub_requested_attributes ial: 1) generate_saml_response(user) - - expect_sp_authentication_cost end end end @@ -1817,12 +1933,4 @@ def stub_requested_attributes expect(subject.external_saml_request?).to eq false end end - - def expect_sp_authentication_cost - sp_cost = SpCost.where( - issuer: 'http://localhost:3000', - cost_type: 'authentication', - ).first - expect(sp_cost).to be_present - end end diff --git a/spec/controllers/users/mfa_selection_controller_spec.rb b/spec/controllers/users/mfa_selection_controller_spec.rb new file mode 100644 index 00000000000..3cdeadece4f --- /dev/null +++ b/spec/controllers/users/mfa_selection_controller_spec.rb @@ -0,0 +1,157 @@ +require 'rails_helper' + +describe Users::MfaSelectionController do + let(:current_sp) { create(:service_provider) } + + describe '#index' do + before do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) + user = build(:user, :signed_up) + stub_sign_in(user) + end + + context 'when the user is using one authenticator option' do + it 'shows the mfa setup screen' do + controller.user_session[:selected_mfa_options] = ['backup_code'] + + get :index + + expect(response).to render_template(:index) + end + end + end + + describe '#update' do + it 'submits the TwoFactorOptionsForm' do + user = build(:user) + stub_sign_in_before_2fa(user) + + voice_params = { + two_factor_options_form: { + selection: 'voice', + }, + } + params = ActionController::Parameters.new(voice_params) + response = FormResponse.new(success: true, errors: {}, extra: { selection: ['voice'] }) + + form = instance_double(TwoFactorOptionsForm) + allow(TwoFactorOptionsForm).to receive(:new).with(user).and_return(form) + 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']) + + patch :update, params: voice_params + end + + context 'when the selection is phone' do + it 'redirects to phone setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'phone', + }, + } + + expect(response).to redirect_to phone_setup_url + end + end + + context 'when multi selection with phone first' do + it 'redirects properly' do + stub_sign_in_before_2fa + patch :update, params: { + two_factor_options_form: { + selection: ['phone', 'auth_app'], + }, + } + + expect(response).to redirect_to phone_setup_url + end + end + + context 'when multi selection with auth app first' do + it 'redirects properly' do + stub_sign_in_before_2fa + patch :update, params: { + two_factor_options_form: { + selection: ['auth_app', 'phone', 'webauthn'], + }, + } + + expect(response).to redirect_to authenticator_setup_url + end + end + + context 'when the selection is auth_app' do + it 'redirects to authentication app setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'auth_app', + }, + } + + expect(response).to redirect_to authenticator_setup_url + end + end + + context 'when the selection is webauthn' do + it 'redirects to webauthn setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'webauthn', + }, + } + + expect(response).to redirect_to webauthn_setup_url + end + end + + context 'when the selection is webauthn platform authenticator' do + it 'redirects to webauthn setup page with the platform param' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'webauthn_platform', + }, + } + + expect(response).to redirect_to webauthn_setup_url(platform: true) + end + end + + context 'when the selection is piv_cac' do + it 'redirects to piv/cac setup page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'piv_cac', + }, + } + + expect(response).to redirect_to setup_piv_cac_url + end + end + + context 'when the selection is not valid' do + it 'renders index page' do + stub_sign_in_before_2fa + + patch :update, params: { + two_factor_options_form: { + selection: 'foo', + }, + } + + expect(response).to render_template(:index) + end + end + end +end 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 96c11d000d8..fb056c55394 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -57,6 +57,7 @@ it 'submits the TwoFactorOptionsForm' do user = build(:user) stub_sign_in_before_2fa(user) + stub_analytics voice_params = { two_factor_options_form: { @@ -74,6 +75,8 @@ expect(form).to receive(:selection).and_return(['voice']) patch :create, params: voice_params + + expect(@analytics).to have_logged_event(Analytics::USER_REGISTRATION_2FA_SETUP, response.to_h) end it 'tracks analytics event' do @@ -81,7 +84,7 @@ stub_analytics result = { - selection: ['voice'], + selection: ['voice', 'auth_app'], success: true, errors: {}, } @@ -91,13 +94,14 @@ patch :create, params: { two_factor_options_form: { - selection: 'voice', + selection: ['voice', 'auth_app'], }, } end - context 'when the selection is phone' do - it 'redirects to phone setup page' do + 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) stub_sign_in_before_2fa patch :create, params: { @@ -105,8 +109,15 @@ selection: 'phone', }, } + end - expect(response).to redirect_to phone_setup_url + it 'the redirect to the form page with an anchor' do + expect(response).to redirect_to(two_factor_options_path(anchor: 'select_phone')) + end + it 'contains a flash message' do + expect(flash[:phone_error]).to eq( + t('errors.two_factor_auth_setup.must_select_additional_option'), + ) end end diff --git a/spec/controllers/verify_controller_spec.rb b/spec/controllers/verify_controller_spec.rb index 573506c2454..c785a50ce87 100644 --- a/spec/controllers/verify_controller_spec.rb +++ b/spec/controllers/verify_controller_spec.rb @@ -2,6 +2,7 @@ describe VerifyController do describe '#show' do + let(:idv_api_enabled_steps) { [] } let(:password) { 'sekrit phrase' } let(:user) { create(:user, :signed_up, password: password) } let(:applicant) do @@ -16,37 +17,106 @@ } end let(:profile) { subject.idv_session.profile } + let(:step) { '' } - subject(:response) { get :show } + subject(:response) { get :show, params: { step: step } } before do - stub_sign_in + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). + and_return(idv_api_enabled_steps) + stub_sign_in(user) stub_idv_session end - context 'with step feature-disabled' do - before do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return([]) - end + it 'renders 404' do + expect(response).to be_not_found + end + + context 'with idv api enabled' do + let(:idv_api_enabled_steps) { ['something'] } + + context 'invalid step' do + let(:step) { 'bad' } - it 'renders 404' do - expect(response).to be_not_found + it 'renders 404' do + expect(response).to be_not_found + end end - end - context 'with step feature-enabled' do - before do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). - and_return([:personal_key, :personal_key_confirm]) + context 'with personal key step enabled' do + let(:idv_api_enabled_steps) { ['personal_key', 'personal_key_confirm'] } + let(:step) { 'personal_key' } + + before do + profile_maker = Idv::ProfileMaker.new( + applicant: applicant, + user: user, + user_password: password, + ) + profile = profile_maker.save_profile + controller.idv_session.pii = profile_maker.pii_attributes + controller.idv_session.profile_id = profile.id + controller.idv_session.personal_key = profile.personal_key + end + + it 'renders view' do + expect(response).to render_template(:show) + end + + it 'sets app data' do + response + + expect(assigns[:app_data]).to include( + app_name: APP_NAME, + base_path: idv_app_path, + completion_url: idv_gpo_verify_url, + enabled_step_names: idv_api_enabled_steps, + initial_values: { 'personalKey' => kind_of(String) }, + store_key: kind_of(String), + ) + end + + context 'empty step' do + let(:step) { nil } + + it 'redirects to first step' do + expect(response).to redirect_to idv_app_path(step: 'personal_key') + end + end end - it 'renders view' do - expect(response).to render_template(:show) + context 'with password confirmation step enabled' do + let(:idv_api_enabled_steps) { ['password_confirm', 'personal_key', 'personal_key_confirm'] } + let(:step) { 'password_confirm' } + + it 'renders view' do + expect(response).to render_template(:show) + end + + it 'sets app data' do + response + + expect(assigns[:app_data]).to include( + app_name: APP_NAME, + base_path: idv_app_path, + completion_url: account_url, + enabled_step_names: idv_api_enabled_steps, + initial_values: { 'userBundleToken' => kind_of(String) }, + store_key: kind_of(String), + ) + end + + context 'empty step' do + let(:step) { nil } + + it 'redirects to first step' do + expect(response).to redirect_to idv_app_path(step: 'password_confirm') + end + end end end def stub_idv_session - stub_sign_in(user) idv_session = Idv::Session.new( user_session: controller.user_session, current_user: user, @@ -54,15 +124,6 @@ def stub_idv_session ) idv_session.applicant = applicant idv_session.resolution_successful = true - profile_maker = Idv::ProfileMaker.new( - applicant: applicant, - user: user, - user_password: password, - ) - profile = profile_maker.save_profile - idv_session.pii = profile_maker.pii_attributes - idv_session.profile_id = profile.id - idv_session.personal_key = profile.personal_key allow(controller).to receive(:idv_session).and_return(idv_session) end end diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb index 1b06be0a036..4f0cdb1414d 100644 --- a/spec/factories/profiles.rb +++ b/spec/factories/profiles.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :profile do association :user, factory: %i[user signed_up] + transient do pii { false } end @@ -18,6 +19,28 @@ deactivation_reason { :password_reset } end + trait :with_liveness do + proofing_components { { liveness_check: 'vendor' } } + end + + trait :with_pii do + pii do + DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + ssn: DocAuthHelper::GOOD_SSN, + phone: '+1 (555) 555-1234', + ) + end + end + + trait :with_pii do + pii do + DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + ssn: DocAuthHelper::GOOD_SSN, + phone: '+1 (555) 555-1234', + ) + end + end + after(:build) do |profile, evaluator| if evaluator.pii pii_attrs = Pii::Attributes.new_from_hash(evaluator.pii) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 0ae6fca1948..1bfa7e8a2cc 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -187,5 +187,13 @@ confirmation_token { 'token' } password { nil } end + + trait :proofed do + signed_up + + after :build do |user| + create(:profile, :active, :verified, :with_pii, user: user) + end + end end end diff --git a/spec/features/accessibility/idv_pages_spec.rb b/spec/features/accessibility/idv_pages_spec.rb index 7188ee54ca8..07892a34d88 100644 --- a/spec/features/accessibility/idv_pages_spec.rb +++ b/spec/features/accessibility/idv_pages_spec.rb @@ -57,7 +57,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to eq idv_personal_key_path + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled @@ -71,7 +71,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to eq idv_personal_key_path + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled @@ -85,7 +85,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - expect(current_path).to eq idv_personal_key_path + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) expect(page).to be_axe_clean.according_to :section508, :"best-practice", :wcag21aa expect(page).to label_required_fields expect(page).to be_uniquely_titled diff --git a/spec/features/ialmax/saml_sign_in_spec.rb b/spec/features/ialmax/saml_sign_in_spec.rb new file mode 100644 index 00000000000..c5925ff6f8e --- /dev/null +++ b/spec/features/ialmax/saml_sign_in_spec.rb @@ -0,0 +1,157 @@ +require 'rails_helper' + +feature 'SAML IALMAX sign in' do + include SamlAuthHelper + + context 'with an ial2 SP' do + context 'with an ial1 user' do + scenario 'piv sign in' do + user = user_with_piv_cac + visit_idp_from_saml_sp_with_ialmax + signin_with_piv(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + + scenario 'password sign in' do + user = create(:user, :signed_up) + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + end + + context 'with an ial2 user' do + scenario 'piv sign in' do + pii = { phone: '+12025555555', ssn: '111111111' } + user = create(:profile, :active, :verified, pii: pii).user + user.piv_cac_configurations.create(x509_dn_uuid: 'helloworld', name: 'My PIV Card') + visit_idp_from_saml_sp_with_ialmax + signin_with_piv(user) + fill_in 'user[password]', with: user.password + click_submit_default + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.not_to raise_exception + expect(xmldoc.attribute_value_for(:ssn)).to eq('111111111') + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(2) + end + + scenario 'password sign in' do + pii = { phone: '+12025555555', ssn: '111111111' } + user = create(:profile, :active, :verified, pii: pii).user + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.not_to raise_exception + expect(xmldoc.attribute_value_for(:ssn)).to eq('111111111') + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(2) + end + end + + context 'with an inactive profile user' do + scenario 'piv sign in' do + user = create(:profile, :active, :verified).user + user.profiles.first.update!( + active: false, + deactivation_reason: :verification_cancelled, + ) + user.piv_cac_configurations.create(x509_dn_uuid: 'helloworld', name: 'My PIV Card') + visit_idp_from_saml_sp_with_ialmax + signin_with_piv(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + + scenario 'password sign in' do + user = create(:profile, :active, :verified).user + user.profiles.first.update!( + active: false, + deactivation_reason: :verification_cancelled, + ) + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + end + end + + context 'with an ial1 SP' do + before do + ServiceProvider. + find_by(issuer: 'saml_sp_ial2'). + update!(ial: 1) + end + + scenario 'returns an ial1 responses even with an ial2 user' do + pii = { phone: '+12025555555', ssn: '111111111' } + user = create(:profile, :active, :verified, pii: pii).user + visit_idp_from_saml_sp_with_ialmax + sign_in_live_with_2fa(user) + click_agree_and_continue + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + expect(xmldoc.attribute_value_for(:ial)).to eq( + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ) + expect { xmldoc.attribute_value_for(:ssn) }.to raise_exception + + sp_return_logs = SpReturnLog.where(user_id: user.id) + expect(sp_return_logs.count).to eq(1) + expect(sp_return_logs.first.ial).to eq(1) + end + end +end diff --git a/spec/features/idv/clearing_and_restarting_spec.rb b/spec/features/idv/clearing_and_restarting_spec.rb index 842c18bc649..05e6d9d5603 100644 --- a/spec/features/idv/clearing_and_restarting_spec.rb +++ b/spec/features/idv/clearing_and_restarting_spec.rb @@ -5,11 +5,11 @@ let(:user) { user_with_2fa } - context 'during GPO otp verification' do + context 'during GPO otp verification', js: true do before do start_idv_from_sp complete_idv_steps_with_gpo_before_confirmation_step(user) - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key end context 'before signing out' do diff --git a/spec/features/idv/gpo_disabled_spec.rb b/spec/features/idv/gpo_disabled_spec.rb index da29d4440b9..9f37df1be7c 100644 --- a/spec/features/idv/gpo_disabled_spec.rb +++ b/spec/features/idv/gpo_disabled_spec.rb @@ -17,7 +17,7 @@ Rails.application.reload_routes! end - it 'allows verification without the option to confirm address with usps' do + it 'allows verification without the option to confirm address with usps', js: true do user = user_with_2fa start_idv_from_sp complete_idv_steps_before_phone_step(user) @@ -36,7 +36,7 @@ click_submit_default fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) end diff --git a/spec/features/idv/proofing_components_spec.rb b/spec/features/idv/proofing_components_spec.rb index f39f0d8a1cb..dfd0a0e68f1 100644 --- a/spec/features/idv/proofing_components_spec.rb +++ b/spec/features/idv/proofing_components_spec.rb @@ -21,11 +21,10 @@ expect(current_path).to eq idv_doc_auth_step_path(step: :welcome) complete_all_doc_auth_steps - click_continue - expect(page).to have_current_path('/verify/review', wait: 5) + click_idv_continue fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key end context 'async proofing', js: true do @@ -51,7 +50,7 @@ end end - it 'clears the liveness enabled proofing component when a user re-proofs without liveness' do + it 'clears liveness enabled proofing component when user re-proofs without liveness', js: true do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) user = user_with_2fa sign_in_and_2fa_user(user) diff --git a/spec/features/idv/steps/confirmation_step_spec.rb b/spec/features/idv/steps/confirmation_step_spec.rb index b69c5a6dd1b..165e98c8e6e 100644 --- a/spec/features/idv/steps/confirmation_step_spec.rb +++ b/spec/features/idv/steps/confirmation_step_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'idv confirmation step' do +feature 'idv confirmation step', js: true do include IdvStepHelper it_behaves_like 'idv confirmation step' @@ -24,4 +24,33 @@ it_behaves_like 'personal key page' end + + context 'with idv app feature enabled' do + before do + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). + and_return(['personal_key', 'personal_key_confirm']) + end + + it_behaves_like 'idv confirmation step' + it_behaves_like 'idv confirmation step', :oidc + it_behaves_like 'idv confirmation step', :saml + + context 'personal key information and actions' do + before do + @user = sign_in_and_2fa_user + + visit idv_path + + complete_idv_steps_before_confirmation_step(@user) + end + + it 'allows the user to refresh and still displays the personal key' do + # Visit the current path is the same as refreshing + visit current_path + expect(page).to have_content(t('headings.personal_key')) + end + + it_behaves_like 'personal key page' + end + end end diff --git a/spec/features/idv/steps/gpo_step_spec.rb b/spec/features/idv/steps/gpo_step_spec.rb index c909468438d..4a360b63e1d 100644 --- a/spec/features/idv/steps/gpo_step_spec.rb +++ b/spec/features/idv/steps/gpo_step_spec.rb @@ -24,7 +24,7 @@ context 'the user has sent a letter but not verified an OTP' do let(:user) { user_with_2fa } - it 'allows the user to resend a letter and redirects to the come back later step' do + it 'allows the user to resend a letter and redirects to the come back later step', js: true do complete_idv_and_return_to_gpo_step expect { click_on t('idv.buttons.mail.resend') }. @@ -34,7 +34,7 @@ expect(page).to have_current_path(idv_come_back_later_path) end - it 'allows the user to return to gpo otp confirmation' do + it 'allows the user to return to gpo otp confirmation', js: true do complete_idv_and_return_to_gpo_step click_doc_auth_back_link @@ -49,7 +49,7 @@ def complete_idv_and_return_to_gpo_step click_on t('idv.buttons.mail.send') fill_in 'Password', with: user_password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key visit root_path click_on t('idv.buttons.cancel') first(:link, t('links.sign_out')).click diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb index d1f92c3b281..fb89d595d37 100644 --- a/spec/features/idv/steps/review_step_spec.rb +++ b/spec/features/idv/steps/review_step_spec.rb @@ -9,10 +9,12 @@ expect(current_path).to eq(root_path) end - it 'requires the user to enter the correct password to redirect to confirmation step' do + it 'requires the user to enter the correct password to redirect to confirmation step', js: true do start_idv_from_sp complete_idv_steps_before_review_step + click_on t('idv.messages.review.intro') + expect(page).to have_content('FAKEY') expect(page).to have_content('MCFAKERSON') expect(page).to have_content('1 FAKE RD') @@ -31,7 +33,7 @@ click_idv_continue expect(page).to have_content(t('headings.personal_key')) - expect(page).to have_current_path(idv_personal_key_path) + expect(current_path).to be_in([idv_personal_key_path, idv_app_path]) end context 'choosing to confirm address with phone' do diff --git a/spec/features/idv/strict_ial2/upgrade_spec.rb b/spec/features/idv/strict_ial2/upgrade_spec.rb index fff19b5612d..e41bc45294d 100644 --- a/spec/features/idv/strict_ial2/upgrade_spec.rb +++ b/spec/features/idv/strict_ial2/upgrade_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Strict IAL2 upgrade' do +feature 'Strict IAL2 upgrade', js: true do include IdvHelper include OidcAuthHelper include SamlAuthHelper @@ -22,10 +22,10 @@ expect(page.current_path).to eq(idv_doc_auth_welcome_step) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') @@ -35,7 +35,7 @@ context 'strict IAL2 does not allow a phone check' do before do allow(IdentityConfig.store).to receive( - :usps_upload_allowed_for_strict_ial2, + :gpo_allowed_for_strict_ial2, ).and_return(false) end @@ -54,10 +54,10 @@ expect(page.current_path).to eq(idv_doc_auth_welcome_step) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') diff --git a/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb new file mode 100644 index 00000000000..74d0a47ef5d --- /dev/null +++ b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +feature 'Strict IAL2 with usps upload disallowed', js: true do + include IdvHelper + include OidcAuthHelper + include IdvHelper + include IdvStepHelper + + before do + allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + allow(IdentityConfig.store).to receive( + :gpo_allowed_for_strict_ial2, + ).and_return(false) + end + + it 'does not allow the user to select the letter flow during proofing' do + user = create(:user, :signed_up) + visit_idp_from_oidc_sp_with_ial2_strict + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + complete_idv_steps_before_phone_step + + # Link is not present on the phone page + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Link is not present on the OTP delivery selection page + fill_out_phone_form_ok('7032231234') + click_idv_continue + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Link is not visible on the OTP entry page + choose_idv_otp_delivery_method_sms + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Link is not visible on error or warning page + visit idv_phone_errors_warning_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + visit idv_phone_errors_jobfail_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + visit idv_phone_errors_timeout_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + visit idv_phone_errors_failure_path + expect(page).to_not have_content(t('idv.troubleshooting.options.verify_by_mail')) + + # Visiting the GPO page redirects + visit idv_gpo_path + expect(current_path).to eq(idv_phone_path) + end + + it 'does not prompt a pending user for a mailed code' do + user = create( + :profile, + deactivation_reason: :verification_pending, + pii: { first_name: 'John', ssn: '111223333' }, + ).user + + visit_idp_from_oidc_sp_with_ial2_strict + sign_in_user(user) + fill_in_code_with_last_phone_otp + click_submit_default + + # Directed to the start of the proofing flow instead of GPO code verification + expect(current_path).to eq(idv_doc_auth_step_path(step: :welcome)) + + complete_all_doc_auth_steps + click_idv_continue + fill_in 'Password', with: user.password + click_continue + acknowledge_and_confirm_personal_key + click_agree_and_continue + + expect(current_url).to start_with('http://localhost:7654/auth/result') + expect(user.active_profile.strict_ial2_proofed?).to be_truthy + end +end diff --git a/spec/features/idv/uak_password_spec.rb b/spec/features/idv/uak_password_spec.rb index f9ed09daebe..e32ee8f0067 100644 --- a/spec/features/idv/uak_password_spec.rb +++ b/spec/features/idv/uak_password_spec.rb @@ -3,7 +3,7 @@ feature 'A user with a UAK passwords attempts IdV' do include IdvStepHelper - it 'allows the user to continue to the SP' do + it 'allows the user to continue to the SP', js: true do user = user_with_2fa user.update!( encrypted_password_digest: Encryption::UakPasswordVerifier.digest(user.password), @@ -12,7 +12,7 @@ start_idv_from_sp(:oidc) complete_idv_steps_with_phone_before_confirmation_step(user) - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) diff --git a/spec/features/multi_factor_authentication/mfa_cta_spec.rb b/spec/features/multi_factor_authentication/mfa_cta_spec.rb index 317bd6a32d2..9ae73ef5f21 100644 --- a/spec/features/multi_factor_authentication/mfa_cta_spec.rb +++ b/spec/features/multi_factor_authentication/mfa_cta_spec.rb @@ -56,11 +56,12 @@ it 'redirects user to select additional authentication methods' do visit_idp_from_sp_with_ial1(:oidc) sign_up_and_set_password - select_2fa_option('backup_code') + check t('two_factor_authentication.two_factor_choice_options.backup_code') click_continue - expect(page).to have_current_path(sign_up_completed_path) - click_on(t('mfa.second_method_warning.link')) - expect(page).to have_content(t('two_factor_authentication.two_factor_choice')) + + set_up_mfa_with_backup_codes + click_link(t('mfa.second_method_warning.link')) + expect(page).to have_current_path(mfa_setup_path) end end end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 5ccfe10a81a..d5a15efd7cf 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -244,7 +244,7 @@ to include('Verified within value must be at least 30 days or older') end - it 'sends the user through idv again via verified_within param' do + it 'sends the user through idv again via verified_within param', js: true do client_id = 'urn:gov:gsa:openidconnect:sp:server' user = user_with_2fa _profile = create( @@ -270,7 +270,7 @@ fill_in t('idv.form.password'), with: Features::SessionHelper::VALID_PASSWORD click_continue - acknowledge_and_confirm_personal_key(js: false) + acknowledge_and_confirm_personal_key end, handoff_page_steps: proc do expect(page).to have_content(t('help_text.requested_attributes.verified_at')) @@ -281,14 +281,16 @@ access_token = token_response[:access_token] expect(access_token).to be_present - page.driver.get api_openid_connect_userinfo_path, - {}, - 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" + Capybara.using_driver(:desktop_rack_test) do + page.driver.get api_openid_connect_userinfo_path, + {}, + 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" - userinfo_response = JSON.parse(page.body).with_indifferent_access - expect(userinfo_response[:email]).to eq(user.email) - expect(userinfo_response[:verified_at]).to be > 60.days.ago.to_i - expect(userinfo_response[:verified_at]).to eq(user.active_profile.verified_at.to_i) + userinfo_response = JSON.parse(page.body).with_indifferent_access + expect(userinfo_response[:email]).to eq(user.email) + expect(userinfo_response[:verified_at]).to be > 60.days.ago.to_i + expect(userinfo_response[:verified_at]).to eq(user.active_profile.verified_at.to_i) + end end it 'prompts for consent if last consent time was over a year ago', driver: :mobile_rack_test do @@ -687,13 +689,15 @@ def sign_in_get_token_response( code = redirect_params[:code] expect(code).to be_present - page.driver.post api_openid_connect_token_path, - grant_type: 'authorization_code', - code: code, - code_verifier: code_verifier - expect(page.status_code).to eq(200) + Capybara.using_driver(:desktop_rack_test) do + page.driver.post api_openid_connect_token_path, + grant_type: 'authorization_code', + code: code, + code_verifier: code_verifier + expect(page.status_code).to eq(200) - JSON.parse(page.body).with_indifferent_access + JSON.parse(page.body).with_indifferent_access + end end def certs_response diff --git a/spec/features/reports/authorization_count_spec.rb b/spec/features/reports/authorization_count_spec.rb index 08899e1c8da..e092f15f33a 100644 --- a/spec/features/reports/authorization_count_spec.rb +++ b/spec/features/reports/authorization_count_spec.rb @@ -38,8 +38,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) include OidcAuthHelper include DocAuthHelper - let(:email) { 'test@test.com' } - let(:password) { RequestHelper::VALID_PASSWORD } + let(:user) { nil } let(:today) { Time.zone.today } let(:client_id_1) { 'urn:gov:gsa:openidconnect:sp:server' } let(:client_id_2) { 'urn:gov:gsa:openidconnect:sp:server_two' } @@ -47,15 +46,16 @@ def visit_idp_from_ial2_saml_sp(issuer:) let(:issuer_2) { 'https://rp3.serviceprovider.com/auth/saml/metadata' } context 'an IAL1 user with an active session' do + let(:user) { create(:user, :signed_up) } + before do - create_ial1_user_from_sp(email) - reset_monthly_auth_count_and_login + reset_monthly_auth_count_and_login(user) end context 'using oidc' do it 'does not count second IAL1 auth at same sp' do visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial1_count_only(client_id_1) visit_idp_from_ial1_oidc_sp(client_id: client_id_1) @@ -65,11 +65,14 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts step up from IAL1 to IAL2 after proofing' do visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial1_count_only(client_id_1) + create(:profile, :active, :verified, :with_pii, user: user) visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - complete_proofing_steps + fill_in t('account.index.password'), with: user.password + click_submit_default + click_agree_and_continue expect_ial1_and_ial2_count(client_id_1) end @@ -79,10 +82,12 @@ def visit_idp_from_ial2_saml_sp(issuer:) expect_ial1_count_only(client_id_1) end - it 'proofs user and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + create(:profile, :active, :verified, :with_pii, :with_liveness, user: user) visit_idp_from_ial2_strict_oidc_sp(client_id: client_id_1) - reproof_for_ial2_strict + fill_in t('account.index.password'), with: user.password + click_submit_default click_agree_and_continue expect_ial2_count_only(client_id_1) end @@ -104,8 +109,11 @@ def visit_idp_from_ial2_saml_sp(issuer:) click_agree_and_continue expect_ial1_count_only(issuer_1) + create(:profile, :active, :verified, :with_pii, user: user) visit_idp_from_ial2_saml_sp(issuer: issuer_1) - complete_proofing_steps + fill_in t('account.index.password'), with: user.password + click_submit_default + click_agree_and_continue expect_ial1_and_ial2_count(issuer_1) end @@ -130,6 +138,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) end it 'counts IAL2 auth when ial2 strict is requested' do + create(:profile, :active, :verified, :with_pii, user: user) visit_saml_authn_request_url( overrides: { issuer: issuer_1, @@ -144,12 +153,15 @@ def visit_idp_from_ial2_saml_sp(issuer:) }, }, ) + fill_in t('account.index.password'), with: user.password + click_submit_default click_agree_and_continue expect_ial2_count_only(issuer_1) end - it 'proofs the user and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + create(:profile, :active, :verified, :with_pii, :with_liveness, user: user) visit_saml_authn_request_url( overrides: { issuer: issuer_1, @@ -164,7 +176,8 @@ def visit_idp_from_ial2_saml_sp(issuer:) }, }, ) - reproof_for_ial2_strict + fill_in t('account.index.password'), with: user.password + click_submit_default click_agree_and_continue expect_ial2_count_only(issuer_1) end @@ -173,15 +186,16 @@ def visit_idp_from_ial2_saml_sp(issuer:) end context 'an IAL2 user with an active session' do + let(:user) { create(:user, :proofed) } + before do - create_ial2_user_from_sp(email) - reset_monthly_auth_count_and_login + reset_monthly_auth_count_and_login(user) end context 'using oidc' do it 'counts IAL1 auth at same sp' do visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) visit_idp_from_ial1_oidc_sp(client_id: client_id_1) @@ -191,7 +205,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'does not count second IAL2 auth at same sp' do visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) visit_idp_from_ial2_oidc_sp(client_id: client_id_1) @@ -211,7 +225,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts IAL2 auth at another sp' do visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) visit_idp_from_ial2_oidc_sp(client_id: client_id_2) @@ -221,7 +235,7 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts IAL1 auth at another sp' do visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial1_count_only(client_id_1) visit_idp_from_ial1_oidc_sp(client_id: client_id_2) @@ -231,14 +245,14 @@ def visit_idp_from_ial2_saml_sp(issuer:) it 'counts IAL2 auth when ial max is requested' do visit_idp_from_ial_max_oidc_sp(client_id: client_id_1) - click_continue + click_agree_and_continue expect_ial2_count_only(client_id_1) end - it 're-proofs and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + user.active_profile.update(proofing_components: { liveness_check: 'vendor' }) visit_idp_from_ial2_strict_oidc_sp(client_id: client_id_1) - reproof_for_ial2_strict click_agree_and_continue expect_ial2_count_only(client_id_1) end @@ -315,8 +329,9 @@ def visit_idp_from_ial2_saml_sp(issuer:) expect_ial2_count_only(issuer_1) end - it 're-proofs and counts IAL2 auth when ial2 strict is requested' do + it 'counts IAL2 auth when ial2 strict is requested' do allow(IdentityConfig.store).to receive(:liveness_checking_enabled).and_return(true) + user.active_profile.update(proofing_components: { liveness_check: 'vendor' }) visit_saml_authn_request_url( overrides: { issuer: issuer_1, @@ -331,7 +346,6 @@ def visit_idp_from_ial2_saml_sp(issuer:) }, }, ) - reproof_for_ial2_strict click_agree_and_continue expect_ial2_count_only(issuer_1) end @@ -377,10 +391,10 @@ def ial1_monthly_auth_count(client_id) Db::MonthlySpAuthCount::SpMonthTotalAuthCounts.call(today, client_id, 1) end - def reset_monthly_auth_count_and_login + def reset_monthly_auth_count_and_login(user) MonthlySpAuthCount.delete_all SpReturnLog.delete_all - visit api_saml_logout2022_url - fill_in_credentials_and_submit(email, RequestHelper::VALID_PASSWORD) + visit api_saml_logout2022_path + sign_in_live_with_2fa(user) end end diff --git a/spec/features/saml/ial2_sso_spec.rb b/spec/features/saml/ial2_sso_spec.rb index 381b24ed922..49f4be230ed 100644 --- a/spec/features/saml/ial2_sso_spec.rb +++ b/spec/features/saml/ial2_sso_spec.rb @@ -29,7 +29,7 @@ def perform_id_verification_with_gpo_without_confirming_code(user) click_on t('idv.buttons.mail.send') fill_in t('idv.form.password'), with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_link t('idv.buttons.continue_plain') end @@ -48,7 +48,7 @@ def update_mailing_address click_on t('idv.buttons.mail.resend') fill_in t('idv.form.password'), with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_link t('idv.buttons.continue_plain') end @@ -90,7 +90,7 @@ def sign_out_user ) end - context 'having previously selected USPS verification' do + context 'having previously selected USPS verification', js: true do let(:phone_confirmed) { false } context 'provides an option to send another letter' do diff --git a/spec/features/saml/multiple_endpoints_spec.rb b/spec/features/saml/multiple_endpoints_spec.rb index 8bf33432a68..8b9e2d62069 100644 --- a/spec/features/saml/multiple_endpoints_spec.rb +++ b/spec/features/saml/multiple_endpoints_spec.rb @@ -81,17 +81,45 @@ expect(cert_base64).to eq(Base64.strict_encode64(endpoint_cert.to_der)) end - it 'includes the correct auth url, and no SingleLogoutService urls' do + it 'includes the correct auth url' do visit endpoint_metadata_path document = REXML::Document.new(page.html) auth_node = REXML::XPath.first(document, '//SingleSignOnService') - logout_node = REXML::XPath.first(document, '//SingleLogoutService') expect(auth_node.attributes['Location']).to include( ['/api/saml/auth', endpoint_suffix].join(''), ) + end + + it 'does not include logout urls if configured' do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(false) + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count).to be_zero + end - expect(logout_node).to be_nil + context 'when configured to include logout endpoints' do + before do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(true) + end + + it 'includes the front-channel logout url' do + visit endpoint_metadata_path + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/logout\d{4}}) }). + to eq(2) + end + + it 'includes the remote logout url' do + visit endpoint_metadata_path + document = REXML::Document.new(page.html) + logout_nodes = REXML::XPath.match(document, '//SingleLogoutService') + expect(logout_nodes.count { |n| n['Location'].match?(%r{/api/saml/remotelogout\d{4}}) }). + to eq(1) + end end end end diff --git a/spec/features/sp_cost_tracking_spec.rb b/spec/features/sp_cost_tracking_spec.rb index a239452995e..bb1ad8ae172 100644 --- a/spec/features/sp_cost_tracking_spec.rb +++ b/spec/features/sp_cost_tracking_spec.rb @@ -12,35 +12,24 @@ let(:email) { 'test@test.com' } let(:password) { Features::SessionHelper::VALID_PASSWORD } - it 'logs the correct costs for an ial1 user creation from sp with oidc' do - create_ial1_user_from_sp(email) - - expect_sp_cost_type(0, 1, 'sms') - expect_sp_cost_type(1, 1, 'user_added') - expect_sp_cost_type(2, 1, 'authentication') - end - - it 'logs the correct costs for an ial2 user creation from sp with oidc' do + it 'logs the correct costs for an ial2 user creation from sp with oidc', js: true do create_ial2_user_from_sp(email) - expect_sp_cost_type(0, 2, 'sms') - expect_sp_cost_type(1, 2, 'acuant_front_image') - expect_sp_cost_type(2, 2, 'acuant_back_image') - expect_sp_cost_type(3, 2, 'acuant_result') + expect_sp_cost_type(0, 2, 'acuant_front_image') + expect_sp_cost_type(1, 2, 'acuant_back_image') + expect_sp_cost_type(2, 2, 'acuant_result') expect_sp_cost_type( - 4, 2, 'lexis_nexis_resolution', + 3, 2, 'lexis_nexis_resolution', transaction_id: Proofing::Mock::ResolutionMockClient::TRANSACTION_ID ) expect_sp_cost_type( - 5, 2, 'aamva', + 4, 2, 'aamva', transaction_id: Proofing::Mock::StateIdMockClient::TRANSACTION_ID ) - expect_sp_cost_type(6, 2, 'lexis_nexis_address') - expect_sp_cost_type(7, 2, 'user_added') - expect_sp_cost_type(8, 2, 'authentication') + expect_sp_cost_type(5, 2, 'lexis_nexis_address') end - it 'logs the cost to the SP for reproofing' do + it 'logs the cost to the SP for reproofing', js: true do create_ial2_user_from_sp(email) # track costs without dealing with 'remember device' @@ -54,10 +43,10 @@ fill_in_code_with_last_phone_otp click_submit_default complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue %w[ @@ -76,56 +65,6 @@ end end - it 'logs the correct costs for an ial1 authentication' do - create_ial1_user_from_sp(email) - SpCost.delete_all - - # track costs without dealing with 'remember device' - Capybara.reset_session! - - visit_idp_from_sp_with_ial1(:oidc) - fill_in_credentials_and_submit(email, password) - fill_in_code_with_last_phone_otp - click_submit_default - - expect_sp_cost_type(0, 1, 'digest') - expect_sp_cost_type(1, 1, 'sms') - expect_sp_cost_type(2, 1, 'authentication') - end - - it 'logs the correct costs for an ial2 authentication' do - create_ial2_user_from_sp(email) - SpCost.delete_all - - # track costs without dealing with 'remember device' - Capybara.reset_session! - - visit_idp_from_sp_with_ial2(:oidc) - fill_in_credentials_and_submit(email, password) - fill_in_code_with_last_phone_otp - click_submit_default - - expect_sp_cost_type(0, 2, 'digest') - expect_sp_cost_type(1, 2, 'sms') - expect_sp_cost_type(2, 2, 'authentication') - end - - it 'logs the correct costs for a direct authentication' do - visit root_path - create_ial1_user_directly(email) - SpCost.delete_all - - # track costs without dealing with 'remember device' - Capybara.reset_session! - - visit root_path - fill_in_credentials_and_submit(email, password) - fill_in_code_with_last_phone_otp - click_submit_default - - expect_direct_cost_type(0, 'digest') - end - def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) sp_cost = sp_costs(sp_cost_index) expect(sp_cost.ial).to eq(ial) @@ -135,13 +74,6 @@ def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) expect(sp_cost.transaction_id).to(eq(transaction_id)) if transaction_id end - def expect_direct_cost_type(sp_cost_index, token) - sp_cost = sp_costs(sp_cost_index) - expect(sp_cost.issuer).to eq('') - expect(sp_cost.agency_id).to eq(0) - expect(sp_cost.cost_type).to eq(token) - end - def sp_costs(index) SpCost.order('id asc')[index] 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 921d5f03681..82b1af21c26 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 @@ -76,6 +76,34 @@ end end + describe 'user attempts to submit with only the phone MFA method selected', js: true do + before do + sign_in_before_2fa + click_2fa_option('phone') + click_on t('forms.buttons.continue') + end + + 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')) + expect( + URI.parse(current_url).path + '#' + URI.parse(current_url).fragment, + ).to eq two_factor_options_path(anchor: 'select_phone') + end + + 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')) + end + + scenario 'clears the error when phone mfa method is unselected' do + click_2fa_option('phone') + expect(page). + to_not have_content(t('errors.two_factor_auth_setup.must_select_additional_option')) + end + end + def click_2fa_option(option) find("label[for='two_factor_options_form_selection_#{option}']").click end diff --git a/spec/features/users/regenerate_personal_key_spec.rb b/spec/features/users/regenerate_personal_key_spec.rb index c6fe1afd4be..95acd31d4d2 100644 --- a/spec/features/users/regenerate_personal_key_spec.rb +++ b/spec/features/users/regenerate_personal_key_spec.rb @@ -31,7 +31,7 @@ end context 'regenerating new code after canceling edit password action' do - scenario 'displays new code' do + scenario 'displays new code', js: true do sign_in_and_2fa_user(user) old_digest = user.encrypted_recovery_code_digest @@ -53,7 +53,7 @@ click_continue expect(page).to have_content(t('headings.personal_key')) - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(user.reload.encrypted_recovery_code_digest).to_not eq old_digest end @@ -140,8 +140,7 @@ def sign_up_and_view_personal_key end def expect_confirmation_modal_to_appear_with_first_code_field_in_focus - expect(page).not_to have_xpath("//div[@id='personal-key-confirm'][@class='display-none']") - expect(page.evaluate_script('document.activeElement.name')).to eq 'personal_key' + expect(page.find(':focus')).to eq page.find_field(t('forms.personal_key.confirmation_label')) end def click_back_button @@ -158,14 +157,14 @@ def expect_to_be_back_on_manage_personal_key_page_with_continue_button_in_focus end def submit_form_without_entering_the_code - click_on t('forms.buttons.continue'), class: 'personal-key-confirm' - expect(page).to have_selector('.validation-message') - expect(page).not_to have_selector('#personal-key-alert') + within('[role=dialog]') { click_continue } + expect(page).to have_content(t('simple_form.required.text')) + expect(page).not_to have_content(t('users.personal_key.confirmation_error')) end def submit_form_with_the_wrong_code fill_in 'personal_key', with: 'hellohellohello' - click_on t('forms.buttons.continue'), class: 'personal-key-confirm' + within('[role=dialog]') { click_continue } expect(page).to have_content(t('users.personal_key.confirmation_error')) - expect(page).not_to have_selector('.validation-message') + expect(page).not_to have_content(t('simple_form.required.text')) end diff --git a/spec/features/users/user_profile_spec.rb b/spec/features/users/user_profile_spec.rb index 1e598c120ed..4546f34cbd9 100644 --- a/spec/features/users/user_profile_spec.rb +++ b/spec/features/users/user_profile_spec.rb @@ -144,7 +144,7 @@ expect(page).to have_content(t('idv.messages.personal_key')) end - it 'allows the user reactivate their profile by reverifying' do + it 'allows the user reactivate their profile by reverifying', js: true do profile = create(:profile, :active, :verified, pii: { ssn: '1234', dob: '1920-01-01' }) user = profile.user @@ -158,7 +158,7 @@ click_idv_continue fill_in 'Password', with: user_password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_path).to eq(sign_up_completed_path) diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 35f9ac55e02..06462763fca 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe Idv::ApiImageUploadForm do + include AnalyticsEvents + subject(:form) do Idv::ApiImageUploadForm.new( ActionController::Parameters.new( @@ -103,21 +105,12 @@ form.submit expect(fake_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, - exception: nil, - doc_auth_result: 'Passed', - billed: true, attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, - state: 'MT', - state_id_type: 'drivers_license', + remaining_attempts: 3, user_id: document_capture_session.user.uuid, - client_image_metrics: { - front: JSON.parse(front_image_metadata, symbolize_names: true), - back: JSON.parse(back_image_metadata, symbolize_names: true), - }, ) end end @@ -144,18 +137,12 @@ form.submit expect(fake_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload form submitted', success: true, errors: {}, - exception: nil, - doc_auth_result: 'Passed', - billed: true, attempts: 1, - remaining_attempts: IdentityConfig.store.doc_auth_max_attempts - 1, + remaining_attempts: 3, user_id: document_capture_session.user.uuid, - client_image_metrics: { - front: JSON.parse(front_image_metadata, symbolize_names: true), - }, ) end end diff --git a/spec/forms/two_factor_options_form_spec.rb b/spec/forms/two_factor_options_form_spec.rb index bdc95b81d5c..b15ed8d6286 100644 --- a/spec/forms/two_factor_options_form_spec.rb +++ b/spec/forms/two_factor_options_form_spec.rb @@ -6,22 +6,33 @@ describe '#submit' do it 'is successful if the selection is valid' do - %w[voice sms auth_app piv_cac webauthn webauthn_platform].each do |selection| + %w[auth_app piv_cac webauthn webauthn_platform].each do |selection| result = subject.submit(selection: selection) expect(result.success?).to eq true end end + it 'is unsuccessful if the selection is invalid for multi mfa' do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) + %w[phone sms voice !!!!].each do |selection| + result = subject.submit(selection: selection) + + expect(result.success?).to eq false + end + end + it 'is unsuccessful if the selection is invalid' do - result = subject.submit(selection: '!!!!') + %w[!!!!].each do |selection| + result = subject.submit(selection: selection) - expect(result.success?).to eq false - expect(result.errors).to include :selection + expect(result.success?).to eq false + expect(result.errors).to include :selection + end end context "when the selection is different from the user's otp_delivery_preference" do - it "updates the user's otp_delivery_preference" do + it "updates the user's otp_delivery_preference if they have an alternate method selected" do user_updater = instance_double(UpdateUser) allow(UpdateUser). to receive(:new). @@ -32,7 +43,7 @@ and_return(user_updater) expect(user_updater).to receive(:call) - subject.submit(selection: 'voice') + subject.submit(selection: ['voice', 'backup_code']) end end diff --git a/spec/javascripts/app/components/modal-spec.js b/spec/javascripts/app/components/modal-spec.js index 64783c3b429..e1a5104282e 100644 --- a/spec/javascripts/app/components/modal-spec.js +++ b/spec/javascripts/app/components/modal-spec.js @@ -1,6 +1,6 @@ import { waitFor } from '@testing-library/dom'; +import { useSandbox } from '@18f/identity-test-helpers'; import BaseModal from '../../../../app/javascript/app/components/modal'; -import { useSandbox } from '../../support/sinon'; describe('components/modal', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/app/webauthn_spec.js b/spec/javascripts/app/webauthn_spec.js index 090a621ad5f..1324995ff08 100644 --- a/spec/javascripts/app/webauthn_spec.js +++ b/spec/javascripts/app/webauthn_spec.js @@ -1,5 +1,5 @@ import { TextEncoder } from 'util'; -import { useSandbox } from '../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; import * as WebAuthn from '../../../app/javascript/app/webauthn'; describe('WebAuthn', () => { diff --git a/spec/javascripts/packages/analytics/index-spec.js b/spec/javascripts/packages/analytics/index-spec.js index 8649be1be87..ff7e9ead970 100644 --- a/spec/javascripts/packages/analytics/index-spec.js +++ b/spec/javascripts/packages/analytics/index-spec.js @@ -1,5 +1,5 @@ import { trackEvent } from '@18f/identity-analytics'; -import { useSandbox } from '../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('trackEvent', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packages/components/alert-spec.jsx b/spec/javascripts/packages/components/alert-spec.jsx deleted file mode 100644 index cd9b5e852a9..00000000000 --- a/spec/javascripts/packages/components/alert-spec.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import { createRef } from 'react'; -import { Alert } from '@18f/identity-components'; -import { render } from '../../support/document-capture'; - -describe('identity-components/alert', () => { - it('should apply alert role', () => { - const { getByRole } = render(Uh oh!); - - const alert = getByRole('alert'); - - expect(alert).to.be.ok(); - }); - - it('accepts additional class names', () => { - const { getByRole } = render( - - Uh oh! - , - ); - - const alert = getByRole('alert'); - - expect(alert.classList.contains('my-class')).to.be.true(); - }); - - it('is optionally focusable', () => { - const { getByRole } = render(); - - const alert = getByRole('alert'); - alert.focus(); - - expect(document.activeElement).to.equal(alert); - }); - - it('forwards ref', () => { - const ref = createRef(); - const { container } = render(); - - expect(ref.current).to.equal(container.firstChild); - }); -}); diff --git a/spec/javascripts/packages/document-capture-polling/index-spec.js b/spec/javascripts/packages/document-capture-polling/index-spec.js index 3a0a0a2c3d6..293e7b0f135 100644 --- a/spec/javascripts/packages/document-capture-polling/index-spec.js +++ b/spec/javascripts/packages/document-capture-polling/index-spec.js @@ -5,7 +5,7 @@ import { MAX_DOC_CAPTURE_POLL_ATTEMPTS, DOC_CAPTURE_POLL_INTERVAL, } from '@18f/identity-document-capture-polling'; -import { useSandbox } from '../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('DocumentCapturePolling', () => { const sandbox = useSandbox({ useFakeTimers: true }); diff --git a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx index 472deefec35..8d4bd58d420 100644 --- a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx @@ -278,9 +278,9 @@ describe('document-capture/components/acuant-capture', () => { await findByText('doc_auth.errors.camera.failed'); expect(window.AcuantCameraUI.end).to.have.been.calledOnce(); expect(container.querySelector('.full-screen')).to.be.null(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Image capture failed', - payload: { field: 'test', error: 'Camera not supported' }, + expect(addPageAction).to.have.been.calledWith('IdV: Image capture failed', { + field: 'test', + error: 'Camera not supported', }); expect(document.activeElement).to.equal(button); }); @@ -313,9 +313,9 @@ describe('document-capture/components/acuant-capture', () => { await findByText('doc_auth.errors.upload_error errors.messages.try_again'); expect(window.AcuantCameraUI.end).to.have.been.calledOnce(); expect(container.querySelector('.full-screen')).to.be.null(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Image capture failed', - payload: { field: 'test', error: 'iOS 15 GPU Highwater failure (SEQUENCE_BREAK_CODE)' }, + expect(addPageAction).to.have.been.calledWith('IdV: Image capture failed', { + field: 'test', + error: 'iOS 15 GPU Highwater failure (SEQUENCE_BREAK_CODE)', }); await waitFor(() => document.activeElement === button); @@ -355,9 +355,9 @@ describe('document-capture/components/acuant-capture', () => { expect(window.AcuantCameraUI.end).to.eventually.be.called(), ]); expect(container.querySelector('.full-screen')).to.be.null(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Image capture failed', - payload: { field: 'test', error: 'User or system denied camera access' }, + expect(addPageAction).to.have.been.calledWith('IdV: Image capture failed', { + field: 'test', + error: 'User or system denied camera access', }); expect(document.activeElement).to.equal(button); }); @@ -573,27 +573,23 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(button); const error = await findByText('doc_auth.errors.glare.failed_short'); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.acuantWebSDKResult', - label: 'IdV: test image added', - payload: { - documentType: 'id', - mimeType: 'image/jpeg', - source: 'acuant', - dpi: 519, - moire: 99, - glare: 49, - height: 1104, - sharpnessScoreThreshold: sinon.match.number, - glareScoreThreshold: 50, - isAssessedAsBlurry: false, - isAssessedAsGlare: true, - assessment: 'glare', - sharpness: 100, - width: 1748, - attempt: sinon.match.number, - size: sinon.match.number, - }, + expect(addPageAction).to.have.been.calledWith('IdV: test image added', { + documentType: 'id', + mimeType: 'image/jpeg', + source: 'acuant', + dpi: 519, + moire: 99, + glare: 49, + height: 1104, + sharpnessScoreThreshold: sinon.match.number, + glareScoreThreshold: 50, + isAssessedAsBlurry: false, + isAssessedAsGlare: true, + assessment: 'glare', + sharpness: 100, + width: 1748, + attempt: sinon.match.number, + size: sinon.match.number, }); expect(error).to.be.ok(); @@ -631,27 +627,23 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(button); const error = await findByText('doc_auth.errors.sharpness.failed_short'); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.acuantWebSDKResult', - label: 'IdV: test image added', - payload: { - documentType: 'id', - mimeType: 'image/jpeg', - source: 'acuant', - dpi: 519, - moire: 99, - glare: 100, - height: 1104, - sharpnessScoreThreshold: 50, - glareScoreThreshold: sinon.match.number, - isAssessedAsBlurry: true, - isAssessedAsGlare: false, - assessment: 'blurry', - sharpness: 49, - width: 1748, - attempt: sinon.match.number, - size: sinon.match.number, - }, + expect(addPageAction).to.have.been.calledWith('IdV: test image added', { + documentType: 'id', + mimeType: 'image/jpeg', + source: 'acuant', + dpi: 519, + moire: 99, + glare: 100, + height: 1104, + sharpnessScoreThreshold: 50, + glareScoreThreshold: sinon.match.number, + isAssessedAsBlurry: true, + isAssessedAsGlare: false, + assessment: 'blurry', + sharpness: 49, + width: 1748, + attempt: sinon.match.number, + size: sinon.match.number, }); expect(error).to.be.ok(); @@ -742,27 +734,23 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(button); await waitFor(() => !error.textContent); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.acuantWebSDKResult', - label: 'IdV: test image added', - payload: { - documentType: 'id', - mimeType: 'image/jpeg', - source: 'acuant', - dpi: 519, - moire: 99, - glare: 100, - height: 1104, - sharpnessScoreThreshold: 50, - glareScoreThreshold: sinon.match.number, - isAssessedAsBlurry: true, - isAssessedAsGlare: false, - assessment: 'blurry', - sharpness: 49, - width: 1748, - attempt: sinon.match.number, - size: sinon.match.number, - }, + expect(addPageAction).to.have.been.calledWith('IdV: test image added', { + documentType: 'id', + mimeType: 'image/jpeg', + source: 'acuant', + dpi: 519, + moire: 99, + glare: 100, + height: 1104, + sharpnessScoreThreshold: 50, + glareScoreThreshold: sinon.match.number, + isAssessedAsBlurry: true, + isAssessedAsGlare: false, + assessment: 'blurry', + sharpness: 49, + width: 1748, + attempt: sinon.match.number, + size: sinon.match.number, }); }); @@ -1002,16 +990,13 @@ describe('document-capture/components/acuant-capture', () => { const input = getByLabelText('Image'); uploadFile(input, validUpload); - await expect(addPageAction).to.eventually.be.calledWith({ - label: 'IdV: test image added', - payload: { - height: sinon.match.number, - mimeType: 'image/jpeg', - source: 'upload', - width: sinon.match.number, - attempt: sinon.match.number, - size: sinon.match.number, - }, + await expect(addPageAction).to.eventually.be.calledWith('IdV: test image added', { + height: sinon.match.number, + mimeType: 'image/jpeg', + source: 'upload', + width: sinon.match.number, + attempt: sinon.match.number, + size: sinon.match.number, }); }); @@ -1045,26 +1030,17 @@ describe('document-capture/components/acuant-capture', () => { fireEvent.click(upload); expect(addPageAction).to.have.been.calledThrice(); - expect(addPageAction.getCall(0)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'placeholder', - isDrop: false, - }, + expect(addPageAction.getCall(0)).to.have.been.calledWith('IdV: test image clicked', { + source: 'placeholder', + isDrop: false, }); - expect(addPageAction.getCall(1)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'button', - isDrop: false, - }, + expect(addPageAction.getCall(1)).to.have.been.calledWith('IdV: test image clicked', { + source: 'button', + isDrop: false, }); - expect(addPageAction.getCall(2)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'upload', - isDrop: false, - }, + expect(addPageAction.getCall(2)).to.have.been.calledWith('IdV: test image clicked', { + source: 'upload', + isDrop: false, }); }); @@ -1081,12 +1057,9 @@ describe('document-capture/components/acuant-capture', () => { const input = getByLabelText('Image'); fireEvent.drop(input); - expect(addPageAction.getCall(0)).to.have.been.calledWith({ - label: 'IdV: test image clicked', - payload: { - source: 'placeholder', - isDrop: true, - }, + expect(addPageAction.getCall(0)).to.have.been.calledWith('IdV: test image clicked', { + source: 'placeholder', + isDrop: true, }); }); @@ -1103,16 +1076,16 @@ describe('document-capture/components/acuant-capture', () => { const input = getByLabelText('Image'); uploadFile(input, validUpload); - await expect(addPageAction).to.eventually.be.calledWith({ - label: 'IdV: test image added', - payload: sinon.match({ attempt: 1 }), - }); + await expect(addPageAction).to.eventually.be.calledWith( + 'IdV: test image added', + sinon.match({ attempt: 1 }), + ); uploadFile(input, validUpload); - await expect(addPageAction).to.eventually.be.calledWith({ - label: 'IdV: test image added', - payload: sinon.match({ attempt: 2 }), - }); + await expect(addPageAction).to.eventually.be.calledWith( + 'IdV: test image added', + sinon.match({ attempt: 2 }), + ); }); }); diff --git a/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx b/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx index f1f46400ef7..019ea912647 100644 --- a/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/capture-advice-spec.jsx @@ -14,22 +14,16 @@ describe('document-capture/components/capture-advice', () => { , ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning shown', - payload: { - location: 'doc_auth_capture_advice', - remaining_attempts: undefined, - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning shown', { + location: 'doc_auth_capture_advice', + remaining_attempts: undefined, }); const button = getByRole('button'); await userEvent.click(button); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning action triggered', - payload: { - location: 'doc_auth_capture_advice', - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning action triggered', { + location: 'doc_auth_capture_advice', }); }); }); diff --git a/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx b/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx index 08028bb9581..b608a59bf36 100644 --- a/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/capture-troubleshooting-spec.jsx @@ -89,17 +89,15 @@ describe('document-capture/context/capture-troubleshooting', () => { ); expect(addPageAction).to.have.been.calledTwice(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Capture troubleshooting shown', - payload: { isAssessedAsGlare: false, isAssessedAsBlurry: false }, + expect(addPageAction).to.have.been.calledWith('IdV: Capture troubleshooting shown', { + isAssessedAsGlare: false, + isAssessedAsBlurry: false, }); const tryAgainButton = getByRole('button', { name: 'idv.failure.button.warning' }); await userEvent.click(tryAgainButton); expect(addPageAction.callCount).to.equal(4); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Capture troubleshooting dismissed', - }); + expect(addPageAction).to.have.been.calledWith('IdV: Capture troubleshooting dismissed'); }); }); diff --git a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx index 01e838fafc3..30d56a94248 100644 --- a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx @@ -16,8 +16,8 @@ import DocumentCapture, { except, } from '@18f/identity-document-capture/components/document-capture'; import { expect } from 'chai'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render, useAcuant, useDocumentCaptureForm } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; import { getFixture, getFixtureFile } from '../../../support/file'; describe('document-capture/components/document-capture', () => { diff --git a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx index dc419c3b5d3..51c24ffc59d 100644 --- a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx @@ -7,8 +7,8 @@ import { } from '@18f/identity-document-capture'; import ReviewIssuesStep from '@18f/identity-document-capture/components/review-issues-step'; import { toFormEntryError } from '@18f/identity-document-capture/services/upload'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/review-issues-step', () => { @@ -23,22 +23,16 @@ describe('document-capture/components/review-issues-step', () => { , ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning shown', - payload: { - location: 'doc_auth_review_issues', - remaining_attempts: 3, - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning shown', { + location: 'doc_auth_review_issues', + remaining_attempts: 3, }); const button = getByRole('button'); await userEvent.click(button); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning action triggered', - payload: { - location: 'doc_auth_review_issues', - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning action triggered', { + location: 'doc_auth_review_issues', }); }); diff --git a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx index da9409a20b9..032c0b2810d 100644 --- a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx @@ -4,8 +4,8 @@ import { cleanup } from '@testing-library/react'; import { I18nContext } from '@18f/identity-react-i18n'; import { I18n } from '@18f/identity-i18n'; import SelfieCapture from '@18f/identity-document-capture/components/selfie-capture'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/selfie-capture', () => { diff --git a/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx b/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx index 70a5a75db8a..fe4161b7c78 100644 --- a/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/submission-complete-spec.jsx @@ -5,8 +5,8 @@ import SubmissionComplete, { RetrySubmissionError, } from '@18f/identity-document-capture/components/submission-complete'; import SuspenseErrorBoundary from '@18f/identity-document-capture/components/suspense-error-boundary'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render, useDocumentCaptureForm } from '../../../support/document-capture'; -import { useSandbox } from '../../../support/sinon'; describe('document-capture/components/submission-complete-step', () => { const onSubmit = useDocumentCaptureForm(); diff --git a/spec/javascripts/packages/document-capture/components/warning-spec.jsx b/spec/javascripts/packages/document-capture/components/warning-spec.jsx index 41f69178889..6726a4bcab9 100644 --- a/spec/javascripts/packages/document-capture/components/warning-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/warning-spec.jsx @@ -30,9 +30,9 @@ describe('document-capture/components/warning', () => { , ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning shown', - payload: { location: 'example', remaining_attempts: undefined }, + expect(addPageAction).to.have.been.calledWith('IdV: warning shown', { + location: 'example', + remaining_attempts: undefined, }); const tryAgainButton = getByRole('button', { name: 'Try again' }); @@ -41,11 +41,8 @@ describe('document-capture/components/warning', () => { expect(getByRole('heading', { name: 'Oops!' })).to.exist(); expect(tryAgainButton).to.exist(); expect(actionOnClick).to.have.been.calledOnce(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: warning action triggered', - payload: { - location: 'example', - }, + expect(addPageAction).to.have.been.calledWith('IdV: warning action triggered', { + location: 'example', }); expect(getByText('Something went wrong')).to.exist(); expect(getByRole('heading', { name: 'Having trouble?' })).to.exist(); diff --git a/spec/javascripts/packages/document-capture/context/acuant-spec.jsx b/spec/javascripts/packages/document-capture/context/acuant-spec.jsx index 84867f1403f..68170181661 100644 --- a/spec/javascripts/packages/document-capture/context/acuant-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/acuant-spec.jsx @@ -156,12 +156,9 @@ describe('document-capture/context/acuant', () => { }); it('logs', () => { - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: true, - isCameraSupported: true, - }, + expect(addPageAction).to.have.been.calledWith('IdV: Acuant SDK loaded', { + success: true, + isCameraSupported: true, }); }); }); @@ -179,12 +176,9 @@ describe('document-capture/context/acuant', () => { }); it('logs', () => { - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: true, - isCameraSupported: false, - }, + expect(addPageAction).to.have.been.calledWith('IdV: Acuant SDK loaded', { + success: true, + isCameraSupported: false, }); }); }); @@ -219,13 +213,10 @@ describe('document-capture/context/acuant', () => { }); it('logs', () => { - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: Acuant SDK loaded', - payload: { - success: false, - code: sinon.match.number, - description: sinon.match.string, - }, + expect(addPageAction).to.have.been.calledWith('IdV: Acuant SDK loaded', { + success: false, + code: sinon.match.number, + description: sinon.match.string, }); }); }); diff --git a/spec/javascripts/packages/document-capture/context/upload-spec.jsx b/spec/javascripts/packages/document-capture/context/upload-spec.jsx index 75ccdfa12cd..5d2ba0bb948 100644 --- a/spec/javascripts/packages/document-capture/context/upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/upload-spec.jsx @@ -4,7 +4,7 @@ import UploadContext, { Provider as UploadContextProvider, } from '@18f/identity-document-capture/context/upload'; import defaultUpload from '@18f/identity-document-capture/services/upload'; -import { useSandbox } from '../../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('document-capture/context/upload', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx index e6a1b108fca..bf8cb91ce8b 100644 --- a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx @@ -6,7 +6,7 @@ import withBackgroundEncryptedUpload, { blobToArrayBuffer, encrypt, } from '@18f/identity-document-capture/higher-order/with-background-encrypted-upload'; -import { useSandbox } from '../../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; /** @@ -189,15 +189,14 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => await onChange.getCall(0).args[0].foo_image_url; expect(addPageAction).to.have.been.calledTwice(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: document capture async upload encryption', - payload: { success: true }, - }); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.asyncUpload', - label: 'IdV: document capture async upload submitted', - payload: { success: true, trace_id: null, status_code: 200 }, - }); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload encryption', + { success: true }, + ); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload submitted', + { success: true, trace_id: null, status_code: 200 }, + ); }); }); @@ -248,10 +247,10 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => sinon.match.instanceOf(BackgroundEncryptedUploadError), { field: 'foo' }, ); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: document capture async upload encryption', - payload: { success: false }, - }); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload encryption', + { success: false }, + ); expect(noticeError).to.have.been.calledWith(error); expect(window.fetch).not.to.have.been.called(); }); @@ -272,19 +271,18 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => await onChange.getCall(0).args[0].foo_image_url.catch(() => {}); expect(addPageAction).to.have.been.calledTwice(); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: document capture async upload encryption', - payload: { success: true }, - }); - expect(addPageAction).to.have.been.calledWith({ - key: 'documentCapture.asyncUpload', - label: 'IdV: document capture async upload submitted', - payload: { + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload encryption', + { success: true }, + ); + expect(addPageAction).to.have.been.calledWith( + 'IdV: document capture async upload submitted', + { success: false, trace_id: '1-67891233-abcdef012345678912345678', status_code: 403, }, - }); + ); }); }); }); diff --git a/spec/javascripts/packages/document-capture/services/upload-spec.js b/spec/javascripts/packages/document-capture/services/upload-spec.js index 1a22f1114f1..2c3dd749523 100644 --- a/spec/javascripts/packages/document-capture/services/upload-spec.js +++ b/spec/javascripts/packages/document-capture/services/upload-spec.js @@ -4,7 +4,7 @@ import upload, { toFormData, toFormEntryError, } from '@18f/identity-document-capture/services/upload'; -import { useSandbox } from '../../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('document-capture/services/upload', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packages/one-time-code-input/index-spec.js b/spec/javascripts/packages/one-time-code-input/index-spec.js index 14581fbc623..f7f15769fe2 100644 --- a/spec/javascripts/packages/one-time-code-input/index-spec.js +++ b/spec/javascripts/packages/one-time-code-input/index-spec.js @@ -2,7 +2,7 @@ import OneTimeCodeInput from '@18f/identity-one-time-code-input'; import { waitFor, screen } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { expect } from 'chai'; -import { useSandbox } from '../../support/sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; describe('OneTimeCodeInput', () => { const sandbox = useSandbox(); diff --git a/spec/javascripts/packs/form-steps-wait-spec.js b/spec/javascripts/packs/form-steps-wait-spec.js index 0175a857274..f04ccaf297b 100644 --- a/spec/javascripts/packs/form-steps-wait-spec.js +++ b/spec/javascripts/packs/form-steps-wait-spec.js @@ -1,6 +1,5 @@ import { fireEvent, findByRole } from '@testing-library/dom'; -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { useSandbox } from '../support/sinon'; +import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers'; import { FormStepsWait, getDOMFromHTML, diff --git a/spec/javascripts/packs/webauthn-setup-spec.js b/spec/javascripts/packs/webauthn-setup-spec.js index 3ed12e62b7d..ef39f1bdc83 100644 --- a/spec/javascripts/packs/webauthn-setup-spec.js +++ b/spec/javascripts/packs/webauthn-setup-spec.js @@ -1,5 +1,4 @@ -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { useSandbox } from '../support/sinon'; +import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers'; import { reloadWithError } from '../../../app/javascript/packs/webauthn-setup'; describe('webauthn-setup', () => { diff --git a/spec/javascripts/packs/webauthn-unhide-spec.js b/spec/javascripts/packs/webauthn-unhide-spec.js index aec71ed128b..063c4005a1e 100644 --- a/spec/javascripts/packs/webauthn-unhide-spec.js +++ b/spec/javascripts/packs/webauthn-unhide-spec.js @@ -1,6 +1,5 @@ import { screen } from '@testing-library/dom'; -import { useDefineProperty } from '@18f/identity-test-helpers'; -import { useSandbox } from '../support/sinon'; +import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers'; import { unhideWebauthn } from '../../../app/javascript/packs/webauthn-unhide'; describe('webauthn-unhide', () => { diff --git a/spec/javascripts/support/sinon.js b/spec/javascripts/support/sinon.js index 904aa30d17a..142103f6bb3 100644 --- a/spec/javascripts/support/sinon.js +++ b/spec/javascripts/support/sinon.js @@ -1,35 +1,3 @@ -import sinon from 'sinon'; - -/** - * Returns an instance of a Sinon sandbox, and automatically restores all stubbed methods after each - * test case. - * - * @param {sinon.SinonSandboxConfig=} config - */ -export function useSandbox(config) { - const { useFakeTimers = false, ...remainingConfig } = config ?? {}; - const sandbox = sinon.createSandbox(remainingConfig); - - beforeEach(() => { - // useFakeTimers overrides global timer functions as soon as sandbox is created, thus leaking - // across tests. Instead, wait until tests start to initialize. - if (useFakeTimers) { - sandbox.useFakeTimers(); - } - }); - - afterEach(() => { - sandbox.reset(); - sandbox.restore(); - - if (useFakeTimers) { - sandbox.clock.restore(); - } - }); - - return sandbox; -} - /** * Chai plugin which allows a combination of `calledWith` and `eventually` to expect an eventual * spy (stub) call. diff --git a/spec/jobs/document_proofing_job_spec.rb b/spec/jobs/document_proofing_job_spec.rb index 62babf24ac8..2e79d0a528b 100644 --- a/spec/jobs/document_proofing_job_spec.rb +++ b/spec/jobs/document_proofing_job_spec.rb @@ -138,7 +138,7 @@ ) expect(job_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, exception: nil, @@ -192,7 +192,7 @@ ) expect(job_analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, + 'IdV: doc auth image upload vendor submitted', success: true, errors: {}, exception: nil, diff --git a/spec/lib/feature_management_spec.rb b/spec/lib/feature_management_spec.rb index d50616111ca..7a5db14502a 100644 --- a/spec/lib/feature_management_spec.rb +++ b/spec/lib/feature_management_spec.rb @@ -364,7 +364,7 @@ context 'with steps enabled' do it 'returns true' do - allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return([:example]) + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps).and_return(['example']) expect(FeatureManagement.idv_api_enabled?).to eq(true) end diff --git a/spec/models/profile_spec.rb b/spec/models/profile_spec.rb index edf6d5b5731..d6c3b6f82cd 100644 --- a/spec/models/profile_spec.rb +++ b/spec/models/profile_spec.rb @@ -102,7 +102,7 @@ context 'the letter flow is allowed for strict IAL2' do before do allow(IdentityConfig.store).to receive( - :usps_upload_allowed_for_strict_ial2, + :gpo_allowed_for_strict_ial2, ).and_return(true) end @@ -124,7 +124,7 @@ context 'the letter flow is not allowed for strict IAL2' do before do allow(IdentityConfig.store).to receive( - :usps_upload_allowed_for_strict_ial2, + :gpo_allowed_for_strict_ial2, ).and_return(false) end diff --git a/spec/presenters/saml_request_presenter_spec.rb b/spec/presenters/saml_request_presenter_spec.rb index 850ce56b1e4..58e8f858ce2 100644 --- a/spec/presenters/saml_request_presenter_spec.rb +++ b/spec/presenters/saml_request_presenter_spec.rb @@ -8,19 +8,68 @@ allow(FakeSamlRequest).to receive(:new).and_return(request) allow(request).to receive(:requested_authn_contexts). and_return([Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF]) + allow(request).to receive(:requested_authn_context_comparison).and_return('exact') + allow(request).to receive(:requested_ial_authn_context). + and_return(Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF) parser = instance_double(SamlRequestParser) allow(SamlRequestParser).to receive(:new).with(request).and_return(parser) allow(parser).to receive(:requested_attributes).and_return(nil) all_attributes = %w[ - email first_name middle_name last_name dob ssn verified_at + email all_emails first_name last_name dob ssn verified_at phone address1 address2 city state zipcode foo ] service_provider = ServiceProvider.new(attribute_bundle: all_attributes) presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) - expect(presenter.requested_attributes).to eq(%i[email verified_at]) + expect(presenter.requested_attributes).to eq(%i[email all_emails verified_at]) + end + end + + context 'with no requested context and IAL2 SP' do + it 'returns SP attribute_bundle' do + request = instance_double(FakeSamlRequest) + allow(FakeSamlRequest).to receive(:new).and_return(request) + allow(request).to receive(:requested_authn_contexts). + and_return([]) + allow(request).to receive(:requested_authn_context_comparison).and_return('exact') + allow(request).to receive(:requested_ial_authn_context). + and_return(nil) + + parser = instance_double(SamlRequestParser) + allow(SamlRequestParser).to receive(:new).with(request).and_return(parser) + allow(parser).to receive(:requested_attributes).and_return(nil) + + sp_attributes = %w[email first_name last_name ssn zipcode] + service_provider = ServiceProvider.new(attribute_bundle: sp_attributes, ial: 2) + presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) + + expect(presenter.requested_attributes).to eq( + %i[email given_name family_name social_security_number address], + ) + end + end + + context 'with no requested context and IAL1 SP' do + it 'returns permitted attribute_bundle' do + request = instance_double(FakeSamlRequest) + allow(FakeSamlRequest).to receive(:new).and_return(request) + allow(request).to receive(:requested_authn_contexts). + and_return([]) + allow(request).to receive(:requested_authn_context_comparison).and_return('exact') + allow(request).to receive(:requested_ial_authn_context). + and_return(nil) + + parser = instance_double(SamlRequestParser) + allow(SamlRequestParser).to receive(:new).with(request).and_return(parser) + allow(parser).to receive(:requested_attributes).and_return(nil) + + sp_attributes = %w[email first_name last_name ssn zipcode all_emails] + service_provider = ServiceProvider.new(attribute_bundle: sp_attributes, ial: 1) + presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) + + expect(presenter.requested_attributes).to eq(%i[email all_emails]) end end @@ -34,12 +83,12 @@ service_provider = ServiceProvider.new( attribute_bundle: %w[ - email first_name middle_name last_name dob foo ssn phone verified_at + email first_name last_name dob foo ssn phone verified_at ], ) presenter = SamlRequestPresenter.new(request: request, service_provider: service_provider) valid_attributes = %i[ - email given_name name family_name birthdate social_security_number phone verified_at + email given_name family_name birthdate social_security_number phone verified_at ] expect(presenter.requested_attributes).to eq(valid_attributes) 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 490c2e8c4ab..a673c34bac2 100644 --- a/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb @@ -4,16 +4,11 @@ let(:presenter) { described_class.new(phone) } describe '#info' do - context 'when a user has a phone configuration' do - let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } - - it 'includes the masked the number' do - expect(presenter.info).to include('(***) ***-5309') - end - end - context 'when a user does not have a phone configuration (first time)' do let(:phone) { nil } + before do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(false) + end it 'includes a note about choosing voice or sms' do expect(presenter.info). @@ -28,11 +23,6 @@ before do allow(IdentityConfig.store).to receive(:voip_block).and_return(true) end - - it 'tells people to not use voip numbers' do - expect(presenter.info). - to include(t('two_factor_authentication.two_factor_choice_options.phone_info_no_voip')) - end end end end diff --git a/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb index da605f2da91..e88accba671 100644 --- a/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb @@ -27,6 +27,16 @@ end end + describe '#info' do + context 'when a user has a phone configuration' do + let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } + + it 'includes the masked the number' do + expect(subject.info).to include('(***) ***-5309') + end + end + end + describe '#disabled?' do let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } diff --git a/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb b/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb index 2d484d95635..da9b47cbcd4 100644 --- a/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb +++ b/spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb @@ -27,6 +27,16 @@ end end + describe '#info' do + context 'when a user has a phone configuration' do + let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } + + it 'includes the masked the number' do + expect(subject.info).to include('(***) ***-5309') + end + end + end + describe '#disabled?' do let(:phone) { build(:phone_configuration, phone: '+1 888 867-5309') } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 75674b35cb6..b53892f5698 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -15,6 +15,7 @@ require 'factory_bot' require 'view_component/test_helpers' require 'capybara/rspec' +require 'capybara/webmock' # Checks for pending migrations before tests are run. # If you are not using ActiveRecord, you can remove this line. @@ -102,7 +103,9 @@ class Analytics config.around(:each, type: :feature) do |example| Bullet.enable = true + Capybara::Webmock.start example.run + Capybara::Webmock.stop Bullet.enable = false end diff --git a/spec/requests/saml_post_spec.rb b/spec/requests/saml_post_spec.rb deleted file mode 100644 index b39aac365dd..00000000000 --- a/spec/requests/saml_post_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'SAML POST handling', type: :request do - include SamlAuthHelper - - describe 'POST /api/saml/auth' do - let(:cookie_regex) { /\A(?\w+)=/ } - - it 'does not set a session cookie' do - post saml_settings.idp_sso_target_url - new_cookies = response.header['Set-Cookie'].split("\n").map do |c| - cookie_regex.match(c)[:cookie] - end - - expect(new_cookies).not_to include('_upaya_session') - end - end -end diff --git a/spec/requests/saml_requests_spec.rb b/spec/requests/saml_requests_spec.rb new file mode 100644 index 00000000000..766a00844a0 --- /dev/null +++ b/spec/requests/saml_requests_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe 'SAML requests', type: :request do + include SamlAuthHelper + + describe 'POST /api/saml/auth' do + let(:cookie_regex) { /\A(?\w+)=/ } + + it 'does not set a session cookie' do + post saml_settings.idp_sso_target_url + new_cookies = response.header['Set-Cookie'].split("\n").map do |c| + cookie_regex.match(c)[:cookie] + end + + expect(new_cookies).not_to include('_upaya_session') + end + end + + describe '/api/saml/remotelogout' do + let(:remote_slo_url) do + saml_settings.idp_slo_target_url.gsub('logout', 'remotelogout') + end + + it 'does not accept GET requests' do + get remote_slo_url + expect(response.status).to eq(404) + end + + it 'does not accept DELETE requests' do + delete remote_slo_url + expect(response.status).to eq(404) + end + + it 'accepts POST requests' do + post remote_slo_url + # fails (:bad_request) without SAMLRequest param but not 404 + expect(response.status).to eq(400) + end + end +end diff --git a/spec/services/attribute_asserter_spec.rb b/spec/services/attribute_asserter_spec.rb index c7a38bf3576..680a2bfdf90 100644 --- a/spec/services/attribute_asserter_spec.rb +++ b/spec/services/attribute_asserter_spec.rb @@ -71,6 +71,18 @@ ) CGI.unescape ial1_aal3_authnrequest.split('SAMLRequest').last end + let(:raw_ialmax_authn_request) do + ialmax_authnrequest = saml_authn_request_url( + overrides: { + issuer: sp1_issuer, + authn_context: [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + ], + authn_context_comparison: 'minimum', + }, + ) + CGI.unescape ialmax_authnrequest.split('SAMLRequest').last + end let(:sp1_authn_request) do SamlIdp::Request.from_deflated_request(raw_sp1_authn_request) end @@ -86,6 +98,9 @@ let(:ial1_aal3_authn_request) do SamlIdp::Request.from_deflated_request(raw_ial1_aal3_authn_request) end + let(:ialmax_authn_request) do + SamlIdp::Request.from_deflated_request(raw_ialmax_authn_request) + end let(:decrypted_pii) do Pii::Attributes.new_from_hash( first_name: 'Jåné', @@ -502,6 +517,66 @@ end end + context 'IALMAX' do + context 'service provider requests IALMAX with IAL1 user' do + let(:service_provider_ial) { 2 } + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: ialmax_authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + + before do + user.profiles.delete_all + subject.build + end + + it 'includes ial' do + expect(user.asserted_attributes.keys).to include(:ial) + end + + it 'creates a getter function for ial attribute' do + expected_ial = Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF + expect(user.asserted_attributes[:ial][:getter].call(user)).to eq expected_ial + end + end + + context 'service provider requests IALMAX with IAL2 user' do + let(:service_provider_ial) { 2 } + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: ialmax_authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + + before do + user.identities << identity + allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). + and_return(%w[email phone first_name]) + subject.build + end + + it 'includes ial' do + expect(user.asserted_attributes.keys).to include(:ial) + end + + it 'creates a getter function for ial attribute' do + expected_ial = Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF + expect(user.asserted_attributes[:ial][:getter].call(user)).to eq expected_ial + end + end + end + shared_examples 'unverified user' do context 'custom bundle does not include email, phone' do before do diff --git a/spec/services/encryption/aes_cipher_spec.rb b/spec/services/encryption/aes_cipher_spec.rb index 6290996fcfd..1b9ca5cd330 100644 --- a/spec/services/encryption/aes_cipher_spec.rb +++ b/spec/services/encryption/aes_cipher_spec.rb @@ -28,4 +28,15 @@ expect { subject.decrypt(ciphertext, cek) }.to raise_error Encryption::EncryptionError end end + + describe '.encryption_cipher' do + it 'returns an AES cipher for encryption operation' do + expect_any_instance_of(OpenSSL::Cipher).to receive(:encrypt).and_call_original + + cipher = subject.class.encryption_cipher + + expect(cipher).to be_kind_of(OpenSSL::Cipher) + expect(cipher.name).to eq 'id-aes256-GCM' + end + end end diff --git a/spec/services/ial_context_spec.rb b/spec/services/ial_context_spec.rb index c67cb497eff..faed99b7635 100644 --- a/spec/services/ial_context_spec.rb +++ b/spec/services/ial_context_spec.rb @@ -10,8 +10,16 @@ ) end let(:user) { nil } - - subject(:ial_context) { IalContext.new(ial: ial, service_provider: service_provider, user: user) } + let(:authn_context_comparison) { nil } + + subject(:ial_context) do + IalContext.new( + ial: ial, + service_provider: service_provider, + user: user, + authn_context_comparison: authn_context_comparison, + ) + end describe '#ial' do context 'with an integer input' do @@ -135,8 +143,24 @@ it { expect(ial_context.ialmax_requested?).to eq(true) } end - context 'when ial 1 is requested' do + context 'when ial 1 is requested without Comparison=minimum and ial 2 SP' do let(:ial) { Idp::Constants::IAL1 } + let(:authn_context_comparison) { 'exact' } + let(:sp_ial) { 2 } + it { expect(ial_context.ialmax_requested?).to eq(false) } + end + + context 'when ial 1 is requested with Comparison=minimum and ial 2 SP' do + let(:ial) { Idp::Constants::IAL1 } + let(:authn_context_comparison) { 'minimum' } + let(:sp_ial) { 2 } + it { expect(ial_context.ialmax_requested?).to eq(true) } + end + + context 'when ial 1 is requested with Comparison=minimum and ial 1 SP' do + let(:ial) { Idp::Constants::IAL1 } + let(:authn_context_comparison) { 'minimum' } + let(:sp_ial) { 1 } it { expect(ial_context.ialmax_requested?).to eq(false) } end @@ -268,11 +292,23 @@ end describe '#ial2_requested?' do - context 'when ialmax is requested' do + context 'when ialmax is requested without a user' do + let(:ial) { Idp::Constants::IAL_MAX } + it { expect(ial_context.ial2_requested?).to eq(false) } + end + + context 'when ialmax is requested with a user with no profile' do let(:ial) { Idp::Constants::IAL_MAX } + let(:user) { create(:user, :signed_up) } it { expect(ial_context.ial2_requested?).to eq(false) } end + context 'when ialmax is requested with a user with a verified profile' do + let(:ial) { Idp::Constants::IAL_MAX } + let(:user) { create(:profile, :active, :verified).user } + it { expect(ial_context.ial2_requested?).to eq(true) } + end + context 'when ial 1 is requested' do let(:ial) { Idp::Constants::IAL1 } it { expect(ial_context.ial2_requested?).to eq(false) } @@ -293,18 +329,6 @@ let(:ial) { Idp::Constants::IAL2 } it { expect(ial_context.ial2_requested?).to eq(true) } end - - context 'when ial max and the user has proofed for ial2' do - let(:ial) { Idp::Constants::IAL_MAX } - let(:user) do - create( - :user, - :signed_up, - profiles: [build(:profile, :active, :verified, pii: { first_name: 'Jane' })], - ) - end - it { expect(ial_context.ial2_requested?).to eq(true) } - end end describe '#ial2_strict_requested?' do diff --git a/spec/services/idv/actions/verify_document_status_action_spec.rb b/spec/services/idv/actions/verify_document_status_action_spec.rb index 048655f6064..f053ac55539 100644 --- a/spec/services/idv/actions/verify_document_status_action_spec.rb +++ b/spec/services/idv/actions/verify_document_status_action_spec.rb @@ -42,7 +42,7 @@ subject.call expect(analytics).to have_logged_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, + 'IdV: doc auth image upload vendor pii validation', success: true, errors: {}, ) diff --git a/spec/services/idv/user_bundle_tokenizer_spec.rb b/spec/services/idv/user_bundle_tokenizer_spec.rb new file mode 100644 index 00000000000..58678741f99 --- /dev/null +++ b/spec/services/idv/user_bundle_tokenizer_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe Idv::UserBundleTokenizer do + let(:public_key) do + OpenSSL::PKey::RSA.new(Base64.strict_decode64(IdentityConfig.store.idv_public_key)) + end + let(:user) { create(:user) } + let(:sp) { create(:service_provider) } + let(:user_session) do + { + idv: { + applicant: { + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'ssn' => '900900900', + 'phone' => '+1 410-555-1212', + }, + address_verification_mechanism: 'phone', + user_phone_confirmation: true, + vendor_phone_confirmation: true, + }, + } + end + let(:idv_session) do + Idv::Session.new(user_session: user_session, current_user: user, service_provider: sp) + end + subject do + Idv::UserBundleTokenizer.new(user: user, idv_session: idv_session) + end + + context 'when initialized with data' do + it 'encodes a signed JWT' do + token = subject.token + decorator = Api::UserBundleDecorator.new(user_bundle: token, public_key: public_key) + + expect(decorator.pii).to eq user_session[:idv][:applicant] + end + end +end diff --git a/spec/services/reactivate_account_session_spec.rb b/spec/services/reactivate_account_session_spec.rb index 3da4f3ee530..534711233d6 100644 --- a/spec/services/reactivate_account_session_spec.rb +++ b/spec/services/reactivate_account_session_spec.rb @@ -63,9 +63,8 @@ pii = Pii::Attributes.new(first_name: 'Test') @reactivate_account_session.store_decrypted_pii(pii) account_reactivation_obj = user_session[:reactivate_account] - expect(account_reactivation_obj[:personal_key]).to be(true) expect(account_reactivation_obj[:validated_personal_key]).to be(true) - expect(account_reactivation_obj[:pii]).to eq(pii.to_json) + expect(user_session[:decrypted_pii]).to eq(pii.to_json) end end diff --git a/spec/services/saml_endpoint_spec.rb b/spec/services/saml_endpoint_spec.rb index 6866426cd20..ca059b822ab 100644 --- a/spec/services/saml_endpoint_spec.rb +++ b/spec/services/saml_endpoint_spec.rb @@ -76,9 +76,28 @@ result = subject.saml_metadata expect(result.configurator.single_service_post_location).to match(%r{api/saml/auth2022\Z}) + end + + it 'does not include the SingLogoutService endpoints when configured' do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(false) + result = subject.saml_metadata + + expect(result.configurator.single_logout_service_post_location).to be_nil + expect(result.configurator.remote_logout_service_post_location).to be_nil + end + + it 'includes the SingLogoutService endpoints when configured' do + allow(IdentityConfig.store).to receive(:include_slo_in_saml_metadata). + and_return(true) + result = subject.saml_metadata + expect(result.configurator.single_logout_service_post_location).to match( %r{api/saml/logout2022\Z}, ) + expect(result.configurator.remote_logout_service_post_location).to match( + %r{api/saml/remotelogout2022\Z}, + ) end end end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 7ac7956da5b..cd94be301f6 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -10,6 +10,7 @@ options.add_argument('--window-size=1200x700') options.add_argument('--no-sandbox') options.add_argument('--disable-dev-shm-usage') + options.add_argument("--proxy-server=127.0.0.1:#{Capybara::Webmock.port_number}") Capybara::Selenium::Driver.new app, browser: :chrome, @@ -31,6 +32,7 @@ options.add_argument('--window-size=414,736') options.add_argument("--user-agent='#{user_agent_string}'") options.add_argument('--use-fake-device-for-media-stream') + options.add_argument("--proxy-server=127.0.0.1:#{Capybara::Webmock.port_number}") Capybara::Selenium::Driver.new app, browser: :chrome, diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index 06336180a11..ea8bd6987df 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -152,9 +152,10 @@ def complete_all_doc_auth_steps(expect_accessible: false) def complete_proofing_steps complete_all_doc_auth_steps click_continue + expect(page).to have_current_path(idv_review_path, wait: 10) fill_in 'Password', with: RequestHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue end diff --git a/spec/support/features/idv_from_sp_helper.rb b/spec/support/features/idv_from_sp_helper.rb index 55f97d62f78..3ed8adebf96 100644 --- a/spec/support/features/idv_from_sp_helper.rb +++ b/spec/support/features/idv_from_sp_helper.rb @@ -11,19 +11,19 @@ def create_ial2_user_from_sp(email, **options) visit_idp_from_sp_with_ial2(:oidc, **options) register_user(email) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue end def reproof_for_ial2_strict complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key end def create_ial1_user_from_sp(email) diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 3cb87426348..ad54e3d111c 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -61,9 +61,11 @@ def visit_idp_from_sp_with_ial2(sp, **extra) }, } if javascript_enabled? - idp_domain_name = "#{page.server.host}:#{page.server.port}" - saml_overrides[:idp_sso_target_url] = "http://#{idp_domain_name}/api/saml/auth" - saml_overrides[:idp_slo_target_url] = "http://#{idp_domain_name}/api/saml/logout" + service_provider = ServiceProvider.find_by(issuer: sp1_issuer) + acs_url = URI.parse(service_provider.acs_url) + acs_url.host = page.server.host + acs_url.port = page.server.port + service_provider.update(acs_url: acs_url.to_s) end visit_saml_authn_request_url(overrides: saml_overrides) elsif sp == :oidc diff --git a/spec/support/features/personal_key_helper.rb b/spec/support/features/personal_key_helper.rb index 3b2a651a25c..cc99ebf315d 100644 --- a/spec/support/features/personal_key_helper.rb +++ b/spec/support/features/personal_key_helper.rb @@ -28,6 +28,6 @@ def trigger_reset_password_and_click_email_link(email) end def scrape_personal_key - page.all(:css, '.separator-text__code').map(&:text).join('-') + page.all('.separator-text__code').map(&:text).join('-') end end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index 3aad175cc8f..6d764b2530d 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -308,18 +308,15 @@ def sign_in_with_totp_enabled_user click_submit_default end - def acknowledge_and_confirm_personal_key(js: true) - button_text = t('forms.buttons.continue') + def acknowledge_and_confirm_personal_key + click_acknowledge_personal_key - click_on button_text, class: 'personal-key-continue' if js - - fill_in 'personal_key', with: scrape_personal_key - - find_all('.personal-key-confirm', text: button_text).first.click + page.find(':focus').fill_in with: scrape_personal_key + within('[role=dialog]') { click_continue } end def click_acknowledge_personal_key - click_on t('forms.buttons.continue'), class: 'personal-key-continue' + click_continue end def enter_personal_key(personal_key:, selector: 'input[type="text"]') diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb index 65e75d6e591..efc78d7c6f0 100644 --- a/spec/support/features/webauthn_helper.rb +++ b/spec/support/features/webauthn_helper.rb @@ -1,4 +1,6 @@ module WebAuthnHelper + include JavascriptDriverHelper + def mock_webauthn_setup_challenge allow(WebAuthn::Credential).to receive(:options_for_create).and_return( instance_double( @@ -35,7 +37,12 @@ def mock_press_button_on_hardware_key_on_setup set_hidden_field('attestation_object', attestation_object) set_hidden_field('client_data_json', setup_client_data_json) - first('#submit-button', visible: false).click + button = first('#submit-button', visible: false) + if javascript_enabled? + button.execute_script('this.click()') + else + button.click + end end def mock_press_button_on_hardware_key_on_verification @@ -50,7 +57,12 @@ def mock_press_button_on_hardware_key_on_verification end def set_hidden_field(id, value) - first("input##{id}", visible: false).set(value) + input = first("input##{id}", visible: false) + if javascript_enabled? + input.execute_script("this.value = #{value.to_json}") + else + input.set(value) + end end def protocol diff --git a/spec/support/idv_examples/clearing_and_restarting.rb b/spec/support/idv_examples/clearing_and_restarting.rb index 205439a5464..73b6ca12087 100644 --- a/spec/support/idv_examples/clearing_and_restarting.rb +++ b/spec/support/idv_examples/clearing_and_restarting.rb @@ -1,5 +1,5 @@ shared_examples 'clearing and restarting idv' do - it 'allows the user to retry verification with phone' do + it 'allows the user to retry verification with phone', js: true do click_on t('idv.messages.clear_and_start_over') expect(user.reload.pending_profile?).to eq(false) @@ -8,13 +8,13 @@ click_idv_continue fill_in 'Password', with: user.password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) expect(user.reload.decorate.identity_verified?).to eq(true) end - it 'allows the user to retry verification with gpo' do + it 'allows the user to retry verification with gpo', js: true do click_on t('idv.messages.clear_and_start_over') expect(user.reload.pending_profile?).to eq(false) @@ -28,7 +28,7 @@ end fill_in 'Password', with: user.password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key gpo_confirmation = GpoConfirmation.order(created_at: :desc).first diff --git a/spec/support/idv_examples/confirmation_step.rb b/spec/support/idv_examples/confirmation_step.rb index 3286c807d80..81436859616 100644 --- a/spec/support/idv_examples/confirmation_step.rb +++ b/spec/support/idv_examples/confirmation_step.rb @@ -6,7 +6,7 @@ end it 'redirects to the come back later url then to the sp or account' do - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(idv_come_back_later_path) click_on t('forms.buttons.continue') @@ -52,7 +52,7 @@ end it 'redirects to the completions page and then to the SP', if: sp.present? do - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) @@ -61,12 +61,12 @@ if sp == :oidc expect(current_url).to start_with('http://localhost:7654/auth/result') else - expect(current_path).to eq(api_saml_auth2022_path) + expect(current_path).to eq(test_saml_decode_assertion_path) end end it 'redirects to the account page', if: sp.nil? do - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_content(t('headings.account.verified_account')) expect(page).to have_current_path(account_path) diff --git a/spec/support/idv_examples/max_attempts.rb b/spec/support/idv_examples/max_attempts.rb index 80d9176fc79..d44a4783b40 100644 --- a/spec/support/idv_examples/max_attempts.rb +++ b/spec/support/idv_examples/max_attempts.rb @@ -32,7 +32,7 @@ expect_user_to_fail_at_phone_step end - scenario 'after 24 hours the user can retry and complete idv' do + scenario 'after 24 hours the user can retry and complete idv', js: true do visit account_path first(:link, t('links.sign_out')).click reattempt_interval = (IdentityConfig.store.idv_attempt_window_in_hours + 1).hours @@ -48,7 +48,7 @@ click_idv_continue fill_in 'Password', with: user.password click_idv_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue expect(current_url).to start_with('http://localhost:7654/auth/result') diff --git a/spec/support/idv_examples/sp_handoff.rb b/spec/support/idv_examples/sp_handoff.rb index 77d2f5e51c3..932f23002c7 100644 --- a/spec/support/idv_examples/sp_handoff.rb +++ b/spec/support/idv_examples/sp_handoff.rb @@ -1,23 +1,24 @@ shared_examples 'sp handoff after identity verification' do |sp| include SamlAuthHelper include IdvHelper + include JavascriptDriverHelper let(:email) { 'test@test.com' } context 'sign up' do let(:user) { User.find_with_email(email) } - it 'requires idv and hands off correctly' do + it 'requires idv and hands off correctly', js: true do visit_idp_from_sp_with_ial2(sp) register_user(email) expect(current_path).to eq idv_doc_auth_step_path(step: :welcome) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_content t( 'titles.sign_up.completion_ial2', @@ -36,7 +37,7 @@ context 'unverified user sign in' do let(:user) { user_with_2fa } - it 'requires idv and hands off successfully' do + it 'requires idv and hands off successfully', js: true do visit_idp_from_sp_with_ial2(sp) sign_in_user(user) fill_in_code_with_last_phone_otp @@ -45,10 +46,10 @@ expect(current_path).to eq idv_doc_auth_step_path(step: :welcome) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_content t( 'titles.sign_up.completion_ial2', @@ -64,16 +65,16 @@ end end - context 'verified user sign in' do + context 'verified user sign in', js: true do let(:user) { user_with_2fa } before do sign_in_and_2fa_user(user) complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key first(:link, t('links.sign_out')).click end @@ -92,7 +93,7 @@ end end - context 'second time a user signs in to an SP' do + context 'second time a user signs in to an SP', js: true do let(:user) { user_with_2fa } before do @@ -103,10 +104,10 @@ fill_in_code_with_last_phone_otp click_submit_default complete_all_doc_auth_steps - click_continue + click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue visit account_path first(:link, t('links.sign_out')).click @@ -128,6 +129,9 @@ end def expect_csp_headers_to_be_present + # Selenium driver does not support response header inspection, but we should be able to expect + # that the browser itself would respect CSP and refuse invalid form targets. + return if javascript_enabled? expect(page.response_headers['Content-Security-Policy']). to(include('form-action \'self\' http://localhost:7654')) end @@ -153,45 +157,47 @@ def expect_successful_oidc_handoff client_assertion = JWT.encode(jwt_payload, client_private_key, 'RS256') client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' - page.driver.post api_openid_connect_token_path, - grant_type: 'authorization_code', - code: code, - client_assertion_type: client_assertion_type, - client_assertion: client_assertion - - expect(page.status_code).to eq(200) - token_response = JSON.parse(page.body).with_indifferent_access - - id_token = token_response[:id_token] - expect(id_token).to be_present - - decoded_id_token, _headers = JWT.decode( - id_token, sp_public_key, true, algorithm: 'RS256' - ).map(&:with_indifferent_access) - - sub = decoded_id_token[:sub] - expect(sub).to be_present - expect(decoded_id_token[:nonce]).to eq(@nonce) - expect(decoded_id_token[:aud]).to eq(@client_id) - expect(decoded_id_token[:acr]).to eq(Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF) - expect(decoded_id_token[:iss]).to eq(root_url) - expect(decoded_id_token[:email]).to eq(user.email) - expect(decoded_id_token[:given_name]).to eq('FAKEY') - expect(decoded_id_token[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) - - access_token = token_response[:access_token] - expect(access_token).to be_present - - page.driver.get api_openid_connect_userinfo_path, - {}, - 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" - - userinfo_response = JSON.parse(page.body).with_indifferent_access - expect(userinfo_response[:sub]).to eq(sub) - expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(sub) - expect(userinfo_response[:email]).to eq(user.email) - expect(userinfo_response[:given_name]).to eq('FAKEY') - expect(userinfo_response[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) + Capybara.using_driver(:desktop_rack_test) do + page.driver.post api_openid_connect_token_path, + grant_type: 'authorization_code', + code: code, + client_assertion_type: client_assertion_type, + client_assertion: client_assertion + + expect(page.status_code).to eq(200) + token_response = JSON.parse(page.body).with_indifferent_access + + id_token = token_response[:id_token] + expect(id_token).to be_present + + decoded_id_token, _headers = JWT.decode( + id_token, sp_public_key, true, algorithm: 'RS256' + ).map(&:with_indifferent_access) + + sub = decoded_id_token[:sub] + expect(sub).to be_present + expect(decoded_id_token[:nonce]).to eq(@nonce) + expect(decoded_id_token[:aud]).to eq(@client_id) + expect(decoded_id_token[:acr]).to eq(Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF) + expect(decoded_id_token[:iss]).to eq(root_url) + expect(decoded_id_token[:email]).to eq(user.email) + expect(decoded_id_token[:given_name]).to eq('FAKEY') + expect(decoded_id_token[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) + + access_token = token_response[:access_token] + expect(access_token).to be_present + + page.driver.get api_openid_connect_userinfo_path, + {}, + 'HTTP_AUTHORIZATION' => "Bearer #{access_token}" + + userinfo_response = JSON.parse(page.body).with_indifferent_access + expect(userinfo_response[:sub]).to eq(sub) + expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(sub) + expect(userinfo_response[:email]).to eq(user.email) + expect(userinfo_response[:given_name]).to eq('FAKEY') + expect(userinfo_response[:social_security_number]).to eq(DocAuthHelper::GOOD_SSN) + end end def expect_successful_saml_handoff @@ -199,7 +205,11 @@ def expect_successful_saml_handoff xmldoc = SamlResponseDoc.new('feature', 'response_assertion') expect(AgencyIdentity.where(user_id: user.id, agency_id: 2).first.uuid).to eq(xmldoc.uuid) - expect(current_url).to eq @saml_authn_request + if javascript_enabled? + expect(current_path).to eq test_saml_decode_assertion_path + else + expect(current_url).to eq @saml_authn_request + end expect(xmldoc.phone_number.children.children.to_s).to eq(Phonelib.parse(profile_phone).e164) end diff --git a/spec/support/idv_examples/sp_requested_attributes.rb b/spec/support/idv_examples/sp_requested_attributes.rb index 0f48478bfb4..cec18edee03 100644 --- a/spec/support/idv_examples/sp_requested_attributes.rb +++ b/spec/support/idv_examples/sp_requested_attributes.rb @@ -13,7 +13,7 @@ end context 'visiting an SP for the first time' do - it 'requires the user to verify the attributes submitted to the SP' do + it 'requires the user to verify the attributes submitted to the SP', js: true do visit_idp_from_sp_with_ial2(sp) sign_in_user(user) fill_in_code_with_last_phone_otp @@ -27,7 +27,7 @@ click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_path).to eq(sign_up_completed_path) @@ -41,12 +41,12 @@ expect(page).to have_content t('help_text.requested_attributes.phone') expect(page).to have_content '+1 202-555-1212' expect(page).to have_content t('help_text.requested_attributes.social_security_number') - expect(page).to have_content good_ssn + expect(page).to have_css '.masked-text__text', text: good_ssn, visible: :hidden end end end - context 'visiting an SP the user has already signed into' do + context 'visiting an SP the user has already signed into', js: true do before do visit_idp_from_sp_with_ial2(sp) sign_in_user(user) @@ -59,7 +59,7 @@ click_idv_continue fill_in 'Password', with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key click_agree_and_continue visit account_path first(:link, t('links.sign_out')).click @@ -75,7 +75,11 @@ if sp == :oidc expect(current_url).to include('http://localhost:7654/auth/result') elsif sp == :saml - expect(current_url).to include(api_saml_auth2022_url) + if javascript_enabled? + expect(current_path).to eq(test_saml_decode_assertion_path) + else + expect(current_url).to include(api_saml_auth2022_url) + end end end end diff --git a/spec/support/saml_auth_helper.rb b/spec/support/saml_auth_helper.rb index 795ac74d448..914f1fd0434 100644 --- a/spec/support/saml_auth_helper.rb +++ b/spec/support/saml_auth_helper.rb @@ -310,6 +310,19 @@ def visit_idp_from_oidc_sp_with_hspd12_and_require_piv_cac ) end + def visit_idp_from_saml_sp_with_ialmax + visit_saml_authn_request_url( + overrides: { + issuer: 'saml_sp_ial2', + authn_context: [ + Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}ssn", + ], + authn_context_comparison: 'minimum', + }, + ) + end + def visit_idp_from_oidc_sp_with_ialmax state = SecureRandom.hex client_id = 'urn:gov:gsa:openidconnect:sp:server' diff --git a/spec/support/saml_response_doc.rb b/spec/support/saml_response_doc.rb index 62401f40143..94600fb751c 100644 --- a/spec/support/saml_response_doc.rb +++ b/spec/support/saml_response_doc.rb @@ -17,11 +17,7 @@ def original_encrypted? end def xml_response - Base64.decode64( - Capybara.current_session.find( - "//input[@id='#{input_id}']", visible: false - ).value, - ) + Base64.decode64(Capybara.current_session.find("##{input_id}", visible: false).value) end def html_response diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 909b3d0d1da..5f9385e0e9e 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -42,7 +42,7 @@ end shared_examples 'creating an IAL2 account using authenticator app for 2FA' do |sp| - it 'does not prompt for recovery code before IdV flow', email: true, idv_job: true do + it 'does not prompt for recovery code before IdV flow', email: true, idv_job: true, js: true do visit_idp_from_sp_with_ial2(sp) register_user_with_authenticator_app expect(page).to have_current_path(idv_doc_auth_step_path(step: :welcome)) @@ -54,15 +54,10 @@ click_submit_default fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key - - if sp == :oidc - expect(page.response_headers['Content-Security-Policy']). - to(include('form-action \'self\' http://localhost:7654')) - end + acknowledge_and_confirm_personal_key click_agree_and_continue - expect(current_url).to eq @saml_authn_request if sp == :saml + expect(current_path).to eq test_saml_decode_assertion_path if sp == :saml if sp == :oidc redirect_uri = URI(current_url) @@ -94,7 +89,7 @@ end shared_examples 'creating an IAL2 account using webauthn for 2FA' do |sp| - it 'does not prompt for recovery code before IdV flow', email: true do + it 'does not prompt for recovery code before IdV flow', email: true, js: true do mock_webauthn_setup_challenge visit_idp_from_sp_with_ial2(sp) confirm_email_and_password('test@test.com') @@ -110,15 +105,10 @@ click_submit_default fill_in 'Password', with: Features::SessionHelper::VALID_PASSWORD click_continue - click_acknowledge_personal_key - - if sp == :oidc - expect(page.response_headers['Content-Security-Policy']). - to(include('form-action \'self\' http://localhost:7654')) - end + acknowledge_and_confirm_personal_key click_agree_and_continue - expect(current_url).to eq @saml_authn_request if sp == :saml + expect(current_path).to eq test_saml_decode_assertion_path if sp == :saml if sp == :oidc redirect_uri = URI(current_url) diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 99792304e4d..a940785c583 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -60,7 +60,7 @@ end shared_examples 'signing in as IAL2 with personal key' do |sp| - it 'does not present personal key as an MFA option', :email do + it 'does not present personal key as an MFA option', :email, js: true do user = create_ial2_account_go_back_to_sp_and_sign_out(sp) Capybara.reset_sessions! @@ -75,19 +75,19 @@ end shared_examples 'signing in as IAL2 with piv/cac' do |sp| - it 'redirects to the SP after authenticating and getting the password', :email do + it 'redirects to the SP after authenticating and getting the password', :email, js: true do ial2_sign_in_with_piv_cac_goes_to_sp(sp) end if sp == :saml context 'no authn_context specified' do - it 'redirects to the SP after authenticating and getting the password', :email do + it 'redirects to the SP after authenticating and getting the password', :email, js: true do no_authn_context_sign_in_with_piv_cac_goes_to_sp(sp) end end end - it 'gets bad password error', :email do + it 'gets bad password error', :email, js: true do ial2_sign_in_with_piv_cac_gets_bad_password_error(sp) end end @@ -119,7 +119,7 @@ end shared_examples 'signing in as IAL2 with personal key after resetting password' do |sp| - xit 'redirects to SP after reactivating account', :email do + xit 'redirects to SP after reactivating account', :email, js: true do user = create_ial2_account_go_back_to_sp_and_sign_out(sp) visit_idp_from_sp_with_ial2(sp) trigger_reset_password_and_click_email_link(user.email) @@ -133,7 +133,7 @@ expect(current_path).to eq manage_personal_key_path new_personal_key = scrape_personal_key - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_path).to eq reactivate_account_path @@ -141,7 +141,7 @@ expect(current_path).to eq manage_personal_key_path - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(current_url).to eq @saml_authn_request if sp == :saml if sp == :oidc @@ -205,7 +205,7 @@ def user_with_broken_personal_key(protocol) end context "protocol: #{protocol}, ial: #{sp_ial}" do - it 'prompts the user to get a new personal key when signing in with email/password' do + it 'prompts the user to get a new personal key when signing in with email/password', js: true do user = user_with_broken_personal_key(protocol) case sp_ial @@ -220,14 +220,14 @@ def user_with_broken_personal_key(protocol) fill_in_credentials_and_submit(user.email, user.password) expect(page).to have_content(t('account.personal_key.needs_new')) - code = page.all('[data-personal-key]').map(&:text).join(' ') - click_acknowledge_personal_key + code = page.all('.separator-text__code').map(&:text).join(' ') + acknowledge_and_confirm_personal_key expect(user.reload.valid_personal_key?(code)).to eq(true) expect(user.active_profile.reload.recover_pii(code)).to be_present end - it 'prompts for password when signing in via PIV/CAC' do + it 'prompts for password when signing in via PIV/CAC', js: true do user = user_with_broken_personal_key(protocol) create(:piv_cac_configuration, user: user) @@ -243,8 +243,8 @@ def user_with_broken_personal_key(protocol) click_button t('forms.buttons.submit.default') expect(page).to have_content(t('account.personal_key.needs_new')) - code = page.all('[data-personal-key]').map(&:text).join(' ') - click_acknowledge_personal_key + code = page.all('.separator-text__code').map(&:text).join(' ') + acknowledge_and_confirm_personal_key expect(user.reload.valid_personal_key?(code)).to eq(true) expect(user.active_profile.reload.recover_pii(code)).to be_present @@ -310,15 +310,14 @@ def ial2_sign_in_with_piv_cac_goes_to_sp(sp) # capture password before redirecting to SP expect(current_url).to eq capture_password_url - if sp == :oidc - expect(page.response_headers['Content-Security-Policy']). - to(include('form-action \'self\' http://localhost:7654')) - end - fill_in_password_and_submit(user.password) if sp == :saml - expect(current_url).to eq @saml_authn_request + if javascript_enabled? + expect(current_path).to eq(test_saml_decode_assertion_path) + else + expect(current_url).to include(@saml_authn_request) + end elsif sp == :oidc redirect_uri = URI(current_url) @@ -347,7 +346,16 @@ def no_authn_context_sign_in_with_piv_cac_goes_to_sp(sp) fill_in_password_and_submit(user.password) - expect(current_url).to eq @saml_authn_request + # needed because the SP default attribute bundle includes the zip_code + # attribute which wasn't originally requested, so consent is required + expect(page).to have_current_path(sign_up_completed_path) + click_agree_and_continue + + if javascript_enabled? + expect(current_path).to eq(test_saml_decode_assertion_path) + else + expect(current_url).to eq @saml_authn_request + end end def ial2_sign_in_with_piv_cac_gets_bad_password_error(sp) diff --git a/spec/support/shared_examples_for_personal_keys.rb b/spec/support/shared_examples_for_personal_keys.rb index ca655eaedd2..67c32ad0010 100644 --- a/spec/support/shared_examples_for_personal_keys.rb +++ b/spec/support/shared_examples_for_personal_keys.rb @@ -1,7 +1,12 @@ shared_examples_for 'personal key page' do include PersonalKeyHelper + include JavascriptDriverHelper context 'informational text' do + before do + click_continue if javascript_enabled? + end + context 'modal content' do it 'displays the modal title' do expect(page).to have_content t('forms.personal_key.title') @@ -30,8 +35,7 @@ click_on t('components.clipboard_button.label') copied_text = page.evaluate_async_script('navigator.clipboard.readText().then(arguments[0])') - code = page.all('[data-personal-key]').map(&:text).join('-') - expect(copied_text).to eq(code) + expect(copied_text).to eq(scrape_personal_key) end it 'validates as case-insensitive, crockford-normalized, length-limited, dash-flexible' do diff --git a/spec/support/sp_auth_helper.rb b/spec/support/sp_auth_helper.rb index da65f25470c..c8eb326e9a9 100644 --- a/spec/support/sp_auth_helper.rb +++ b/spec/support/sp_auth_helper.rb @@ -22,7 +22,7 @@ def create_ial2_account_go_back_to_sp_and_sign_out(sp) click_idv_continue fill_in t('idv.form.password'), with: user.password click_continue - click_acknowledge_personal_key + acknowledge_and_confirm_personal_key expect(page).to have_current_path(sign_up_completed_path) click_agree_and_continue visit sign_out_url diff --git a/spec/views/idv/phone_errors/failure.html.erb_spec.rb b/spec/views/idv/phone_errors/failure.html.erb_spec.rb index e87091921dd..02ac43246e8 100644 --- a/spec/views/idv/phone_errors/failure.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/failure.html.erb_spec.rb @@ -11,6 +11,7 @@ before do decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, true) allow(IdentityConfig.store).to receive(:idv_attempt_window_in_hours).and_return(timeout_hours) @expires_at = Time.zone.now + timeout_hours.hours diff --git a/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb b/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb index 5a71334b921..92f1d5a77e9 100644 --- a/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/jobfail.html.erb_spec.rb @@ -2,14 +2,12 @@ describe 'idv/phone_errors/jobfail.html.erb' do let(:sp_name) { 'Example SP' } - let(:enable_gpo_verification) { false } + let(:gpo_letter_available) { false } before do - allow(FeatureManagement).to receive(:enable_gpo_verification?). - and_return(enable_gpo_verification) - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, gpo_letter_available) render end @@ -43,7 +41,7 @@ end context 'gpo verification enabled' do - let(:enable_gpo_verification) { true } + let(:gpo_letter_available) { true } it 'renders a list of troubleshooting options' do expect(rendered).to have_link( diff --git a/spec/views/idv/phone_errors/timeout.html.erb_spec.rb b/spec/views/idv/phone_errors/timeout.html.erb_spec.rb index 2f9f3135b9d..0c481b89724 100644 --- a/spec/views/idv/phone_errors/timeout.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/timeout.html.erb_spec.rb @@ -2,14 +2,12 @@ describe 'idv/phone_errors/timeout.html.erb' do let(:sp_name) { 'Example SP' } - let(:enable_gpo_verification) { false } + let(:gpo_letter_available) { false } before do - allow(FeatureManagement).to receive(:enable_gpo_verification?). - and_return(enable_gpo_verification) - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, gpo_letter_available) render end @@ -43,7 +41,7 @@ end context 'gpo verification enabled' do - let(:enable_gpo_verification) { true } + let(:gpo_letter_available) { true } it 'renders a list of troubleshooting options' do expect(rendered).to have_link( diff --git a/spec/views/idv/phone_errors/warning.html.erb_spec.rb b/spec/views/idv/phone_errors/warning.html.erb_spec.rb index 10a83fefed9..71e20bc0a2e 100644 --- a/spec/views/idv/phone_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/phone_errors/warning.html.erb_spec.rb @@ -3,14 +3,12 @@ describe 'idv/phone_errors/warning.html.erb' do let(:sp_name) { 'Example SP' } let(:remaining_attempts) { 5 } - let(:enable_gpo_verification) { false } + let(:gpo_letter_available) { false } before do - allow(FeatureManagement).to receive(:enable_gpo_verification?). - and_return(enable_gpo_verification) - decorated_session = instance_double(ServiceProviderSessionDecorator, sp_name: sp_name) allow(view).to receive(:decorated_session).and_return(decorated_session) + assign(:gpo_letter_available, gpo_letter_available) assign(:remaining_attempts, remaining_attempts) @@ -43,7 +41,7 @@ end context 'gpo verification enabled' do - let(:enable_gpo_verification) { true } + let(:gpo_letter_available) { true } it 'renders a list of troubleshooting options' do expect(rendered).to have_link( diff --git a/spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb b/spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb new file mode 100644 index 00000000000..ea6d2e3f6b8 --- /dev/null +++ b/spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe 'partials/multi_factor_authentication/_mfa_selection.html.erb' do + include SimpleForm::ActionViewExtensions::FormHelper + include Devise::Test::ControllerHelpers + + let(:lookup_context) { ActionView::LookupContext.new(ActionController::Base.view_paths) } + let(:view_context) { ActionView::Base.new(lookup_context, {}, controller) } + let(:form_object) { User.new } + let(:presenter) { TwoFactorOptionsPresenter.new(user_agent: nil) } + let(:form_builder) do + SimpleForm::FormBuilder.new(form_object.model_name.param_key, form_object, view_context, {}) + end + + subject(:rendered) do + render partial: 'mfa_selection', locals: { + form: form_builder, + option: presenter.options[4], + } + end + + it 'renders an lg-validated-field tag' do + expect(rendered).to have_css('.mfa-selection') + end + + context 'before selecting options' do + it 'does not display any errors' do + expect(rendered).to_not have_css('.checkbox__invalid') + end + end +end