diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index 51cbbbcd48b..9655d5e8ecc 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -67,7 +67,9 @@ def render_new_with_error_message def send_phone_confirmation_otp_and_handle_result save_delivery_preference result = send_phone_confirmation_otp - analytics.idv_phone_confirmation_otp_sent(**result.to_h) + analytics.idv_phone_confirmation_otp_sent( + **result.to_h.merge(adapter: Telephony.config.adapter), + ) irs_attempts_api_tracker.idv_phone_otp_sent( phone_number: @idv_phone, diff --git a/app/controllers/users/two_factor_authentication_controller.rb b/app/controllers/users/two_factor_authentication_controller.rb index eeb01398869..d6e69a867a3 100644 --- a/app/controllers/users/two_factor_authentication_controller.rb +++ b/app/controllers/users/two_factor_authentication_controller.rb @@ -219,6 +219,7 @@ def track_events(otp_delivery_preference:) context: context, otp_delivery_preference: otp_delivery_preference, resend: params.dig(:otp_delivery_selection_form, :resend), + adapter: Telephony.config.adapter, telephony_response: @telephony_result.to_h, success: @telephony_result.success?, ) diff --git a/app/forms/idv/api_image_upload_form.rb b/app/forms/idv/api_image_upload_form.rb index e697dca995f..adcf3da3024 100644 --- a/app/forms/idv/api_image_upload_form.rb +++ b/app/forms/idv/api_image_upload_form.rb @@ -210,13 +210,16 @@ def update_analytics(client_response) ).merge(native_camera_ab_test_data), ) pii_from_doc = client_response.pii_from_doc || {} - store_encrypted_images_if_required + stored_image_result = store_encrypted_images_if_required irs_attempts_api_tracker.idv_document_upload_submitted( success: client_response.success?, document_state: pii_from_doc[:state], document_number: pii_from_doc[:state_id_number], document_issued: pii_from_doc[:state_id_issued], document_expiration: pii_from_doc[:state_id_expiration], + document_front_image_filename: stored_image_result&.front_filename, + document_back_image_filename: stored_image_result&.back_filename, + document_image_encryption_key: stored_image_result&.encryption_key, first_name: pii_from_doc[:first_name], last_name: pii_from_doc[:last_name], date_of_birth: pii_from_doc[:dob], @@ -230,7 +233,9 @@ def store_encrypted_images_if_required encrypted_document_storage_writer.encrypt_and_write_document( front_image: front_image_bytes, + front_image_content_type: front.content_type, back_image: back_image_bytes, + back_image_content_type: back.content_type, ) end diff --git a/app/forms/idv/inherited_proofing/base_form.rb b/app/forms/idv/inherited_proofing/base_form.rb index 63559f6575a..16e144690ef 100644 --- a/app/forms/idv/inherited_proofing/base_form.rb +++ b/app/forms/idv/inherited_proofing/base_form.rb @@ -11,23 +11,9 @@ def model_name def namespaced_model_name self.to_s.gsub('::', '') end - - def fields - @fields ||= required_fields + optional_fields - end - - def required_fields - raise NotImplementedError, - 'Override this method and return an Array of required field names as Symbols' - end - - def optional_fields - raise NotImplementedError, - 'Override this method and return an Array of optional field names as Symbols' - end end - private_class_method :namespaced_model_name, :required_fields, :optional_fields + private_class_method :namespaced_model_name attr_reader :payload_hash @@ -35,16 +21,12 @@ def initialize(payload_hash:) raise ArgumentError, 'payload_hash is blank?' if payload_hash.blank? raise ArgumentError, 'payload_hash is not a Hash' unless payload_hash.is_a? Hash - self.class.attr_accessor(*self.class.fields) - @payload_hash = payload_hash.dup populate_field_data end def submit - validate - FormResponse.new( success: valid?, errors: errors, diff --git a/app/forms/idv/inherited_proofing/va/form.rb b/app/forms/idv/inherited_proofing/va/form.rb index 5f8a0874c47..97878154d4a 100644 --- a/app/forms/idv/inherited_proofing/va/form.rb +++ b/app/forms/idv/inherited_proofing/va/form.rb @@ -2,18 +2,34 @@ module Idv module InheritedProofing module Va class Form < Idv::InheritedProofing::BaseForm - class << self - def required_fields - @required_fields ||= %i[first_name last_name birth_date ssn address_street address_zip] - end - - def optional_fields - @optional_fields ||= %i[phone address_street2 address_city address_state - address_country] - end - end + REQUIRED_FIELDS = %i[first_name + last_name + birth_date + ssn + address_street + address_zip].freeze + OPTIONAL_FIELDS = %i[phone + address_street2 + address_city + address_state + address_country + service_error].freeze + FIELDS = (REQUIRED_FIELDS + OPTIONAL_FIELDS).freeze + + attr_accessor(*FIELDS) + validate :add_service_error, if: :service_error? + validates(*REQUIRED_FIELDS, presence: true, unless: :service_error?) - validates(*required_fields, presence: true) + def submit + extra = {} + extra = { service_error: service_error } if service_error? + + FormResponse.new( + success: validate, + errors: errors, + extra: extra, + ) + end def user_pii raise 'User PII is invalid' unless valid? @@ -30,6 +46,22 @@ def user_pii user_pii[:zipcode] = address_zip user_pii end + + def service_error? + service_error.present? + end + + private + + def add_service_error + errors.add( + :service_provider, + # Use a "safe" error message for the model in case it's displayed + # to the user at any point. + I18n.t('inherited_proofing.errors.service_provider.communication'), + type: :service_error, + ) + end end end end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index b603263bd25..a3d6500717d 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -621,6 +621,12 @@ def idv_inherited_proofing_get_started_visited(flow_path:, step:, **extra) ) end + # Retry retrieving the user PII in the case where the first attempt fails + # in the agreement step, and the user initiates a "retry". + def idv_inherited_proofing_redo_retrieve_user_info_submitted(**extra) + track_event('IdV: inherited proofing retry retrieve user information submitted', **extra) + end + # @param [String] flow_path Document capture path ("hybrid" or "standard") # The user visited the in person proofing location step def idv_in_person_location_visited(flow_path:, **extra) @@ -1146,7 +1152,7 @@ def idv_phone_confirmation_otp_rate_limit_sends(proofing_components: nil, **extr # @param [Boolean] success # @param [Hash] errors - # @param ["sms","voice"] otp_delivery_preference which chaennel the OTP was delivered by + # @param ["sms","voice"] otp_delivery_preference which channel the OTP was delivered by # @param [String] country_code country code of phone number # @param [String] area_code area code of phone number # @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt @@ -1180,13 +1186,14 @@ def idv_phone_confirmation_otp_resent( # @param [Boolean] success # @param [Hash] errors - # @param ["sms","voice"] otp_delivery_preference which chaennel the OTP was delivered by + # @param ["sms","voice"] otp_delivery_preference which channel the OTP was delivered by # @param [String] country_code country code of phone number # @param [String] area_code area code of phone number # @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt # @param [String] phone_fingerprint the hmac fingerprint of the phone number formatted as e164 # @param [Hash] telephony_response response from Telephony gem # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components + # @param [:test, :pinpoint] adapter which adapter the OTP was delivered with # The user requested an OTP to confirm their phone during the IDV phone step def idv_phone_confirmation_otp_sent( success:, @@ -1197,6 +1204,7 @@ def idv_phone_confirmation_otp_sent( rate_limit_exceeded:, phone_fingerprint:, telephony_response:, + adapter:, proofing_components: nil, **extra ) @@ -1210,6 +1218,7 @@ def idv_phone_confirmation_otp_sent( rate_limit_exceeded: rate_limit_exceeded, phone_fingerprint: phone_fingerprint, telephony_response: telephony_response, + adapter: adapter, proofing_components: proofing_components, **extra, ) @@ -2644,6 +2653,7 @@ def sms_opt_in_visit( # @param ["sms","voice"] otp_delivery_preference the channel used to send the message # @param [Boolean] resend # @param [Hash] telephony_response + # @param [:test, :pinpoint] adapter which adapter the OTP was delivered with # @param [Boolean] success # A phone one-time password send was attempted def telephony_otp_sent( @@ -2654,6 +2664,7 @@ def telephony_otp_sent( otp_delivery_preference:, resend:, telephony_response:, + adapter:, success:, **extra ) @@ -2667,6 +2678,7 @@ def telephony_otp_sent( otp_delivery_preference: otp_delivery_preference, resend: resend, telephony_response: telephony_response, + adapter: adapter, success: success, **extra, }, diff --git a/app/services/encrypted_document_storage/document_writer.rb b/app/services/encrypted_document_storage/document_writer.rb index 68c1f1c3496..6dbadc84114 100644 --- a/app/services/encrypted_document_storage/document_writer.rb +++ b/app/services/encrypted_document_storage/document_writer.rb @@ -1,21 +1,25 @@ module EncryptedDocumentStorage class DocumentWriter - def encrypt_and_write_document(front_image:, back_image:) + def encrypt_and_write_document( + front_image:, + front_image_content_type:, + back_image:, + back_image_content_type: + ) key = SecureRandom.bytes(32) encrypted_front_image = aes_cipher.encrypt(front_image, key) encrypted_back_image = aes_cipher.encrypt(back_image, key) - front_image_uuid = SecureRandom.uuid - back_image_uiid = SecureRandom.uuid + front_filename = build_filename_for_content_type(front_image_content_type) + back_filename = build_filename_for_content_type(back_image_content_type) - storage.write_image(encrypted_image: encrypted_front_image, name: front_image_uuid) - storage.write_image(encrypted_image: encrypted_back_image, name: back_image_uiid) + storage.write_image(encrypted_image: encrypted_front_image, name: front_filename) + storage.write_image(encrypted_image: encrypted_back_image, name: back_filename) WriteDocumentResult.new( - front_uuid: front_image_uuid, - back_uuid: back_image_uiid, - front_encryption_key: Base64.strict_encode64(key), - back_encryption_key: Base64.strict_encode64(key), + front_filename: front_filename, + back_filename: back_filename, + encryption_key: Base64.strict_encode64(key), ) end @@ -32,5 +36,11 @@ def storage def aes_cipher @aes_cipher ||= Encryption::AesCipher.new end + + # @return [String] A new, unique S3 key for an image of the given content type. + def build_filename_for_content_type(content_type) + ext = Rack::Mime::MIME_TYPES.rassoc(content_type)&.first + "#{SecureRandom.uuid}#{ext}" + end end end diff --git a/app/services/encrypted_document_storage/local_storage.rb b/app/services/encrypted_document_storage/local_storage.rb index eb9a29da0b1..e55be4b1092 100644 --- a/app/services/encrypted_document_storage/local_storage.rb +++ b/app/services/encrypted_document_storage/local_storage.rb @@ -1,5 +1,11 @@ module EncryptedDocumentStorage class LocalStorage + # Used in tests to verify results + def read_image(name:) + filepath = tmp_document_storage_dir.join(name) + File.read(filepath) + end + def write_image(encrypted_image:, name:) FileUtils.mkdir_p(tmp_document_storage_dir) filepath = tmp_document_storage_dir.join(name) diff --git a/app/services/encrypted_document_storage/write_document_result.rb b/app/services/encrypted_document_storage/write_document_result.rb index ea8fb247fa6..4e9a21e1107 100644 --- a/app/services/encrypted_document_storage/write_document_result.rb +++ b/app/services/encrypted_document_storage/write_document_result.rb @@ -1,9 +1,8 @@ module EncryptedDocumentStorage WriteDocumentResult = Struct.new( - :front_uuid, - :back_uuid, - :front_encryption_key, - :back_encryption_key, + :front_filename, + :back_filename, + :encryption_key, keyword_init: true, ) end diff --git a/app/services/flow/base_flow.rb b/app/services/flow/base_flow.rb index 8bcf66618be..6f5779794e7 100644 --- a/app/services/flow/base_flow.rb +++ b/app/services/flow/base_flow.rb @@ -1,5 +1,7 @@ module Flow class BaseFlow + include Failure + attr_accessor :flow_session attr_reader :steps, :actions, :current_user, :current_sp, :params, :request, :json, :http_status, :controller diff --git a/app/services/flow/base_step.rb b/app/services/flow/base_step.rb index f138d0d15df..c6f55650d25 100644 --- a/app/services/flow/base_step.rb +++ b/app/services/flow/base_step.rb @@ -1,6 +1,7 @@ module Flow class BaseStep include Rails.application.routes.url_helpers + include Failure def initialize(flow, name) @flow = flow @@ -51,13 +52,6 @@ def form_submit FormResponse.new(success: true) end - def failure(message, extra = nil) - flow_session[:error_message] = message - form_response_params = { success: false, errors: { message: message } } - form_response_params[:extra] = extra unless extra.nil? - FormResponse.new(**form_response_params) - end - def flow_params params[@name] end diff --git a/app/services/flow/failure.rb b/app/services/flow/failure.rb new file mode 100644 index 00000000000..c84beedae71 --- /dev/null +++ b/app/services/flow/failure.rb @@ -0,0 +1,12 @@ +module Flow + module Failure + private + + def failure(message, extra = nil) + flow_session[:error_message] = message + form_response_params = { success: false, errors: { message: message } } + form_response_params[:extra] = extra unless extra.nil? + FormResponse.new(**form_response_params) + end + end +end diff --git a/app/services/gpo_confirmation_exporter.rb b/app/services/gpo_confirmation_exporter.rb index da7d4375bd4..b56a7829e23 100644 --- a/app/services/gpo_confirmation_exporter.rb +++ b/app/services/gpo_confirmation_exporter.rb @@ -22,7 +22,7 @@ def run def make_psv(csv) csv << make_header_row(confirmations.size) confirmations.each do |confirmation| - csv << make_entry_row(confirmation.entry) + csv << make_entry_row(confirmation) end end @@ -30,9 +30,11 @@ def make_header_row(num_entries) [HEADER_ROW_ID, num_entries] end - def make_entry_row(entry) + def make_entry_row(confirmation) now = current_date - due = now + OTP_MAX_VALID_DAYS.days + due = confirmation.created_at + OTP_MAX_VALID_DAYS.days + + entry = confirmation.entry service_provider = ServiceProvider.find_by(issuer: entry[:issuer]) [ diff --git a/app/services/idv/actions/inherited_proofing/redo_retrieve_user_info_action.rb b/app/services/idv/actions/inherited_proofing/redo_retrieve_user_info_action.rb new file mode 100644 index 00000000000..a9fd7ed2c2e --- /dev/null +++ b/app/services/idv/actions/inherited_proofing/redo_retrieve_user_info_action.rb @@ -0,0 +1,19 @@ +module Idv + module Actions + module InheritedProofing + class RedoRetrieveUserInfoAction < Idv::Steps::InheritedProofing::VerifyWaitStepShow + class << self + def analytics_submitted_event + :idv_inherited_proofing_redo_retrieve_user_info_submitted + end + end + + def call + enqueue_job unless api_call_already_in_progress? + + super + end + end + end + end +end diff --git a/app/services/idv/data_url_image.rb b/app/services/idv/data_url_image.rb index 073f80e39d6..5fa71a7a27c 100644 --- a/app/services/idv/data_url_image.rb +++ b/app/services/idv/data_url_image.rb @@ -10,6 +10,11 @@ def initialize(data_url) @data = data end + # @return [String] + def content_type + @header.split(';', 2).first + end + # @return [String] def read if base64_encoded? diff --git a/app/services/idv/flows/inherited_proofing_flow.rb b/app/services/idv/flows/inherited_proofing_flow.rb index 94a559c537c..2fc56d71103 100644 --- a/app/services/idv/flows/inherited_proofing_flow.rb +++ b/app/services/idv/flows/inherited_proofing_flow.rb @@ -18,7 +18,9 @@ class InheritedProofingFlow < Flow::BaseFlow { name: :secure_account }, ].freeze - ACTIONS = {}.freeze + ACTIONS = { + redo_retrieve_user_info: Idv::Actions::InheritedProofing::RedoRetrieveUserInfoAction, + }.freeze attr_reader :idv_session diff --git a/app/services/idv/inherited_proofing/va/mocks/service.rb b/app/services/idv/inherited_proofing/va/mocks/service.rb index fc1ed38269f..e1150a9528d 100644 --- a/app/services/idv/inherited_proofing/va/mocks/service.rb +++ b/app/services/idv/inherited_proofing/va/mocks/service.rb @@ -36,7 +36,7 @@ class Service }.freeze ERROR_HASH = { - errors: 'InheritedProofing::Errors::MHVIdentityDataNotFoundError', + service_error: 'the server responded with status 401', }.freeze def initialize(service_provider_data) diff --git a/app/services/idv/inherited_proofing/va/service.rb b/app/services/idv/inherited_proofing/va/service.rb index fc7e70cd91e..7e7c76a7e28 100644 --- a/app/services/idv/inherited_proofing/va/service.rb +++ b/app/services/idv/inherited_proofing/va/service.rb @@ -17,12 +17,35 @@ def initialize(service_provider_data) def execute raise 'The provided auth_code is blank?' if auth_code.blank? - response = request - payload_to_hash decrypt_payload(response) + begin + response = request + return payload_to_hash decrypt_payload(response) if response.status == 200 + + service_error(not_200_service_error(response.status)) + rescue => error + service_error(error.message) + end end private + def service_error(message) + { service_error: message } + end + + def not_200_service_error(http_status) + # Under certain circumstances, Faraday may return a nil http status. + # https://lostisland.github.io/faraday/middleware/raise-error + if http_status.blank? + http_status = 'unavailable' + http_status_description = 'unavailable' + else + http_status_description = Rack::Utils::HTTP_STATUS_CODES[http_status] + end + "The service provider API returned an http status other than 200: " \ + "#{http_status} (#{http_status_description})" + end + def request connection.get(request_uri) { |req| req.headers = request_headers } end diff --git a/app/services/idv/steps/inherited_proofing/agreement_step.rb b/app/services/idv/steps/inherited_proofing/agreement_step.rb index c3b125b24c0..b49e3d7ec7d 100644 --- a/app/services/idv/steps/inherited_proofing/agreement_step.rb +++ b/app/services/idv/steps/inherited_proofing/agreement_step.rb @@ -2,6 +2,8 @@ module Idv module Steps module InheritedProofing class AgreementStep < VerifyBaseStep + include UserPiiJobInitiator + delegate :controller, :idv_session, to: :@flow STEP_INDICATOR_STEP = :getting_started @@ -24,28 +26,6 @@ def form_submit def consent_form_params params.require(:inherited_proofing).permit(:ial2_consent_given) end - - def enqueue_job - return if api_call_already_in_progress? - - doc_capture_session = create_document_capture_session( - inherited_proofing_verify_step_document_capture_session_uuid_key, - ) - - doc_capture_session.create_proofing_session - - InheritedProofingJob.perform_later( - controller.inherited_proofing_service_provider, - controller.inherited_proofing_service_provider_data, - doc_capture_session.uuid, - ) - end - - def api_call_already_in_progress? - DocumentCaptureSession.find_by( - uuid: flow_session['inherited_proofing_verify_step_document_capture_session_uuid'], - )&.in_progress? - end end end end diff --git a/app/services/idv/steps/inherited_proofing/user_pii_job_initiator.rb b/app/services/idv/steps/inherited_proofing/user_pii_job_initiator.rb new file mode 100644 index 00000000000..3d39f3b0599 --- /dev/null +++ b/app/services/idv/steps/inherited_proofing/user_pii_job_initiator.rb @@ -0,0 +1,35 @@ +module Idv + module Steps + module InheritedProofing + module UserPiiJobInitiator + private + + def enqueue_job + return if api_call_already_in_progress? + + create_document_capture_session( + inherited_proofing_verify_step_document_capture_session_uuid_key, + ).tap do |doc_capture_session| + doc_capture_session.create_proofing_session + + InheritedProofingJob.perform_later( + controller.inherited_proofing_service_provider, + controller.inherited_proofing_service_provider_data, + doc_capture_session.uuid, + ) + end + end + + def api_call_already_in_progress? + DocumentCaptureSession.find_by( + uuid: flow_session[inherited_proofing_verify_step_document_capture_session_uuid_key], + ).present? + end + + def delete_async + flow_session.delete(inherited_proofing_verify_step_document_capture_session_uuid_key) + end + end + end + end +end diff --git a/app/services/idv/steps/inherited_proofing/verify_wait_step_show.rb b/app/services/idv/steps/inherited_proofing/verify_wait_step_show.rb index 45df7920b8c..611c8d8731d 100644 --- a/app/services/idv/steps/inherited_proofing/verify_wait_step_show.rb +++ b/app/services/idv/steps/inherited_proofing/verify_wait_step_show.rb @@ -2,6 +2,7 @@ module Idv module Steps module InheritedProofing class VerifyWaitStepShow < VerifyBaseStep + include UserPiiJobInitiator include UserPiiManagable include Idv::InheritedProofing::ServiceProviderForms delegate :controller, :idv_session, to: :@flow @@ -21,17 +22,15 @@ def call private def process_async_state(current_async_state) + return if current_async_state.in_progress? + if current_async_state.none? mark_step_incomplete(:agreement) - elsif current_async_state.in_progress? - nil elsif current_async_state.missing? flash[:error] = I18n.t('idv.failure.timeout') - # Need to add path to error pages once they exist - # LG-7257 - # This method overrides VerifyBaseStep#process_async_state: - # See the VerifyBaseStep#process_async_state "elsif current_async_state.missing?" - # logic as to what is typically needed/performed when hitting this logic path. + delete_async + mark_step_incomplete(:agreement) + @flow.analytics.idv_proofing_resolution_result_missing elsif current_async_state.done? async_state_done(current_async_state) end @@ -54,11 +53,16 @@ def async_state_done(_current_async_state) ) form_response = form.submit + delete_async + if form_response.success? inherited_proofing_save_user_pii_to_session!(form.user_pii) mark_step_complete(:verify_wait) + elsif throttle.throttled? + idv_failure(form_response) else - mark_step_incomplete(:agreement) + mark_step_complete(:agreement) + idv_failure(form_response) end form_response @@ -76,6 +80,34 @@ def document_capture_session def api_job_result document_capture_session.load_proofing_result end + + # Base class overrides + + def throttle + @throttle ||= Throttle.new( + user: current_user, + throttle_type: :inherited_proofing, + ) + end + + def idv_failure_log_throttled + @flow.analytics.throttler_rate_limit_triggered( + throttle_type: throttle.throttle_type, + step_name: self.class.name, + ) + end + + def throttled_url + idv_inherited_proofing_errors_failure_url(flow: :inherited_proofing) + end + + def exception_url + idv_inherited_proofing_errors_failure_url(flow: :inherited_proofing) + end + + def warning_url + idv_inherited_proofing_errors_no_information_url(flow: :inherited_proofing) + end end end end diff --git a/app/services/idv/steps/verify_base_step.rb b/app/services/idv/steps/verify_base_step.rb index 7a0e912f9f6..9865bac8fbb 100644 --- a/app/services/idv/steps/verify_base_step.rb +++ b/app/services/idv/steps/verify_base_step.rb @@ -78,26 +78,41 @@ def throttle def idv_failure(result) throttle.increment! if result.extra.dig(:proofing_results, :exception).blank? if throttle.throttled? - @flow.irs_attempts_api_tracker.idv_verification_rate_limited - @flow.analytics.throttler_rate_limit_triggered( - throttle_type: :idv_resolution, - step_name: self.class.name, - ) - redirect_to idv_session_errors_failure_url + idv_failure_log_throttled + redirect_to throttled_url elsif result.extra.dig(:proofing_results, :exception).present? - @flow.analytics.idv_doc_auth_exception_visited( - step_name: self.class.name, - remaining_attempts: throttle.remaining_count, - ) + idv_failure_log_error redirect_to exception_url else - @flow.analytics.idv_doc_auth_warning_visited( - step_name: self.class.name, - remaining_attempts: throttle.remaining_count, - ) + idv_failure_log_warning redirect_to warning_url end - result + end + + def idv_failure_log_throttled + @flow.irs_attempts_api_tracker.idv_verification_rate_limited + @flow.analytics.throttler_rate_limit_triggered( + throttle_type: :idv_resolution, + step_name: self.class.name, + ) + end + + def idv_failure_log_error + @flow.analytics.idv_doc_auth_exception_visited( + step_name: self.class.name, + remaining_attempts: throttle.remaining_count, + ) + end + + def idv_failure_log_warning + @flow.analytics.idv_doc_auth_warning_visited( + step_name: self.class.name, + remaining_attempts: throttle.remaining_count, + ) + end + + def throttled_url + idv_session_errors_failure_url end def exception_url diff --git a/app/services/irs_attempts_api/tracker_events.rb b/app/services/irs_attempts_api/tracker_events.rb index 891994bb98f..4f8302a7e95 100644 --- a/app/services/irs_attempts_api/tracker_events.rb +++ b/app/services/irs_attempts_api/tracker_events.rb @@ -89,6 +89,9 @@ def idv_document_upload_rate_limited # @param [String] document_number # @param [String] document_issued # @param [String] document_expiration + # @param [String] document_front_image_filename Filename in S3 w/ encrypted data for the front. + # @param [String] document_back_image_filename Filename in S3 w/ encrypted data for the back. + # @param [String] document_image_encryption_key Base64-encoded AES key used for images. # @param [String] first_name # @param [String] last_name # @param [String] date_of_birth @@ -101,6 +104,9 @@ def idv_document_upload_submitted( document_number: nil, document_issued: nil, document_expiration: nil, + document_front_image_filename: nil, + document_back_image_filename: nil, + document_image_encryption_key: nil, first_name: nil, last_name: nil, date_of_birth: nil, @@ -114,6 +120,9 @@ def idv_document_upload_submitted( document_number: document_number, document_issued: document_issued, document_expiration: document_expiration, + document_front_image_filename: document_front_image_filename, + document_back_image_filename: document_back_image_filename, + document_image_encryption_key: document_image_encryption_key, first_name: first_name, last_name: last_name, date_of_birth: date_of_birth, diff --git a/app/services/throttle.rb b/app/services/throttle.rb index df0e89c150e..c0fc4bf7e2b 100644 --- a/app/services/throttle.rb +++ b/app/services/throttle.rb @@ -49,6 +49,10 @@ class Throttle max_attempts: IdentityConfig.store.phone_confirmation_max_attempts, attempt_window: IdentityConfig.store.phone_confirmation_max_attempt_window_in_minutes, }, + inherited_proofing: { + max_attempts: IdentityConfig.store.inherited_proofing_max_attempts, + attempt_window: IdentityConfig.store.inherited_proofing_max_attempt_window_in_minutes, + }, }.with_indifferent_access.freeze def initialize(throttle_type:, user: nil, target: nil) diff --git a/app/views/idv/inherited_proofing_errors/warning.html.erb b/app/views/idv/inherited_proofing_errors/warning.html.erb index b06fa1930bb..58044bd9f77 100644 --- a/app/views/idv/inherited_proofing_errors/warning.html.erb +++ b/app/views/idv/inherited_proofing_errors/warning.html.erb @@ -8,7 +8,7 @@ <%= t('inherited_proofing.errors.cannot_retrieve.info') %>

- <%= link_to t('inherited_proofing.buttons.try_again'), root_url, class: 'usa-button usa-button--big usa-button--wide' %> + <%= button_to t('inherited_proofing.buttons.try_again'), idv_inherited_proofing_step_path(step: :redo_retrieve_user_info), method: :put, class: 'usa-button usa-button--big usa-button--wide' %> <%= render( 'shared/troubleshooting_options', diff --git a/config/application.yml.default b/config/application.yml.default index 91df708da7d..355f85ebf3d 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -127,6 +127,8 @@ in_person_completion_survey_url: 'https://login.gov' include_slo_in_saml_metadata: false inherited_proofing_enabled: false inherited_proofing_va_base_url: 'https://staging-api.va.gov' +inherited_proofing_max_attempts: 2 +inherited_proofing_max_attempt_window_in_minutes: 1 va_inherited_proofing_mock_enabled: false irs_attempt_api_audience: 'https://irs.gov' irs_attempt_api_auth_tokens: '' diff --git a/config/locales/inherited_proofing/en.yml b/config/locales/inherited_proofing/en.yml index 6914473751b..c25dad3569c 100644 --- a/config/locales/inherited_proofing/en.yml +++ b/config/locales/inherited_proofing/en.yml @@ -27,6 +27,8 @@ en: info: We are temporarily having trouble retrieving your information. Please try again. title: Couldn’t retrieve information + service_provider: + communication: 'communication was unsuccessful' headings: lets_go: How verifying your identity works retrieval: We are retrieving your information from My HealtheVet diff --git a/config/locales/inherited_proofing/es.yml b/config/locales/inherited_proofing/es.yml index 228bc52bc0a..dc6d924f0a4 100644 --- a/config/locales/inherited_proofing/es.yml +++ b/config/locales/inherited_proofing/es.yml @@ -28,6 +28,8 @@ es: info: Estamos teniendo problemas temporalmente para obtener su información. Inténtelo de nuevo. title: to be implemented + service_provider: + communication: 'la comunicacion no tuvo exito' headings: lets_go: to be implemented retrieval: Estamos recuperando su información de My HealtheVet diff --git a/config/locales/inherited_proofing/fr.yml b/config/locales/inherited_proofing/fr.yml index 209686a40aa..0ff9ad3878e 100644 --- a/config/locales/inherited_proofing/fr.yml +++ b/config/locales/inherited_proofing/fr.yml @@ -29,6 +29,8 @@ fr: info: Nous avons temporairement des difficultés à récupérer vos informations. Veuillez réessayer. title: to be implemented + service_provider: + communication: 'la communication a échoué' headings: lets_go: to be implemented retrieval: Nous récupérons vos informations depuis My HealtheVet. diff --git a/lib/identity_config.rb b/lib/identity_config.rb index e3a734946ae..d6214abc001 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -206,6 +206,8 @@ def self.build_store(config_map) config.add(:include_slo_in_saml_metadata, type: :boolean) config.add(:inherited_proofing_enabled, type: :boolean) config.add(:inherited_proofing_va_base_url, type: :string) + config.add(:inherited_proofing_max_attempts, type: :integer) + config.add(:inherited_proofing_max_attempt_window_in_minutes, type: :integer) config.add(:va_inherited_proofing_mock_enabled, type: :boolean) config.add(:irs_attempt_api_audience) config.add(:irs_attempt_api_auth_tokens, type: :comma_separated_string_list) diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index c9aa52b010b..4fbc9f4410e 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -1,8 +1,13 @@ require 'rails_helper' describe Idv::ImageUploadsController do + let(:document_filename_regex) { /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}\.[a-z]+$/ } + let(:base64_regex) { /^[a-z0-9+\/]+=*$/i } + describe '#create' do - subject(:action) { post :create, params: params } + subject(:action) do + post :create, params: params + end let(:user) { create(:user) } let!(:document_capture_session) { user.document_capture_sessions.create!(user: user) } @@ -18,6 +23,12 @@ end let(:json) { JSON.parse(response.body, symbolize_names: true) } + let(:store_encrypted_images) { false } + + before do + allow(controller).to receive(:store_encrypted_images?).and_return(store_encrypted_images) + end + before do Funnel::DocAuth::RegisterStep.new(user.id, '').call('welcome', :view, true) end @@ -294,6 +305,9 @@ :idv_document_upload_submitted, success: true, failure_reason: nil, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, document_state: 'MT', document_number: '1111111111111', document_issued: '2019-12-31', @@ -309,6 +323,27 @@ expect_funnel_update_counts(user, 1) end + context 'encrypted document storage is enabled' do + let(:store_encrypted_images) { true } + + it 'includes image fields in attempts api event' do + stub_attempts_tracker + + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :idv_document_upload_submitted, + hash_including( + success: true, + failure_reason: nil, + document_back_image_filename: match(document_filename_regex), + document_front_image_filename: match(document_filename_regex), + document_image_encryption_key: match(base64_regex), + ), + ) + + action + end + end + context 'but doc_pii validation fails' do let(:first_name) { 'FAKEY' } let(:last_name) { 'MCFAKERSON' } @@ -334,6 +369,34 @@ ) end + context 'encrypted document storage is enabled' do + let(:store_encrypted_images) { true } + let(:first_name) { nil } + + it 'includes image references in attempts api' do + stub_attempts_tracker + + expect(@irs_attempts_api_tracker).to receive(:track_event).with( + :idv_document_upload_submitted, + success: true, + failure_reason: nil, + document_state: 'ND', + document_number: nil, + document_issued: nil, + document_expiration: nil, + first_name: nil, + last_name: 'MCFAKERSON', + date_of_birth: '10/06/1938', + address: nil, + document_back_image_filename: match(document_filename_regex), + document_front_image_filename: match(document_filename_regex), + document_image_encryption_key: match(base64_regex), + ) + + action + end + end + context 'due to invalid Name' do let(:first_name) { nil } @@ -403,6 +466,9 @@ last_name: 'MCFAKERSON', date_of_birth: '10/06/1938', address: nil, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, ) action @@ -478,6 +544,9 @@ last_name: 'MCFAKERSON', date_of_birth: '10/06/1938', address: nil, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, ) action @@ -545,6 +614,9 @@ :idv_document_upload_submitted, success: true, failure_reason: nil, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, document_state: 'ND', document_number: nil, document_issued: nil, @@ -631,6 +703,9 @@ failure_reason: { front: [I18n.t('doc_auth.errors.general.multiple_front_id_failures')], }, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, document_state: nil, document_number: nil, document_issued: nil, @@ -713,6 +788,9 @@ general: [I18n.t('doc_auth.errors.alerts.barcode_content_check')], back: [I18n.t('doc_auth.errors.general.fallback_field_level')], }, + document_back_image_filename: nil, + document_front_image_filename: nil, + document_image_encryption_key: nil, document_state: nil, document_number: nil, document_issued: nil, diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index 195b48d4705..2d10a977a3e 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -238,6 +238,7 @@ 'IdV: phone confirmation otp sent', hash_including( success: false, + adapter: :test, telephony_response: telephony_response, ), ) diff --git a/spec/controllers/users/two_factor_authentication_controller_spec.rb b/spec/controllers/users/two_factor_authentication_controller_spec.rb index da2116d0340..c5f57f10503 100644 --- a/spec/controllers/users/two_factor_authentication_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_controller_spec.rb @@ -325,6 +325,7 @@ def index ordered. with('Telephony: OTP sent', hash_including( resend: 'true', success: true, **otp_preference_sms, + adapter: :test )) get :send_code, params: { @@ -472,6 +473,7 @@ def index with('Telephony: OTP sent', hash_including( success: true, otp_delivery_preference: 'voice', + adapter: :test, country_code: 'US', telephony_response: hash_including( origination_phone_number: Telephony::Test::VoiceSender::ORIGINATION_PHONE_NUMBER, diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 94860e939e0..639b7a00502 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -36,7 +36,7 @@ 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: Phone OTP delivery Selection Visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, - 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, + 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202', adapter: :test, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: phone confirmation otp visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: review info visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, @@ -119,7 +119,7 @@ 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: Phone OTP delivery Selection Visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms', proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, - 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202', proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, + 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202', adapter: :test, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: phone confirmation otp visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, 'IdV: review info visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } }, diff --git a/spec/features/idv/inherited_proofing/inherited_proofing_cancel_spec.rb b/spec/features/idv/inherited_proofing/inherited_proofing_cancel_spec.rb index d3be7768af1..cf594bd0c6c 100644 --- a/spec/features/idv/inherited_proofing/inherited_proofing_cancel_spec.rb +++ b/spec/features/idv/inherited_proofing/inherited_proofing_cancel_spec.rb @@ -1,66 +1,19 @@ require 'rails_helper' -# Simulates a user (in this case, a VA inherited proofing-authorized user) -# coming over to login.gov from a service provider, and hitting the -# OpenidConnect::AuthorizationController#index action. -def send_user_from_service_provider_to_login_gov_openid_connect(user) - expect(user).to_not be_nil - # NOTE: VA user. - visit_idp_from_oidc_va_with_ial2 -end - -def complete_idv_steps_up_to_inherited_proofing_get_started_step(user, expect_accessible: false) - unless current_path == idv_inherited_proofing_step_path(step: :get_started) - complete_idv_steps_before_phone_step(user) - click_link t('links.cancel') - click_button t('idv.cancel.actions.start_over') - expect(page).to have_current_path(idv_inherited_proofing_step_path(step: :get_started)) - end - expect(page).to be_axe_clean.according_to :section508, :"best-practice" if expect_accessible -end - -def complete_idv_steps_up_to_inherited_proofing_how_verifying_step(user, expect_accessible: false) - complete_idv_steps_up_to_inherited_proofing_get_started_step user, - expect_accessible: expect_accessible - unless current_path == idv_inherited_proofing_step_path(step: :agreement) - click_on t('inherited_proofing.buttons.continue') - end -end - -def complete_idv_steps_up_to_inherited_proofing_we_are_retrieving_step(user, - expect_accessible: false) - complete_idv_steps_up_to_inherited_proofing_how_verifying_step( - user, - expect_accessible: expect_accessible, - ) - unless current_path == idv_inherited_proofing_step_path(step: :verify_wait) - check t('inherited_proofing.instructions.consent', app_name: APP_NAME), allow_label_click: true - click_on t('inherited_proofing.buttons.continue') - end -end - -def complete_idv_steps_up_to_inherited_proofing_verify_your_info_step(user, - expect_accessible: false) - complete_idv_steps_up_to_inherited_proofing_we_are_retrieving_step( - user, - expect_accessible: expect_accessible, - ) -end - feature 'inherited proofing cancel process', :js do - include InheritedProofingHelper - include_context 'va_user_context' + include InheritedProofingWithServiceProviderHelper before do allow(IdentityConfig.store).to receive(:va_inherited_proofing_mock_enabled).and_return true - send_user_from_service_provider_to_login_gov_openid_connect user + send_user_from_service_provider_to_login_gov_openid_connect user, inherited_proofing_auth end let!(:user) { user_with_2fa } + let(:inherited_proofing_auth) { Idv::InheritedProofing::Va::Mocks::Service::VALID_AUTH_CODE } context 'from the "Get started verifying your identity" view, and clicking the "Cancel" link' do before do - complete_idv_steps_up_to_inherited_proofing_get_started_step user + complete_steps_up_to_inherited_proofing_get_started_step user end it 'should have current path equal to the Getting Started page' do @@ -106,7 +59,7 @@ def complete_idv_steps_up_to_inherited_proofing_verify_your_info_step(user, context 'from the "How verifying your identify works" view, and clicking the "Cancel" link' do before do - complete_idv_steps_up_to_inherited_proofing_how_verifying_step user + complete_steps_up_to_inherited_proofing_how_verifying_step user end it 'should have current path equal to the How Verifying (agreement step) page' do @@ -152,7 +105,7 @@ def complete_idv_steps_up_to_inherited_proofing_verify_your_info_step(user, context 'from the "Verify your information..." view, and clicking the "Cancel" link' do before do - complete_idv_steps_up_to_inherited_proofing_verify_your_info_step user + complete_steps_up_to_inherited_proofing_verify_your_info_step user end it 'should have current path equal to the Verify your information (verify_info step) page' do diff --git a/spec/features/idv/inherited_proofing/verify_wait_step_spec.rb b/spec/features/idv/inherited_proofing/verify_wait_step_spec.rb new file mode 100644 index 00000000000..3c17b33d889 --- /dev/null +++ b/spec/features/idv/inherited_proofing/verify_wait_step_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +feature 'inherited proofing verify wait', :js do + include InheritedProofingWithServiceProviderHelper + + before do + allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow(IdentityConfig.store).to receive(:va_inherited_proofing_mock_enabled).and_return(true) + send_user_from_service_provider_to_login_gov_openid_connect(user, inherited_proofing_auth) + end + + let!(:user) { user_with_2fa } + let(:inherited_proofing_auth) { Idv::InheritedProofing::Va::Mocks::Service::VALID_AUTH_CODE } + let(:fake_analytics) { FakeAnalytics.new } + + context 'when on the "How verifying your identify works" page, ' \ + 'and the user clicks the "Continue" button' do + before do + complete_steps_up_to_inherited_proofing_we_are_retrieving_step user + end + + context 'when there are no service-related errors' do + it 'displays the "Verify your information" page' do + expect(page).to have_current_path( + idv_inherited_proofing_step_path(step: :verify_info), + ) + end + end + + context 'when there are service-related errors on the first attempt' do + let(:inherited_proofing_auth) { 'invalid-auth-code' } + + it 'displays the warning page and allows retries' do + expect(page).to have_current_path( + idv_inherited_proofing_errors_no_information_path(flow: :inherited_proofing), + ) + expect(page).to have_selector(:link_or_button, t('inherited_proofing.buttons.try_again')) + end + end + + context 'when there are service-related errors on the second attempt' do + let(:inherited_proofing_auth) { 'invalid-auth-code' } + + it 'redirects to the error page, prohibits retries and logs the event' do + click_button t('inherited_proofing.buttons.try_again') + expect(page).to have_current_path( + idv_inherited_proofing_errors_failure_url(flow: :inherited_proofing), + ) + expect(fake_analytics).to have_logged_event( + 'Throttler Rate Limit Triggered', + throttle_type: :inherited_proofing, + step_name: Idv::Actions::InheritedProofing::RedoRetrieveUserInfoAction.name, + ) + end + end + end + + context 'when the async state is missing during polling' do + before do + allow_any_instance_of(ProofingSessionAsyncResult).to receive(:missing?).and_return(true) + complete_steps_up_to_inherited_proofing_we_are_retrieving_step user + end + + it 'redirects back to the agreement step and logs the event' do + expect(page).to have_current_path( + idv_inherited_proofing_step_path(step: :agreement), + ) + expect(fake_analytics).to have_logged_event( + 'Proofing Resolution Result Missing', + ) + end + end +end diff --git a/spec/forms/idv/api_image_upload_form_spec.rb b/spec/forms/idv/api_image_upload_form_spec.rb index ba2a5e9b212..9188e11a320 100644 --- a/spec/forms/idv/api_image_upload_form_spec.rb +++ b/spec/forms/idv/api_image_upload_form_spec.rb @@ -252,24 +252,36 @@ let(:store_encrypted_images) { true } it 'writes encrypted documents' do - # This is not a _great_ way to test this. Once we start writing these events to the - # attempts API we should use the fake attempts API to grab the 'reference` value for the - # front and back image and check that those files are written. + form.submit + + upload_events = irs_attempts_api_tracker.events[:idv_document_upload_submitted] + expect(upload_events).to have_attributes(length: 1) + upload_event = upload_events.first + document_writer = form.send(:encrypted_document_storage_writer) - expect(document_writer).to receive(:encrypt_and_write_document).with( - front_image: DocAuthImageFixtures.document_front_image_multipart.read, - back_image: DocAuthImageFixtures.document_back_image_multipart.read, - ).and_call_original + front_image.rewind + back_image.rewind - form.submit + cipher = Encryption::AesCipher.new + + front_image_ciphertext = + document_writer.storage.read_image(name: upload_event[:document_front_image_filename]) + + back_image_ciphertext = + document_writer.storage.read_image(name: upload_event[:document_back_image_filename]) + + key = Base64.decode64(upload_event[:document_image_encryption_key]) + + expect(cipher.decrypt(front_image_ciphertext, key)).to eq(front_image.read) + expect(cipher.decrypt(back_image_ciphertext, key)).to eq(back_image.read) end end - context 'when the attempts API is not enabled' do + context 'when encrypted image storage is disabled' do let(:store_encrypted_images) { false } - it 'when encrypted image storage is disabled' do + it 'does not write images' do document_writer = instance_double(EncryptedDocumentStorage::DocumentWriter) allow(form).to receive(:encrypted_document_storage_writer).and_return(document_writer) @@ -277,6 +289,18 @@ form.submit end + + it 'does not send image info to attempts api' do + expect(irs_attempts_api_tracker).to receive(:idv_document_upload_submitted).with( + hash_including( + document_front_image_filename: nil, + document_back_image_filename: nil, + document_image_encryption_key: nil, + ), + ) + + form.submit + end end end diff --git a/spec/forms/idv/inherited_proofing/base_form_spec.rb b/spec/forms/idv/inherited_proofing/base_form_spec.rb index de84e22d7ea..871142de3eb 100644 --- a/spec/forms/idv/inherited_proofing/base_form_spec.rb +++ b/spec/forms/idv/inherited_proofing/base_form_spec.rb @@ -40,24 +40,6 @@ def user_pii; {} end describe '#initialize' do subject { form_class } - context 'when .required_fields is not overridden' do - it 'raises an error' do - subject.singleton_class.send(:remove_method, :required_fields) - expected_error = 'Override this method and return ' \ - 'an Array of required field names as Symbols' - expect { subject.new(payload_hash: payload_hash) }.to raise_error(expected_error) - end - end - - context 'when .optional_fields is not overridden' do - it 'raises an error' do - subject.singleton_class.send(:remove_method, :optional_fields) - expected_error = 'Override this method and return ' \ - 'an Array of optional field names as Symbols' - expect { subject.new(payload_hash: payload_hash) }.to raise_error(expected_error) - end - end - context 'when .user_pii is not overridden' do subject do Class.new(Idv::InheritedProofing::BaseForm) do @@ -82,31 +64,6 @@ def optional_fields; [] end expect(described_class.model_name).to eq 'IdvInheritedProofingBaseForm' end end - - describe '.fields' do - subject do - Class.new(Idv::InheritedProofing::BaseForm) do - class << self - def required_fields; %i[required] end - - def optional_fields; %i[optional] end - end - - def user_pii; {} end - end - end - - let(:expected_field_names) do - [ - :required, - :optional, - ].sort - end - - it 'returns the right field names' do - expect(subject.fields).to match_array expected_field_names - end - end end describe '#initialize' do @@ -217,8 +174,8 @@ def user_pii; {} end subject.submit end - it 'calls #validate' do - expect(subject).to receive(:validate).once + it 'calls #valid?' do + expect(subject).to receive(:valid?).once end end end diff --git a/spec/forms/idv/inherited_proofing/va/form_spec.rb b/spec/forms/idv/inherited_proofing/va/form_spec.rb index 050b3f6ca7a..ad734ab37c1 100644 --- a/spec/forms/idv/inherited_proofing/va/form_spec.rb +++ b/spec/forms/idv/inherited_proofing/va/form_spec.rb @@ -4,7 +4,9 @@ subject(:form) { described_class.new payload_hash: payload_hash } let(:required_fields) { %i[first_name last_name birth_date ssn address_street address_zip] } - let(:optional_fields) { %i[phone address_street2 address_city address_state address_country] } + let(:optional_fields) do + %i[phone address_street2 address_city address_state address_country service_error] + end let(:payload_hash) do { @@ -24,28 +26,30 @@ } end - describe 'class methods' do - describe '.model_name' do - it 'returns the right model name' do - expect(described_class.model_name).to eq 'IdvInheritedProofingVaForm' - end - end - - describe '.fields' do + describe 'constants' do + describe 'FIELDS' do it 'returns all the fields' do - expect(described_class.fields).to match_array required_fields + optional_fields + expect(described_class::FIELDS).to match_array required_fields + optional_fields end end - describe '.required_fields' do + describe 'REQUIRED_FIELDS' do it 'returns the required fields' do - expect(described_class.required_fields).to match_array required_fields + expect(described_class::REQUIRED_FIELDS).to match_array required_fields end end - describe '.optional_fields' do + describe 'OPTIONAL_FIELDS' do it 'returns the optional fields' do - expect(described_class.optional_fields).to match_array optional_fields + expect(described_class::OPTIONAL_FIELDS).to match_array optional_fields + end + end + end + + describe 'class methods' do + describe '.model_name' do + it 'returns the right model name' do + expect(described_class.model_name).to eq 'IdvInheritedProofingVaForm' end end end @@ -56,7 +60,7 @@ let(:payload_hash) { :x } it 'raises an error' do - expect { subject }.to raise_error 'payload_hash is not a Hash' + expect { form }.to raise_error 'payload_hash is not a Hash' end end @@ -75,7 +79,7 @@ context 'when passing a valid payload hash' do it 'raises no errors' do - expect { subject }.to_not raise_error + expect { form }.to_not raise_error end end end @@ -83,7 +87,7 @@ describe '#validate' do context 'with valid payload data' do it 'returns true' do - expect(subject.validate).to eq true + expect(form.validate).to be true end end @@ -119,12 +123,12 @@ end it 'returns false' do - expect(subject.validate).to eq false + expect(form.validate).to be false end it 'adds the correct error messages for missing fields' do subject.validate - expect(subject.errors.full_messages).to match_array expected_error_messages + expect(form.errors.full_messages).to match_array expected_error_messages end end @@ -160,12 +164,12 @@ end it 'returns false' do - expect(subject.validate).to eq false + expect(form.validate).to be false end it 'adds the correct error messages for required fields that are missing data' do subject.validate - expect(subject.errors.full_messages).to match_array expected_error_messages + expect(form.errors.full_messages).to match_array expected_error_messages end end @@ -189,7 +193,24 @@ end it 'returns true' do - expect(subject.validate).to eq true + expect(form.validate).to be true + end + end + + context 'when there is a service-related error' do + before do + subject.validate + end + + let(:payload_hash) { { service_error: 'service error' } } + + it 'returns false' do + expect(form.valid?).to be false + end + + it 'adds a user-friendly model error' do + expect(form.errors.full_messages).to \ + match_array ['Service provider communication was unsuccessful'] end end end @@ -230,11 +251,33 @@ it 'returns a FormResponse indicating the correct errors and status' do form_response = subject.submit - expect(form_response.success?).to eq false + expect(form_response.success?).to be false expect(form_response.errors).to match_array expected_errors + expect(form_response.extra).to eq({}) end end end + + context 'with a valid payload' do + it 'returns a FormResponse indicating the no errors and successful status' do + form_response = subject.submit + expect(form_response.success?).to be true + expect(form_response.errors).to eq({}) + expect(form_response.extra).to eq({}) + end + end + + context 'when there is a service-related error' do + let(:payload_hash) { { service_error: 'service error' } } + + it 'adds the unfiltered error to the FormResponse :extra Hash' do + form_response = subject.submit + expect(form_response.success?).to be false + expect(form_response.errors).to \ + eq({ service_provider: ['communication was unsuccessful'] }) + expect(form_response.extra).to eq({ service_error: 'service error' }) + end + end end describe '#user_pii' do @@ -252,7 +295,23 @@ } end it 'returns the correct user pii' do - expect(subject.user_pii).to eq expected_user_pii + expect(form.user_pii).to eq expected_user_pii + end + end + + describe '#service_error?' do + context 'when there is a service-related error' do + let(:payload_hash) { { service_error: 'service error' } } + + it 'returns true' do + expect(form.service_error?).to be true + end + end + + context 'when there is not a service-related error' do + it 'returns false' do + expect(form.service_error?).to be false + end end end end diff --git a/spec/services/encrypted_document_storage/document_writer_spec.rb b/spec/services/encrypted_document_storage/document_writer_spec.rb index a89294f3538..4f410d2fc26 100644 --- a/spec/services/encrypted_document_storage/document_writer_spec.rb +++ b/spec/services/encrypted_document_storage/document_writer_spec.rb @@ -8,30 +8,53 @@ result = EncryptedDocumentStorage::DocumentWriter.new.encrypt_and_write_document( front_image: front_image, + front_image_content_type: 'image/jpeg', back_image: back_image, + back_image_content_type: 'image/png', ) - front_file_path = Rails.root.join('tmp', 'encrypted_doc_storage', result.front_uuid) - back_file_path = Rails.root.join('tmp', 'encrypted_doc_storage', result.back_uuid) - front_key = Base64.strict_decode64(result.front_encryption_key) - back_key = Base64.strict_decode64(result.back_encryption_key) + front_filename = Rails.root.join('tmp', 'encrypted_doc_storage', result.front_filename) + back_filename = Rails.root.join('tmp', 'encrypted_doc_storage', result.back_filename) + key = Base64.strict_decode64(result.encryption_key) aes_cipher = Encryption::AesCipher.new - written_front_image = aes_cipher.decrypt( - File.read(front_file_path), - front_key, - ) - written_back_image = aes_cipher.decrypt( - File.read(back_file_path), - back_key, - ) + written_front_image = aes_cipher.decrypt(File.read(front_filename), key) + written_back_image = aes_cipher.decrypt(File.read(back_filename), key) expect(written_front_image).to eq(front_image) expect(written_back_image).to eq(back_image) end end + describe '#build_filename_for_content_type' do + let(:filename) { described_class.new.build_filename_for_content_type(content_type) } + let(:content_type) { nil } + + describe 'extension assigning' do + subject { File.extname(filename) } + + context 'jpeg' do + let(:content_type) { 'image/jpeg' } + it { is_expected.to eql('.jpeg') } + end + + context 'png' do + let(:content_type) { 'image/png' } + it { is_expected.to eql('.png') } + end + + context 'nonsense' do + let(:content_type) { 'yabba/dabbadoo' } + it { is_expected.to eql('') } + end + + context nil do + it { is_expected.to eql('') } + end + end + end + describe '#storage' do subject { EncryptedDocumentStorage::DocumentWriter.new } diff --git a/spec/services/gpo_confirmation_exporter_spec.rb b/spec/services/gpo_confirmation_exporter_spec.rb index 6305f5b3aea..aba6e5a3173 100644 --- a/spec/services/gpo_confirmation_exporter_spec.rb +++ b/spec/services/gpo_confirmation_exporter_spec.rb @@ -17,6 +17,7 @@ otp: 'ZYX987', issuer: issuer, }, + created_at: Time.zone.parse('2018-06-29T01:02:03Z'), ), GpoConfirmation.new( entry: { @@ -30,6 +31,7 @@ otp: 'ABC123', issuer: '', }, + created_at: Time.zone.parse('2018-07-04T01:02:03Z'), ), ] end @@ -45,8 +47,8 @@ it 'creates psv string' do result = <<~HEREDOC 01|2\r - 02|John Johnson|123 Sesame St|""|Anytown|WA|98021|ZYX987|July 6, 2018|July 16, 2018|#{service_provider.friendly_name}|#{IdentityConfig.store.domain_name}\r - 02|Söme Öne|123 Añy St|Sté 123|Sömewhere|KS|66666-1234|ABC123|July 6, 2018|July 16, 2018|Login.gov|#{IdentityConfig.store.domain_name}\r + 02|John Johnson|123 Sesame St|""|Anytown|WA|98021|ZYX987|July 6, 2018|July 9, 2018|#{service_provider.friendly_name}|#{IdentityConfig.store.domain_name}\r + 02|Söme Öne|123 Añy St|Sté 123|Sömewhere|KS|66666-1234|ABC123|July 6, 2018|July 14, 2018|Login.gov|#{IdentityConfig.store.domain_name}\r HEREDOC psv_contents = subject.run diff --git a/spec/services/idv/data_url_image_spec.rb b/spec/services/idv/data_url_image_spec.rb index 25d51e812f1..0d019a1e90b 100644 --- a/spec/services/idv/data_url_image_spec.rb +++ b/spec/services/idv/data_url_image_spec.rb @@ -15,6 +15,12 @@ end end + describe '#content_type' do + it 'returns the content type' do + expect(data_url_image.content_type).to eq('image/jpeg') + end + end + describe '#read' do it 'returns the data associated with the image' do expect(data_url_image.read).to eq(data) diff --git a/spec/services/idv/inherited_proofing/va/service_spec.rb b/spec/services/idv/inherited_proofing/va/service_spec.rb index 81695a06e1e..7a5dece157b 100644 --- a/spec/services/idv/inherited_proofing/va/service_spec.rb +++ b/spec/services/idv/inherited_proofing/va/service_spec.rb @@ -62,5 +62,63 @@ it_behaves_like 'an invalid auth code error is raised' end end + + context 'when a request error is raised' do + before do + allow(service).to receive(:request).and_raise('Boom!') + end + + it 'rescues and returns the error' do + expect(service.execute).to eq({ service_error: 'Boom!' }) + end + end + + context 'when a decryption error is raised' do + it 'rescues and returns the error' do + freeze_time do + stub_request(:get, request_uri). + with(headers: request_headers). + to_return(status: 200, body: 'xyz', headers: {}) + + expect(service.execute[:service_error]).to match(/unexpected token at 'xyz'/) + end + end + end + + context 'when a non-200 error is raised' do + it 'rescues and returns the error' do + freeze_time do + stub_request(:get, request_uri). + with(headers: request_headers). + to_return(status: 302, body: encrypted_user_attributes, headers: {}) + + expect(service.execute.to_s).to \ + match(/The service provider API returned an http status other than 200/) + end + end + + context 'when http status is unavailable (nil)' do + before do + allow_any_instance_of(Faraday::Response).to receive(:status).and_return(nil) + end + + let(:expected_error) do + { + service_error: 'The service provider API returned an http status other than 200: ' \ + 'unavailable (unavailable)', + } + end + + it 'rescues and returns the error' do + freeze_time do + stub_request(:get, request_uri). + with(headers: request_headers). + to_return(status: nil, body: encrypted_user_attributes, headers: {}) + + expect(service.execute).to eq expected_error + end + end + end + end end end diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index c244ac17b6c..80572201f57 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -124,7 +124,8 @@ def visit_idp_from_oidc_va_with_ial2( client_id: sp_oidc_issuer, state: SecureRandom.hex, nonce: SecureRandom.hex, - verified_within: nil + verified_within: nil, + inherited_proofing_auth: Idv::InheritedProofing::Va::Mocks::Service::VALID_AUTH_CODE ) @state = state @client_id = sp_oidc_issuer @@ -139,7 +140,7 @@ def visit_idp_from_oidc_va_with_ial2( prompt: 'select_account', nonce: nonce, verified_within: verified_within, - inherited_proofing_auth: Idv::InheritedProofing::Va::Mocks::Service::VALID_AUTH_CODE, + inherited_proofing_auth: inherited_proofing_auth, ) end diff --git a/spec/support/features/inherited_proofing_with_service_provider_helper.rb b/spec/support/features/inherited_proofing_with_service_provider_helper.rb new file mode 100644 index 00000000000..f899b293d2c --- /dev/null +++ b/spec/support/features/inherited_proofing_with_service_provider_helper.rb @@ -0,0 +1,55 @@ +require_relative 'idv_step_helper' +require_relative 'doc_auth_helper' + +module InheritedProofingWithServiceProviderHelper + include IdvStepHelper + include DocAuthHelper + + # Simulates a user (in this case, a VA inherited proofing-authorized user) + # coming over to login.gov from a service provider, and hitting the + # OpenidConnect::AuthorizationController#index action. + def send_user_from_service_provider_to_login_gov_openid_connect(user, inherited_proofing_auth) + expect(user).to_not be_nil + # NOTE: VA user. + visit_idp_from_oidc_va_with_ial2 inherited_proofing_auth: inherited_proofing_auth + end + + def complete_steps_up_to_inherited_proofing_get_started_step(user, expect_accessible: false) + unless current_path == idv_inherited_proofing_step_path(step: :get_started) + complete_idv_steps_before_phone_step(user) + click_link t('links.cancel') + click_button t('idv.cancel.actions.start_over') + expect(page).to have_current_path(idv_inherited_proofing_step_path(step: :get_started)) + end + expect(page).to be_axe_clean.according_to :section508, :"best-practice" if expect_accessible + end + + def complete_steps_up_to_inherited_proofing_how_verifying_step(user, expect_accessible: false) + complete_steps_up_to_inherited_proofing_get_started_step user, + expect_accessible: expect_accessible + unless current_path == idv_inherited_proofing_step_path(step: :agreement) + click_on t('inherited_proofing.buttons.continue') + end + end + + def complete_steps_up_to_inherited_proofing_we_are_retrieving_step(user, + expect_accessible: false) + complete_steps_up_to_inherited_proofing_how_verifying_step( + user, + expect_accessible: expect_accessible, + ) + unless current_path == idv_inherited_proofing_step_path(step: :verify_wait) + check t('inherited_proofing.instructions.consent', app_name: APP_NAME), + allow_label_click: true + click_on t('inherited_proofing.buttons.continue') + end + end + + def complete_steps_up_to_inherited_proofing_verify_your_info_step(user, + expect_accessible: false) + complete_steps_up_to_inherited_proofing_we_are_retrieving_step( + user, + expect_accessible: expect_accessible, + ) + end +end