diff --git a/Gemfile b/Gemfile index 238b81c4b0e..d2c794d0dcc 100644 --- a/Gemfile +++ b/Gemfile @@ -73,7 +73,7 @@ gem 'rqrcode' gem 'ruby-progressbar' gem 'ruby-saml' gem 'safe_target_blank', '>= 1.0.2' -gem 'saml_idp', github: '18F/saml_idp', tag: '0.23.5-18f' +gem 'saml_idp', github: '18F/saml_idp', tag: '0.23.6-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' gem 'stringex', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 13e3544b201..02778feb0f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,14 +36,15 @@ GIT GIT remote: https://github.com/18F/saml_idp.git - revision: bdf8e1f93707e413ecbd0f48d803e18812e19f90 - tag: 0.23.5-18f + revision: 32e9be98c30bc5d01b4088500e4d518f724aadc5 + tag: 0.23.6-18f specs: - saml_idp (0.23.5.pre.18f) + saml_idp (0.23.6.pre.18f) activesupport builder faraday nokogiri (>= 1.10.2) + ostruct pkcs11 GIT diff --git a/Makefile b/Makefile index 990c7c3c9dc..76f6e640dcf 100644 --- a/Makefile +++ b/Makefile @@ -26,6 +26,7 @@ ARTIFACT_DESTINATION_FILE ?= ./tmp/idp.tar.gz lint \ lint_analytics_events \ lint_analytics_events_sorted \ + lint_tracker_events \ lint_country_dialing_codes \ lint_database_schema_files \ lint_erb \ @@ -77,6 +78,7 @@ endif @echo "--- analytics_events ---" make lint_analytics_events make lint_analytics_events_sorted + make lint_tracker_events @echo "--- brakeman ---" make brakeman # JavaScript @@ -305,11 +307,14 @@ lint_analytics_events_sorted: @test "$(shell grep '^ def ' app/services/analytics_events.rb)" = "$(shell grep '^ def ' app/services/analytics_events.rb | sort)" \ || (echo '\033[1;31mError: methods in analytics_events.rb are not sorted alphabetically\033[0m' && exit 1) +lint_tracker_events: .yardoc ## Checks that all methods on AnalyticsEvents are documented + bundle exec ruby lib/analytics_events_documenter.rb --class-name="AttemptsApi::TrackerEvents" --check --skip-extra-params $< + public/api/_analytics-events.json: .yardoc .yardoc/objects/root.dat mkdir -p public/api bundle exec ruby lib/analytics_events_documenter.rb --class-name="AnalyticsEvents" --json $< > $@ -.yardoc .yardoc/objects/root.dat: app/services/analytics_events.rb +.yardoc .yardoc/objects/root.dat: app/services/analytics_events.rb app/services/attempts_api/tracker_events.rb bundle exec yard doc \ --no-progress \ --fail-on-warning \ diff --git a/app/controllers/api/attempts/configuration_controller.rb b/app/controllers/api/attempts/configuration_controller.rb new file mode 100644 index 00000000000..101f787ecc2 --- /dev/null +++ b/app/controllers/api/attempts/configuration_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Api + module Attempts + class ConfigurationController < ApplicationController + include RenderConditionConcern + prepend_before_action :skip_session_load + prepend_before_action :skip_session_expiration + + check_or_render_not_found -> { IdentityConfig.store.attempts_api_enabled } + + def index + render json: AttemptsConfigurationPresenter.new.configuration + end + end + end +end diff --git a/app/controllers/api/attempts/events_controller.rb b/app/controllers/api/attempts/events_controller.rb new file mode 100644 index 00000000000..77b81e8fb89 --- /dev/null +++ b/app/controllers/api/attempts/events_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Api + module Attempts + class EventsController < ApplicationController + include RenderConditionConcern + check_or_render_not_found -> { IdentityConfig.store.attempts_api_enabled } + + prepend_before_action :skip_session_load + prepend_before_action :skip_session_expiration + + def poll + head :method_not_allowed + end + + def status + render json: { + status: :disabled, + reason: :not_yet_implemented, + } + end + end + end +end diff --git a/app/controllers/idv/in_person/address_controller.rb b/app/controllers/idv/in_person/address_controller.rb index 02043a87019..f2b2214bcbc 100644 --- a/app/controllers/idv/in_person/address_controller.rb +++ b/app/controllers/idv/in_person/address_controller.rb @@ -7,7 +7,6 @@ class AddressController < ApplicationController include IdvStepConcern before_action :confirm_step_allowed - before_action :confirm_in_person_address_step_needed, only: :show before_action :set_usps_form_presenter def show @@ -50,7 +49,9 @@ def self.step_info key: :ipp_address, controller: self, next_steps: [:ipp_ssn], - preconditions: ->(idv_session:, user:) { idv_session.ipp_state_id_complete? }, + preconditions: ->(idv_session:, user:) { + idv_session.ipp_state_id_complete? + }, undo_step: ->(idv_session:, user:) do idv_session.invalidate_in_person_address_step! end, @@ -96,13 +97,6 @@ def redirect_to_next_page end end - def confirm_in_person_address_step_needed - return if pii_from_user&.dig(:same_address_as_id) == 'false' && - !pii_from_user.has_key?(:address1) - return if request.referer == idv_in_person_verify_info_url - redirect_to idv_in_person_ssn_url - end - def set_usps_form_presenter @presenter = Idv::InPerson::UspsFormPresenter.new end diff --git a/app/controllers/idv/in_person/ssn_controller.rb b/app/controllers/idv/in_person/ssn_controller.rb index 62f8c7f02e3..f71774a767b 100644 --- a/app/controllers/idv/in_person/ssn_controller.rb +++ b/app/controllers/idv/in_person/ssn_controller.rb @@ -9,9 +9,8 @@ class SsnController < ApplicationController include Steps::ThreatMetrixStepHelper include ThreatMetrixConcern + before_action :confirm_step_allowed before_action :confirm_not_rate_limited_after_doc_auth - before_action :confirm_in_person_address_step_complete - before_action :confirm_repeat_ssn, only: :show before_action :override_csp_for_threat_metrix, if: -> { FeatureManagement.proofing_device_profiling_collecting_enabled? } @@ -66,23 +65,17 @@ def self.step_info key: :ipp_ssn, controller: self, next_steps: [:ipp_verify_info], - preconditions: ->(idv_session:, user:) { idv_session.ipp_document_capture_complete? }, - undo_step: ->(idv_session:, user:) { idv_session.ssn = nil }, + preconditions: ->(idv_session:, user:) { + idv_session.ipp_document_capture_complete? + }, + undo_step: ->(idv_session:, user:) { + idv_session.invalidate_ssn_step! + }, ) end private - def flow_session - user_session.fetch('idv/in_person', {}) - end - - def confirm_repeat_ssn - return if !idv_session.ssn - return if request.referer == idv_in_person_verify_info_url - redirect_to idv_in_person_verify_info_url - end - def next_url idv_in_person_verify_info_url end @@ -96,11 +89,6 @@ def analytics_arguments }.merge(ab_test_analytics_buckets) .merge(**extra_analytics_properties) end - - def confirm_in_person_address_step_complete - return if flow_session[:pii_from_user] && flow_session[:pii_from_user][:address1].present? - redirect_to idv_in_person_address_url - end end end end diff --git a/app/controllers/idv/in_person/verify_info_controller.rb b/app/controllers/idv/in_person/verify_info_controller.rb index c113293face..90d03756462 100644 --- a/app/controllers/idv/in_person/verify_info_controller.rb +++ b/app/controllers/idv/in_person/verify_info_controller.rb @@ -10,8 +10,7 @@ class VerifyInfoController < ApplicationController include VerifyInfoConcern before_action :confirm_not_rate_limited_after_doc_auth, except: [:show] - before_action :confirm_pii_data_present - before_action :confirm_ssn_step_complete + before_action :confirm_step_allowed def show @step_indicator_steps = step_indicator_steps @@ -40,7 +39,8 @@ def self.step_info controller: self, next_steps: [:phone], preconditions: ->(idv_session:, user:) do - idv_session.ssn && idv_session.ipp_document_capture_complete? + idv_session.ssn && idv_session.ipp_document_capture_complete? && + threatmetrix_session_id_present_or_not_required?(idv_session:) end, undo_step: ->(idv_session:, user:) do idv_session.residential_resolution_vendor = nil @@ -89,17 +89,6 @@ def analytics_arguments }.merge(ab_test_analytics_buckets) .merge(**extra_analytics_properties) end - - def confirm_ssn_step_complete - return if pii.present? && idv_session.ssn.present? - redirect_to prev_url - end - - def confirm_pii_data_present - unless user_session.dig('idv/in_person').present? - redirect_to idv_path - end - end end end end diff --git a/app/controllers/idv/ssn_controller.rb b/app/controllers/idv/ssn_controller.rb index 04c7dcb8e9c..05af8c453f5 100644 --- a/app/controllers/idv/ssn_controller.rb +++ b/app/controllers/idv/ssn_controller.rb @@ -65,7 +65,9 @@ def self.step_info controller: self, next_steps: [:verify_info], preconditions: ->(idv_session:, user:) { idv_session.remote_document_capture_complete? }, - undo_step: ->(idv_session:, user:) { idv_session.ssn = nil }, + undo_step: ->(idv_session:, user:) { + idv_session.invalidate_ssn_step! + }, ) end diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb index f2a9e2c9cb8..94eacd36a13 100644 --- a/app/controllers/sign_up/completions_controller.rb +++ b/app/controllers/sign_up/completions_controller.rb @@ -123,6 +123,7 @@ def pii end def send_in_person_completion_survey + return unless IdentityConfig.store.in_person_completion_survey_delivery_enabled return unless resolved_authn_context_result.identity_proofing? Idv::InPerson::CompletionSurveySender.send_completion_survey( diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index ec969c0302b..33c6109b64a 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -132,6 +132,10 @@ interface AcuantCaptureProps { * Prefix to prepend to user action analytics labels. */ name: string; + /** + * Determine whether the selfie help text shoule be shown. + */ + showSelfieHelp: () => void; } /** @@ -308,6 +312,7 @@ function AcuantCapture( allowUpload = true, errorMessage, name, + showSelfieHelp, }: AcuantCaptureProps, ref: Ref, ) { @@ -545,6 +550,7 @@ function AcuantCapture( }); setImageCaptureText(''); + showSelfieHelp(); setIsCapturingEnvironment(false); } diff --git a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx index d5cf14d439a..250fcb3d07e 100644 --- a/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-review-issues.tsx @@ -83,6 +83,7 @@ function DocumentCaptureReviewIssues({ selfieValue={value.selfie} isReviewStep showHelp={false} + showSelfieHelp={() => undefined} /> )} diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx index 0a85cea92e1..5325d1ee36c 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx @@ -23,6 +23,7 @@ interface DocumentSideAcuantCaptureProps { onError: OnErrorCallback; className?: string; isReviewStep: boolean; + showSelfieHelp: () => void; } /** @@ -54,6 +55,7 @@ function DocumentSideAcuantCapture({ onError, className, isReviewStep, + showSelfieHelp, }: DocumentSideAcuantCaptureProps) { const error = errors.find(({ field }) => field === side)?.error; const { changeStepCanComplete } = useContext(FormStepsContext); @@ -97,6 +99,7 @@ function DocumentSideAcuantCapture({ name={side} className={className} allowUpload={isUploadAllowed} + showSelfieHelp={showSelfieHelp} /> ); } diff --git a/app/javascript/packages/document-capture/components/documents-step.tsx b/app/javascript/packages/document-capture/components/documents-step.tsx index cbbb0075af8..bde38974bfd 100644 --- a/app/javascript/packages/document-capture/components/documents-step.tsx +++ b/app/javascript/packages/document-capture/components/documents-step.tsx @@ -36,6 +36,7 @@ export function DocumentsCaptureStep({ side={side} value={value[side]} isReviewStep={isReviewStep} + showSelfieHelp={() => undefined} /> ))} diff --git a/app/javascript/packages/document-capture/components/selfie-step.tsx b/app/javascript/packages/document-capture/components/selfie-step.tsx index 05521b021b9..36c7eb7f72f 100644 --- a/app/javascript/packages/document-capture/components/selfie-step.tsx +++ b/app/javascript/packages/document-capture/components/selfie-step.tsx @@ -24,11 +24,13 @@ export function SelfieCaptureStep({ selfieValue, isReviewStep, showHelp, + showSelfieHelp, }: { defaultSideProps: DefaultSideProps; selfieValue: ImageValue; isReviewStep: boolean; showHelp: boolean; + showSelfieHelp: () => void; }) { const { t } = useI18n(); @@ -55,6 +57,7 @@ export function SelfieCaptureStep({ side="selfie" value={selfieValue} isReviewStep={isReviewStep} + showSelfieHelp={showSelfieHelp} /> )} @@ -74,6 +77,10 @@ export default function SelfieStep({ const { showHelpInitially } = useContext(SelfieCaptureContext); const [showHelp, setShowHelp] = useState(showHelpInitially); + const showSelfieHelp = () => { + setShowHelp(true); + }; + function TakeSelfieButton() { return (
@@ -106,6 +113,7 @@ export default function SelfieStep({ selfieValue={value.selfie} isReviewStep={false} showHelp={showHelp} + showSelfieHelp={showSelfieHelp} /> {showHelp && } {!showHelp && isLastStep && } diff --git a/app/jobs/reports/sp_idv_weekly_dropoff_report.rb b/app/jobs/reports/sp_idv_weekly_dropoff_report.rb index 018883799f2..3e942c3e2a4 100644 --- a/app/jobs/reports/sp_idv_weekly_dropoff_report.rb +++ b/app/jobs/reports/sp_idv_weekly_dropoff_report.rb @@ -55,7 +55,7 @@ def send_report(report_config) end def build_report_maker(issuers:, agency_abbreviation:, time_range:) - Reporting::SpProofingEventsByUuid.new(issuers:, agency_abbreviation:, time_range:) + Reporting::SpIdvWeeklyDropoffReport.new(issuers:, agency_abbreviation:, time_range:) end end end diff --git a/app/presenters/attempts_configuration_presenter.rb b/app/presenters/attempts_configuration_presenter.rb new file mode 100644 index 00000000000..3360da685d1 --- /dev/null +++ b/app/presenters/attempts_configuration_presenter.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# https://openid.net/specs/openid-sharedsignals-framework-1_0-ID3.html#name-transmitter-configuration-m +class AttemptsConfigurationPresenter + include Rails.application.routes.url_helpers + + DELIVERY_METHOD_POLL = 'https://schemas.openid.net/secevent/risc/delivery-method/poll' + + def configuration + { + issuer: root_url, + jwks_uri: api_openid_connect_certs_url, + delivery_methods_supported: [ + DELIVERY_METHOD_POLL, + ], + delivery: [ + { + delivery_method: DELIVERY_METHOD_POLL, + url: api_attempts_poll_url, + }, + ], + status_endpoint: api_attempts_status_url, + } + end + + def url_options + {} + end +end diff --git a/app/services/attempts_api/tracker.rb b/app/services/attempts_api/tracker.rb new file mode 100644 index 00000000000..6d1b228f94f --- /dev/null +++ b/app/services/attempts_api/tracker.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module AttemptsApi + class Tracker + attr_reader :session_id, :enabled_for_session, :request, :user, :sp, :cookie_device_uuid, + :sp_request_uri, :analytics + + def initialize(session_id:, request:, user:, sp:, cookie_device_uuid:, + sp_request_uri:, enabled_for_session:, analytics:) + @session_id = session_id + @request = request + @user = user + @sp = sp + @cookie_device_uuid = cookie_device_uuid + @sp_request_uri = sp_request_uri + @enabled_for_session = enabled_for_session + @analytics = analytics + end + include TrackerEvents + + def track_event(event_type, metadata = {}) + return unless enabled? + + extra_metadata = + if metadata.has_key?(:failure_reason) && + (metadata[:failure_reason].blank? || metadata[:success].present?) + metadata.except(:failure_reason) + else + metadata + end + + event_metadata = { + user_agent: request&.user_agent, + unique_session_id: hashed_session_id, + user_uuid: sp && AgencyIdentityLinker.for(user: user, service_provider: sp)&.uuid, + device_fingerprint: hashed_cookie_device_uuid, + user_ip_address: request&.remote_ip, + application_url: sp_request_uri, + client_port: CloudFrontHeaderParser.new(request).client_port, + } + + event_metadata.merge!(extra_metadata) + + event = AttemptEvent.new( + event_type: event_type, + session_id: session_id, + occurred_at: Time.zone.now, + event_metadata: event_metadata, + ) + + jwe = event.to_jwe( + issuer: sp.issuer, + public_key: sp.ssl_certs.first.public_key, + ) + + redis_client.write_event( + event_key: event.jti, + jwe: jwe, + timestamp: event.occurred_at, + issuer: sp.issuer, + ) + + event + end + + def parse_failure_reason(result) + return result.to_h[:error_details] || result.errors.presence + end + + private + + def hashed_session_id + return nil unless user&.unique_session_id + Digest::SHA1.hexdigest(user&.unique_session_id) + end + + def hashed_cookie_device_uuid + return nil unless cookie_device_uuid + Digest::SHA1.hexdigest(cookie_device_uuid) + end + + def enabled? + IdentityConfig.store.attempts_api_enabled && @enabled_for_session + end + + def redis_client + @redis_client ||= AttemptsApi::RedisClient.new + end + end +end diff --git a/app/services/attempts_api/tracker_events.rb b/app/services/attempts_api/tracker_events.rb new file mode 100644 index 00000000000..88c189d0616 --- /dev/null +++ b/app/services/attempts_api/tracker_events.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +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:) + track_event( + 'login-email-and-password-auth', + email: email, + success: success, + ) + end + end +end diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 196b2e3b9a5..70435fb86c8 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -302,6 +302,12 @@ def ssn_step_complete? ssn.present? end + def invalidate_ssn_step! + if user_session[:idv].has_key?(:ssn) + user_session[:idv].delete(:ssn) + end + end + def verify_info_step_complete? resolution_successful end diff --git a/config/application.yml.default b/config/application.yml.default index 4c95ae8166a..bc7af40f6cf 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -38,6 +38,7 @@ allowed_verified_within_providers: '[]' asset_host: '' async_stale_job_timeout_seconds: 300 async_wait_timeout_seconds: 60 +attempts_api_enabled: false attempts_api_event_ttl_seconds: 3_600 attribute_encryption_key: attribute_encryption_key_queue: '[]' @@ -172,6 +173,7 @@ idv_socure_reason_code_download_enabled: false idv_socure_shadow_mode_enabled: false idv_socure_shadow_mode_enabled_for_docv_users: true idv_sp_required: false +in_person_completion_survey_delivery_enabled: false in_person_completion_survey_url: 'https://login.gov' in_person_doc_auth_button_enabled: true in_person_eipp_enrollment_validity_in_days: 7 diff --git a/config/routes.rb b/config/routes.rb index 5114f550a78..4201dae14e7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,11 @@ post '/api/webhooks/socure/event' => 'socure_webhook#create' namespace :api do + namespace :attempts do + post '/poll' => 'events#poll', as: :poll + get '/status' => 'events#status', as: :status + end + namespace :internal do get '/sessions' => 'sessions#show' put '/sessions' => 'sessions#update' @@ -34,6 +39,9 @@ end end + # Attempts API + get '/.well-known/ssf-configuration' => 'api/attempts/configuration#index' + # SAML secret rotation paths constraints(path_year: SamlEndpoint.suffixes) do get '/api/saml/metadata(:path_year)' => 'saml_idp#metadata', format: false diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 58b89a7d4c5..0218ab2e76b 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -57,6 +57,7 @@ def self.store config.add(:async_stale_job_timeout_seconds, type: :integer) config.add(:async_wait_timeout_seconds, type: :integer) config.add(:attempts_api_event_ttl_seconds, type: :integer) + config.add(:attempts_api_enabled, type: :boolean) config.add(:attribute_encryption_key, type: :string) config.add(:attribute_encryption_key_queue, type: :json) config.add(:available_locales, type: :comma_separated_string_list) @@ -198,6 +199,7 @@ def self.store config.add(:idv_socure_shadow_mode_enabled, type: :boolean) config.add(:idv_socure_shadow_mode_enabled_for_docv_users, type: :boolean) config.add(:idv_sp_required, type: :boolean) + config.add(:in_person_completion_survey_delivery_enabled, type: :boolean) config.add(:in_person_completion_survey_url, type: :string) config.add(:in_person_doc_auth_button_enabled, type: :boolean) config.add(:in_person_eipp_enrollment_validity_in_days, type: :integer) diff --git a/lib/reporting/sp_proofing_events_by_uuid.rb b/lib/reporting/sp_proofing_events_by_uuid.rb index d9cc5eca312..81726121ea8 100644 --- a/lib/reporting/sp_proofing_events_by_uuid.rb +++ b/lib/reporting/sp_proofing_events_by_uuid.rb @@ -183,11 +183,13 @@ def build_uuid_map(uuids) uuid_map = Hash.new uuids.each_slice(1000) do |uuid_slice| - AgencyIdentity.joins(:user).where( - agency:, - users: { uuid: uuid_slice }, - ).each do |agency_identity| - uuid_map[agency_identity.user.uuid] = agency_identity.uuid + Reports::BaseReport.transaction_with_timeout do + AgencyIdentity.joins(:user).where( + agency:, + users: { uuid: uuid_slice }, + ).each do |agency_identity| + uuid_map[agency_identity.user.uuid] = agency_identity.uuid + end end end diff --git a/spec/controllers/api/attempts/events_controller_spec.rb b/spec/controllers/api/attempts/events_controller_spec.rb new file mode 100644 index 00000000000..32d1a728964 --- /dev/null +++ b/spec/controllers/api/attempts/events_controller_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe Api::Attempts::EventsController do + include Rails.application.routes.url_helpers + let(:enabled) { false } + + before do + allow(IdentityConfig.store).to receive(:attempts_api_enabled).and_return(enabled) + end + + describe '#poll' do + let(:action) { post :poll } + + context 'when the Attempts API is not enabled' do + it 'returns 404 not found' do + expect(action.status).to eq(404) + end + end + + context 'when the Attempts API is enabled' do + let(:enabled) { true } + it 'returns 405 method not allowed' do + expect(action.status).to eq(405) + end + end + end + + describe 'status' do + let(:action) { get :status } + + context 'when the Attempts API is not enabled' do + it 'returns 404 not found' do + expect(action.status).to eq(404) + end + end + + context 'when the Attempts API is enabled' do + let(:enabled) { true } + it 'returns a 200' do + expect(action.status).to eq(200) + end + + it 'returns the disabled status and reason' do + body = JSON.parse(action.body, symbolize_names: true) + expect(body[:status]).to eq('disabled') + expect(body[:reason]).to eq('not_yet_implemented') + end + end + end +end diff --git a/spec/controllers/idv/in_person/address_controller_spec.rb b/spec/controllers/idv/in_person/address_controller_spec.rb index a20a89be12b..a05ea29cd7b 100644 --- a/spec/controllers/idv/in_person/address_controller_spec.rb +++ b/spec/controllers/idv/in_person/address_controller_spec.rb @@ -14,12 +14,11 @@ allow(IdentityConfig.store).to receive(:usps_ipp_transliteration_enabled) .and_return(true) stub_sign_in(user) - stub_up_to(:hybrid_handoff, idv_session: subject.idv_session) + stub_up_to(:ipp_state_id, idv_session: subject.idv_session) allow(user).to receive(:establishing_in_person_enrollment).and_return(enrollment) subject.user_session['idv/in_person'] = { pii_from_user: pii_from_user, } - subject.idv_session.ssn = nil stub_analytics end @@ -97,17 +96,6 @@ expect(response).to render_template :show end - context 'when address1 present' do - before do - subject.user_session['idv/in_person'][:pii_from_user][:address1] = '123 Main St' - end - it 'redirects to ssn page' do - get :show - - expect(response).to redirect_to idv_in_person_ssn_url - end - end - it 'logs idv_in_person_proofing_address_visited' do get :show @@ -170,6 +158,12 @@ ) end + it 'enables the user to navigate to the ssn page after entering their residential address' do + put :update, params: params + + expect(response).to redirect_to(idv_in_person_ssn_url) + end + it 'logs idv_in_person_proofing_address_submitted with 5-digit zipcode' do put :update, params: params @@ -178,6 +172,7 @@ context 'when updating the residential address' do before do + stub_up_to(:ipp_verify_info, idv_session: subject.idv_session) subject.user_session['idv/in_person'][:pii_from_user][:address1] = '123 New Residential Ave' end diff --git a/spec/controllers/idv/in_person/ssn_controller_spec.rb b/spec/controllers/idv/in_person/ssn_controller_spec.rb index da9ea25d4ef..989d067d941 100644 --- a/spec/controllers/idv/in_person/ssn_controller_spec.rb +++ b/spec/controllers/idv/in_person/ssn_controller_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe Idv::InPerson::SsnController do + include FlowPolicyHelper + let(:pii_from_user) { Idp::Constants::MOCK_IDV_APPLICANT_SAME_ADDRESS_AS_ID_WITH_NO_SSN.dup } let(:flow_session) do @@ -25,13 +27,22 @@ end describe 'before_actions' do - context '#confirm_in_person_address_step_complete' do - it 'redirects if address page not completed' do - subject.user_session['idv/in_person'][:pii_from_user].delete(:address1) - get :show + before do + stub_up_to(:ipp_state_id, idv_session: subject.idv_session) + subject.user_session['idv/in_person'][:pii_from_user].delete(:address1) + allow(user).to receive(:has_establishing_in_person_enrollment?).and_return(true) + end + it 'redirects if address page not completed' do + get :show - expect(response).to redirect_to idv_in_person_address_url - end + expect(response).to redirect_to idv_in_person_address_url + end + + it 'checks that step is allowed' do + expect(subject).to have_actions( + :before, + :confirm_step_allowed, + ) end end @@ -67,37 +78,24 @@ ) end - it 'adds a threatmetrix session id to idv_session' do - expect { get :show }.to change { controller.idv_session.threatmetrix_session_id }.from(nil) - end - - it 'does not change threatmetrix_session_id when updating ssn' do - controller.idv_session.ssn = ssn - expect { get :show }.not_to change { controller.idv_session.threatmetrix_session_id } - end + context 'threatmetrix_session_id is nil' do + it 'adds a threatmetrix session id to idv_session' do + expect { get :show }.to change { controller.idv_session.threatmetrix_session_id }.from(nil) + end - context 'with an ssn in idv_session' do - let(:referer) { idv_in_person_address_url } - before do + it 'sets a threatmetrix_session_id when updating ssn' do controller.idv_session.ssn = ssn - request.env['HTTP_REFERER'] = referer + expect { get :show }.to change { controller.idv_session.threatmetrix_session_id }.from(nil) end + end - context 'referer is not verify_info' do - it 'redirects to verify_info' do - get :show - - expect(response).to redirect_to(idv_in_person_verify_info_url) - end + context 'threatmetrix_session_id is not nil' do + before do + stub_up_to(:ipp_ssn, idv_session: controller.idv_session) end - - context 'referer is verify_info' do - let(:referer) { idv_in_person_verify_info_url } - it 'does not redirect' do - get :show - - expect(response).to render_template 'idv/shared/ssn' - end + it 'does not change threatmetrix_session_id when updating ssn' do + controller.idv_session.ssn = ssn + expect { get :show }.not_to change { controller.idv_session.threatmetrix_session_id } end end diff --git a/spec/controllers/idv/in_person/verify_info_controller_spec.rb b/spec/controllers/idv/in_person/verify_info_controller_spec.rb index 7ba8e8e4ae5..b62ba461327 100644 --- a/spec/controllers/idv/in_person/verify_info_controller_spec.rb +++ b/spec/controllers/idv/in_person/verify_info_controller_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe Idv::InPerson::VerifyInfoController do + include FlowPolicyHelper + let(:pii_from_user) { Idp::Constants::MOCK_IDV_APPLICANT_SAME_ADDRESS_AS_ID.dup } let(:flow_session) do { pii_from_user: pii_from_user } @@ -8,13 +10,16 @@ let(:user) { create(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) } let(:service_provider) { create(:service_provider) } + let(:enrollment) { InPersonEnrollment.new } before do + stub_analytics stub_sign_in(user) subject.idv_session.flow_path = 'standard' subject.idv_session.ssn = Idp::Constants::MOCK_IDV_APPLICANT_SAME_ADDRESS_AS_ID[:ssn] subject.idv_session.idv_consent_given_at = Time.zone.now.to_s subject.user_session['idv/in_person'] = flow_session + stub_up_to(:ipp_ssn, idv_session: subject.idv_session) end describe '#step_info' do @@ -69,25 +74,14 @@ ) end - it 'confirms ssn step complete' do - expect(subject).to have_actions( - :before, - :confirm_ssn_step_complete, - ) - end - - it 'confirms idv/in_person data is present' do + it 'confirms the verify info step is allowed' do expect(subject).to have_actions( :before, - :confirm_pii_data_present, + :confirm_step_allowed, ) end end - before do - stub_analytics - end - describe '#show' do it 'renders the show template' do get :show @@ -242,12 +236,14 @@ context 'when idv/in_person data is missing' do before do + stub_up_to(:ipp_verify_info, idv_session: subject.idv_session) + allow(user).to receive(:has_establishing_in_person_enrollment?).and_return(true) subject.user_session['idv/in_person'] = {} end - it 'redirects to idv_path' do + it 'redirects to the in person state id page' do get :show - expect(response).to redirect_to(idv_path) + expect(response).to redirect_to(idv_in_person_state_id_path) end end @@ -319,7 +315,6 @@ end let(:pii_from_user) { Idp::Constants::MOCK_IDV_APPLICANT_STATE_ID_ADDRESS.dup } - let(:enrollment) { InPersonEnrollment.new } before do allow(user).to receive(:establishing_in_person_enrollment).and_return(enrollment) end @@ -345,7 +340,7 @@ .with( kind_of(DocumentCaptureSession), trace_id: subject.send(:amzn_trace_id), - threatmetrix_session_id: nil, + threatmetrix_session_id: 'a-random-session-id', user_id: anything, request_ip: request.remote_ip, ipp_enrollment_in_progress: false, @@ -357,6 +352,11 @@ end context 'a user does have an establishing in person enrollment associated with them' do + before do + subject.idv_session.send(:user_session)['idv/in_person'] = { + pii_from_user: Idp::Constants::MOCK_IDV_APPLICANT_STATE_ID_ADDRESS, + } + end it 'indicates to the IDV agent that ipp_enrollment_in_progress is enabled' do expect_any_instance_of(Idv::Agent).to receive(:proof_resolution).with( kind_of(DocumentCaptureSession), @@ -390,7 +390,7 @@ .with( kind_of(DocumentCaptureSession), trace_id: subject.send(:amzn_trace_id), - threatmetrix_session_id: nil, + threatmetrix_session_id: 'a-random-session-id', user_id: anything, request_ip: request.remote_ip, ipp_enrollment_in_progress: true, @@ -448,4 +448,22 @@ put :update end end + + context 'when proofing_device_profiling is enabled' do + before do + allow(user).to receive(:establishing_in_person_enrollment).and_return(enrollment) + allow(IdentityConfig.store).to receive(:proofing_device_profiling).and_return(:enabled) + end + + context 'when idv_session is missing threatmetrix_session_id' do + before do + subject.idv_session.threatmetrix_session_id = nil + end + + it 'redirects back to the SSN step' do + get :show + expect(response).to redirect_to(idv_in_person_ssn_url) + end + end + end end diff --git a/spec/controllers/sign_up/completions_controller_spec.rb b/spec/controllers/sign_up/completions_controller_spec.rb index 051072162ba..a598df3375a 100644 --- a/spec/controllers/sign_up/completions_controller_spec.rb +++ b/spec/controllers/sign_up/completions_controller_spec.rb @@ -355,26 +355,137 @@ end end - it 'sends the in-person proofing completion survey' do - user = create(:user, profiles: [create(:profile, :verified, :active)]) - stub_sign_in(user) - sp = create(:service_provider, issuer: 'https://awesome') - subject.session[:sp] = { - issuer: sp.issuer, - acr_values: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - request_url: 'http://example.com', - requested_attributes: %w[email first_name verified_at], - } - allow(@linker).to receive(:link_identity).with( - verified_attributes: %w[email first_name verified_at], - last_consented_at: now, - clear_deleted_at: true, - ) - expect(Idv::InPerson::CompletionSurveySender).to receive(:send_completion_survey) - .with(user, sp.issuer) - freeze_time do - travel_to(now) + context 'in person completion survey delievery enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_completion_survey_delivery_enabled) + .and_return(true) + end + + it 'sends the in-person proofing completion survey' do + user = create(:user, profiles: [create(:profile, :verified, :active)]) + stub_sign_in(user) + sp = create( + :service_provider, issuer: 'https://awesome', + in_person_proofing_enabled: true + ) + + subject.session[:sp] = { + issuer: sp.issuer, + acr_values: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + request_url: 'http://example.com', + requested_attributes: %w[email first_name verified_at], + } + allow(@linker).to receive(:link_identity).with( + verified_attributes: %w[email first_name verified_at], + last_consented_at: now, + clear_deleted_at: true, + ) + expect(Idv::InPerson::CompletionSurveySender).to receive(:send_completion_survey) + .with(user, sp.issuer) + freeze_time do + travel_to(now) + patch :update + end + end + + it 'updates follow_up_survey_sent on enrollment to true' do + user = create(:user, profiles: [create(:profile, :verified, :active)]) + stub_sign_in(user) + sp = create( + :service_provider, issuer: 'https://awesome', + in_person_proofing_enabled: true + ) + e = create( + :in_person_enrollment, status: 'passed', doc_auth_result: 'Passed', + user: user, issuer: sp.issuer + ) + + expect(e.follow_up_survey_sent).to be false + + subject.session[:sp] = { + issuer: sp.issuer, + acr_values: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + request_url: 'http://example.com', + requested_attributes: %w[email first_name verified_at], + } + allow(@linker).to receive(:link_identity).with( + verified_attributes: %w[email first_name verified_at], + last_consented_at: now, + clear_deleted_at: true, + ) + + patch :update + e.reload + + expect(e.follow_up_survey_sent).to be true + end + end + + context 'in person completion survey delievery disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_completion_survey_delivery_enabled) + .and_return(false) + end + + it 'does not send the in-person proofing completion survey' do + user = create(:user, profiles: [create(:profile, :verified, :active)]) + stub_sign_in(user) + sp = create( + :service_provider, issuer: 'https://awesome', + in_person_proofing_enabled: true + ) + + subject.session[:sp] = { + issuer: sp.issuer, + acr_values: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + request_url: 'http://example.com', + requested_attributes: %w[email first_name verified_at], + } + allow(@linker).to receive(:link_identity).with( + verified_attributes: %w[email first_name verified_at], + last_consented_at: now, + clear_deleted_at: true, + ) + expect(Idv::InPerson::CompletionSurveySender).not_to receive(:send_completion_survey) + .with(user, sp.issuer) + freeze_time do + travel_to(now) + patch :update + end + end + + it 'does not update enrollment' do + user = create(:user, profiles: [create(:profile, :verified, :active)]) + stub_sign_in(user) + sp = create( + :service_provider, issuer: 'https://awesome', + in_person_proofing_enabled: true + ) + e = create( + :in_person_enrollment, status: 'passed', doc_auth_result: 'Passed', + user: user, issuer: sp.issuer + ) + + expect(e.follow_up_survey_sent).to be false + + subject.session[:sp] = { + issuer: sp.issuer, + acr_values: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + request_url: 'http://example.com', + requested_attributes: %w[email first_name verified_at], + } + allow(@linker).to receive(:link_identity).with( + verified_attributes: %w[email first_name verified_at], + last_consented_at: now, + clear_deleted_at: true, + ) + patch :update + e.reload + + expect(e.follow_up_survey_sent).to be false end end end diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 8550a4d8bfa..febfef85a33 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -9,6 +9,8 @@ before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:in_person_completion_survey_delivery_enabled) + .and_return(true) end it 'works for a happy path', allow_browser_log: true do diff --git a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx index 9a63f206b08..aa8a6f99308 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -1068,6 +1068,7 @@ describe('document-capture/components/acuant-capture', () => { context('mobile selfie', () => { const trackEvent = sinon.stub(); + const showSelfieHelp = sinon.stub(); beforeEach(async () => { // Set up the components so that everything is as it would actually be -except- the AcuantSDK @@ -1076,7 +1077,7 @@ describe('document-capture/components/acuant-capture', () => { - + , @@ -1132,6 +1133,16 @@ describe('document-capture/components/acuant-capture', () => { ); }); + it('calls showSelfieHelp from onSelfieCaptureClosed', () => { + initialize({ + selfieStart: sinon.stub().callsFake((callbacks) => { + callbacks.onClosed(); + }), + }); + + expect(showSelfieHelp).to.have.been.called(); + }); + it('calls trackEvent from onSelfieCaptureSuccess', () => { // In real use the `start` method opens the Acuant SDK full screen selfie capture window. // Because we can't do that in test (AcuantSDK does not allow), this doesn't attempt to load diff --git a/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx b/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx index f45e22de68e..45a2b5acf10 100644 --- a/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx +++ b/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx @@ -11,6 +11,7 @@ describe('DocumentSideAcuantCapture', () => { onChange: () => undefined, onError: () => undefined, isReviewStep: false, + showSelfieHelp: () => undefined, }; context('when selfie is _not_ enabled', () => { diff --git a/spec/presenters/attempts_configuration_presenter_spec.rb b/spec/presenters/attempts_configuration_presenter_spec.rb new file mode 100644 index 00000000000..a0a2d252b8e --- /dev/null +++ b/spec/presenters/attempts_configuration_presenter_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe AttemptsConfigurationPresenter do + include Rails.application.routes.url_helpers + + subject { AttemptsConfigurationPresenter.new } + + describe '#configuration' do + let(:configuration) { subject.configuration } + + it 'includes information about the RISC integration' do + aggregate_failures do + expect(configuration[:issuer]).to eq(root_url) + expect(configuration[:jwks_uri]).to eq(api_openid_connect_certs_url) + expect(configuration[:delivery_methods_supported]) + .to eq([AttemptsConfigurationPresenter::DELIVERY_METHOD_POLL]) + + expect(configuration[:delivery]).to eq( + [ + delivery_method: AttemptsConfigurationPresenter::DELIVERY_METHOD_POLL, + url: api_attempts_poll_url, + ], + ) + + expect(configuration[:status_endpoint]).to eq(api_attempts_status_url) + end + end + end +end diff --git a/spec/services/attempts_api/tracker_spec.rb b/spec/services/attempts_api/tracker_spec.rb new file mode 100644 index 00000000000..971a8da3681 --- /dev/null +++ b/spec/services/attempts_api/tracker_spec.rb @@ -0,0 +1,150 @@ +require 'rails_helper' + +RSpec.describe AttemptsApi::Tracker do + before do + allow(IdentityConfig.store).to receive(:attempts_api_enabled) + .and_return(attempts_api_enabled) + allow(request).to receive(:user_agent).and_return('example/1.0') + allow(request).to receive(:remote_ip).and_return('192.0.2.1') + allow(request).to receive(:headers).and_return( + { 'CloudFront-Viewer-Address' => '192.0.2.1:1234' }, + ) + end + + let(:attempts_api_enabled) { true } + let(:session_id) { 'test-session-id' } + let(:enabled_for_session) { true } + let(:request) { instance_double(ActionDispatch::Request) } + let(:service_provider) { create(:service_provider) } + 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( + session_id: session_id, + request: request, + user: user, + sp: service_provider, + cookie_device_uuid: cookie_device_uuid, + sp_request_uri: sp_request_uri, + enabled_for_session: enabled_for_session, + analytics: analytics, + ) + end + + describe '#track_event' do + it 'omit failure reason when success is true' do + freeze_time do + event = subject.track_event(:test_event, foo: :bar, success: true, failure_reason: nil) + expect(event.event_metadata).to_not have_key(:failure_reason) + end + end + + it 'omit failure reason when failure_reason is blank' do + freeze_time do + event = subject.track_event(:test_event, foo: :bar, failure_reason: nil) + expect(event.event_metadata).to_not have_key(:failure_reason) + end + end + + it 'should not omit failure reason when success is false and failure_reason is not blank' do + freeze_time do + event = subject.track_event( + :test_event, foo: :bar, success: false, + failure_reason: { foo: [:bar] } + ) + expect(event.event_metadata).to have_key(:failure_reason) + expect(event.event_metadata).to have_key(:success) + end + end + + it 'records the event in redis' do + freeze_time do + subject.track_event(:test_event, foo: :bar) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.values.length).to eq(1) + end + end + + it 'does not store events in plaintext in redis' do + freeze_time do + subject.track_event(:event, first_name: Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.keys.first).to_not include('first_name') + expect(events.values.first).to_not include(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + end + end + + context 'the current session is not an attempts API session' do + let(:enabled_for_session) { false } + + it 'does not record any events in redis' do + freeze_time do + subject.track_event(:test_event, foo: :bar) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.values.length).to eq(0) + end + end + end + + context 'the attempts API is not enabled' do + let(:attempts_api_enabled) { false } + + it 'does not record any events in redis' do + freeze_time do + subject.track_event(:test_event, foo: :bar) + + events = AttemptsApi::RedisClient.new.read_events( + timestamp: Time.zone.now, + issuer: service_provider.issuer, + ) + + expect(events.values.length).to eq(0) + end + end + end + end + + describe '#parse_failure_reason' do + let(:mock_error_message) { 'failure_reason_from_error' } + let(:mock_error_details) { [{ mock_error: 'failure_reason_from_error_details' }] } + + it 'parses failure_reason from error_details' do + test_failure_reason = subject.parse_failure_reason( + { errors: mock_error_message, + error_details: mock_error_details }, + ) + + expect(test_failure_reason).to eq(mock_error_details) + end + + it 'parses failure_reason from errors when no error_details present' do + mock_failure_reason = double( + 'MockFailureReason', + errors: mock_error_message, + to_h: {}, + ) + + test_failure_reason = subject.parse_failure_reason(mock_failure_reason) + + expect(test_failure_reason).to eq(mock_error_message) + end + end +end diff --git a/spec/support/flow_policy_helper.rb b/spec/support/flow_policy_helper.rb index cc555129a10..e99ad069d12 100644 --- a/spec/support/flow_policy_helper.rb +++ b/spec/support/flow_policy_helper.rb @@ -38,6 +38,7 @@ def stub_step(key:, idv_session:) pii_from_user: Idp::Constants::MOCK_IDV_APPLICANT_SAME_ADDRESS_AS_ID.dup, } idv_session.ssn = Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN[:ssn] + idv_session.threatmetrix_session_id = 'a-random-session-id' when :verify_info idv_session.mark_verify_info_step_complete! idv_session.applicant = Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.dup