diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index fc6294546e0..00000000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,53 +0,0 @@ -version: '2' # required to adjust maintainability checks -checks: - argument-count: - config: - threshold: 4 - complex-logic: - enabled: false - file-lines: - enabled: false - method-complexity: - enabled: false - method-count: - enabled: false - method-lines: - enabled: false - nested-control-flow: - config: - threshold: 4 - return-statements: - enabled: false - similar-code: - enabled: false - config: - threshold: # language-specific defaults. an override will affect all languages. - identical-code: - enabled: false - config: - threshold: # language-specific defaults. an override will affect all languages. - -plugins: - duplication: - enabled: true - config: - languages: - ruby: - javascript: - mass_threshold: 50 - exclude_patterns: - - 'spec/**/*' - - 'node_modules/**/*' - - 'db/schema.rb' - - 'app/forms/password_form.rb' - -exclude_patterns: - - 'db/schema.rb' - - 'node_modules/' - - 'lib/proofer_mocks/' - - 'lib/tasks/create_test_accounts.rb' - - 'scripts/load_testing/' - - 'tmp/' - - 'config/initializers/jwt.rb' - - 'spec/' - - 'public/acuant/' diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b3d6d9af022..ff1476b15b1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -26,7 +26,7 @@ class ApplicationController < ActionController::Base rescue_from error, with: :render_timeout end - helper_method :decorated_sp_session, :user_fully_authenticated? + helper_method :decorated_sp_session, :current_sp, :user_fully_authenticated? prepend_before_action :add_new_relic_trace_attributes prepend_before_action :session_expires_at @@ -77,6 +77,19 @@ def analytics_user current_user || AnonymousUser.new end + def attempts_api_tracker + @attempts_api_tracker ||= AttemptsApi::Tracker.new( + session_id: attempts_api_session_id, + request:, + user: current_user, + sp: current_sp, + cookie_device_uuid: cookies[:device], + # this only works for oidc + sp_request_uri: decorated_sp_session.request_url_params[:redirect_uri], + enabled_for_session: attempts_api_enabled_for_session?, + ) + end + def user_event_creator @user_event_creator ||= UserEventCreator.new(request: request, current_user: current_user) end @@ -127,6 +140,14 @@ def current_sp private + def attempts_api_enabled_for_session? + current_sp&.attempts_api_enabled? && attempts_api_session_id.present? + end + + def attempts_api_session_id + @attempts_api_session_id ||= decorated_sp_session.attempts_api_session_id + end + # These attributes show up in New Relic traces for all requests. # https://docs.newrelic.com/docs/agents/manage-apm-agents/agent-data/collect-custom-attributes def add_new_relic_trace_attributes diff --git a/app/controllers/sign_up/passwords_controller.rb b/app/controllers/sign_up/passwords_controller.rb index cdfc07e9169..05c3b571120 100644 --- a/app/controllers/sign_up/passwords_controller.rb +++ b/app/controllers/sign_up/passwords_controller.rb @@ -38,6 +38,12 @@ def forbidden_passwords def track_analytics(result) analytics.password_creation(**result) + + failure_reason = attempts_api_tracker.parse_failure_reason(result) + attempts_api_tracker.user_registration_password_submitted( + success: result.success?, + failure_reason:, + ) end def permitted_params diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index 06c4405112c..d456a705e52 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -124,6 +124,7 @@ def confirm_voice_capability return if params[:otp_delivery_preference] == 'sms' phone_is_confirmed = UserSessionContext.authentication_or_reauthentication_context?(context) + capabilities = PhoneNumberCapabilities.new(phone, phone_confirmed: phone_is_confirmed) return if capabilities.supports_voice? @@ -233,6 +234,7 @@ def update_phone_attributes user: current_user, attributes: { phone_id: user_session[:phone_id], phone: user_session[:unconfirmed_phone], + phone_confirmed_at: Time.zone.now, otp_make_default_number: selected_otp_make_default_number }, ) end diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index bcea97b60ef..7cb37339b39 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -23,6 +23,7 @@ def index result = BackupCodeSetupForm.new(current_user).submit visit_result = result.to_h.merge(analytics_properties_for_visit) analytics.backup_code_setup_visit(**visit_result) + attempts_api_tracker.mfa_enroll_backup_code(success: result.success?) generate_codes track_backup_codes_created @@ -35,6 +36,7 @@ def create result = BackupCodeSetupForm.new(current_user).submit visit_result = result.to_h.merge(analytics_properties_for_visit) analytics.backup_code_setup_visit(**visit_result) + attempts_api_tracker.mfa_enroll_backup_code(success: result.success?) generate_codes track_backup_codes_created diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index d032adf41b6..cef07f24ee2 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -235,6 +235,7 @@ def track_authentication_attempt remember_device: remember_device_cookie.present?, new_device: success ? new_device? : nil, ) + attempts_api_tracker.email_and_password_auth(success:) end def user_locked_out?(user) diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index 7e50930a4fb..87e861613bb 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -30,6 +30,8 @@ def confirm properties = result.to_h.merge(analytics_properties) analytics.multi_factor_auth_setup(**properties) + attempts_api_tracker.mfa_enroll_totp(success: result.success?) + if result.success? process_valid_code user_session.delete(:mfa_attempts) diff --git a/app/decorators/null_service_provider_session.rb b/app/decorators/null_service_provider_session.rb index 694e30802cd..9de0bf95dc5 100644 --- a/app/decorators/null_service_provider_session.rb +++ b/app/decorators/null_service_provider_session.rb @@ -21,10 +21,6 @@ def sp_name; end def sp_issuer; end - def sp_logo; end - - def sp_logo_url; end - def sp_redirect_uris; end def requested_attributes; end @@ -47,6 +43,8 @@ def current_user view_context&.current_user end + def attempts_api_session_id; end + private attr_reader :view_context diff --git a/app/decorators/service_provider_session.rb b/app/decorators/service_provider_session.rb index 2cd95729dfb..35bc51b5da3 100644 --- a/app/decorators/service_provider_session.rb +++ b/app/decorators/service_provider_session.rb @@ -4,8 +4,6 @@ class ServiceProviderSession include ActionView::Helpers::TranslationHelper include Rails.application.routes.url_helpers - DEFAULT_LOGO = 'generic.svg' - def initialize(sp:, view_context:, sp_session:, service_provider_request:) @sp = sp @view_context = view_context @@ -13,6 +11,10 @@ def initialize(sp:, view_context:, sp_session:, service_provider_request:) @service_provider_request = service_provider_request end + def attempts_api_session_id + request_url_params['attempts_api_session_id'] + end + def remember_device_default sp_aal < 2 end @@ -21,33 +23,6 @@ def sp_redirect_uris @sp.redirect_uris end - def sp_logo - sp.logo.presence || DEFAULT_LOGO - end - - def sp_logo_url - if FeatureManagement.logo_upload_enabled? && sp.remote_logo_key.present? - s3_logo_url(sp) - else - legacy_logo_url - end - end - - def s3_logo_url(service_provider) - region = IdentityConfig.store.aws_region - bucket = IdentityConfig.store.aws_logo_bucket - key = service_provider.remote_logo_key - - "https://s3.#{region}.amazonaws.com/#{bucket}/#{key}" - end - - def legacy_logo_url - logo = sp_logo - ActionController::Base.helpers.image_path("sp-logos/#{logo}") - rescue Propshaft::MissingAssetError - '' - end - def new_session_heading I18n.t('headings.sign_in_with_sp', sp: sp_name) end diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index 241c1c35a9b..d56dd51de67 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -172,7 +172,7 @@ def extra_attributes submit_attempts: submit_attempts, remaining_submit_attempts: remaining_submit_attempts, user_id: user_uuid, - pii_like_keypaths: DocPiiForm.pii_like_keypaths, + pii_like_keypaths: DocPiiForm.pii_like_keypaths(document_type: document_type), flow_path: params[:flow_path], } diff --git a/app/forms/idv/doc_pii_form.rb b/app/forms/idv/doc_pii_form.rb index 07acb09c7fd..d9afff38fe9 100644 --- a/app/forms/idv/doc_pii_form.rb +++ b/app/forms/idv/doc_pii_form.rb @@ -6,22 +6,9 @@ class DocPiiForm validate :name_valid? validate :dob_valid? - validates_presence_of :address1, { message: proc { - I18n.t('doc_auth.errors.alerts.address_check') - } } - validate :zipcode_valid? - validates :jurisdiction, :state, inclusion: { in: Idp::Constants::STATE_AND_TERRITORY_CODES, - message: proc { - I18n.t('doc_auth.errors.general.no_liveness') - } } - - validates_presence_of :state_id_number, { message: proc { - I18n.t('doc_auth.errors.general.no_liveness') - } } - validate :state_id_expired? + validate :state_id_or_passport - attr_reader :first_name, :last_name, :dob, :address1, :state, :zipcode, :attention_with_barcode, - :jurisdiction, :state_id_number, :state_id_expiration + attr_reader :first_name, :last_name, :dob, :state_id_type, :attention_with_barcode alias_method :attention_with_barcode?, :attention_with_barcode def initialize(pii:, attention_with_barcode: false) @@ -29,12 +16,7 @@ def initialize(pii:, attention_with_barcode: false) @first_name = pii[:first_name] @last_name = pii[:last_name] @dob = pii[:dob] - @address1 = pii[:address1] - @state = pii[:state] - @zipcode = pii[:zipcode] - @jurisdiction = pii[:state_id_jurisdiction] - @state_id_number = pii[:state_id_number] - @state_id_expiration = pii[:state_id_expiration] + @state_id_type = pii[:state_id_type] @attention_with_barcode = attention_with_barcode end @@ -43,19 +25,27 @@ def submit success: valid?, errors: errors, extra: { - pii_like_keypaths: self.class.pii_like_keypaths, + pii_like_keypaths: self.class.pii_like_keypaths(document_type: state_id_type), attention_with_barcode: attention_with_barcode?, id_issued_status: pii_from_doc[:state_id_issued].present? ? 'present' : 'missing', id_expiration_status: pii_from_doc[:state_id_expiration].present? ? 'present' : 'missing', + passport_issued_status: pii_from_doc[:passport_issued].present? ? 'present' : 'missing', + passport_expiration_status: pii_from_doc[:passport_expiration].present? ? + 'present' : 'missing', }, ) response.pii_from_doc = pii_from_doc response end - def self.pii_like_keypaths + def self.pii_like_keypaths(document_type:) keypaths = [[:pii]] - attrs = %i[name dob dob_min_age address1 state zipcode jurisdiction state_id_number] + document_attrs = document_type&.downcase == 'passport' ? + DocPiiPassport.pii_like_keypaths : + DocPiiStateId.pii_like_keypaths + + attrs = %i[name dob dob_min_age] + document_attrs + attrs.each do |k| keypaths << [:errors, k] keypaths << [:error_details, k] @@ -84,6 +74,7 @@ def self.present_error(existing_errors) PII_ERROR_KEYS = %i[name dob address1 state zipcode jurisdiction state_id_number dob_min_age].freeze + STATE_ID_TYPES = ['drivers_license', 'state_id_card', 'identification_card'].freeze attr_reader :pii_from_doc @@ -108,22 +99,19 @@ def dob_valid? end end - def state_id_expired? - # temporary fix, tracked for removal in LG-15600 - return if IdentityConfig.store.socure_docv_verification_data_test_mode && - DateParser.parse_legacy(state_id_expiration) == Date.parse('2020-01-01') - - if state_id_expiration && DateParser.parse_legacy(state_id_expiration).past? - errors.add(:state_id_expiration, generic_error, type: :state_id_expiration) + def state_id_or_passport + case state_id_type + when *STATE_ID_TYPES + state_id_validation = DocPiiStateId.new(pii: pii_from_doc) + state_id_validation.valid? || errors.merge!(state_id_validation.errors) + when 'passport' + passport_validation = DocPiiPassport.new(pii: pii_from_doc) + passport_validation.valid? || errors.merge!(passport_validation.errors) + else + errors.add(:no_document, generic_error, type: :no_document) end end - def zipcode_valid? - return if zipcode.is_a?(String) && zipcode.present? - - errors.add(:zipcode, generic_error, type: :zipcode) - end - def generic_error I18n.t('doc_auth.errors.general.no_liveness') end diff --git a/app/forms/idv/doc_pii_passport.rb b/app/forms/idv/doc_pii_passport.rb new file mode 100644 index 00000000000..417be6b871c --- /dev/null +++ b/app/forms/idv/doc_pii_passport.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Idv + class DocPiiPassport + include ActiveModel::Model + + validates :birth_place, + :passport_issued, + :nationality_code, + :mrz, + presence: { message: proc { I18n.t('doc_auth.errors.general.no_liveness') } } + + validates :issuing_country_code, + :nationality_code, + inclusion: { + in: 'USA', message: proc { I18n.t('doc_auth.errors.general.no_liveness') } + } + + validate :passport_expired? + + attr_reader :birth_place, :passport_expiration, :passport_issued, :state_id_type, + :issuing_country_code, :nationality_code, :mrz + + def initialize(pii:) + @pii_from_doc = pii + @birth_place = pii[:birth_place] + @passport_expiration = pii[:passport_expiration] + @passport_issued = pii[:passport_issued] + @issuing_country_code = pii[:issuing_country_code] + @nationality_code = pii[:nationality_code] + @mrz = pii[:mrz] + end + + def self.pii_like_keypaths + %i[birth_place passport_issued issuing_country_code nationality_code mrz] + end + + private + + attr_reader :pii_from_doc + + def generic_error + I18n.t('doc_auth.errors.general.no_liveness') + end + + def passport_expired? + if passport_expiration && DateParser.parse_legacy(passport_expiration).past? + errors.add(:passport_expiration, generic_error, type: :passport_expiration) + end + end + end +end diff --git a/app/forms/idv/doc_pii_state_id.rb b/app/forms/idv/doc_pii_state_id.rb new file mode 100644 index 00000000000..751ad30fe86 --- /dev/null +++ b/app/forms/idv/doc_pii_state_id.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Idv + class DocPiiStateId + include ActiveModel::Model + + validates_presence_of :address1, { message: proc { + I18n.t('doc_auth.errors.alerts.address_check') + } } + + validate :zipcode_valid? + validates :jurisdiction, :state, inclusion: { in: Idp::Constants::STATE_AND_TERRITORY_CODES, + message: proc { + I18n.t('doc_auth.errors.general.no_liveness') + } } + + validates_presence_of :state_id_number, { message: proc { + I18n.t('doc_auth.errors.general.no_liveness') + } } + validate :state_id_expired? + + attr_reader :address1, :state, :zipcode, :attention_with_barcode, :jurisdiction, + :state_id_number, :state_id_expiration + alias_method :attention_with_barcode?, :attention_with_barcode + + def initialize(pii:) + @pii_from_doc = pii + @address1 = pii[:address1] + @state = pii[:state] + @zipcode = pii[:zipcode] + @jurisdiction = pii[:state_id_jurisdiction] + @state_id_number = pii[:state_id_number] + @state_id_expiration = pii[:state_id_expiration] + @attention_with_barcode = attention_with_barcode + end + + def self.pii_like_keypaths + %i[address1 state zipcode jurisdiction state_id_number] + end + + private + + attr_reader :pii_from_doc + + def generic_error + I18n.t('doc_auth.errors.general.no_liveness') + end + + def state_id_expired? + # temporary fix, tracked for removal in LG-15600 + return if IdentityConfig.store.socure_docv_verification_data_test_mode && + DateParser.parse_legacy(state_id_expiration) == Date.parse('2020-01-01') + + if state_id_expiration && DateParser.parse_legacy(state_id_expiration).past? + errors.add(:state_id_expiration, generic_error, type: :state_id_expiration) + end + end + + def zipcode_valid? + return if zipcode.is_a?(String) && zipcode.present? + + errors.add(:zipcode, generic_error, type: :zipcode) + end + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 611e4c48d07..562afb31b59 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -305,12 +305,19 @@ def in_person_ready_to_verify(enrollment:, is_enhanced_ipp:) IdentityConfig.store.in_person_outage_emailed_by_date.present? && IdentityConfig.store.in_person_outage_expected_update_date.present? @header = is_enhanced_ipp ? - t('in_person_proofing.headings.barcode_eipp') : t('in_person_proofing.headings.barcode') + t('in_person_proofing.headings.barcode_eipp') : t('in_person_proofing.headings.barcode') @presenter = Idv::InPerson::ReadyToVerifyPresenter.new( enrollment: enrollment, barcode_image_url: attachments['barcode.png'].url, is_enhanced_ipp: is_enhanced_ipp, ) + + if enrollment&.service_provider&.logo_is_email_compatible? + @logo_url = enrollment.service_provider.logo_url + else + @logo_url = nil + end + @sp_name = enrollment.service_provider&.friendly_name @is_enhanced_ipp = is_enhanced_ipp mail( diff --git a/app/models/service_provider.rb b/app/models/service_provider.rb index ff1d631d755..d4c62e001a3 100644 --- a/app/models/service_provider.rb +++ b/app/models/service_provider.rb @@ -46,6 +46,8 @@ class ServiceProvider < ApplicationRecord scope(:internal, -> { where(iaa: IAA_INTERNAL) }) scope(:external, -> { where.not(iaa: IAA_INTERNAL).or(where(iaa: nil)) }) + DEFAULT_LOGO = 'generic.svg' + def metadata attributes.symbolize_keys.merge(certs: ssl_certs) end @@ -82,8 +84,51 @@ def facial_match_ial_allowed? IdentityConfig.store.facial_match_general_availability_enabled end + def attempts_api_enabled? + IdentityConfig.store.attempts_api_enabled && attempts_config.present? + end + + def attempts_public_key + if attempts_config.present? && attempts_config['keys'].present? + OpenSSL::PKey::RSA.new(attempts_config['keys'].first) + else + ssl_certs.first.public_key + end + end + + def logo_url + if FeatureManagement.logo_upload_enabled? && remote_logo_key.present? + s3_logo_url + else + legacy_logo_url + end + end + + def logo_is_email_compatible? + logo_url.end_with?('.png') + end + private + def s3_logo_url + region = IdentityConfig.store.aws_region + bucket = IdentityConfig.store.aws_logo_bucket + + "https://s3.#{region}.amazonaws.com/#{bucket}/#{remote_logo_key}" + end + + def legacy_logo_url + ActionController::Base.helpers.image_path("sp-logos/#{logo || DEFAULT_LOGO}") + rescue Propshaft::MissingAssetError + '' + end + + def attempts_config + IdentityConfig.store.allowed_attempts_providers.find do |config| + config['issuer'] == issuer + end + end + # @return [String,nil] def load_cert(cert) if cert.include?('-----BEGIN CERTIFICATE-----') diff --git a/app/models/sp_return_log.rb b/app/models/sp_return_log.rb index 2285ce96e1a..30d9df3a23b 100644 --- a/app/models/sp_return_log.rb +++ b/app/models/sp_return_log.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class SpReturnLog < ApplicationRecord - self.ignored_columns = %w[requested_at] - # rubocop:disable Rails/InverseOf belongs_to :user belongs_to :service_provider, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 03271de8fba..0a513410e6e 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -2082,6 +2082,8 @@ def idv_doc_auth_submitted_image_upload_vendor( # @param [Boolean] liveness_checking_required Whether or not the selfie is required # @param ["present","missing"] id_issued_status Status of state_id_issued field presence # @param ["present","missing"] id_expiration_status Status of state_id_expiration field presence + # @param ["present","missing"] passport_issued_status Status of passport_issued field presence + # @param ["present","missing"] passport_expiration_status Status of passport_expiration field # @param [Boolean] attention_with_barcode Whether result was attention with barcode # @param [Integer] submit_attempts Times that user has tried submitting # @param [String] front_image_fingerprint Fingerprint of front image data @@ -2098,6 +2100,8 @@ def idv_doc_auth_submitted_pii_validation( attention_with_barcode:, id_issued_status:, id_expiration_status:, + passport_issued_status:, + passport_expiration_status:, submit_attempts:, errors: nil, error_details: nil, @@ -2118,6 +2122,8 @@ def idv_doc_auth_submitted_pii_validation( attention_with_barcode:, id_issued_status:, id_expiration_status:, + passport_issued_status:, + passport_expiration_status:, submit_attempts:, remaining_submit_attempts:, flow_path:, diff --git a/app/services/attempts_api/request_token_validator.rb b/app/services/attempts_api/request_token_validator.rb index a01334d9db0..ef6c2f5863f 100644 --- a/app/services/attempts_api/request_token_validator.rb +++ b/app/services/attempts_api/request_token_validator.rb @@ -28,14 +28,6 @@ def config_data_exists ) end - def issuer_is_authorized - errors.add( - :issuer, - :not_authorized, - message: 'Issuer is not authorized to use Attempts API', - ) - end - def service_provider_exists return if service_provider.present? diff --git a/app/services/attempts_api/tracker.rb b/app/services/attempts_api/tracker.rb index 6d1b228f94f..122f025c8c5 100644 --- a/app/services/attempts_api/tracker.rb +++ b/app/services/attempts_api/tracker.rb @@ -3,10 +3,10 @@ module AttemptsApi class Tracker attr_reader :session_id, :enabled_for_session, :request, :user, :sp, :cookie_device_uuid, - :sp_request_uri, :analytics + :sp_request_uri def initialize(session_id:, request:, user:, sp:, cookie_device_uuid:, - sp_request_uri:, enabled_for_session:, analytics:) + sp_request_uri:, enabled_for_session:) @session_id = session_id @request = request @user = user @@ -14,7 +14,6 @@ def initialize(session_id:, request:, user:, sp:, cookie_device_uuid:, @cookie_device_uuid = cookie_device_uuid @sp_request_uri = sp_request_uri @enabled_for_session = enabled_for_session - @analytics = analytics end include TrackerEvents @@ -50,7 +49,7 @@ def track_event(event_type, metadata = {}) jwe = event.to_jwe( issuer: sp.issuer, - public_key: sp.ssl_certs.first.public_key, + public_key: sp.attempts_public_key, ) redis_client.write_event( diff --git a/app/services/attempts_api/tracker_events.rb b/app/services/attempts_api/tracker_events.rb index 88c189d0616..8dbe1d829ca 100644 --- a/app/services/attempts_api/tracker_events.rb +++ b/app/services/attempts_api/tracker_events.rb @@ -2,14 +2,44 @@ module AttemptsApi module TrackerEvents - # @param [String] email The submitted email address # @param [Boolean] success True if the email and password matched # A user has submitted an email address and password for authentication - def email_and_password_auth(email:, success:) + def email_and_password_auth(success:) track_event( 'login-email-and-password-auth', - email: email, - success: success, + success:, + ) + end + + # @param [Boolean] success + # A user has attempted to enroll the Backup Codes MFA method to their account + def mfa_enroll_backup_code(success:) + track_event( + 'mfa-enroll-backup-code', + success:, + ) + end + + # @param [Boolean] success + # A user has attempted to enroll the TOTP MFA method to their account + def mfa_enroll_totp(success:) + track_event( + 'mfa-enroll-totp', + success:, + ) + end + + # Tracks when user submits registration password + # @param [Boolean] success + # @param [Hash>] failure_reason + def user_registration_password_submitted( + success:, + failure_reason: nil + ) + track_event( + 'user-registration-password-submitted', + success:, + failure_reason:, ) end end diff --git a/app/services/update_user_phone_configuration.rb b/app/services/update_user_phone_configuration.rb index ff11b1730b7..f34c8db29ce 100644 --- a/app/services/update_user_phone_configuration.rb +++ b/app/services/update_user_phone_configuration.rb @@ -14,7 +14,10 @@ def initialize(user:, attributes:) def call result = user.update!( - attributes.except(:phone_id, :phone, :otp_make_default_number), + attributes.except( + :phone_id, :phone, :phone_confirmed_at, + :otp_make_default_number + ), ) manage_phone_configuration result @@ -51,6 +54,7 @@ def duplicate_phone? def phone_attributes @phone_attributes ||= { phone: attributes[:phone], + confirmed_at: attributes[:phone_confirmed_at], delivery_preference: attribute(:otp_delivery_preference), made_default_at: made_default_at_date, }.delete_if { |_, value| value.nil? } diff --git a/app/views/idv/address/new.html.erb b/app/views/idv/address/new.html.erb index ae48405183e..5496e8dd3a5 100644 --- a/app/views/idv/address/new.html.erb +++ b/app/views/idv/address/new.html.erb @@ -20,6 +20,8 @@ end %> +

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

+ <%= simple_form_for( @address_form, url: idv_address_path, diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index 562ff68439a..4c849abb4c1 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -37,6 +37,30 @@ + <% if @sp_name %> + + + + <% else %> + <% end %>
+ + + + <% if @logo_url %> + + <% else %> + + <% end %> + + +
+ <%= image_tag( + attachments['logo.png'].url, + size: '142x19', + style: 'width: 142px; height: 19px;', + alt: t('mailer.logo', app_name: APP_NAME), + ) %> + <%= image_tag(@logo_url, height: 40, width: 'auto', alt: @sp_name) %> <%= @sp_name =%>
+
@@ -54,6 +78,7 @@
diff --git a/app/views/shared/_nav_branded.html.erb b/app/views/shared/_nav_branded.html.erb index 8dfcf6fe79c..01121d949be 100644 --- a/app/views/shared/_nav_branded.html.erb +++ b/app/views/shared/_nav_branded.html.erb @@ -1,3 +1,3 @@ <%= image_tag(asset_url('logo.svg'), height: 15, width: 111, alt: APP_NAME) %>
-<%= image_tag(decorated_sp_session.sp_logo_url, height: 40, alt: decorated_sp_session.sp_name) %> +<%= image_tag(current_sp.logo_url, height: 40, alt: decorated_sp_session.sp_name) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index c4bdc8a36b7..2d28ebe903c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -592,7 +592,7 @@ doc_auth.headers.low_resolution: Your device’s camera may not be supported. doc_auth.headers.unaccepted_id_type: Use a driver’s license or a state ID doc_auth.headers.underage: Age requirement not met doc_auth.headers.unreadable_id: We could not read your ID -doc_auth.headings.address: Update your mailing address +doc_auth.headings.address: Update your current residential address doc_auth.headings.back: Back of your driver’s license or state ID doc_auth.headings.capture_complete: We verified your identity document doc_auth.headings.capture_scan_warning_html: We couldn’t read the barcode on your ID. If the information below is incorrect, please %{link_html} of your state‑issued ID. @@ -633,6 +633,7 @@ doc_auth.hybrid_flow_warning.only_add_own_account: You are using your own %{app_ doc_auth.hybrid_flow_warning.only_add_phone_verify: You asked %{app_name} to verify your ID using your phone doc_auth.hybrid_flow_warning.only_add_sp_services_html: You are trying to access %{service_provider_name} services doc_auth.info.add_id_consent_with_phone: Only add your ID if you asked %{app_name} to verify your ID using your phone. +doc_auth.info.address: If your residential address and mailing address are different, you can also try to verify your mailing address. doc_auth.info.address_guidance_puerto_rico_html: Puerto Rico residents:

Edit your address to list your urbanization or condominium on address line 2. doc_auth.info.capture_status_big_document: Too Close doc_auth.info.capture_status_capturing: Capturing @@ -1170,7 +1171,7 @@ idv.messages.gpo.start_over_link_text: start over and verify with your new addre idv.messages.gpo.timeframe_html: You’ll get a letter with a verification code in 5 to 10 days. idv.messages.otp_delivery_method_description: If you entered a landline above, please select “Phone call” below. idv.messages.phone.alert_html: 'Enter a phone number that is:' -idv.messages.phone.description: We’ll check your number with records and send you a one-time code to verify your identity. +idv.messages.phone.description: We will check records to verify that your phone number matches your verified information, and send a one-time code. idv.messages.phone.failed_number.alert_text: We couldn’t match you to this number. idv.messages.phone.failed_number.gpo_alert_html: Try another number or %{link_html}. idv.messages.phone.failed_number.gpo_verify_link: verify by mail @@ -1181,7 +1182,7 @@ idv.messages.phone.rules: idv.messages.return_to_profile: '‹ Return to your %{app_name} profile' idv.messages.sessions.enter_password_message: When you re-enter your password, %{app_name} will protect the information you’ve given us, so that only you can access it. idv.messages.sessions.no_pii: TEST SITE - Do not use real personal information (demo purposes only) - TEST SITE -idv.messages.verify_info: We read your information from your ID. Review it and make any updates before submitting for verification. +idv.messages.verify_info: We will check records to verify that your address and Social Security number match the information on your ID. idv.messages.verifying: Verifying… idv.titles.activated: Your identity has already been verified idv.titles.come_back_later: Your letter is on the way @@ -1611,7 +1612,7 @@ titles.backup_codes: Don’t lose your backup codes titles.cancel_exit_login: Are you sure you want to cancel and exit %{app_name}? titles.confirmations.delete: Please confirm titles.confirmations.show: Choose a password -titles.doc_auth.address: Update your mailing address +titles.doc_auth.address: Update your current residential address titles.doc_auth.doc_capture: Add your ID titles.doc_auth.hybrid_handoff: Verify your ID titles.doc_auth.link_sent: Link sent diff --git a/config/locales/es.yml b/config/locales/es.yml index 27d85b42b68..69a76bea1ed 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -603,7 +603,7 @@ doc_auth.headers.low_resolution: Es posible que la cámara de su dispositivo no doc_auth.headers.unaccepted_id_type: Use una licencia de conducir o una identificación estatal doc_auth.headers.underage: No se cumplió con el requisito de edad doc_auth.headers.unreadable_id: No pudimos leer su identificación -doc_auth.headings.address: Actualice su dirección postal +doc_auth.headings.address: Actualice su domicilio actual doc_auth.headings.back: Reverso de su licencia de conducir o identificación estatal doc_auth.headings.capture_complete: Verificamos su documento de identidad doc_auth.headings.capture_scan_warning_html: No pudimos leer el código de barras en su identificación. Si la información que aparece a continuación es incorrecta, %{link_html} de su identificación emitida por el estado. @@ -644,6 +644,7 @@ doc_auth.hybrid_flow_warning.only_add_own_account: Está usando su propia cuenta doc_auth.hybrid_flow_warning.only_add_phone_verify: Usted solicitó a %{app_name} que verificara su identificación con su teléfono doc_auth.hybrid_flow_warning.only_add_sp_services_html: Está tratando de acceder a los servicios de %{service_provider_name} doc_auth.info.add_id_consent_with_phone: Solo agregue su identificación si solicitó a %{app_name} verificar su identidad usando su teléfono. +doc_auth.info.address: Si su domicilio y su dirección postal no son iguales, también puede intentar verificar su dirección postal. doc_auth.info.address_guidance_puerto_rico_html: Residentes en Puerto Rico:

Edite su dirección para que el edificio de su vivienda o condominio figure en la línea de dirección 2. doc_auth.info.capture_status_big_document: Demasiado cerca doc_auth.info.capture_status_capturing: Capturando @@ -1181,7 +1182,7 @@ idv.messages.gpo.start_over_link_text: empezar de nuevo y verificar con su nueva idv.messages.gpo.timeframe_html: Recibirá una carta con un código de verificación en un plazo de 5 a 10 días. idv.messages.otp_delivery_method_description: Si introdujo un teléfono fijo arriba, seleccione “Llamada telefónica” a continuación. idv.messages.phone.alert_html: 'Introduzca un número de teléfono que sea:' -idv.messages.phone.description: Comprobaremos su número con los registros y le enviaremos un código de un solo uso para verificar su identidad. +idv.messages.phone.description: Revisaremos los registros para verificar que su número de teléfono coincida con su información verificada, y enviaremos un código de un solo uso. idv.messages.phone.failed_number.alert_text: No pudimos asociarlo a este número. idv.messages.phone.failed_number.gpo_alert_html: Intente con otro número o %{link_html}. idv.messages.phone.failed_number.gpo_verify_link: verificar por correo @@ -1192,7 +1193,7 @@ idv.messages.phone.rules: idv.messages.return_to_profile: '‹ Vuelva a su perfil de %{app_name}' idv.messages.sessions.enter_password_message: Cuando vuelva a ingresar su contraseña, %{app_name} protegerá la información que nos dio, para que solo usted pueda acceder a ella. idv.messages.sessions.no_pii: SITIO DE PRUEBA - No use información personal real (para fines de demostración únicamente) - SITIO DE PRUEBA -idv.messages.verify_info: Leímos sus datos en su identificación. Revíselos y haga los cambios necesarios antes de enviarlos para verificación. +idv.messages.verify_info: Revisaremos los registros para verificar que su dirección y su número de Seguro Social coincidan con la información de su identificación. idv.messages.verifying: Verificando… idv.titles.activated: Ya se verificó su identidad idv.titles.come_back_later: Su carta está en camino @@ -1623,7 +1624,7 @@ titles.backup_codes: No pierda sus códigos de recuperación titles.cancel_exit_login: '¿Está seguro de que desea cancelar y salir de %{app_name}?' titles.confirmations.delete: Confirme titles.confirmations.show: Elija una contraseña -titles.doc_auth.address: Actualice su dirección postal +titles.doc_auth.address: Actualice su domicilio actual titles.doc_auth.doc_capture: Agregue su identificación titles.doc_auth.hybrid_handoff: Verifique su identidad titles.doc_auth.link_sent: Vínculo enviado diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f2d8de121fb..62083c54e82 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -592,7 +592,7 @@ doc_auth.headers.low_resolution: Il se peut que la caméra de votre appareil ne doc_auth.headers.unaccepted_id_type: Utiliser un permis de conduire ou une carte d’identité d’un État doc_auth.headers.underage: Condition d’âge non remplie doc_auth.headers.unreadable_id: Nous n’avons pas pu lire votre pièce d’identité -doc_auth.headings.address: Mettre à jour votre adresse postale +doc_auth.headings.address: Mettre à jour votre adresse personnelle doc_auth.headings.back: Verso de votre permis de conduire ou de votre carte d’identité d’un État doc_auth.headings.capture_complete: Nous avons vérifié votre pièce d’identité doc_auth.headings.capture_scan_warning_html: Nous n’avons pas pu lire le code-barres de votre pièce d’identité. Si les informations ci-dessous ne sont pas correctes, veuillez %{link_html} de votre carte d’identité délivrée par l’État. @@ -633,6 +633,7 @@ doc_auth.hybrid_flow_warning.only_add_own_account: Vous utilisez votre propre co doc_auth.hybrid_flow_warning.only_add_phone_verify: Vous avez demandé à %{app_name} de confirmer votre identité à l’aide de votre téléphone doc_auth.hybrid_flow_warning.only_add_sp_services_html: Vous essayez d’accéder aux services de %{service_provider_name} doc_auth.info.add_id_consent_with_phone: Ajoutez uniquement votre pièce d’identité si vous avez demandé à %{app_name} de vérifier votre identité à l’aide de votre téléphone. +doc_auth.info.address: Si votre adresse personnelle et votre adresse postale de contact sont différentes, vous pouvez également essayer de confirmer votre adresse postale. doc_auth.info.address_guidance_puerto_rico_html: Résidents de Porto Rico :

modifiez votre adresse pour indiquer votre lotissement ou votre condominium à la ligne 2. doc_auth.info.capture_status_big_document: Trop près doc_auth.info.capture_status_capturing: Prise de la photo @@ -1170,7 +1171,7 @@ idv.messages.gpo.start_over_link_text: recommencer et vérifier avec votre nouve idv.messages.gpo.timeframe_html: Vous recevrez une lettre avec un code de vérification dans un délai de 5 à 10 jours. idv.messages.otp_delivery_method_description: Si vous avez saisi une ligne fixe ci-dessus, veuillez sélectionner « Appel téléphonique » ci-dessous. idv.messages.phone.alert_html: 'Saisissez un numéro de téléphone qui est :' -idv.messages.phone.description: Nous vérifierons votre numéro dans nos données enregistrées et vous enverrons un code à usage unique afin de confirmer votre identité. +idv.messages.phone.description: Nous vérifierons que votre numéro de téléphone correspond aux renseignements figurant sur votre pièce d’identité. idv.messages.phone.failed_number.alert_text: Nous n’avons pas pu vous associer à ce numéro. idv.messages.phone.failed_number.gpo_alert_html: Essayez un autre numéro ou %{link_html}. idv.messages.phone.failed_number.gpo_verify_link: vérifiez par courrier @@ -1181,7 +1182,7 @@ idv.messages.phone.rules: idv.messages.return_to_profile: '‹ Revenir à votre profil %{app_name}' idv.messages.sessions.enter_password_message: Lorsque vous ressaisirez votre mot de passe, %{app_name} protégera les informations que vous nous aurez données pour que vous seul puissiez y accéder. idv.messages.sessions.no_pii: SITE DE TEST - N’utilisez pas de véritables informations personnelles (à des fins de démonstration uniquement) - SITE DE TEST -idv.messages.verify_info: Nous lisons vos informations à partir de votre pièce d’identité. Passez-les en revue et mettez-les à jour avant de les envoyer pour vérification. +idv.messages.verify_info: Nous vérifierons que votre adresse et votre numéro de sécurité sociale correspondent aux informations figurant sur votre pièce d’identité. idv.messages.verifying: Vérification en cours… idv.titles.activated: Votre identité a déjà été vérifiée idv.titles.come_back_later: Votre lettre est en route @@ -1611,7 +1612,7 @@ titles.backup_codes: Ne perdez pas vos codes de sauvegarde titles.cancel_exit_login: Êtes-vous sûr de vouloir annuler et quitter %{app_name}? titles.confirmations.delete: Veuillez confirmer titles.confirmations.show: Choisir un mot de passe -titles.doc_auth.address: Mettre à jour votre adresse postale +titles.doc_auth.address: Mettre à jour votre adresse personnelle titles.doc_auth.doc_capture: Ajouter votre pièce d’identité titles.doc_auth.hybrid_handoff: Vérifier votre identité titles.doc_auth.link_sent: Lien envoyé diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 22ef4e7f01e..87ac370a2d7 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -603,7 +603,7 @@ doc_auth.headers.low_resolution: 你设备的相机可能不受支持 doc_auth.headers.unaccepted_id_type: 使用驾驶执照或州颁发的身份证件 doc_auth.headers.underage: 不符合年龄规定 doc_auth.headers.unreadable_id: 我们无法读取你的身份证件 -doc_auth.headings.address: 更新你的邮政地址 +doc_auth.headings.address: 更新你当前的居住地址 doc_auth.headings.back: 驾照或州政府颁发身份证件的背面。 doc_auth.headings.capture_complete: 我们验证了你的身份文件 doc_auth.headings.capture_scan_warning_html: 我们读取不到你身份证件上的条形码。如果以下信息不正确,请将州政府颁发的身份证件%{link_html}。 @@ -644,6 +644,7 @@ doc_auth.hybrid_flow_warning.only_add_own_account: 你在使用自己的 %{app_n doc_auth.hybrid_flow_warning.only_add_phone_verify: 你要求 %{app_name} 使用你的电话来验证你的身份证件。 doc_auth.hybrid_flow_warning.only_add_sp_services_html: 你在试图访问 %{service_provider_name} 服务。 doc_auth.info.add_id_consent_with_phone: 仅在你已请求%{app_name}使用你的电话来验证你的身份的情况下才添加你的身份证件。 +doc_auth.info.address: 如果你的居住地址和邮寄地址不同,也可以尝试验证你的邮寄地址。 doc_auth.info.address_guidance_puerto_rico_html: 波多黎各居民:

编辑你的地址,在地址第 2 行列出你的 urbanization 或 condominium。 doc_auth.info.capture_status_big_document: 太近了 doc_auth.info.capture_status_capturing: 扫描中 @@ -1183,7 +1184,7 @@ idv.messages.gpo.start_over_link_text: 重新开始并用你新地址进行验 idv.messages.gpo.timeframe_html: 你会在 5 到 10 天 里收到带有验证码的信。 idv.messages.otp_delivery_method_description: 如果你在上面输入的是座机电话,请在下边选择“接听电话”。 idv.messages.phone.alert_html: '输入一个符合以下条件的电话号码:' -idv.messages.phone.description: 我们会将你的号码与记录核对并给你发个一次性代码来验证你的身份。 +idv.messages.phone.description: 我们会检查记录以验证你的电话号码是否与你验证过的信息相符,并发送一个一次性代码。 idv.messages.phone.failed_number.alert_text: 我们无法将你与该号码匹配。 idv.messages.phone.failed_number.gpo_alert_html: 试试 另一个号码或者%{link_html}。 idv.messages.phone.failed_number.gpo_verify_link: 通过普通邮件验证 @@ -1194,7 +1195,7 @@ idv.messages.phone.rules: idv.messages.return_to_profile: '‹ 返回你的 %{app_name} 用户资料' idv.messages.sessions.enter_password_message: 你重新输入密码时, %{app_name} 会保护你给我们的信息,这样只有你能访问这些信息。 idv.messages.sessions.no_pii: 测试站点 - 请勿使用真实个人信息(仅为演示目的)- 测试站点 -idv.messages.verify_info: 我们从你的身份证件上读取你的信息。提交进行验证之前请检查一下并做出必要更新。 +idv.messages.verify_info: 我们会检查记录以验证你的地址和社会保障号码是否与你身份证件上的信息相符。 idv.messages.verifying: 验证中。。。 idv.titles.activated: 你的身份已验证 idv.titles.come_back_later: 你的信件已寄出。 @@ -1624,7 +1625,7 @@ titles.backup_codes: 别丢了你的备用代码 titles.cancel_exit_login: 你确定要取消并退出 %{app_name} 吗? titles.confirmations.delete: 请确认 titles.confirmations.show: 选择一个密码 -titles.doc_auth.address: 更新你的邮政地址 +titles.doc_auth.address: 更新你当前的居住地址 titles.doc_auth.doc_capture: 添加你的身份证件 titles.doc_auth.hybrid_handoff: 验证你的身份证件 titles.doc_auth.link_sent: 链接已发送 diff --git a/db/primary_migrate/20250325164321_drop_requested_at_from_sp_return_log.rb b/db/primary_migrate/20250325164321_drop_requested_at_from_sp_return_log.rb new file mode 100644 index 00000000000..f02986d929c --- /dev/null +++ b/db/primary_migrate/20250325164321_drop_requested_at_from_sp_return_log.rb @@ -0,0 +1,5 @@ +class DropRequestedAtFromSpReturnLog < ActiveRecord::Migration[8.0] + def change + safety_assured { remove_column :sp_return_logs, :requested_at } + end +end diff --git a/db/schema.rb b/db/schema.rb index 9d63e03380c..d6ac988b823 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_03_21_141653) do +ActiveRecord::Schema[8.0].define(version: 2025_03_25_164321) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -581,7 +581,6 @@ end create_table "sp_return_logs", force: :cascade do |t| - t.datetime "requested_at", precision: nil, comment: "sensitive=false" t.string "request_id", null: false, comment: "sensitive=false" t.integer "ial", null: false, comment: "sensitive=false" t.string "issuer", null: false, comment: "sensitive=false" diff --git a/docs/attempts-api/schemas/events/sign-in/MfaLoginBackupCodeSubmitted.yml b/docs/attempts-api/schemas/events/sign-in/MfaLoginBackupCodeSubmitted.yml index 17ab80fbdb1..07d44760432 100644 --- a/docs/attempts-api/schemas/events/sign-in/MfaLoginBackupCodeSubmitted.yml +++ b/docs/attempts-api/schemas/events/sign-in/MfaLoginBackupCodeSubmitted.yml @@ -1,4 +1,4 @@ -description: During a login attempt, the user uses face or touch unlock for MFA. +description: During a login attempt, the user submits a code from their backup code list. allOf: - $ref: '../shared/EventProperties.yml' - type: object diff --git a/docs/attempts-api/schemas/events/sign-in/MfaLoginWebauthnRoamingSubmitted.yml b/docs/attempts-api/schemas/events/sign-in/MfaLoginWebauthnRoamingSubmitted.yml index 0206e0cda13..93a5461b7dc 100644 --- a/docs/attempts-api/schemas/events/sign-in/MfaLoginWebauthnRoamingSubmitted.yml +++ b/docs/attempts-api/schemas/events/sign-in/MfaLoginWebauthnRoamingSubmitted.yml @@ -1,4 +1,4 @@ -description: During a login attempt, the user uses face or touch unlock for MFA. +description: During a login attempt, the user uses a security key for MFA. allOf: - $ref: '../shared/EventProperties.yml' - type: object diff --git a/lib/data_requests/deployed/create_mfa_configurations_report.rb b/lib/data_requests/deployed/create_mfa_configurations_report.rb index 246d31f7927..d5c827d40ca 100644 --- a/lib/data_requests/deployed/create_mfa_configurations_report.rb +++ b/lib/data_requests/deployed/create_mfa_configurations_report.rb @@ -45,6 +45,7 @@ def phone_configurations_report id: phone_configuration.id, phone: phone_configuration.phone, created_at: phone_configuration.created_at, + confirmed_at: phone_configuration.confirmed_at, } end end diff --git a/lib/tasks/create_test_accounts.rb b/lib/tasks/create_test_accounts.rb index 6e5fb718d5c..aff3634fc56 100644 --- a/lib/tasks/create_test_accounts.rb +++ b/lib/tasks/create_test_accounts.rb @@ -18,6 +18,7 @@ def create_account(email: 'joe.smith@email.com', password: 'salty pickles', mfa_ user.save! MfaContext.new(user).phone_configurations.create( phone: mfa_phone || phone, + confirmed_at: Time.zone.now, delivery_preference: user.otp_delivery_preference ) Event.create(user_id: user.id, event_type: :account_created) diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake index 4f8bd1d4572..e719bba7b1f 100644 --- a/lib/tasks/dev.rake +++ b/lib/tasks/dev.rake @@ -237,6 +237,7 @@ namespace :dev do { delivery_preference: user.otp_delivery_preference, phone: format('+1 (415) 555-%04d', args[:num]), + confirmed_at: Time.zone.now, } end end diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index dc190a3cb63..ee3c2d32f0f 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -593,6 +593,86 @@ def index end end + describe '#attempts_api_tracker' do + let(:enabled) { true } + let(:sp) { create(:service_provider) } + let(:user) { create(:user) } + + before do + expect(IdentityConfig.store).to receive(:attempts_api_enabled).and_return enabled + allow(controller).to receive(:current_user).and_return(user) + allow(controller).to receive(:current_sp).and_return(sp) + end + + context 'when the attempts api is not enabled' do + let(:enabled) { false } + + it 'calls the AttemptsApi::Tracker class with enabled_for_session set to false' do + expect(AttemptsApi::Tracker).to receive(:new).with( + user:, request:, sp:, session_id: nil, + cookie_device_uuid: nil, sp_request_uri: nil, enabled_for_session: false + ) + + controller.attempts_api_tracker + end + end + + context 'when attempts api is enabled' do + context 'when the service provider is not authorized' do + before do + expect(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return([]) + end + + it 'calls the AttemptsApi::Tracker class with enabled_for_session set to false' do + expect(AttemptsApi::Tracker).to receive(:new).with( + user:, request:, sp:, session_id: nil, + cookie_device_uuid: nil, sp_request_uri: nil, enabled_for_session: false + ) + + controller.attempts_api_tracker + end + end + + context 'when the service provider is authorized' do + before do + expect(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return( + [ + { + 'issuer' => sp.issuer, + }, + ], + ) + end + + context 'when there is no attempts_api_session_id' do + it 'calls the AttemptsApi::Tracker class with enabled_for_session set to false' do + expect(AttemptsApi::Tracker).to receive(:new).with( + user:, request:, sp:, session_id: nil, + cookie_device_uuid: nil, sp_request_uri: nil, enabled_for_session: false + ) + + controller.attempts_api_tracker + end + end + + context 'when there is an attempts_api_session_id' do + before do + expect(controller.decorated_sp_session).to receive(:attempts_api_session_id) + .and_return('abc123') + end + it 'calls the AttemptsApi::Tracker class with enabled_for_session set to true' do + expect(AttemptsApi::Tracker).to receive(:new).with( + user:, request:, sp:, session_id: 'abc123', + cookie_device_uuid: nil, sp_request_uri: nil, enabled_for_session: true + ) + + controller.attempts_api_tracker + end + end + end + end + end + def expect_user_event_to_have_been_created(user, event_type) device = Device.first expect(device.user_id).to eq(user.id) diff --git a/spec/controllers/idv/enter_password_controller_spec.rb b/spec/controllers/idv/enter_password_controller_spec.rb index ed86a3988e1..ba62d1b64e4 100644 --- a/spec/controllers/idv/enter_password_controller_spec.rb +++ b/spec/controllers/idv/enter_password_controller_spec.rb @@ -1042,6 +1042,7 @@ def show receive(:resolved_authn_context_result).and_return(authn_context_result), ) end + it 'passes the correct param to the enrollment helper method' do expect(UspsInPersonProofing::EnrollmentHelper).to receive(:schedule_in_person_enrollment) .with( diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 1f31e38aaee..a4b9606bc72 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -405,6 +405,8 @@ classification_info: a_kind_of(Hash), id_issued_status: 'present', id_expiration_status: 'present', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', document_type: an_instance_of(String), ) @@ -543,6 +545,8 @@ ), id_issued_status: 'missing', id_expiration_status: 'present', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', document_type: an_instance_of(String), ) end @@ -621,6 +625,8 @@ ), id_issued_status: 'missing', id_expiration_status: 'present', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', document_type: an_instance_of(String), ) end @@ -696,6 +702,8 @@ classification_info: hash_including(:Front, :Back), id_issued_status: 'missing', id_expiration_status: 'present', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', document_type: an_instance_of(String), ) end @@ -770,6 +778,8 @@ classification_info: hash_including(:Front, :Back), id_issued_status: 'missing', id_expiration_status: 'present', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', document_type: an_instance_of(String), ) end @@ -845,6 +855,8 @@ classification_info: hash_including(:Front, :Back), id_issued_status: 'missing', id_expiration_status: 'present', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', document_type: an_instance_of(String), ) end diff --git a/spec/controllers/sign_up/passwords_controller_spec.rb b/spec/controllers/sign_up/passwords_controller_spec.rb index 0841c3dda46..3dc192d1b79 100644 --- a/spec/controllers/sign_up/passwords_controller_spec.rb +++ b/spec/controllers/sign_up/passwords_controller_spec.rb @@ -59,13 +59,14 @@ let(:password_confirmation) { password } let(:success_properties) { { success: true } } + before do + stub_analytics + stub_attempts_tracker + end + context 'with valid password' do let!(:user) { create(:user, :unconfirmed, confirmation_token: token) } - before do - stub_analytics - end - it 'tracks analytics' do subject @@ -77,6 +78,14 @@ ) end + it 'creates attempts event' do + expect(@attempts_api_tracker).to receive(:user_registration_password_submitted).with( + success: true, + failure_reason: nil, + ) + subject + end + it 'confirms the user' do subject @@ -101,13 +110,15 @@ context 'with an invalid password' do let!(:user) { create(:user, :unconfirmed, confirmation_token: token) } - before do - stub_analytics - end - context 'with a password that is too short' do let(:password) { 'NewVal' } let(:password_confirmation) { 'NewVal' } + let(:error_details) do + { + password: { too_short: true }, + password_confirmation: { too_short: true }, + } + end it 'tracks an invalid password event' do subject @@ -115,19 +126,29 @@ expect(@analytics).to have_logged_event( 'Password Creation', success: false, - error_details: { - password: { too_short: true }, - password_confirmation: { too_short: true }, - }, + error_details:, user_id: user.uuid, request_id_present: false, ) end + + it 'creates attempts event' do + expect(@attempts_api_tracker).to receive(:user_registration_password_submitted).with( + success: false, + failure_reason: error_details, + ) + subject + end end context 'when password confirmation does not match' do let(:password) { 'NewVal!dPassw0rd' } let(:password_confirmation) { 'bad match password' } + let(:error_details) do + { + password_confirmation: { mismatch: true }, + } + end it 'tracks invalid password_confirmation error' do subject @@ -135,13 +156,19 @@ expect(@analytics).to have_logged_event( 'Password Creation', success: false, - error_details: { - password_confirmation: { mismatch: true }, - }, + error_details:, user_id: user.uuid, request_id_present: false, ) end + + it 'creates attempts event' do + expect(@attempts_api_tracker).to receive(:user_registration_password_submitted).with( + success: false, + failure_reason: error_details, + ) + subject + end end end @@ -171,6 +198,11 @@ expect(user.confirmed?).to eq false expect(response).to redirect_to(sign_up_register_url) end + + it 'does not create attempts event' do + expect(@attempts_api_tracker).not_to receive(:user_registration_password_submitted) + subject + end end end end diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index 306dc23e2ff..e2b6e980a0d 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -607,9 +607,10 @@ expect(subject.user_session[:unconfirmed_phone]).to eq('+1 (703) 555-5555') end - it 'does not update user phone' do + it 'does not update user phone or phone_confirmed_at attributes' do first_configuration = MfaContext.new(subject.current_user).phone_configurations.first expect(first_configuration.phone).to eq('+1 202-555-1212') + expect(first_configuration.confirmed_at).to eq(@previous_phone_confirmed_at) end it 'renders :show' do diff --git a/spec/controllers/users/backup_code_setup_controller_spec.rb b/spec/controllers/users/backup_code_setup_controller_spec.rb index 53123e9ea69..e99d73cbefa 100644 --- a/spec/controllers/users/backup_code_setup_controller_spec.rb +++ b/spec/controllers/users/backup_code_setup_controller_spec.rb @@ -26,7 +26,9 @@ it 'creates backup codes and logs expected events' do stub_analytics + stub_attempts_tracker allow(controller).to receive(:in_multi_mfa_selection_flow?).and_return(true) + expect(@attempts_api_tracker).to receive(:mfa_enroll_backup_code).with(success: true) Funnel::Registration::AddMfa.call(user.id, 'phone', @analytics, threatmetrix_attrs) expect(PushNotification::HttpPush).to receive(:deliver) diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index 25975cabf69..192ee61a742 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -76,6 +76,9 @@ describe 'POST /' do include AccountResetHelper + before do + stub_attempts_tracker + end context 'successful authentication' do let(:user) { create(:user, :fully_registered) } @@ -86,6 +89,9 @@ it 'tracks the successful authentication for existing user' do stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: true, + ) response @@ -159,6 +165,9 @@ it 'tracks as not being from a new device' do stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: true, + ) response @@ -221,6 +230,10 @@ user = create(:user, :fully_registered) stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ).exactly(9).times + travel_to (3.hours + 1.minute).ago do 2.times do post :create, params: { user: { email: user.email, password: 'incorrect' } } @@ -264,6 +277,9 @@ stub_analytics(user:) expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ) post :create, params: { user: { email: user.email.upcase, password: 'invalid_password' } } @@ -284,6 +300,9 @@ it 'tracks the authentication attempt for nonexistent user' do stub_analytics(user: kind_of(AnonymousUser)) expect(SCrypt::Engine).to receive(:hash_secret).once.and_call_original + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ) post :create, params: { user: { email: 'foo@example.com', password: 'password' } } @@ -308,6 +327,9 @@ ) stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ) post :create, params: { user: { email: user.email.upcase, password: user.password } } @@ -348,6 +370,9 @@ user = create(:user, :fully_registered) stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ) post :create, params: { user: { email: user.email, password: user.password, score: 0.1 } } @@ -378,6 +403,9 @@ let(:sign_in_failure_window) { IdentityConfig.store.max_sign_in_failures_window_in_seconds } it 'prevents attempt after exceeding maximum rate limit' do allow(IdentityConfig.store).to receive(:max_sign_in_failures).and_return(5) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ).exactly(6).times user = create(:user, :fully_registered) freeze_time do @@ -412,6 +440,9 @@ ) stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ).exactly(2).times post :create, params: { user: { email: user.email.upcase, password: 'invalid' } } post :create, params: { user: { email: user.email.upcase, password: 'invalid' } } @@ -431,6 +462,9 @@ it 'tracks the presence of SP request_url in session' do subject.session[:sp] = { request_url: mock_valid_site } stub_analytics(user: kind_of(AnonymousUser)) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: false, + ) post :create, params: { user: { email: 'foo@example.com', password: 'password' } } @@ -602,6 +636,9 @@ ) stub_analytics(user:) + expect(@attempts_api_tracker).to receive(:email_and_password_auth).with( + success: true, + ) post :create, params: { user: { email: user.email, password: user.password } } diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index 69e551f0855..4db9ab5e51a 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -83,13 +83,19 @@ describe '#confirm' do let(:name) { SecureRandom.hex } + let(:success) { false } + + before do + stub_analytics + stub_attempts_tracker + expect(@attempts_api_tracker).to receive(:mfa_enroll_totp).with(success:) + end context 'user is already signed up' do context 'when user presents invalid code' do before do user = build(:user, personal_key: 'ABCD-DEFG-HIJK-LMNO') stub_sign_in(user) - stub_analytics subject.user_session[:new_totp_secret] = 'abcdehij' patch :confirm, params: { name: name, code: 123 } @@ -113,11 +119,11 @@ end context 'when user presents correct code' do + let(:success) { true } before do user = create(:user, :fully_registered) secret = ROTP::Base32.random_base32 stub_sign_in(user) - stub_analytics subject.user_session[:new_totp_secret] = secret patch :confirm, params: { name: name, code: generate_totp_code(secret) } @@ -141,11 +147,11 @@ end context 'when user presents correct code after submitting an incorrect code' do + let(:success) { false } before do user = create(:user, :fully_registered) secret = ROTP::Base32.random_base32 stub_sign_in(user) - stub_analytics subject.user_session[:new_totp_secret] = 'abcdehij' @@ -153,6 +159,8 @@ subject.user_session[:new_totp_secret] = secret + # calls the tracker again with success: true + expect(@attempts_api_tracker).to receive(:mfa_enroll_totp).with(success: true) patch :confirm, params: { name: name, code: generate_totp_code(secret) } end @@ -175,7 +183,6 @@ user = create(:user, :fully_registered) secret = ROTP::Base32.random_base32 stub_sign_in(user) - stub_analytics subject.user_session[:new_totp_secret] = secret patch :confirm, params: { name: name } @@ -203,7 +210,6 @@ user = create(:user, :fully_registered) secret = ROTP::Base32.random_base32 stub_sign_in(user) - stub_analytics subject.user_session[:new_totp_secret] = secret patch :confirm, params: { code: generate_totp_code(secret) } @@ -232,7 +238,6 @@ context 'when user presents invalid code' do before do stub_sign_in_before_2fa - stub_analytics subject.user_session[:new_totp_secret] = 'abcdehij' patch :confirm, params: { name: name, code: 123 } @@ -260,7 +265,6 @@ before do secret = ROTP::Base32.random_base32 stub_sign_in_before_2fa - stub_analytics subject.user_session[:new_totp_secret] = secret subject.user_session[:mfa_selections] = mfa_selections subject.user_session[:in_account_creation_flow] = true @@ -269,6 +273,7 @@ end context 'when user selected only one method on account creation' do + let(:success) { true } it 'redirects to auth method confirmation path with a success message' do expect(response).to redirect_to(auth_method_confirmation_path) expect(subject.user_session[:new_totp_secret]).to be_nil @@ -288,6 +293,7 @@ context 'when user has multiple MFA methods left in user session' do let(:mfa_selections) { ['auth_app', 'voice'] } + let(:success) { true } it 'redirects to next mfa path with a success message and still logs analytics' do expect(response).to redirect_to(phone_setup_url) @@ -309,7 +315,6 @@ context 'when totp secret is no longer in user_session' do before do stub_sign_in_before_2fa - stub_analytics patch :confirm, params: { name: name, code: 123 } end diff --git a/spec/decorators/null_service_provider_session_spec.rb b/spec/decorators/null_service_provider_session_spec.rb index 6c00ddc1fee..501ea3e35d4 100644 --- a/spec/decorators/null_service_provider_session_spec.rb +++ b/spec/decorators/null_service_provider_session_spec.rb @@ -9,15 +9,15 @@ end end - describe '#sp_logo' do + describe '#sp_name' do it 'returns nil' do - expect(subject.sp_logo).to be_nil + expect(subject.sp_name).to be_nil end end - describe '#sp_name' do + describe '#attempts_api_session_id' do it 'returns nil' do - expect(subject.sp_name).to be_nil + expect(subject.attempts_api_session_id).to be_nil end end diff --git a/spec/decorators/service_provider_session_spec.rb b/spec/decorators/service_provider_session_spec.rb index 11f20fef845..395d86a14f8 100644 --- a/spec/decorators/service_provider_session_spec.rb +++ b/spec/decorators/service_provider_session_spec.rb @@ -90,87 +90,6 @@ end end - describe '#sp_logo' do - context 'service provider has a logo' do - it 'returns the logo' do - sp_logo = 'real_logo.svg' - sp = build_stubbed(:service_provider, logo: sp_logo) - - subject = ServiceProviderSession.new( - sp: sp, - view_context: view_context, - sp_session: {}, - service_provider_request: ServiceProviderRequestProxy.new, - ) - - expect(subject.sp_logo).to eq sp_logo - end - end - - context 'service provider does not have a logo' do - it 'returns the default logo' do - sp = build_stubbed(:service_provider, logo: nil) - - subject = ServiceProviderSession.new( - sp: sp, - view_context: view_context, - sp_session: {}, - service_provider_request: ServiceProviderRequestProxy.new, - ) - - expect(subject.sp_logo).to eq 'generic.svg' - end - end - end - - describe '#sp_logo_url' do - context 'service provider has a logo' do - it 'returns the logo' do - sp_logo = '18f.svg' - sp = build_stubbed(:service_provider, logo: sp_logo) - - subject = ServiceProviderSession.new( - sp: sp, - view_context: view_context, - sp_session: {}, - service_provider_request: ServiceProviderRequestProxy.new, - ) - - expect(subject.sp_logo_url).to match(%r{sp-logos/18f-[0-9a-f]+\.svg$}) - end - end - - context 'service provider does not have a logo' do - it 'returns the default logo' do - sp = build_stubbed(:service_provider, logo: nil) - - subject = ServiceProviderSession.new( - sp: sp, - view_context: view_context, - sp_session: {}, - service_provider_request: ServiceProviderRequestProxy.new, - ) - - expect(subject.sp_logo_url).to match(%r{/sp-logos/generic-.+\.svg}) - end - end - - context 'service provider has a poorly configured logo' do - it 'does not raise an exception' do - sp = build_stubbed(:service_provider, logo: 'abc') - - subject = ServiceProviderSession.new( - sp: sp, - view_context: view_context, - sp_session: {}, - service_provider_request: ServiceProviderRequestProxy.new, - ) - - expect(subject.sp_logo_url).to be_kind_of(String) - end - end - end - describe '#cancel_link_url' do subject(:decorator) do ServiceProviderSession.new( @@ -290,4 +209,24 @@ end end end + + describe '#attempt_api_session_id' do + let(:service_provider_request) { ServiceProviderRequest.new(url:) } + + context 'without an an attempts_api_session_id in the request_url_params' do + let(:url) { 'https://example.com/auth?param0=p0¶m1=p1¶m2=p2' } + + it 'returns nil' do + expect(subject.attempts_api_session_id).to be nil + end + end + + context 'with an attempts_api_session_id in the request_url_params' do + let(:url) { 'https://example.com/auth?attempts_api_session_id=abc123¶m1=p1¶m2=p2' } + + it 'returns the value in the attempts_api_session_id param' do + expect(subject.attempts_api_session_id).to eq 'abc123' + end + end + end end diff --git a/spec/factories/phone_configurations.rb b/spec/factories/phone_configurations.rb index 0a02480e61c..804dcb09241 100644 --- a/spec/factories/phone_configurations.rb +++ b/spec/factories/phone_configurations.rb @@ -2,6 +2,7 @@ Faker::Config.locale = :en factory :phone_configuration do + confirmed_at { Time.zone.now } phone { '+1 202-555-1212' } mfa_enabled { true } user { association :user } diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 72b2138f6ff..893adf1921d 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -121,7 +121,7 @@ user_id: -1, }.merge( evaluator.with.slice( - :phone, :delivery_preference, :mfa_enabled + :phone, :confirmed_at, :delivery_preference, :mfa_enabled ), ), ) @@ -135,7 +135,7 @@ delivery_preference: user.otp_delivery_preference, }.merge( evaluator.with.slice( - :phone, :delivery_preference, :mfa_enabled + :phone, :confirmed_at, :delivery_preference, :mfa_enabled ), ), ) diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 4704ed2e2af..d6681bf0236 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -241,7 +241,7 @@ }, 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'standard', attention_with_barcode: false, doc_auth_result: 'Passed', liveness_checking_required: boolean, document_type: an_instance_of(String)), 'IdV: doc auth image upload vendor pii validation' => { - success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', document_type: an_instance_of(String) + success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', passport_issued_status: 'missing', passport_expiration_status: 'missing', document_type: an_instance_of(String) }, 'IdV: doc auth document_capture submitted' => hash_including(success: true, flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean, proofing_components: { document_check: 'mock', document_type: 'state_id' }), 'IdV: doc auth ssn visited' => { @@ -364,7 +364,7 @@ }, 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'hybrid', attention_with_barcode: false, doc_auth_result: 'Passed', liveness_checking_required: boolean), 'IdV: doc auth image upload vendor pii validation' => { - success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', document_type: an_instance_of(String) + success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'hybrid', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', passport_issued_status: 'missing', passport_expiration_status: 'missing', document_type: an_instance_of(String) }, 'IdV: doc auth document_capture submitted' => { success: true, flow_path: 'hybrid', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean @@ -486,7 +486,7 @@ }, 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'standard', attention_with_barcode: false, doc_auth_result: 'Passed', liveness_checking_required: boolean), 'IdV: doc auth image upload vendor pii validation' => { - success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', document_type: an_instance_of(String) + success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', passport_issued_status: 'missing', passport_expiration_status: 'missing', document_type: an_instance_of(String) }, 'IdV: doc auth document_capture submitted' => { success: true, flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: boolean, @@ -730,7 +730,7 @@ }, 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'standard', attention_with_barcode: false, doc_auth_result: 'Passed'), 'IdV: doc auth image upload vendor pii validation' => { - success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', document_type: an_instance_of(String) + success: true, user_id: user.uuid, submit_attempts: 1, remaining_submit_attempts: 3, flow_path: 'standard', attention_with_barcode: false, front_image_fingerprint: an_instance_of(String), back_image_fingerprint: an_instance_of(String), selfie_image_fingerprint: an_instance_of(String), liveness_checking_required: boolean, classification_info: {}, id_issued_status: 'present', id_expiration_status: 'present', passport_issued_status: 'missing', passport_expiration_status: 'missing', document_type: an_instance_of(String) }, 'IdV: doc auth document_capture submitted' => { success: true, flow_path: 'standard', step: 'document_capture', analytics_id: 'Doc Auth', selfie_check_required: boolean, liveness_checking_required: true, diff --git a/spec/features/idv/doc_auth/how_to_verify_spec.rb b/spec/features/idv/doc_auth/how_to_verify_spec.rb index 0f6dc2de6b3..28c87202c24 100644 --- a/spec/features/idv/doc_auth/how_to_verify_spec.rb +++ b/spec/features/idv/doc_auth/how_to_verify_spec.rb @@ -25,6 +25,7 @@ facial_match_required: facial_match_required } ) sign_in_via_branded_page(user) + expect(page).to have_current_path(idv_welcome_path) complete_doc_auth_steps_before_agreement_step complete_agreement_step end diff --git a/spec/features/idv/doc_auth/socure_document_capture_spec.rb b/spec/features/idv/doc_auth/socure_document_capture_spec.rb index c2734d51653..f7a0ae0e0cb 100644 --- a/spec/features/idv/doc_auth/socure_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/socure_document_capture_spec.rb @@ -524,7 +524,7 @@ context 'Pii validation fails' do before do - allow_any_instance_of(Idv::DocPiiForm).to receive(:zipcode).and_return(:invalid_junk) + allow_any_instance_of(Idv::DocPiiStateId).to receive(:zipcode).and_return(:invalid_junk) end it 'presents as a type 1 error' do diff --git a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb index dc7fa90cb63..bb848f82c0f 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb @@ -635,7 +635,7 @@ context 'Pii validation fails' do before do - allow_any_instance_of(Idv::DocPiiForm).to receive(:zipcode).and_return(:invalid_junk) + allow_any_instance_of(Idv::DocPiiStateId).to receive(:zipcode).and_return(:invalid_junk) end it 'presents as a type 1 error', js: true do diff --git a/spec/features/phone/add_phone_spec.rb b/spec/features/phone/add_phone_spec.rb index 55cc5994ddf..a924e341fe9 100644 --- a/spec/features/phone/add_phone_spec.rb +++ b/spec/features/phone/add_phone_spec.rb @@ -17,6 +17,8 @@ expect(page).to have_current_path(account_path) expect(user.reload.phone_configurations.count).to eq(2) + expect(user.phone_configurations[0].confirmed_at).to be_present + expect(user.phone_configurations[1].confirmed_at).to be_present end scenario 'adding a new phone number sends the user an email with a disavowal link' do diff --git a/spec/features/phone/confirmation_spec.rb b/spec/features/phone/confirmation_spec.rb index 76315dfcc85..5839bd42e58 100644 --- a/spec/features/phone/confirmation_spec.rb +++ b/spec/features/phone/confirmation_spec.rb @@ -21,6 +21,7 @@ def expect_successful_otp_confirmation(delivery_method) expect(page).to have_content(t('notices.phone_confirmed')) expect(page).to have_current_path(auth_method_confirmation_path) + expect(phone_configuration.confirmed_at).to_not be_nil expect(phone_configuration.delivery_preference).to eq(delivery_method.to_s) end @@ -76,6 +77,7 @@ def visit_otp_confirmation(delivery_method) def expect_successful_otp_confirmation(delivery_method) expect(page).to have_content(t('notices.phone_confirmed')) expect(page).to have_current_path(account_path) + expect(phone_configuration.confirmed_at).to_not be_nil expect(phone_configuration.delivery_preference).to eq(delivery_method.to_s) end diff --git a/spec/fixtures/data_request.json b/spec/fixtures/data_request.json index 4879fe1efec..71a099d0e2b 100644 --- a/spec/fixtures/data_request.json +++ b/spec/fixtures/data_request.json @@ -15,7 +15,8 @@ { "id": 123456, "phone": "+1 555-555-5555", - "created_at": "2021-10-21T14:53:08.803Z" + "created_at": "2021-10-21T14:53:08.803Z", + "confirmed_at": "2021-10-21T14:53:08.790Z" } ], "auth_app_configurations": [], diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index 6302f31f993..d1842a2b2ce 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -558,10 +558,12 @@ success: false, errors: { doc_pii: 'bad' }, extra: { - pii_like_keypaths: pii_like_keypaths, + pii_like_keypaths: pii_like_keypaths_state_id, attention_with_barcode: false, id_issued_status: 'missing', id_expiration_status: 'missing', + passport_issued_status: 'missing', + passport_expiration_status: 'missing', }, ) end diff --git a/spec/forms/idv/doc_pii_form_spec.rb b/spec/forms/idv/doc_pii_form_spec.rb index 4fa18c4e8e6..73ee9ce1a86 100644 --- a/spec/forms/idv/doc_pii_form_spec.rb +++ b/spec/forms/idv/doc_pii_form_spec.rb @@ -7,10 +7,43 @@ let(:subject) { Idv::DocPiiForm.new(pii: pii) } let(:valid_dob) { (Time.zone.today - (IdentityConfig.store.idv_min_age_years + 1).years).to_s } let(:valid_state_id_expiration) { Time.zone.today.to_s } + let(:state_id_type) { 'drivers_license' } let(:too_young_dob) do (Time.zone.today - (IdentityConfig.store.idv_min_age_years - 1).years).to_s end - let(:good_pii) do + let(:mrz) do + 'P service_provider.issuer }], + ) + end + + it 'returns true' do + expect(service_provider.attempts_api_enabled?).to be(true) + end + end + end + + context 'when attempts api availability is disabled' do + before do + allow(IdentityConfig.store).to receive(:attempts_api_enabled) + .and_return(false) + end + + context 'when the service provider is on the allowlist for attempts api' do + before do + allow(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return( + [{ 'issuer' => service_provider.issuer }], + ) + end + + it 'returns false' do + expect(service_provider.attempts_api_enabled?).to be(false) + end + end + + context 'when the service provider is not on the allowlist for attempts api' do + it 'returns false' do + expect(service_provider.attempts_api_enabled?).to be(false) + end + end + end + end + + describe '#attempts_public_key' do + context 'when the sp is configured to use the attempts api' do + context 'when there is no public key set in the configuration' do + before do + allow(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return( + [{ 'issuer' => service_provider.issuer }], + ) + end + + it "returns the sp's first public key" do + expect(service_provider.attempts_public_key.to_pem).to eq( + service_provider.ssl_certs.first.public_key.to_pem, + ) + end + end + + context 'when the public key is set in the configuration' do + let(:private_key) { OpenSSL::PKey::RSA.new(2048) } + let(:public_key) { private_key.public_key } + + before do + allow(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return( + [ + { 'issuer' => service_provider.issuer, + 'keys' => [public_key.to_pem] }, + + ], + ) + end + + it "returns the sp's first public key" do + expect(service_provider.attempts_public_key.to_pem).to eq( + public_key.to_pem, + ) + end + end + end + end + describe '#ssl_certs' do context 'with an empty string plural cert' do let(:service_provider) { build(:service_provider, certs: ['']) } @@ -146,4 +237,78 @@ end end end + + describe '#logo_is_email_compatible?' do + subject { ServiceProvider.new(logo: logo) } + before do + allow(FeatureManagement).to receive(:logo_upload_enabled?).and_return(true) + end + + context 'service provider has a png logo' do + let(:logo) { 'gsa.png' } + + it 'returns true' do + expect(subject.logo_is_email_compatible?).to be(true) + end + end + + context 'service provider has a svg logo' do + let(:logo) { '18f.svg' } + + it 'returns false' do + expect(subject.logo_is_email_compatible?).to be(false) + end + end + + context 'service provider has no logo' do + let(:logo) { nil } + + it 'returns false' do + expect(subject.logo_is_email_compatible?).to be(false) + end + end + end + + describe '#logo_url' do + let(:logo) { '18f.svg' } + subject { ServiceProvider.new(logo: logo) } + context 'service provider has a logo' do + it 'returns the logo' do + expect(subject.logo_url).to match(%r{sp-logos/18f-[0-9a-f]+\.svg$}) + end + end + + context 'service provider does not have a logo' do + let(:logo) { nil } + it 'returns the default logo' do + expect(subject.logo_url).to match(%r{/sp-logos/generic-.+\.svg}) + end + end + + context 'service provider has a poorly configured logo' do + let(:logo) { 'abc' } + it 'does not raise an exception' do + expect(subject.logo_url).to be_kind_of(String) + end + end + + context 'when the logo upload feature is enabled' do + let(:aws_region) { 'us-west-2' } + let(:aws_logo_bucket) { 'logo-bucket' } + let(:remote_logo_key) { 'llave' } + before do + allow(FeatureManagement).to receive(:logo_upload_enabled?).and_return(true) + allow(IdentityConfig.store).to receive(:aws_logo_bucket) + .and_return(aws_logo_bucket) + end + + context 'when the remote logo key is present' do + subject { ServiceProvider.new(logo: logo, remote_logo_key: remote_logo_key) } + + it 'uses the s3_logo_url' do + expect(subject.logo_url).to match("https://s3.#{aws_region}.amazonaws.com/#{aws_logo_bucket}/#{remote_logo_key}") + end + end + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index e766747e17c..245c4515d7d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -43,6 +43,7 @@ config.include Capybara::RSpecMatchers, type: :component config.include AgreementsHelper config.include AnalyticsHelper + config.include AttemptsApiTrackingHelper config.include AwsCloudwatchHelper config.include AwsKmsClientHelper config.include DiffHelper @@ -162,7 +163,6 @@ class Analytics allow: [ /localhost/, /127\.0\.0\.1/, - /codeclimate.com/, # For uploading coverage reports /chromedriver\.storage\.googleapis\.com/, # For fetching a chromedriver binary ], ) diff --git a/spec/services/attempts_api/tracker_spec.rb b/spec/services/attempts_api/tracker_spec.rb index fbc0a9794a4..01795fab127 100644 --- a/spec/services/attempts_api/tracker_spec.rb +++ b/spec/services/attempts_api/tracker_spec.rb @@ -19,7 +19,6 @@ let(:cookie_device_uuid) { 'device_id' } let(:sp_request_uri) { 'https://example.com/auth_page' } let(:user) { create(:user) } - let(:analytics) { FakeAnalytics.new } subject do described_class.new( @@ -30,7 +29,6 @@ cookie_device_uuid: cookie_device_uuid, sp_request_uri: sp_request_uri, enabled_for_session: enabled_for_session, - analytics: analytics, ) end diff --git a/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb b/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb index c2bfd607731..cd0440ab3c9 100644 --- a/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb +++ b/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb @@ -46,7 +46,9 @@ it 'returns without error' do expect do - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp: false) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp: false, + ) end.not_to raise_error end end @@ -69,7 +71,9 @@ it 'uses a mock proofer' do expect(UspsInPersonProofing::Mock::Proofer).to receive(:new).and_call_original - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) end end @@ -84,7 +88,9 @@ it 'updates the existing enrollment record' do expect(user.in_person_enrollments.length).to eq(1) - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) enrollment.reload # tests that the value of current_address_matches_id on the enrollment corresponds @@ -120,14 +126,18 @@ UspsInPersonProofing::Mock::Proofer.new.request_enroll(applicant, is_enhanced_ipp) end - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) end it <<~STR.squish do sets enrollment status to pending, sponsor_id to usps_ipp_sponsor_id, and sets established at date and unique id STR - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect(user.in_person_enrollments.first.status).to eq(InPersonEnrollment::STATUS_PENDING) expect(user.in_person_enrollments.first.sponsor_id).to eq(usps_ipp_sponsor_id) @@ -138,7 +148,9 @@ context 'event logging' do context 'with no service provider' do it 'logs event' do - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect(subject_analytics).to have_logged_event( 'USPS IPPaaS enrollment created', @@ -169,7 +181,9 @@ let(:is_enhanced_ipp) { true } it 'logs event' do - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect(subject_analytics).to have_logged_event( 'USPS IPPaaS enrollment created', @@ -184,7 +198,9 @@ context 'when the enrollment is not enhanced_ipp' do it 'logs event' do - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect(subject_analytics).to have_logged_event( 'USPS IPPaaS enrollment created', @@ -211,7 +227,9 @@ it 'does not log the presence of address line 2 only in residential address' do pii['identity_doc_address2'] = nil - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect(subject_analytics).to have_logged_event( 'USPS IPPaaS enrollment created', @@ -229,7 +247,9 @@ pii['same_address_as_id'] = false pii['address2'] = nil - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect(subject_analytics).to have_logged_event( 'USPS IPPaaS enrollment created', @@ -246,7 +266,9 @@ let(:opt_in) { true } it 'logs user\'s opt-in choice' do - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:, opt_in:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, opt_in:, + ) expect(subject_analytics).to have_logged_event( 'USPS IPPaaS enrollment created', @@ -261,7 +283,9 @@ end it 'sends verification emails' do - subject.schedule_in_person_enrollment(user:, pii:, is_enhanced_ipp:) + subject.schedule_in_person_enrollment( + user:, pii:, is_enhanced_ipp:, + ) expect_delivered_email_count(1) expect_delivered_email( diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7ceecfcbc8f..d853727cf61 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -39,7 +39,6 @@ allow: [ /localhost/, /127\.0\.0\.1/, - /codeclimate.com/, # For uploading coverage reports /chromedriver\.storage\.googleapis\.com/, # For fetching a chromedriver binary ], ) diff --git a/spec/support/attempts_api_tracking_helper.rb b/spec/support/attempts_api_tracking_helper.rb new file mode 100644 index 00000000000..e32e67944a9 --- /dev/null +++ b/spec/support/attempts_api_tracking_helper.rb @@ -0,0 +1,34 @@ +module AttemptsApiTrackingHelper + class AttemptsApiEventDecryptor + def self.public_key + private_key.public_key + end + + def self.private_key + @private_key ||= OpenSSL::PKey::RSA.new(4096) + end + + def decrypted_events_from_store(timestamp:) + jwes = AttemptsApi::RedisClient.new.read_events(timestamp: timestamp) + jwes.transform_values do |jwe| + AttemptsApi::AttemptEvent.from_jwe(jwe, self.class.private_key) + end + end + end + + def attempts_api_tracked_events(timestamp:) + AttemptsApiEventDecryptor.new.decrypted_events_from_store(timestamp: timestamp).values + end + + def stub_attempts_tracker + attempts_api_tracker = FakeAttemptsTracker.new + + if respond_to?(:controller) + allow(controller).to receive(:attempts_api_tracker).and_return(attempts_api_tracker) + else + allow(self).to receive(:attempts_api_tracker).and_return(attempts_api_tracker) + end + + @attempts_api_tracker = attempts_api_tracker + end +end diff --git a/spec/support/doc_pii_helper.rb b/spec/support/doc_pii_helper.rb index af3532d96ca..f405af512ac 100644 --- a/spec/support/doc_pii_helper.rb +++ b/spec/support/doc_pii_helper.rb @@ -1,5 +1,5 @@ module DocPiiHelper - def pii_like_keypaths + def pii_like_keypaths_state_id [ [:pii], [:errors, :name], @@ -28,4 +28,34 @@ def pii_like_keypaths [:error_details, :state_id_number, :state_id_number], ] end + + def pii_like_keypaths_passport + [ + [:pii], + [:errors, :name], + [:error_details, :name], + [:error_details, :name, :name], + [:errors, :dob], + [:error_details, :dob], + [:error_details, :dob, :dob], + [:errors, :dob_min_age], + [:error_details, :dob_min_age], + [:error_details, :dob_min_age, :dob_min_age], + [:errors, :birth_place], + [:error_details, :birth_place], + [:error_details, :birth_place, :birth_place], + [:errors, :passport_issued], + [:error_details, :passport_issued], + [:error_details, :passport_issued, :passport_issued], + [:errors, :issuing_country_code], + [:error_details, :issuing_country_code], + [:error_details, :issuing_country_code, :issuing_country_code], + [:errors, :nationality_code], + [:error_details, :nationality_code], + [:error_details, :nationality_code, :nationality_code], + [:errors, :mrz], + [:error_details, :mrz], + [:error_details, :mrz, :mrz], + ] + end end diff --git a/spec/support/fake_attempts_tracker.rb b/spec/support/fake_attempts_tracker.rb new file mode 100644 index 00000000000..f4e0af80c1c --- /dev/null +++ b/spec/support/fake_attempts_tracker.rb @@ -0,0 +1,29 @@ +module AttemptsApiTrackingHelper + class FakeAttemptsTracker + include AttemptsApi::TrackerEvents + + attr_reader :events + + def initialize + @events = Hash.new + end + + def track_event(event, attributes = {}) + events[event] ||= [] + events[event] << attributes + nil + end + + def parse_failure_reason(result) + return result.to_h[:error_details] || result.errors.presence + end + + def track_mfa_submit_event(_attributes) + # no-op + end + + def browser_attributes + {} + end + end +end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index feaa8174111..09c039ad9cf 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -537,7 +537,9 @@ def skip_second_mfa_prompt end def sign_in_via_branded_page(user) + expect(page).to have_current_path new_user_session_path fill_in_credentials_and_submit(user.last_sign_in_email_address.email, user.password) + expect(page).to have_current_path(login_two_factor_path(otp_delivery_preference: 'sms')) fill_in_code_with_last_phone_otp click_submit_default end diff --git a/spec/views/layouts/mailer.html.erb_spec.rb b/spec/views/layouts/mailer.html.erb_spec.rb index b87977b1ca2..390bc06b6ba 100644 --- a/spec/views/layouts/mailer.html.erb_spec.rb +++ b/spec/views/layouts/mailer.html.erb_spec.rb @@ -3,53 +3,104 @@ RSpec.describe 'layouts/mailer.html.erb' do let(:user) { build_stubbed(:user) } - before do - @mail = UserMailer.with(user: user, email_address: user.email_addresses.first).email_added - allow(view).to receive(:message).and_return(@mail) - allow(view).to receive(:attachments).and_return(@mail.attachments) + context 'email_added emails' do + before do + @mail = UserMailer.with(user: user, email_address: user.email_addresses.first).email_added + allow(view).to receive(:message).and_return(@mail) + allow(view).to receive(:attachments).and_return(@mail.attachments) - render - end + render + end - it 'includes the message subject as the title' do - expect(rendered).to have_title @mail.subject - end + it 'includes the message subject as the title' do + expect(rendered).to have_title @mail.subject + end - it 'includes the app logo' do - expect(rendered).to have_css("img[src*='.mail']") - end + it 'includes the app logo' do + expect(rendered).to have_css("img[src*='.mail']") + end - it 'includes alt text for app logo that reads Login.gov logo' do - expect(rendered).to have_css("img[alt='#{t('mailer.logo', app_name: APP_NAME)}']") - end + it 'includes alt text for app logo that reads Login.gov logo' do + expect(rendered).to have_css("img[alt='#{t('mailer.logo', app_name: APP_NAME)}']") + end - it 'includes the message subject in the body' do - expect(rendered).to have_content @mail.subject - end + it 'includes the message subject in the body' do + expect(rendered).to have_content @mail.subject + end - it 'includes a request to not reply to this messsage' do - expect(rendered).to have_content(t('mailer.no_reply')) - end + it 'includes a request to not reply to this messsage' do + expect(rendered).to have_content(t('mailer.no_reply')) + end - it 'includes the support text and link' do - expect(rendered).to have_content(t('mailer.no_reply')) - expect(rendered).to have_content( - t('mailer.help_html', app_name: APP_NAME, link_html: MarketingSite.nice_help_url), - ) - expect(rendered).to have_link(MarketingSite.nice_help_url, href: MarketingSite.help_url) - end + it 'includes the support text and link' do + expect(rendered).to have_content(t('mailer.no_reply')) + expect(rendered).to have_content( + t('mailer.help_html', app_name: APP_NAME, link_html: MarketingSite.nice_help_url), + ) + expect(rendered).to have_link(MarketingSite.nice_help_url, href: MarketingSite.help_url) + end + + it 'includes link to About Login.gov' do + expect(rendered).to have_link( + t('mailer.about', app_name: APP_NAME), + href: MarketingSite.base_url, + ) + end - it 'includes link to About Login.gov' do - expect(rendered).to have_link( - t('mailer.about', app_name: APP_NAME), - href: MarketingSite.base_url, - ) + it 'includes link to the privacy policy' do + expect(rendered).to have_link( + t('mailer.privacy_policy'), + href: MarketingSite.security_and_privacy_practices_url, + ) + end end - it 'includes link to the privacy policy' do - expect(rendered).to have_link( - t('mailer.privacy_policy'), - href: MarketingSite.security_and_privacy_practices_url, - ) + context 'in-person proofing ready to verify emails' do + let(:user) { create(:user, :with_pending_in_person_enrollment) } + let(:sp_name) { 'Friendly Service Provider' } + let(:service_provider) do + create(:service_provider, logo: logo, friendly_name: sp_name) + end + let(:enrollment) { create(:in_person_enrollment, :pending, service_provider: service_provider) } + + before do + @mail = UserMailer.with( + user: user, + email_address: user.email_addresses.first, + ).in_person_ready_to_verify(enrollment:, is_enhanced_ipp: false) + allow(view).to receive(:message).and_return(@mail) + allow(view).to receive(:attachments).and_return(@mail.attachments) + @sp_name = sp_name + @logo_url = logo_url + + render + end + + context 'when the partner agency logo is a png' do + let(:logo) { 'gsa.png' } + let(:logo_url) { '/assets/sp-logos/gsa.png' } + + it 'displays the partner agency logo' do + expect(rendered).to have_css("img[src*='gsa.png']") + end + end + + context 'when the partner agency logo is a svg' do + let(:logo) { 'generic.svg' } + let(:logo_url) { nil } + + it 'displays the partner agency name' do + expect(rendered).to have_content('Friendly Service Provider') + end + end + + context 'when there is no partner agency logo' do + let(:logo) { nil } + let(:logo_url) { nil } + + it 'displays the partner agency name' do + expect(rendered).to have_content('Friendly Service Provider') + end + end end end diff --git a/spec/views/shared/_banner.html.erb_spec.rb b/spec/views/shared/_banner.html.erb_spec.rb index 1c7d5cf819a..35ff2f6da32 100644 --- a/spec/views/shared/_banner.html.erb_spec.rb +++ b/spec/views/shared/_banner.html.erb_spec.rb @@ -1,18 +1,23 @@ require 'rails_helper' RSpec.describe 'shared/_banner.html.erb' do - before do - sp_with_logo = build_stubbed( + let(:sp_with_logo) do + build_stubbed( :service_provider, logo: 'generic.svg', friendly_name: 'Best SP ever' ) - - decorated_sp_session = ServiceProviderSession.new( + end + let(:decorated_sp_session) do + ServiceProviderSession.new( sp: sp_with_logo, view_context: '', sp_session: {}, service_provider_request: nil, ) + end + + before do allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) + allow(view).to receive(:current_sp).and_return(sp_with_logo) end it 'properly HTML escapes the secure notification' do diff --git a/spec/views/shared/_nav_branded.html.erb_spec.rb b/spec/views/shared/_nav_branded.html.erb_spec.rb index dca15917aba..36d1f73f2a3 100644 --- a/spec/views/shared/_nav_branded.html.erb_spec.rb +++ b/spec/views/shared/_nav_branded.html.erb_spec.rb @@ -2,19 +2,24 @@ RSpec.describe 'shared/_nav_branded.html.erb' do let(:view_context) { ActionController::Base.new.view_context } + let(:sp_with_logo) do + build_stubbed( + :service_provider, logo: 'generic.svg', friendly_name: 'Best SP ever' + ) + end + let(:decorated_sp_session) do + ServiceProviderSession.new( + sp: sp_with_logo, + view_context: view_context, + sp_session: {}, + service_provider_request: nil, + ) + end context 'with a SP-logo configured' do before do - sp_with_logo = build_stubbed( - :service_provider, logo: 'generic.svg', friendly_name: 'Best SP ever' - ) - decorated_sp_session = ServiceProviderSession.new( - sp: sp_with_logo, - view_context: view_context, - sp_session: {}, - service_provider_request: nil, - ) allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) + allow(view).to receive(:current_sp).and_return(sp_with_logo) render end @@ -35,17 +40,20 @@ let(:bucket) { 'bucket_id' } let(:region) { IdentityConfig.store.aws_region } let(:img_url) { "https://s3.#{region}.amazonaws.com/#{bucket}/key-to-logo" } - - before do - allow(IdentityConfig.store).to receive(:aws_logo_bucket).and_return(bucket) - allow(FeatureManagement).to receive(:logo_upload_enabled?).and_return(true) - decorated_sp_session = ServiceProviderSession.new( + let(:decorated_sp_session) do + ServiceProviderSession.new( sp: sp_with_s3_logo, view_context: view_context, sp_session: {}, service_provider_request: nil, ) + end + + before do + allow(IdentityConfig.store).to receive(:aws_logo_bucket).and_return(bucket) + allow(FeatureManagement).to receive(:logo_upload_enabled?).and_return(true) allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) + allow(view).to receive(:current_sp).and_return(sp_with_s3_logo) render end @@ -56,15 +64,19 @@ end context 'without a SP-logo configured' do - before do - sp_without_logo = build_stubbed(:service_provider, friendly_name: 'No logo no problem') - decorated_sp_session = ServiceProviderSession.new( + let(:sp_without_logo) { build_stubbed(:service_provider, friendly_name: 'No logo no problem') } + let(:decorated_sp_session) do + ServiceProviderSession.new( sp: sp_without_logo, view_context: view_context, sp_session: {}, service_provider_request: nil, ) + end + + before do allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) + allow(view).to receive(:current_sp).and_return(sp_without_logo) render end @@ -74,15 +86,18 @@ end context 'service provider has a poorly configured logo' do - before do - sp = build_stubbed(:service_provider, logo: 'abc') - decorated_sp_session = ServiceProviderSession.new( + let(:sp) { build_stubbed(:service_provider, logo: 'abc') } + let(:decorated_sp_session) do + ServiceProviderSession.new( sp:, view_context:, sp_session: {}, service_provider_request: nil, ) + end + before do allow(view).to receive(:decorated_sp_session).and_return(decorated_sp_session) + allow(view).to receive(:current_sp).and_return(sp) end it 'does not raise an exception' do