diff --git a/Brewfile b/Brewfile index 76ea11b5282..df94e09b8dd 100644 --- a/Brewfile +++ b/Brewfile @@ -4,4 +4,5 @@ brew 'redis' brew 'node@16' brew 'yarn' brew 'openssl@1.1' +brew 'jq' cask 'chromedriver' diff --git a/Gemfile b/Gemfile index 6fa687cbcb2..5d324100567 100644 --- a/Gemfile +++ b/Gemfile @@ -46,13 +46,13 @@ gem 'maxminddb' gem 'multiset' gem 'net-sftp' gem 'newrelic_rpm', '~> 9.0' -gem 'puma', '~> 5.6.7' +gem 'puma', '~> 6.0' gem 'pg' gem 'phonelib' gem 'premailer-rails', '>= 1.12.0' gem 'profanity_filter' gem 'propshaft' -gem 'rack', '>= 2.2.3.1' +gem 'rack', '>= 3.0' gem 'rack-attack', '>= 6.2.1' gem 'rack-cors', '>= 1.0.5', require: 'rack/cors' gem 'rack-headers_filter' diff --git a/Gemfile.lock b/Gemfile.lock index 1a6dd0d8289..5f81ca39ee0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,73 +60,74 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.1) - actionpack (= 7.1.1) - activesupport (= 7.1.1) + actioncable (7.1.2) + actionpack (= 7.1.2) + activesupport (= 7.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.1) - actionpack (= 7.1.1) - activejob (= 7.1.1) - activerecord (= 7.1.1) - activestorage (= 7.1.1) - activesupport (= 7.1.1) + actionmailbox (7.1.2) + actionpack (= 7.1.2) + activejob (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.1) - actionpack (= 7.1.1) - actionview (= 7.1.1) - activejob (= 7.1.1) - activesupport (= 7.1.1) + actionmailer (7.1.2) + actionpack (= 7.1.2) + actionview (= 7.1.2) + activejob (= 7.1.2) + activesupport (= 7.1.2) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.1) - actionview (= 7.1.1) - activesupport (= 7.1.1) + actionpack (7.1.2) + actionview (= 7.1.2) + activesupport (= 7.1.2) nokogiri (>= 1.8.5) + racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.1) - actionpack (= 7.1.1) - activerecord (= 7.1.1) - activestorage (= 7.1.1) - activesupport (= 7.1.1) + actiontext (7.1.2) + actionpack (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.1) - activesupport (= 7.1.1) + actionview (7.1.2) + activesupport (= 7.1.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.1) - activesupport (= 7.1.1) + activejob (7.1.2) + activesupport (= 7.1.2) globalid (>= 0.3.6) - activemodel (7.1.1) - activesupport (= 7.1.1) - activerecord (7.1.1) - activemodel (= 7.1.1) - activesupport (= 7.1.1) + activemodel (7.1.2) + activesupport (= 7.1.2) + activerecord (7.1.2) + activemodel (= 7.1.2) + activesupport (= 7.1.2) timeout (>= 0.4.0) activerecord-postgis-adapter (9.0.1) activerecord (~> 7.1.0) rgeo-activerecord (~> 7.0.0) - activestorage (7.1.1) - actionpack (= 7.1.1) - activejob (= 7.1.1) - activerecord (= 7.1.1) - activesupport (= 7.1.1) + activestorage (7.1.2) + actionpack (= 7.1.2) + activejob (= 7.1.2) + activerecord (= 7.1.2) + activesupport (= 7.1.2) marcel (~> 1.0) - activesupport (7.1.1) + activesupport (7.1.2) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -146,7 +147,7 @@ GEM android_key_attestation (0.3.0) ast (2.4.2) awrence (1.2.1) - aws-eventstream (1.2.0) + aws-eventstream (1.3.0) aws-partitions (1.792.0) aws-sdk-cloudwatchlogs (1.49.0) aws-sdk-core (~> 3, >= 3.122.0) @@ -178,7 +179,7 @@ GEM aws-sdk-sqs (1.53.0) aws-sdk-core (~> 3, >= 3.165.0) aws-sigv4 (~> 1.1) - aws-sigv4 (1.6.0) + aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) axe-core-api (4.7.0) dumb_delegator @@ -193,7 +194,7 @@ GEM thread_safe (~> 0.3, >= 0.3.1) barby (0.6.8) base32-crockford (0.1.0) - base64 (0.1.1) + base64 (0.2.0) bcrypt (3.1.19) benchmark-ips (2.12.0) better_errors (2.10.1) @@ -214,7 +215,7 @@ GEM brakeman (6.0.1) browser (5.3.1) builder (3.2.4) - bullet (7.1.0) + bullet (7.1.4) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.9.1) @@ -252,7 +253,7 @@ GEM addressable cssbundling-rails (1.0.0) railties (>= 6.0.0) - date (3.3.3) + date (3.3.4) dead_end (4.0.0) derailed_benchmarks (2.1.2) benchmark-ips (~> 2) @@ -361,7 +362,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.1.2) railties (>= 6.0.0) - json (2.6.3) + json (2.7.0) jwe (0.4.0) jwt (2.7.1) knapsack (4.0.0) @@ -412,12 +413,12 @@ GEM msgpack (1.7.2) multiset (0.5.3) mutex_m (0.2.0) - net-imap (0.4.2) + net-imap (0.4.6) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout net-sftp (3.0.0) net-ssh (>= 5.0.0, < 7.0.0) @@ -426,7 +427,7 @@ GEM net-ssh (6.1.0) newrelic_rpm (9.6.0) base64 - nio4r (2.5.9) + nio4r (2.6.1) nokogiri (1.14.5) mini_portile2 (~> 2.8.0) racc (~> 1.4) @@ -471,45 +472,45 @@ GEM psych (5.1.1.1) stringio public_suffix (5.0.3) - puma (5.6.7) + puma (6.4.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.7.3) - rack (2.2.8) - rack-attack (6.5.0) - rack (>= 1.0, < 3) - rack-cors (1.1.1) + rack (3.0.8) + rack-attack (6.7.0) + rack (>= 1.0, < 4) + rack-cors (2.0.1) rack (>= 2.0.0) rack-headers_filter (0.0.1) rack-mini-profiler (3.1.1) rack (>= 1.2.0) - rack-proxy (0.7.4) + rack-proxy (0.7.7) rack - rack-session (1.0.1) - rack (< 3) + rack-session (2.0.0) + rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rack-timeout (0.6.0) + rack-timeout (0.6.3) rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rackup (1.0.0) - rack (< 3) - webrick - rails (7.1.1) - actioncable (= 7.1.1) - actionmailbox (= 7.1.1) - actionmailer (= 7.1.1) - actionpack (= 7.1.1) - actiontext (= 7.1.1) - actionview (= 7.1.1) - activejob (= 7.1.1) - activemodel (= 7.1.1) - activerecord (= 7.1.1) - activestorage (= 7.1.1) - activesupport (= 7.1.1) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rails (7.1.2) + actioncable (= 7.1.2) + actionmailbox (= 7.1.2) + actionmailer (= 7.1.2) + actionpack (= 7.1.2) + actiontext (= 7.1.2) + actionview (= 7.1.2) + activejob (= 7.1.2) + activemodel (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) bundler (>= 1.15.0) - railties (= 7.1.1) + railties (= 7.1.2) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -524,9 +525,9 @@ GEM rails-i18n (7.0.6) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.1) - actionpack (= 7.1.1) - activesupport (= 7.1.1) + railties (7.1.2) + actionpack (= 7.1.2) + activesupport (= 7.1.2) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -799,8 +800,8 @@ DEPENDENCIES pry-doc pry-rails psych - puma (~> 5.6.7) - rack (>= 2.2.3.1) + puma (~> 6.0) + rack (>= 3.0) rack-attack (>= 6.2.1) rack-cors (>= 1.0.5) rack-headers_filter diff --git a/app/components/javascript_required_component.html.erb b/app/components/javascript_required_component.html.erb index 514068d4efe..857ca8d211c 100644 --- a/app/components/javascript_required_component.html.erb +++ b/app/components/javascript_required_component.html.erb @@ -16,7 +16,7 @@

<%= t('components.javascript_required.next_step') %>

<% end %> - +
<% if was_no_js? %> diff --git a/app/components/javascript_required_component.rb b/app/components/javascript_required_component.rb index 5b0cd32d547..4860e28e393 100644 --- a/app/components/javascript_required_component.rb +++ b/app/components/javascript_required_component.rb @@ -1,7 +1,7 @@ class JavascriptRequiredComponent < BaseComponent include LinkHelper - attr_reader :header, :intro + attr_reader :header, :location, :intro BROWSER_RESOURCES = [ { name: 'Google Chrome', url: 'https://support.google.com' }, @@ -10,8 +10,9 @@ class JavascriptRequiredComponent < BaseComponent { name: 'Apple Safari', url: 'https://support.apple.com/safari' }, ].to_set.freeze - def initialize(header:, intro: nil) + def initialize(header:, location:, intro: nil) @header = header + @location = location @intro = intro end diff --git a/app/components/tab_navigation_component.rb b/app/components/tab_navigation_component.rb index 18df3d78c7d..1569e451be5 100644 --- a/app/components/tab_navigation_component.rb +++ b/app/components/tab_navigation_component.rb @@ -9,8 +9,8 @@ def initialize(label:, routes:, **tag_options) def is_current_path?(path) recognized_path = Rails.application.routes.recognize_path(path, method: request.method) - request[:controller] == recognized_path[:controller] && - request[:action] == recognized_path[:action] + request.params[:controller] == recognized_path[:controller] && + request.params[:action] == recognized_path[:action] rescue ActionController::RoutingError false end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a3ebcfb457f..ee07e3feb88 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -125,8 +125,8 @@ def amzn_trace_id end def disable_caching - response.headers['Cache-Control'] = 'no-store' - response.headers['Pragma'] = 'no-cache' + response.headers[Rack::CACHE_CONTROL] = 'no-store' + response.headers['pragma'] = 'no-cache' end def cache_issuer_in_cookie diff --git a/app/controllers/idv/agreement_controller.rb b/app/controllers/idv/agreement_controller.rb index 2ae35f34da2..19d538284c7 100644 --- a/app/controllers/idv/agreement_controller.rb +++ b/app/controllers/idv/agreement_controller.rb @@ -33,7 +33,8 @@ def update if result.success? idv_session.idv_consent_given = true - if IdentityConfig.store.in_person_proofing_opt_in_enabled + if IdentityConfig.store.in_person_proofing_opt_in_enabled && + IdentityConfig.store.in_person_proofing_enabled redirect_to idv_how_to_verify_url else redirect_to idv_hybrid_handoff_url diff --git a/app/controllers/idv/by_mail/enter_code_controller.rb b/app/controllers/idv/by_mail/enter_code_controller.rb index 8cdfbc671e0..1b84019752b 100644 --- a/app/controllers/idv/by_mail/enter_code_controller.rb +++ b/app/controllers/idv/by_mail/enter_code_controller.rb @@ -42,7 +42,8 @@ def index end def pii - Pii::Cacher.new(current_user, user_session).fetch + Pii::Cacher.new(current_user, user_session). + fetch(current_user.gpo_verification_pending_profile.id) end def create @@ -62,8 +63,12 @@ def create ) if !result.success? - flash[:error] = @gpo_verify_form.errors.first.message if !rate_limiter.limited? - redirect_to idv_verify_by_mail_enter_code_url + if rate_limiter.limited? + redirect_to idv_enter_code_rate_limited_url + else + flash[:error] = @gpo_verify_form.errors.first.message if !rate_limiter.limited? + redirect_to idv_verify_by_mail_enter_code_url + end return end diff --git a/app/controllers/idv/how_to_verify_controller.rb b/app/controllers/idv/how_to_verify_controller.rb index 919a5a405af..1ca5263c274 100644 --- a/app/controllers/idv/how_to_verify_controller.rb +++ b/app/controllers/idv/how_to_verify_controller.rb @@ -22,9 +22,14 @@ def show def update clear_future_steps! result = Idv::HowToVerifyForm.new.submit(how_to_verify_form_params) + if how_to_verify_form_params[:selection] == [] + sendable_form_params = {} + else + sendable_form_params = how_to_verify_form_params + end analytics.idv_doc_auth_how_to_verify_submitted( - **analytics_arguments.merge(result.to_h), + **analytics_arguments.merge(sendable_form_params).merge(result.to_h), ) if result.success? @@ -43,6 +48,11 @@ def update end end + def self.enabled? + IdentityConfig.store.in_person_proofing_opt_in_enabled && + IdentityConfig.store.in_person_proofing_enabled + end + def self.step_info Idv::StepInfo.new( key: :how_to_verify, @@ -55,17 +65,15 @@ def self.step_info ) end - def self.enabled? - IdentityConfig.store.in_person_proofing_opt_in_enabled - end - private def analytics_arguments { step: 'how_to_verify', analytics_id: 'Doc Auth', - } + skip_hybrid_handoff: idv_session.skip_hybrid_handoff, + irs_reproofing: irs_reproofing?, + }.merge(ab_test_analytics_buckets) end def how_to_verify_form_params diff --git a/app/controllers/idv/otp_verification_controller.rb b/app/controllers/idv/otp_verification_controller.rb index 792734c0f2a..4d05e945610 100644 --- a/app/controllers/idv/otp_verification_controller.rb +++ b/app/controllers/idv/otp_verification_controller.rb @@ -27,7 +27,7 @@ def update ) if result.success? - idv_session.user_phone_confirmation = true + idv_session.mark_phone_step_complete! save_in_person_notification_phone flash[:success] = t('idv.messages.enter_password.phone_verified') redirect_to idv_enter_password_url diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index 57ffbee41eb..f28608ae523 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -41,6 +41,7 @@ def new def create clear_future_steps! + idv_session.invalidate_phone_step! result = idv_form.submit(step_params) Funnel::DocAuth::RegisterStep.new(current_user.id, current_sp&.issuer). call(:verify_phone, :update, result.success?) diff --git a/app/controllers/idv/phone_errors_controller.rb b/app/controllers/idv/phone_errors_controller.rb index 0ddadb5d828..8c6cdfd7fb3 100644 --- a/app/controllers/idv/phone_errors_controller.rb +++ b/app/controllers/idv/phone_errors_controller.rb @@ -1,13 +1,11 @@ module Idv class PhoneErrorsController < ApplicationController include Idv::AvailabilityConcern + include IdvStepConcern include StepIndicatorConcern - include IdvSession include Idv::AbTestAnalyticsConcern - before_action :confirm_two_factor_authenticated - before_action :confirm_idv_phone_step_needed - before_action :confirm_idv_phone_step_submitted, except: [:failure] + before_action :confirm_step_allowed, except: [:failure] before_action :set_gpo_letter_available before_action :ignore_form_step_wait_requests @@ -39,21 +37,23 @@ def failure track_event(type: :failure) end + def self.step_info + Idv::StepInfo.new( + key: :phone_errors, + controller: self, + action: :failure, + next_steps: [FlowPolicy::FINAL], + preconditions: ->(idv_session:, user:) { idv_session.previous_phone_step_params.present? }, + undo_step: ->(idv_session:, user:) {}, + ) + end + private def rate_limiter RateLimiter.new(user: idv_session.current_user, rate_limit_type: :proof_address) end - def confirm_idv_phone_step_needed - return unless user_fully_authenticated? - redirect_to idv_enter_password_url if idv_session.user_phone_confirmation == true - end - - def confirm_idv_phone_step_submitted - redirect_to idv_phone_url if idv_session.previous_phone_step_params.nil? - end - def ignore_form_step_wait_requests head(:no_content) if request.headers['HTTP_X_FORM_STEPS_WAIT'] end diff --git a/app/controllers/no_js_controller.rb b/app/controllers/no_js_controller.rb index 6dbd79944fa..fdcacc45450 100644 --- a/app/controllers/no_js_controller.rb +++ b/app/controllers/no_js_controller.rb @@ -3,6 +3,7 @@ class NoJsController < ApplicationController def index session[SESSION_KEY] = true + analytics.no_js_detect_stylesheet_loaded(location: params[:location]) render body: '', content_type: 'text/css' end end diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.spec.ts b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.spec.ts new file mode 100644 index 00000000000..9b940e1c3d2 --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.spec.ts @@ -0,0 +1,47 @@ +import { useDefineProperty } from '@18f/identity-test-helpers'; +import isWebauthnPlatformAuthenticatorAvailable from './is-webauthn-platform-authenticator-available'; + +describe('isWebauthnPlatformAuthenticatorAvailable', () => { + const defineProperty = useDefineProperty(); + + context('browser does not support webauthn', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: undefined, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformAuthenticatorAvailable()).to.eventually.equal(false); + }); + }); + + context('browser supports webauthn', () => { + context('device does not have platform authenticator available', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(false) }, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformAuthenticatorAvailable()).to.eventually.equal(false); + }); + }); + + context('device has platform authenticator available', () => { + beforeEach(() => { + defineProperty(window, 'PublicKeyCredential', { + configurable: true, + value: { isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true) }, + }); + }); + + it('resolves to false', async () => { + await expect(isWebauthnPlatformAuthenticatorAvailable()).to.eventually.equal(true); + }); + }); + }); +}); diff --git a/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.ts b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.ts new file mode 100644 index 00000000000..673b59a5473 --- /dev/null +++ b/app/javascript/packages/webauthn/is-webauthn-platform-authenticator-available.ts @@ -0,0 +1,6 @@ +export type IsWebauthnPlatformAvailable = () => Promise; + +const isWebauthnPlatformAuthenticatorAvailable: IsWebauthnPlatformAvailable = async () => + !!(await window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable()); + +export default isWebauthnPlatformAuthenticatorAvailable; diff --git a/app/javascript/packages/webauthn/webauthn-input-element.spec.ts b/app/javascript/packages/webauthn/webauthn-input-element.spec.ts index 7c6ee1a0ffb..afd363cc143 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.spec.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.spec.ts @@ -1,6 +1,8 @@ import sinon from 'sinon'; import quibble from 'quibble'; +import { waitFor } from '@testing-library/dom'; import type { IsWebauthnPasskeySupported } from './is-webauthn-passkey-supported'; +import type { IsWebauthnPlatformAvailable } from './is-webauthn-platform-authenticator-available'; describe('WebauthnInputElement', () => { const isWebauthnPasskeySupported = sinon.stub< @@ -8,8 +10,14 @@ describe('WebauthnInputElement', () => { ReturnType >(); + const isWebauthnPlatformAvailable = sinon.stub< + Parameters, + ReturnType + >(); + before(async () => { quibble('./is-webauthn-passkey-supported', isWebauthnPasskeySupported); + quibble('./is-webauthn-platform-authenticator-available', isWebauthnPlatformAvailable); await import('./webauthn-input-element'); }); @@ -21,6 +29,7 @@ describe('WebauthnInputElement', () => { context('unsupported passkey not shown', () => { beforeEach(() => { isWebauthnPasskeySupported.returns(false); + isWebauthnPlatformAvailable.resolves(false); document.body.innerHTML = ``; }); @@ -34,6 +43,7 @@ describe('WebauthnInputElement', () => { context('unsupported passkey shown', () => { beforeEach(() => { isWebauthnPasskeySupported.returns(false); + isWebauthnPlatformAvailable.resolves(false); document.body.innerHTML = ``; }); @@ -47,15 +57,32 @@ describe('WebauthnInputElement', () => { }); context('device supports passkey', () => { - beforeEach(() => { - isWebauthnPasskeySupported.returns(true); - document.body.innerHTML = ``; + context('unsupported publickeycredential not shown', () => { + beforeEach(() => { + isWebauthnPlatformAvailable.resolves(false); + isWebauthnPasskeySupported.returns(true); + document.body.innerHTML = ``; + }); + + it('stays hidden', () => { + const element = document.querySelector('lg-webauthn-input')!; + + expect(element.hidden).to.be.true(); + }); }); - it('becomes visible', () => { - const element = document.querySelector('lg-webauthn-input')!; + context('publickeycredential input is shown', () => { + beforeEach(() => { + isWebauthnPasskeySupported.returns(true); + isWebauthnPlatformAvailable.resolves(true); + document.body.innerHTML = ``; + }); + + it('becomes visible', async () => { + const element = document.querySelector('lg-webauthn-input')!; - expect(element.hidden).to.be.false(); + await waitFor(() => expect(element.hidden).to.be.false()); + }); }); }); }); diff --git a/app/javascript/packages/webauthn/webauthn-input-element.ts b/app/javascript/packages/webauthn/webauthn-input-element.ts index 11938ab51a6..954cac625c0 100644 --- a/app/javascript/packages/webauthn/webauthn-input-element.ts +++ b/app/javascript/packages/webauthn/webauthn-input-element.ts @@ -1,4 +1,5 @@ import isWebauthnPasskeySupported from './is-webauthn-passkey-supported'; +import isWebauthnPlatformAuthenticatorAvailable from './is-webauthn-platform-authenticator-available'; export class WebauthnInputElement extends HTMLElement { connectedCallback() { @@ -13,12 +14,12 @@ export class WebauthnInputElement extends HTMLElement { return this.hasAttribute('show-unsupported-passkey'); } - toggleVisibleIfPasskeySupported() { + async toggleVisibleIfPasskeySupported() { if (!this.hasAttribute('hidden')) { return; } - if (isWebauthnPasskeySupported()) { + if (isWebauthnPasskeySupported() && (await isWebauthnPlatformAuthenticatorAvailable())) { this.hidden = false; } else if (this.showUnsupportedPasskey) { this.hidden = false; diff --git a/app/jobs/reports/monthly_key_metrics_report.rb b/app/jobs/reports/monthly_key_metrics_report.rb index 68b1f98f09e..e4bde6c51a7 100644 --- a/app/jobs/reports/monthly_key_metrics_report.rb +++ b/app/jobs/reports/monthly_key_metrics_report.rb @@ -27,7 +27,7 @@ def perform(date = Time.zone.yesterday.end_of_day) ReportMailer.tables_report( email: email_addresses, - subject: "Monthly Key Metrics Report - #{date}", + subject: "Monthly Key Metrics Report - #{date.to_date}", reports: reports, message: preamble, attachment_format: :xlsx, diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 76da8bb73e5..907a2f0889e 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -123,12 +123,13 @@ def personal_key_sign_in(disavowal_token:) end end - def new_device_sign_in(date:, location:, disavowal_token:) + def new_device_sign_in(date:, location:, device_name:, disavowal_token:) return unless email_should_receive_nonessential_notifications?(email_address.email) with_user_locale(user) do @login_date = date @login_location = location + @device_name = device_name @disavowal_token = disavowal_token mail( to: email_address.email, diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index 7903768ffdb..ec2324b27c9 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -136,7 +136,7 @@ def due_date end def days_to_due_date - today = Time.zone.now.utc + today = Time.zone.now (due_date - today).seconds.in_days.to_i end diff --git a/app/policies/idv/flow_policy.rb b/app/policies/idv/flow_policy.rb index c32175ae3c2..2c95c55facc 100644 --- a/app/policies/idv/flow_policy.rb +++ b/app/policies/idv/flow_policy.rb @@ -63,6 +63,7 @@ def steps ipp_verify_info: Idv::InPerson::VerifyInfoController.step_info, address: Idv::AddressController.step_info, phone: Idv::PhoneController.step_info, + phone_errors: Idv::PhoneErrorsController.step_info, otp_verification: Idv::OtpVerificationController.step_info, request_letter: Idv::ByMail::RequestLetterController.step_info, enter_password: Idv::EnterPasswordController.step_info, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index a22bc7e2c94..b5e699ba7ee 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -3381,6 +3381,12 @@ def multi_region_kms_migration_user_migration_summary( ) end + # @param [String] location Placement location + # Logged when a browser with JavaScript disabled loads the detection stylesheet + def no_js_detect_stylesheet_loaded(location:, **extra) + track_event(:no_js_detect_stylesheet_loaded, location:, **extra) + end + # @param [Boolean] success # @param [String] client_id # @param [Boolean] client_id_parameter_present diff --git a/app/services/idv/phone_step.rb b/app/services/idv/phone_step.rb index 3e179592068..74f208b7f32 100644 --- a/app/services/idv/phone_step.rb +++ b/app/services/idv/phone_step.rb @@ -130,10 +130,8 @@ def failed_due_to_timeout_or_exception? end def update_idv_session - idv_session.address_verification_mechanism = :phone idv_session.applicant = applicant - idv_session.vendor_phone_confirmation = true - idv_session.user_phone_confirmation = false + idv_session.mark_phone_step_started! ProofingComponent.find_or_create_by(user: idv_session.current_user). update(address_check: 'lexis_nexis_address') diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index a018810327b..c268162f2bc 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -210,12 +210,18 @@ def invalidate_verify_info_step! session[:resolution_successful] = nil end - def invalidate_steps_after_verify_info! - session[:address_verification_mechanism] = 'phone' - invalidate_phone_step! + def mark_phone_step_started! + session[:address_verification_mechanism] = :phone + session[:vendor_phone_confirmation] = true + session[:user_phone_confirmation] = false + end + + def mark_phone_step_complete! + session[:user_phone_confirmation] = true end def invalidate_phone_step! + session[:address_verification_mechanism] = nil session[:vendor_phone_confirmation] = nil session[:user_phone_confirmation] = nil end diff --git a/app/services/reporting/account_reuse_report.rb b/app/services/reporting/account_reuse_report.rb index ab345eec729..89b2cf0febd 100644 --- a/app/services/reporting/account_reuse_report.rb +++ b/app/services/reporting/account_reuse_report.rb @@ -41,7 +41,7 @@ def account_reuse_emailable_report end def stats_month - report_date.prev_month(1).strftime('%b-%Y') + report_date.strftime('%b-%Y') end private diff --git a/app/services/user_alerts/alert_user_about_new_device.rb b/app/services/user_alerts/alert_user_about_new_device.rb index 4b48e7cec63..1c572e39541 100644 --- a/app/services/user_alerts/alert_user_about_new_device.rb +++ b/app/services/user_alerts/alert_user_about_new_device.rb @@ -1,12 +1,16 @@ module UserAlerts class AlertUserAboutNewDevice def self.call(user, device, disavowal_token) - login_location = DeviceDecorator.new(device).last_sign_in_location_and_ip + device_decorator = DeviceDecorator.new(device) + login_location = device_decorator.last_sign_in_location_and_ip + device_name = device_decorator.nice_name + user.confirmed_email_addresses.each do |email_address| UserMailer.with(user: user, email_address: email_address).new_device_sign_in( date: device.last_used_at.in_time_zone('Eastern Time (US & Canada)'). strftime('%B %-d, %Y %H:%M Eastern Time'), location: login_location, + device_name: device_name, disavowal_token: disavowal_token, ).deliver_now_or_later end diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 484b580da0a..f4550fd95ac 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -84,3 +84,5 @@ ) %>

<% end %> + + diff --git a/app/views/idv/welcome/show.html.erb b/app/views/idv/welcome/show.html.erb index f470abd531b..545c3886db9 100644 --- a/app/views/idv/welcome/show.html.erb +++ b/app/views/idv/welcome/show.html.erb @@ -12,6 +12,7 @@ <%= render JavascriptRequiredComponent.new( header: t('idv.welcome.no_js_header'), intro: t('idv.welcome.no_js_intro', sp_name: decorated_sp_session.sp_name || APP_NAME), + location: :idv_welcome, ) do %> <%= render PageHeadingComponent.new.with_content(t('doc_auth.headings.welcome')) %> diff --git a/app/views/two_factor_authentication/backup_code_verification/show.html.erb b/app/views/two_factor_authentication/backup_code_verification/show.html.erb index 363cd2d6726..2453849fddf 100644 --- a/app/views/two_factor_authentication/backup_code_verification/show.html.erb +++ b/app/views/two_factor_authentication/backup_code_verification/show.html.erb @@ -1,4 +1,4 @@ -<% self.title = t('titles.enter_2fa_code.security_code') %> +<% self.title = t('two_factor_authentication.backup_code_header_text') %> <%= render PageHeadingComponent.new.with_content(t('two_factor_authentication.backup_code_header_text')) %> diff --git a/app/views/user_mailer/new_device_sign_in.html.erb b/app/views/user_mailer/new_device_sign_in.html.erb index d681881cb4e..ae3cd4c2b51 100644 --- a/app/views/user_mailer/new_device_sign_in.html.erb +++ b/app/views/user_mailer/new_device_sign_in.html.erb @@ -18,13 +18,15 @@

<%= t( - 'user_mailer.new_device_sign_in.info_html', + 'user_mailer.new_device_sign_in.info', app_name: APP_NAME, - date: @login_date, - location: @login_location, - ) %> + ) %>

+
+ +

<%= @login_date %>
<%= @login_location %>, <%= @device_name %>

+

<%= t( 'user_mailer.new_device_sign_in.help_html', diff --git a/config/application.rb b/config/application.rb index efb9e11c73f..a0c15a0c479 100644 --- a/config/application.rb +++ b/config/application.rb @@ -54,7 +54,7 @@ class Application < Rails::Application end end - config.load_defaults '7.0' + config.load_defaults '7.1' config.active_record.belongs_to_required_by_default = false config.active_job.queue_adapter = :good_job diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 5016c76171e..4385cf76121 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -240,7 +240,7 @@ def headers # If you want to return 503 so that the attacker might be fooled into # believing that they've successfully broken your app (or you just want to # customize the response), then uncomment these lines. - self.throttled_response = lambda do |_env| + self.throttled_responder = lambda do |_env| [ 429, # status { 'Content-Type' => 'text/html' }, # headers @@ -248,7 +248,7 @@ def headers ] end - self.blocklisted_response = throttled_response + self.blocklisted_responder = throttled_responder end end diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 53126ff4332..9463cdf84bf 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -208,8 +208,7 @@ en: help_html: If you did not make this change, %{disavowal_link_html}. For more help, please visit the %{app_name_html} %{help_link_html} or %{contact_link_html}. - info_html: '

Your %{app_name} account was just used to sign in on a new - device.


%{date}
%{location}

' + info: 'Your %{app_name} account was just used to sign in on a new device.' subject: New sign-in with your %{app_name} account password_changed: disavowal_link: reset your password diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 9de0a63aa39..6f93f6dda26 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -222,8 +222,7 @@ es: disavowal_link: restablecer su contraseña help_html: Si no realizó este cambio, %{disavowal_link_html}. Para más ayuda, visite el %{app_name_html} %{help_link_html} o el %{contact_link_html}. - info_html: '

Su cuenta %{app_name} acaba de iniciar sesión en un nuevo - dispositivo.


%{date}
%{location}

' + info: 'Su cuenta %{app_name} acaba de iniciar sesión en un nuevo dispositivo.' subject: Nuevo initio de sesion con su %{app_name} cuenta password_changed: disavowal_link: restablecer su contraseña diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 39f1a5615b1..b0a9337a88a 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -228,8 +228,7 @@ fr: help_html: Si vous n’avez pas effectué ce changement, %{disavowal_link_html}. Pour plus d’aide, veuillez visiter le %{help_link_html} de %{app_name_html} ou %{contact_link_html}. - info_html: '

Votre compte %{app_name} a été connecté sur un nouvel - appareil.


%{date}
%{location}

' + info: 'Votre compte %{app_name} a été connecté sur un nouvel appareil.' subject: Nouvelle connexion avec votre compte %{app_name} password_changed: disavowal_link: réinitialiser votre mot de passe diff --git a/db/primary_migrate/20231204232215_add_idv_level_to_profile.rb b/db/primary_migrate/20231204232215_add_idv_level_to_profile.rb new file mode 100644 index 00000000000..07a29f1d29e --- /dev/null +++ b/db/primary_migrate/20231204232215_add_idv_level_to_profile.rb @@ -0,0 +1,5 @@ +class AddIdvLevelToProfile < ActiveRecord::Migration[7.1] + def change + add_column :profiles, :idv_level, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 789767c2929..ccc8c79c8d2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2023_11_02_211426) do +ActiveRecord::Schema[7.1].define(version: 2023_12_04_232215) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_stat_statements" @@ -457,6 +457,7 @@ t.text "encrypted_pii_multi_region" t.text "encrypted_pii_recovery_multi_region" t.datetime "gpo_verification_expired_at" + t.integer "idv_level" t.index ["fraud_pending_reason"], name: "index_profiles_on_fraud_pending_reason" t.index ["fraud_rejection_at"], name: "index_profiles_on_fraud_rejection_at" t.index ["fraud_review_pending_at"], name: "index_profiles_on_fraud_review_pending_at" diff --git a/lib/secure_cookies.rb b/lib/secure_cookies.rb index b432ba90cce..72a2dc25d25 100644 --- a/lib/secure_cookies.rb +++ b/lib/secure_cookies.rb @@ -2,7 +2,6 @@ # Reimplements SecureHeaders secure cookie functionality to make sure all cookies are secure class SecureCookies - COOKIE_SEPARATOR = "\n" SECURE_REGEX = /; Secure/i HTTP_ONLY_REGEX = /; HttpOnly/i SAME_SITE_REGEX = /; SameSite/i @@ -13,19 +12,17 @@ def initialize(app) def call(env) status, headers, body = @app.call(env) - - if (cookie_header = headers['Set-Cookie']).present? - cookies = cookie_header.split(COOKIE_SEPARATOR) - - cookies.each do |cookie| - next if cookie.blank? - + cookies = headers[Rack::SET_COOKIE] + if cookies + cookies = Array(cookies).map do |cookie| cookie << '; Secure' if env['HTTPS'] == 'on' && !cookie.match?(SECURE_REGEX) cookie << '; HttpOnly' if !cookie.match?(HTTP_ONLY_REGEX) cookie << '; SameSite=Lax' if !cookie.match?(SAME_SITE_REGEX) + + cookie end - headers['Set-Cookie'] = cookies.join(COOKIE_SEPARATOR) + headers[Rack::SET_COOKIE] = cookies end [status, headers, body] diff --git a/spec/components/javascript_required_component_spec.rb b/spec/components/javascript_required_component_spec.rb index b9e506f21c0..4f8a8bc1296 100644 --- a/spec/components/javascript_required_component_spec.rb +++ b/spec/components/javascript_required_component_spec.rb @@ -5,10 +5,11 @@ let(:header) { 'You must enable JavaScript' } let(:intro) { nil } + let(:location) { 'example' } let(:content) { 'JavaScript-required content' } subject(:rendered) do - render_inline described_class.new(header:, intro:).with_content(content) + render_inline described_class.new(header:, intro:, location:).with_content(content) end it 'renders instructions to enable JavaScript' do @@ -25,7 +26,7 @@ end it 'loads css resource for setting session key in JavaScript-disabled environments' do - expect(rendered).to have_css("noscript link[href='#{no_js_detect_css_path}']") + expect(rendered).to have_css("noscript link[href='#{no_js_detect_css_path(location:)}']") end context 'with intro' do @@ -49,7 +50,7 @@ it 'only renders the alert once' do rendered - second_rendered = render_inline described_class.new(header:) + second_rendered = render_inline described_class.new(header:, location:) expect(second_rendered).not_to have_content(t('components.javascript_required.enabled_alert')) end diff --git a/spec/config/initializers/secure_headers_spec.rb b/spec/config/initializers/secure_headers_spec.rb index 3695f20bcbb..d77094fdfe6 100644 --- a/spec/config/initializers/secure_headers_spec.rb +++ b/spec/config/initializers/secure_headers_spec.rb @@ -1,3 +1,5 @@ +require 'rails_helper' + RSpec.describe 'config.ssl_options' do subject(:ssl_options) { Rails.application.config.ssl_options } @@ -8,7 +10,7 @@ request = { 'HTTPS' => 'on' } _status, headers, _body = ssl_middleware.call(request) - expect(headers['Strict-Transport-Security']). + expect(headers['strict-transport-security']). to eq('max-age=31556952; includeSubDomains; preload') end end diff --git a/spec/controllers/idv/agreement_controller_spec.rb b/spec/controllers/idv/agreement_controller_spec.rb index 16d51d7fd1b..dd13721b7e8 100644 --- a/spec/controllers/idv/agreement_controller_spec.rb +++ b/spec/controllers/idv/agreement_controller_spec.rb @@ -147,26 +147,91 @@ }.from(nil) end - it 'redirects to hybrid handoff' do - put :update, params: params - expect(response).to redirect_to(idv_hybrid_handoff_url) - end + context 'on success' do + context 'skip_hybrid_handoff present in params' do + let(:skip_hybrid_handoff) { '' } + + it 'sets flow_path to standard' do + expect do + put :update, params: params + end.to change { + subject.idv_session.flow_path + }.from(nil).to('standard').and change { + subject.idv_session.skip_hybrid_handoff + }.from(nil).to(true) + end - context 'skip_hybrid_handoff present in params' do - let(:skip_hybrid_handoff) { '' } - it 'sets flow_path to standard' do - expect do + it 'redirects to hybrid handoff' do put :update, params: params - end.to change { - subject.idv_session.flow_path - }.from(nil).to('standard').and change { - subject.idv_session.skip_hybrid_handoff - }.from(nil).to(true) + + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + + context 'when both ipp and opt-in ipp are enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + end + + it 'redirects to how to verify' do + put :update, params: params + expect(response).to redirect_to(idv_how_to_verify_url) + end + end + + context 'when ipp is enabled but opt-in ipp is disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + end + + it 'redirects to hybrid handoff' do + put :update, params: params + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + + context 'when ipp is disabled and opt-in ipp is enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + end + + it 'redirects to hybrid handoff' do + put :update, params: params + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + + context 'when both ipp and opt-in ipp are disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + end + + it 'redirects to hybrid handoff' do + put :update, params: params + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + end + + context 'on failure' do + let(:skip_hybrid_handoff) { nil } + + let(:params) do + { + doc_auth: { + idv_consent_given: nil, + }, + skip_hybrid_handoff: skip_hybrid_handoff, + }.compact end - it 'redirects to hybrid handoff' do + it 'redirects to idv agreement' do put :update, params: params - expect(response).to redirect_to(idv_hybrid_handoff_url) + expect(response).to redirect_to(idv_agreement_url) end end end diff --git a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb index 9bc0a47e173..5cea7645e3c 100644 --- a/spec/controllers/idv/by_mail/enter_code_controller_spec.rb +++ b/spec/controllers/idv/by_mail/enter_code_controller_spec.rb @@ -1,69 +1,52 @@ require 'rails_helper' RSpec.describe Idv::ByMail::EnterCodeController do - let(:has_pending_profile) { true } - let(:success) { true } - let(:otp) { 'ABC123' } - let(:submitted_otp) { otp } - let(:user) { create(:user) } - let(:profile_created_at) { Time.zone.now } - let(:pending_profile) do - if user - create( - :profile, - :with_pii, - user: user, - proofing_components: proofing_components, - created_at: profile_created_at, - ) - end - end - let(:proofing_components) { nil } + let(:good_otp) { 'ABCDE12345' } + let(:bad_otp) { 'bad-otp' } let(:threatmetrix_enabled) { false } let(:gpo_enabled) { true } + let(:pii_cacher) { Pii::Cacher.new(user, controller.user_session) } let(:params) { nil } before do stub_analytics stub_attempts_tracker + stub_sign_in(user) - if user - stub_sign_in(user) - pending_user = stub_user_with_pending_profile(user) - creation_time = - (IdentityConfig.store.minimum_wait_before_another_usps_letter_in_hours + 1).hours.ago - create( - :gpo_confirmation_code, - profile: pending_profile, - otp_fingerprint: Pii::Fingerprinter.fingerprint(otp), - created_at: creation_time, - updated_at: creation_time, - ) - allow(pending_user).to receive(:gpo_verification_pending_profile?). - and_return(has_pending_profile) - end - + allow(Pii::Cacher).to receive(:new).and_return(pii_cacher) + allow(pii_cacher).to receive(:fetch).and_call_original + allow(UserAlerts::AlertUserAboutAccountVerified).to receive(:call) + allow(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted) allow(IdentityConfig.store).to receive(:proofing_device_profiling). and_return(threatmetrix_enabled ? :enabled : :disabled) allow(IdentityConfig.store).to receive(:enable_usps_verification).and_return(gpo_enabled) end describe '#index' do - subject(:action) do - get(:index, params: params) - end + subject(:action) { get(:index, params: params) } context 'user has pending profile' do - it 'renders page' do + let(:profile_created_at) { 2.days.ago } + let(:user) { create(:user, :with_pending_gpo_profile, created_at: profile_created_at) } + let(:pending_profile) { user.gpo_verification_pending_profile } + + before do controller.user_session[:decrypted_pii] = { address1: 'Address1' }.to_json - expect(@analytics).to receive(:track_event).with( + end + + it 'renders page' do + action + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code visited', source: nil, ) + expect(response).to render_template('idv/by_mail/enter_code/index') + end + it 'uses the PII from the pending profile' do action - - expect(response).to render_template('idv/by_mail/enter_code/index') + expect(pii_cacher).to have_received(:fetch).with(pending_profile.id) end it 'sets @can_request_another_letter to true' do @@ -71,16 +54,30 @@ expect(assigns(:can_request_another_letter)).to eql(true) end - it 'shows rate limited page if user is rate limited' do - RateLimiter.new(rate_limit_type: :verify_gpo_key, user: user).increment_to_limited! + context 'when the user is rate limited' do + before do + RateLimiter.new(rate_limit_type: :verify_gpo_key, user: user).increment_to_limited! + end - action + it 'shows rate limited page' do + action + + expect(response).to redirect_to(idv_enter_code_rate_limited_url) + end + + it 'logs an analytics event' do + action - expect(response).to redirect_to(idv_enter_code_rate_limited_url) + expect(@analytics).to have_logged_event( + 'IdV: enter verify by mail code visited', + source: nil, + ) + end end - context 'but that profile is > 30 days old' do + context 'but that profile is too old' do let(:profile_created_at) { 31.days.ago } + it 'sets @can_request_another_letter to false' do action expect(assigns(:can_request_another_letter)).to eql(false) @@ -88,15 +85,13 @@ end context 'user clicked a "i did not receive my letter" link' do - let(:params) do - { - did_not_receive_letter: 1, - } - end + let(:params) { { did_not_receive_letter: 1 } } + it 'sets @user_did_not_receive_letter to true' do action expect(assigns(:user_did_not_receive_letter)).to eql(true) end + it 'augments analytics event' do action expect(@analytics).to have_logged_event( @@ -107,59 +102,48 @@ end end - context 'user does not have pending profile' do - let(:has_pending_profile) { false } + context 'user does not have a pending profile' do + let(:user) { create(:user) } - it 'redirects to account page' do + it 'uses no PII' do action - expect(response).to redirect_to(account_url) + expect(pii_cacher).not_to have_received(:fetch) end - end - - context 'with rate limit reached' do - before do - RateLimiter.new(rate_limit_type: :verify_gpo_key, user: user).increment_to_limited! - end - - it 'redirects to the rate limited page' do - expect(@analytics).to receive(:track_event).with( - 'IdV: enter verify by mail code visited', - source: nil, - ).once + it 'redirects to account page' do action - expect(response).to redirect_to(idv_enter_code_rate_limited_url) + expect(response).to redirect_to(account_url) end end context 'session says user did not receive letter' do + let(:user) { create(:user, :with_pending_gpo_profile, created_at: 2.days.ago) } + before do session[:gpo_user_did_not_receive_letter] = true - action end + it 'redirects user to url with querystring' do + action expect(response).to redirect_to( idv_verify_by_mail_enter_code_path(did_not_receive_letter: 1), ) end + it 'clears session value' do + action expect(session).not_to include(gpo_user_did_not_receive_letter: anything) end end - context 'querystring says user did not receive letter' do - let(:params) do - { did_not_receive_letter: 1 } - end - - context 'not logged in' do - let(:user) { nil } + context 'not logged in, and querystring says user did not receive letter' do + let(:user) { nil } + let(:params) { { did_not_receive_letter: 1 } } - it 'sets value in session' do - expect { action }.to change { session[:gpo_user_did_not_receive_letter ] }.to eql(true) - end + it 'sets value in session' do + expect { action }.to change { session[:gpo_user_did_not_receive_letter ] }.to eql(true) end end end @@ -168,22 +152,38 @@ let(:otp_code_error_message) { { otp: [t('errors.messages.confirmation_code_incorrect')] } } let(:success_properties) { { success: true } } - subject(:action) do - post( - :create, - params: { - gpo_verify_form: { - otp: submitted_otp, - }, - }, - ) + context 'user does not have a pending profile' do + let(:user) { create(:user, :fully_registered) } + + it 'uses no PII' do + expect(pii_cacher).not_to have_received(:fetch) + end end context 'with a valid form' do + subject(:action) do + post(:create, params: { gpo_verify_form: { otp: good_otp } }) + end + + let(:user) { create(:user, :with_pending_gpo_profile, created_at: 2.days.ago) } + let(:pending_profile) { user.gpo_verification_pending_profile } let(:success) { true } + it 'uses the PII from the pending profile' do + # action will make the profile active, so grab the ID here. + pending_profile_id = pending_profile.id + + action + expect(pii_cacher).to have_received(:fetch).with(pending_profile_id) + end + it 'redirects to the sign_up/completions page' do - expect(@analytics).to receive(:track_event).with( + action + + expect(@irs_attempts_api_tracker).to have_received(:idv_gpo_verification_submitted). + with(success_properties) + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', success: true, errors: {}, @@ -193,13 +193,7 @@ which_letter: 1, letter_count: 1, attempts: 1, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) - expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). - with(success_properties) - - action - event_count = user.events.where(event_type: :account_verified, ip: '0.0.0.0'). where(disavowal_token_fingerprint: nil).count expect(event_count).to eq 1 @@ -207,9 +201,9 @@ end it 'dispatches account verified alert' do - expect(UserAlerts::AlertUserAboutAccountVerified).to receive(:call) - action + + expect(UserAlerts::AlertUserAboutAccountVerified).to have_received(:call) end context 'with establishing in person enrollment' do @@ -218,14 +212,10 @@ :in_person_enrollment, :pending, user: user, - profile: pending_profile, + profile: user.pending_profile, ) end - let(:proofing_components) do - ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) - end - before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) allow(controller).to receive(:pii). @@ -233,7 +223,12 @@ end it 'redirects to personal key page' do - expect(@analytics).to receive(:track_event).with( + action + + expect(@irs_attempts_api_tracker).to have_received(:idv_gpo_verification_submitted). + with(success_properties) + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', success: true, errors: {}, @@ -243,36 +238,28 @@ which_letter: 1, letter_count: 1, attempts: 1, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) - expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). - with(success_properties) - - action - expect(response).to redirect_to(idv_personal_key_url) end it 'does not dispatch account verified alert' do - expect(UserAlerts::AlertUserAboutAccountVerified).not_to receive(:call) - action + + expect(UserAlerts::AlertUserAboutAccountVerified).not_to have_received(:call) end end context 'threatmetrix disabled' do context 'with threatmetrix status of "reject"' do - let(:pending_profile) do - create( - :profile, - :with_pii, - fraud_pending_reason: 'threatmetrix_reject', - user: user, - ) - end + let(:user) { create(:user, :gpo_pending_with_fraud_rejection) } it 'redirects to the sign_up/completions page' do - expect(@analytics).to receive(:track_event).with( + action + + expect(@irs_attempts_api_tracker).to have_received(:idv_gpo_verification_submitted). + with(success_properties) + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', success: true, errors: {}, @@ -282,13 +269,7 @@ which_letter: 1, letter_count: 1, attempts: 1, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) - expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). - with(success_properties) - - action - event_count = user.events.where(event_type: :account_verified, ip: '0.0.0.0'). where(disavowal_token_fingerprint: nil).count expect(event_count).to eq 1 @@ -301,17 +282,12 @@ let(:threatmetrix_enabled) { true } context 'with threatmetrix status of "reject"' do - let(:pending_profile) do - create( - :profile, - :with_pii, - fraud_pending_reason: 'threatmetrix_reject', - user: user, - ) - end + let(:user) { create(:user, :gpo_pending_with_fraud_rejection) } it 'is reflected in analytics' do - expect(@analytics).to receive(:track_event).with( + action + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', success: true, errors: {}, @@ -321,37 +297,30 @@ which_letter: 1, letter_count: 1, attempts: 1, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) - action - expect(response).to redirect_to(idv_personal_key_url) end it 'does not show a flash message' do - expect(flash[:success]).to be_nil action + expect(flash[:success]).to be_nil end it 'does not dispatch account verified alert' do - expect(UserAlerts::AlertUserAboutAccountVerified).not_to receive(:call) action + + expect(UserAlerts::AlertUserAboutAccountVerified).not_to have_received(:call) end end context 'with threatmetrix status of "review"' do - let(:pending_profile) do - create( - :profile, - :with_pii, - fraud_pending_reason: 'threatmetrix_review', - user: user, - ) - end + let(:user) { create(:user, :gpo_pending_with_fraud_review) } it 'is reflected in analytics' do - expect(@analytics).to receive(:track_event).with( + action + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', success: true, errors: {}, @@ -361,11 +330,8 @@ which_letter: 1, letter_count: 1, attempts: 1, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) - action - expect(response).to redirect_to(idv_personal_key_url) end end @@ -373,10 +339,19 @@ end context 'with an invalid form' do - let(:submitted_otp) { 'the-wrong-otp' } + subject(:action) do + post(:create, params: { gpo_verify_form: { otp: bad_otp } }) + end + + let(:user) { create(:user, :with_pending_gpo_profile, created_at: 2.days.ago) } it 'redirects to the index page to show errors' do - expect(@analytics).to receive(:track_event).with( + action + + expect(@irs_attempts_api_tracker).to have_received(:idv_gpo_verification_submitted). + with(success: false) + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', success: false, errors: otp_code_error_message, @@ -387,33 +362,30 @@ letter_count: 1, attempts: 1, error_details: { otp: { confirmation_code_incorrect: true } }, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) - expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). - with(success: false) - - action - expect(response).to redirect_to(idv_verify_by_mail_enter_code_url) end it 'does not 500 with missing form keys' do - expect { post(:create, params: { otp: submitted_otp }) }.to raise_exception( + expect { post(:create, params: {}) }.to raise_exception( ActionController::ParameterMissing, ) end end context 'final attempt before rate limited' do - let(:invalid_otp) { 'a-wrong-otp' } + let(:user) { create(:user, :with_pending_gpo_profile) } let(:max_attempts) { 2 } before do allow(IdentityConfig.store).to receive(:verify_gpo_key_max_attempts). and_return(max_attempts) + (max_attempts - 1).times do |i| + post(:create, params: { gpo_verify_form: { otp: bad_otp } }) + end end - context 'user is rate limited' do + context 'invalid code is submitted' do it 'redirects to the rate limited index page to show errors' do analytics_args = { success: false, @@ -425,36 +397,19 @@ letter_count: 1, attempts: 1, error_details: { otp: { confirmation_code_incorrect: true } }, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], } - expect(@analytics).to receive(:track_event).with( + post(:create, params: { gpo_verify_form: { otp: bad_otp } }) + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', **analytics_args, - ).once + ) + analytics_args[:attempts] = 2 - expect(@analytics).to receive(:track_event).with( + + expect(@analytics).to have_logged_event( 'IdV: enter verify by mail code submitted', **analytics_args, - ).once - - max_attempts.times do |i| - post( - :create, - params: { - gpo_verify_form: { - otp: invalid_otp, - }, - }, - ) - end - - post( - :create, - params: { - gpo_verify_form: { - otp: submitted_otp, - }, - }, ) expect(response).to redirect_to(idv_enter_code_rate_limited_url) @@ -462,55 +417,24 @@ end context 'valid code is submitted' do + let(:user) { create(:user, :with_pending_gpo_profile) } + it 'redirects to personal key page' do - expect(@analytics).to receive(:track_event).with( - 'IdV: enter verify by mail code submitted', - success: false, - errors: otp_code_error_message, - pending_in_person_enrollment: false, - fraud_check_failed: false, - enqueued_at: nil, - which_letter: nil, - letter_count: 1, - attempts: 1, - error_details: { otp: { confirmation_code_incorrect: true } }, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], - ).exactly(max_attempts - 1).times - expect(@analytics).to receive(:track_event).with( - 'IdV: enter verify by mail code submitted', - success: true, - errors: {}, - pending_in_person_enrollment: false, - fraud_check_failed: false, - enqueued_at: user.pending_profile.gpo_confirmation_codes.last.code_sent_at, - which_letter: 1, - letter_count: 1, - attempts: 2, - pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], - ).once - expect(@irs_attempts_api_tracker).to receive(:idv_gpo_verification_submitted). + post(:create, params: { gpo_verify_form: { otp: good_otp } }) + + expect(@irs_attempts_api_tracker).to have_received(:idv_gpo_verification_submitted). exactly(max_attempts).times - (max_attempts - 1).times do |i| - post( - :create, - params: { - gpo_verify_form: { - otp: invalid_otp, - }, - }, - ) - end + failed_gpo_submission_events = + @analytics.events['IdV: enter verify by mail code submitted']. + reject { |event_attributes| event_attributes[:errors].empty? } - post( - :create, - params: { - gpo_verify_form: { - otp: submitted_otp, - }, - }, - ) + successful_gpo_submission_events = + @analytics.events['IdV: enter verify by mail code submitted']. + select { |event_attributes| event_attributes[:errors].empty? } + expect(failed_gpo_submission_events.count).to eq(max_attempts - 1) + expect(successful_gpo_submission_events.count).to eq(1) expect(response).to redirect_to(idv_personal_key_url) end end diff --git a/spec/controllers/idv/how_to_verify_controller_spec.rb b/spec/controllers/idv/how_to_verify_controller_spec.rb index d52d7268a53..6d3c2c72923 100644 --- a/spec/controllers/idv/how_to_verify_controller_spec.rb +++ b/spec/controllers/idv/how_to_verify_controller_spec.rb @@ -3,21 +3,21 @@ RSpec.describe Idv::HowToVerifyController do let(:user) { create(:user) } let(:enabled) { true } + let(:ab_test_args) do + { sample_bucket1: :sample_value1, sample_bucket2: :sample_value2 } + end before do - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { enabled } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } stub_sign_in(user) stub_analytics + allow(@analytics).to receive(:track_event) + allow(subject).to receive(:ab_test_analytics_buckets).and_return(ab_test_args) subject.idv_session.welcome_visited = true subject.idv_session.idv_consent_given = true end - describe '#step_info' do - it 'returns a valid StepInfo object' do - expect(Idv::HowToVerifyController.step_info).to be_valid - end - end - describe 'before_actions' do it 'includes authentication before_action' do expect(subject).to have_actions( @@ -25,9 +25,75 @@ :confirm_two_factor_authenticated, ) end + + context 'confirm_step_allowed' do + context 'when ipp is disabled and opt-in ipp is enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + end + + it 'disables the how to verify step and redirects to hybrid handoff' do + get :show + + expect(Idv::HowToVerifyController.enabled?).to be false + expect(subject.idv_session.skip_doc_auth).to be_nil + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + + context 'when ipp is enabled but opt-in ipp is disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + end + + it 'disables the how to verify step and redirects to hybrid handoff' do + get :show + + expect(Idv::HowToVerifyController.enabled?).to be false + expect(subject.idv_session.skip_doc_auth).to be_nil + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + + context 'when both ipp and opt-in ipp are disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + end + + it 'disables the how to verify step and redirects to hybrid handoff' do + get :show + + expect(Idv::HowToVerifyController.enabled?).to be false + expect(subject.idv_session.skip_doc_auth).to be_nil + expect(response).to redirect_to(idv_hybrid_handoff_url) + end + end + + context 'when both ipp and opt-in ipp are enabled' do + it 'renders the show template for how to verify' do + get :show + + expect(Idv::HowToVerifyController.enabled?).to be true + expect(subject.idv_session.skip_doc_auth).to be_nil + expect(response).to render_template :show + end + end + end end describe '#show' do + let(:analytics_name) { :idv_doc_auth_how_to_verify_visited } + let(:analytics_args) do + { + step: 'how_to_verify', + analytics_id: 'Doc Auth', + skip_hybrid_handoff: nil, + irs_reproofing: false, + }.merge(ab_test_args) + end it 'renders the show template' do get :show @@ -35,6 +101,12 @@ expect(response).to render_template :show end + it 'sends analytics_visited event' do + get :show + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end + context 'agreement step not completed' do before do subject.idv_session.idv_consent_given = nil @@ -54,32 +126,85 @@ idv_how_to_verify_form: { selection: selection }, } end - let(:selection) { 'remote' } + let(:analytics_name) { :idv_doc_auth_how_to_verify_submitted } + context 'no selection made' do + let(:analytics_args) do + { + step: 'how_to_verify', + analytics_id: 'Doc Auth', + skip_hybrid_handoff: nil, + irs_reproofing: false, + error_details: { selection: { blank: true } }, + errors: { selection: ['Select a way to verify your identity.'] }, + success: false, + }.merge(ab_test_args) + end + + it 'invalidates future steps' do + expect(subject).to receive(:clear_future_steps!) + + put :update + end - it 'invalidates future steps' do - expect(subject).to receive(:clear_future_steps!) + it 'sends analytics_submitted event when nothing is selected' do + put :update - put :update + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end end context 'remote' do + let(:selection) { 'remote' } + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + skip_hybrid_handoff: nil, + step: 'how_to_verify', + irs_reproofing: false, + errors: {}, + success: true, + 'selection' => selection, + }.merge(ab_test_args) + end it 'sets skip doc auth on idv session to false and redirects to hybrid handoff' do put :update, params: params expect(subject.idv_session.skip_doc_auth).to be false expect(response).to redirect_to(idv_hybrid_handoff_url) end + + it 'sends analytics_submitted event when remote proofing is selected' do + put :update, params: params + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end end context 'ipp' do let(:selection) { 'ipp' } - + let(:analytics_args) do + { + analytics_id: 'Doc Auth', + skip_hybrid_handoff: nil, + step: 'how_to_verify', + irs_reproofing: false, + errors: {}, + success: true, + 'selection' => selection, + }.merge(ab_test_args) + end it 'sets skip doc auth on idv session to true and redirects to document capture' do put :update, params: params expect(subject.idv_session.skip_doc_auth).to be true expect(response).to redirect_to(idv_document_capture_url) end + + it 'sends analytics_submitted event when remote proofing is selected' do + put :update, params: params + + expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) + end end context 'undo/back' do @@ -91,4 +216,10 @@ end end end + + describe '#step_info' do + it 'returns a valid StepInfo object' do + expect(Idv::HowToVerifyController.step_info).to be_valid + end + end end diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index e29505e7a70..1be321178aa 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -274,6 +274,16 @@ expect(response).to render_template(:new) end + it 'invalidates phone step in idv_session' do + subject.idv_session.vendor_phone_confirmation = true + subject.idv_session.user_phone_confirmation = true + + put :create, params: improbable_phone_form + + expect(subject.idv_session.vendor_phone_confirmation).to be_nil + expect(subject.idv_session.user_phone_confirmation).to be_nil + end + it 'disallows non-US numbers' do put :create, params: { idv_phone_form: { phone: international_phone } } @@ -320,25 +330,31 @@ end context 'when form is valid' do + let(:phone_params) do + { idv_phone_form: { + phone: good_phone, + otp_delivery_preference: :sms, + } } + end + before do stub_analytics stub_attempts_tracker allow(@analytics).to receive(:track_event) end - it 'invalidates future steps' do + it 'invalidates future steps and invalidates phone step' do user = build(:user, :with_phone, with: { phone: good_phone, confirmed_at: Time.zone.now }) stub_verify_steps_one_and_two(user) - expect(subject).to receive(:clear_future_steps!) + subject.idv_session.vendor_phone_confirmation = true + subject.idv_session.user_phone_confirmation = true - phone_params = { - idv_phone_form: { - phone: good_phone, - otp_delivery_preference: :sms, - }, - } + expect(subject).to receive(:clear_future_steps!) put :create, params: phone_params + + expect(subject.idv_session.vendor_phone_confirmation).to be_nil + expect(subject.idv_session.user_phone_confirmation).to be_nil end it 'tracks events with valid phone' do @@ -350,13 +366,6 @@ phone_number: good_phone, ) - phone_params = { - idv_phone_form: { - phone: good_phone, - otp_delivery_preference: :sms, - }, - } - put :create, params: phone_params result = { @@ -403,12 +412,7 @@ it 'redirects to otp delivery page' do original_applicant = subject.idv_session.applicant.dup - put :create, params: { - idv_phone_form: { - phone: good_phone, - otp_delivery_preference: 'sms', - }, - } + put :create, params: phone_params expect(response).to redirect_to idv_phone_path get :new @@ -452,12 +456,7 @@ end it 'redirects to otp page and does not set phone_confirmed_at' do - put :create, params: { - idv_phone_form: { - phone: good_phone, - otp_delivery_preference: 'sms', - }, - } + put :create, params: phone_params expect(response).to redirect_to idv_phone_path get :new diff --git a/spec/controllers/idv/phone_errors_controller_spec.rb b/spec/controllers/idv/phone_errors_controller_spec.rb index eb9a367e6d6..697d8326044 100644 --- a/spec/controllers/idv/phone_errors_controller_spec.rb +++ b/spec/controllers/idv/phone_errors_controller_spec.rb @@ -5,6 +5,12 @@ { sample_bucket1: :sample_value1, sample_bucket2: :sample_value2 } end + describe '#step_info' do + it 'returns a valid StepInfo object' do + expect(Idv::PhoneErrorsController.step_info).to be_valid + end + end + before do allow(subject).to receive(:remaining_attempts).and_return(5) stub_analytics @@ -13,6 +19,13 @@ if user stub_sign_in(user) + subject.idv_session.welcome_visited = true + subject.idv_session.idv_consent_given = true + subject.idv_session.flow_path = 'standard' + subject.idv_session.pii_from_doc = Idp::Constants::MOCK_IDV_APPLICANT + subject.idv_session.ssn = '123-45-6789' + subject.idv_session.applicant = Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE + subject.idv_session.resolution_successful = true subject.idv_session.user_phone_confirmation = false subject.idv_session.previous_phone_step_params = previous_phone_step_params end @@ -28,7 +41,7 @@ context 'authenticated user' do let(:user) { create(:user) } - context 'the user has not submtted a phone number' do + context 'the user has not submitted a phone number' do it 'redirects to phone step' do subject.idv_session.previous_phone_step_params = nil get action @@ -78,19 +91,10 @@ subject.idv_session.user_phone_confirmation = true end - it 'redirects to the review url' do + it 'allows the back button and renders the template' do get action - expect(response).to redirect_to(idv_enter_password_url) - end - it 'does not log an event' do - expect(@analytics).not_to receive(:track_event).with( - 'IdV: phone error visited', - hash_including( - type: action, - ), - ) - get action + expect(response).to render_template(template) end end end diff --git a/spec/controllers/no_js_controller_spec.rb b/spec/controllers/no_js_controller_spec.rb index ba3a3b1c53e..1578e7d0796 100644 --- a/spec/controllers/no_js_controller_spec.rb +++ b/spec/controllers/no_js_controller_spec.rb @@ -2,7 +2,12 @@ RSpec.describe NoJsController do describe '#index' do - subject(:response) { get :index } + let(:location) { 'example' } + subject(:response) { get :index, params: { location: } } + + before do + stub_analytics + end it 'returns empty css' do expect(response.content_type.split(';').first).to eq('text/css') @@ -14,5 +19,11 @@ expect(session[NoJsController::SESSION_KEY]).to eq(true) end + + it 'logs an event' do + response + + expect(@analytics).to have_logged_event(:no_js_detect_stylesheet_loaded, location:) + end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 2782b012c0c..5a625a1ef05 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -215,11 +215,14 @@ :with_pii, gpo_verification_pending_at: context.code_sent_at, user: user, - created_at: context.created_at, + created_at: context.code_sent_at, + updated_at: context.code_sent_at, ) create( :gpo_confirmation_code, profile: profile, + created_at: context.code_sent_at, + updated_at: context.code_sent_at, code_sent_at: context.code_sent_at, ) create( @@ -228,6 +231,7 @@ device: create(:device, user: user), event_type: :gpo_mail_sent, created_at: context.code_sent_at, + updated_at: context.code_sent_at, ) end end @@ -257,6 +261,22 @@ end end + trait :gpo_pending_with_fraud_rejection do + with_pending_gpo_profile + after :create do |user| + user.pending_profile.fraud_rejection_at = 15.days.ago + user.pending_profile.fraud_pending_reason = :threatmetrix_reject + end + end + + trait :gpo_pending_with_fraud_review do + with_pending_gpo_profile + after :create do |user| + user.pending_profile.fraud_review_pending_at = 15.days.ago + user.pending_profile.fraud_pending_reason = :threatmetrix_review + end + end + trait :fraud_rejection do fully_registered diff --git a/spec/features/idv/doc_auth/how_to_verify_spec.rb b/spec/features/idv/doc_auth/how_to_verify_spec.rb index e6ef8c4701c..532f0319c6d 100644 --- a/spec/features/idv/doc_auth/how_to_verify_spec.rb +++ b/spec/features/idv/doc_auth/how_to_verify_spec.rb @@ -4,25 +4,61 @@ include IdvHelper include DocAuthHelper - let(:enabled) { true } + context 'when ipp is enabled and opt-in ipp is disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { enabled } + sign_in_and_2fa_user + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end - sign_in_and_2fa_user - complete_doc_auth_steps_before_agreement_step - complete_agreement_step + it 'skips when disabled and redirects to hybird handoff)' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end end - context 'opt-in ipp is turned off' do - let(:enabled) { false } + context 'when ipp is disabled and opt-in ipp is enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + + sign_in_and_2fa_user + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end - it 'skips when disabled' do + it 'skips when disabled and redirects to hybird handoff' do expect(page).to have_current_path(idv_hybrid_handoff_url) end end - context 'opt-in ipp is turned on' do + context 'when both ipp and opt-in ipp are disabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { false } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { false } + + sign_in_and_2fa_user + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + + it 'skips when disabled and redirects to hybird handoff' do + expect(page).to have_current_path(idv_hybrid_handoff_url) + end + end + + context 'when both ipp and opt-in ipp are enabled' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled) { true } + allow(IdentityConfig.store).to receive(:in_person_proofing_opt_in_enabled) { true } + + sign_in_and_2fa_user + complete_doc_auth_steps_before_agreement_step + complete_agreement_step + end + it 'displays expected content and requires a choice' do expect(page).to have_current_path(idv_how_to_verify_path) diff --git a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb index 4e8a1d3b3bd..13cb54461f4 100644 --- a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb +++ b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb @@ -268,7 +268,10 @@ end it 'regenerates backup codes path if a user clicks that they need new backup codes' do - click_link strip_tags(t('two_factor_authentication.backup_codes.new_backup_codes_html')) + find( + 'a', + text: t('two_factor_authentication.backup_codes.new_backup_codes_html').gsub(' ', ' '), + ).click expect(page).to have_current_path backup_code_regenerate_path end end diff --git a/spec/features/webauthn/hidden_spec.rb b/spec/features/webauthn/hidden_spec.rb index 28ba155b4bc..7567ee11224 100644 --- a/spec/features/webauthn/hidden_spec.rb +++ b/spec/features/webauthn/hidden_spec.rb @@ -2,6 +2,7 @@ RSpec.describe 'webauthn hide' do include JavascriptDriverHelper + include WebAuthnHelper describe 'security key' do let(:option_id) { 'two_factor_options_form_selection_webauthn' } @@ -58,9 +59,11 @@ expect(webauthn_option_hidden?).to eq(true) end - context 'with supported browser', driver: :headless_chrome_mobile do + context 'with supported browser and platform authenticator available', + driver: :headless_chrome_mobile do it 'displays the authenticator option' do sign_up_and_set_password + simulate_platform_authenticator_available expect(webauthn_option_hidden?).to eq(false) end 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 28fd65c4487..a542fd4c00f 100644 --- a/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascript/packages/document-capture/components/acuant-capture-spec.jsx @@ -1113,6 +1113,25 @@ describe('document-capture/components/acuant-capture', () => { }); }); + context('mobile selfie', () => { + it('renders the selfie capture loading div in acuant-capture', async () => { + // What we want to test is that the selfie version of the FileInput appears + // when the name="selfie". The only difference between the selfie and document + // versions is what happens when you click the FileInput, so this test clicks + // the file input, then checks that the full screen div opened + const { getByRole, getByLabelText } = render( + + + + + , + ); + + await userEvent.click(getByLabelText('Image')); + expect(getByRole('dialog')).to.be.ok(); + }); + }); + it('optionally disallows upload', () => { const { getByText } = render( diff --git a/spec/javascript/packages/document-capture/components/acuant-selfie-camera-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-selfie-camera-spec.jsx new file mode 100644 index 00000000000..d392b21026c --- /dev/null +++ b/spec/javascript/packages/document-capture/components/acuant-selfie-camera-spec.jsx @@ -0,0 +1,68 @@ +import { AcuantContextProvider, DeviceContext } from '@18f/identity-document-capture'; +import AcuantSelfieCamera from '@18f/identity-document-capture/components/acuant-selfie-camera'; +import AcuantSelfieCaptureCanvas from '@18f/identity-document-capture/components/acuant-selfie-capture-canvas'; +import { render, useAcuant } from '../../../support/document-capture'; + +describe('document-capture/components/acuant-selfie-camera', () => { + const { initialize } = useAcuant(); + + it('waits for initialization', () => { + render( + + + + + + + , + ); + + // At this point, it's assumed `window.AcuantPassivelivenss.start` has not been called. This can't be + // asserted, since the global is only assigned as part of `initialize` itself. But we can rely + // on the fact that if it was called, an error would be thrown, and the test would fail. + + initialize(); + + expect(window.AcuantPassiveLiveness.start.calledOnce).to.be.true(); + + const callbacks = window.AcuantPassiveLiveness.start.getCall(0).args[0]; + const callbackNames = Object.keys(callbacks).sort; + const expectedCallbackNames = [ + 'onDetectorInitialized', + 'onDetection', + 'onOpened', + 'onClosed', + 'onError', + 'onPhotoTaken', + 'onPhotoRetake', + 'onCaptured', + ].sort; + expect(callbackNames).to.equal(expectedCallbackNames); + + expect(window.AcuantPassiveLiveness.start.getCall(0).args[1]).to.deep.equal({ + FACE_NOT_FOUND: 'FACE NOT FOUND', + TOO_MANY_FACES: 'TOO MANY FACES', + FACE_ANGLE_TOO_LARGE: 'FACE ANGLE TOO LARGE', + PROBABILITY_TOO_SMALL: 'PROBABILITY TOO SMALL', + FACE_TOO_SMALL: 'FACE TOO SMALL', + FACE_CLOSE_TO_BORDER: 'TOO CLOSE TO THE FRAME', + }); + }); + + it('ends on unmount', () => { + const { unmount } = render( + + + + + + + , + ); + + initialize(); + unmount(); + + expect(window.AcuantPassiveLiveness.end.calledOnce).to.be.true(); + }); +}); diff --git a/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx b/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx new file mode 100644 index 00000000000..3558b5a2b7e --- /dev/null +++ b/spec/javascript/packages/document-capture/components/acuant-selfie-capture-canvas-spec.jsx @@ -0,0 +1,29 @@ +import AcuantSelfieCaptureCanvas from '@18f/identity-document-capture/components/acuant-selfie-capture-canvas'; +import { AcuantContext, DeviceContext } from '@18f/identity-document-capture'; +import { render } from '../../../support/document-capture'; + +it('shows the loading spinner when the script hasnt loaded', () => { + const { getByRole, container } = render( + + + + + , + ); + + expect(getByRole('dialog')).to.be.ok(); + expect(container.querySelector('#acuant-face-capture-container')).to.not.exist(); +}); + +it('shows the Acuant div when the script has loaded', () => { + const { queryByRole, container } = render( + + + + + , + ); + + expect(queryByRole('dialog')).to.not.exist(); + expect(container.querySelector('#acuant-face-capture-container')).to.exist(); +}); diff --git a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx index 62e4b5a169c..c54dcc0b1be 100644 --- a/spec/javascript/packages/document-capture/components/documents-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.jsx @@ -15,14 +15,16 @@ import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/documents-step', () => { - it('renders with front and back inputs', () => { - const { getByLabelText } = render(); + it('renders with only front and back inputs by default', () => { + const { getByLabelText, queryByLabelText } = render(); const front = getByLabelText('doc_auth.headings.document_capture_front'); const back = getByLabelText('doc_auth.headings.document_capture_back'); + const selfie = queryByLabelText('doc_auth.headings.document_capture_selfie'); expect(front).to.be.ok(); expect(back).to.be.ok(); + expect(selfie).to.not.exist(); }); it('calls onChange callback with uploaded image', async () => { @@ -148,4 +150,29 @@ describe('document-capture/components/documents-step', () => { expect(queryByRole('heading', { name: 'doc_auth.not_ready.header', level: 2 })).to.be.null(); }); }); + + context('selfie capture', () => { + it('renders with front, back, and selfie inputs when featureflag is on', () => { + const App = composeComponents( + [ + FeatureFlagContext.Provider, + { + value: { + selfieCaptureEnabled: true, + }, + }, + ], + [DocumentsStep], + ); + const { getByLabelText, queryByLabelText } = render(); + + const front = getByLabelText('doc_auth.headings.document_capture_front'); + const back = getByLabelText('doc_auth.headings.document_capture_back'); + const selfie = queryByLabelText('doc_auth.headings.document_capture_selfie'); + + expect(front).to.be.ok(); + expect(back).to.be.ok(); + expect(selfie).to.be.ok(); + }); + }); }); diff --git a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx index aa44c2c17a6..e7eac8637d1 100644 --- a/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascript/packages/document-capture/components/review-issues-step-spec.jsx @@ -166,13 +166,37 @@ describe('document-capture/components/review-issues-step', () => { expect(getByLabelText('doc_auth.headings.document_capture_front')).to.be.ok(); }); - it('renders with front and back inputs', async () => { - const { getByLabelText, getByRole } = render(); + it('renders with only front and back inputs only by default', async () => { + const { getByLabelText, queryByLabelText, getByRole } = render( + , + ); + + await userEvent.click(getByRole('button', { name: 'idv.failure.button.warning' })); + + expect(getByLabelText('doc_auth.headings.document_capture_front')).to.be.ok(); + expect(getByLabelText('doc_auth.headings.document_capture_back')).to.be.ok(); + expect(queryByLabelText('doc_auth.headings.document_capture_selfie')).to.not.exist(); + }); + + it('renders with front, back, and selfie inputs when featureflag', async () => { + const App = composeComponents( + [ + FeatureFlagContext.Provider, + { + value: { + selfieCaptureEnabled: true, + }, + }, + ], + [ReviewIssuesStep, DEFAULT_PROPS], + ); + const { getByLabelText, queryByLabelText, getByRole } = render(); await userEvent.click(getByRole('button', { name: 'idv.failure.button.warning' })); expect(getByLabelText('doc_auth.headings.document_capture_front')).to.be.ok(); expect(getByLabelText('doc_auth.headings.document_capture_back')).to.be.ok(); + expect(queryByLabelText('doc_auth.headings.document_capture_selfie')).to.be.ok(); }); it('calls onChange callback with uploaded image', async () => { diff --git a/spec/javascript/support/document-capture.jsx b/spec/javascript/support/document-capture.jsx index bd480bd36f0..24dea01116a 100644 --- a/spec/javascript/support/document-capture.jsx +++ b/spec/javascript/support/document-capture.jsx @@ -60,6 +60,7 @@ export function useAcuant() { delete window.AcuantJavascriptWebSdk; delete window.AcuantCamera; delete window.AcuantCameraUI; + delete window.AcuantPassiveLiveness; delete window.loadAcuantSdk; }); @@ -91,6 +92,7 @@ export function useAcuant() { }), end, }; + window.AcuantPassiveLiveness = { start: sinon.stub(), end: sinon.stub() }; window.loadAcuantSdk = () => {}; const sdkScript = document.querySelector('[data-acuant-sdk]'); sdkScript.onload(); diff --git a/spec/jobs/reports/monthly_key_metrics_report_spec.rb b/spec/jobs/reports/monthly_key_metrics_report_spec.rb index a11f14a413a..c3ab446953e 100644 --- a/spec/jobs/reports/monthly_key_metrics_report_spec.rb +++ b/spec/jobs/reports/monthly_key_metrics_report_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe Reports::MonthlyKeyMetricsReport do - let(:report_date) { Date.new(2021, 3, 2) } + let(:report_date) { Date.new(2021, 3, 2).in_time_zone('UTC').end_of_day } subject(:report) { Reports::MonthlyKeyMetricsReport.new(report_date) } let(:name) { 'monthly-key-metrics-report' } diff --git a/spec/mailers/previews/report_mailer_preview.rb b/spec/mailers/previews/report_mailer_preview.rb index da697115001..a4f691ceca0 100644 --- a/spec/mailers/previews/report_mailer_preview.rb +++ b/spec/mailers/previews/report_mailer_preview.rb @@ -15,7 +15,7 @@ def monthly_key_metrics_report ReportMailer.tables_report( email: 'test@example.com', - subject: 'Example Key Metrics Report', + subject: "Example Key Metrics Report - #{Time.zone.now.to_date}", message: monthly_key_metrics_report.preamble, attachment_format: :xlsx, reports: monthly_key_metrics_report.reports, diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 3bef56f1d54..7aedde0f5b3 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -59,6 +59,7 @@ def new_device_sign_in UserMailer.with(user: user, email_address: email_address_record).new_device_sign_in( date: 'February 25, 2019 15:02', location: 'Washington, DC', + device_name: 'Chrome ABC on macOS 123', disavowal_token: SecureRandom.hex, ) end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 62878274f3d..0e40835743b 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -177,11 +177,13 @@ describe '#new_device_sign_in' do date = 'February 25, 2019 15:02' location = 'Washington, DC' + device_name = 'Chrome ABC on macOS 123' disavowal_token = 'asdf1234' let(:mail) do UserMailer.with(user: user, email_address: email_address).new_device_sign_in( date: date, location: location, + device_name: device_name, disavowal_token: disavowal_token, ) end @@ -201,8 +203,11 @@ to have_content( strip_tags( t( - 'user_mailer.new_device_sign_in.info_html', - date: date, location: location, app_name: APP_NAME, + 'user_mailer.new_device_sign_in.info', + date: date, + location: location, + device_name: device_name, + app_name: APP_NAME, ), ), ) @@ -217,6 +222,7 @@ mail = UserMailer.with(user: user, email_address: email_address).new_device_sign_in( date: date, location: location, + device_name: device_name, disavowal_token: disavowal_token, ) expect(mail.to).to eq(nil) diff --git a/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb b/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb index 4c66d801dae..31ce444326d 100644 --- a/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb +++ b/spec/presenters/idv/in_person/ready_to_verify_presenter_spec.rb @@ -4,9 +4,9 @@ let(:user) { build(:user) } let(:profile) { build(:profile, user: user) } let(:current_address_matches_id) { true } - let(:created_at) { described_class::USPS_SERVER_TIMEZONE.parse('2022-07-14T00:00:00Z') } + let(:created_at) { described_class::USPS_SERVER_TIMEZONE.parse('2023-06-14T00:00:00Z') } let(:enrollment_established_at) do - described_class::USPS_SERVER_TIMEZONE.parse('2022-08-14T00:00:00Z') + described_class::USPS_SERVER_TIMEZONE.parse('2023-07-14T00:00:00Z') end let(:enrollment_selected_location_details) do JSON.parse(UspsInPersonProofing::Mock::Fixtures.enrollment_selected_location_details) @@ -31,13 +31,13 @@ end it 'returns a formatted due date' do - expect(formatted_due_date).to eq 'September 12, 2022' + expect(formatted_due_date).to eq 'August 12, 2023' end context 'there is no enrollment_established_at' do let(:enrollment_established_at) { nil } it 'returns formatted due date when no enrollment_established_at' do - expect(formatted_due_date).to eq 'August 12, 2022' + expect(formatted_due_date).to eq 'July 13, 2023' end end end diff --git a/spec/requests/saml_requests_spec.rb b/spec/requests/saml_requests_spec.rb index a5748cda323..1603977bd4e 100644 --- a/spec/requests/saml_requests_spec.rb +++ b/spec/requests/saml_requests_spec.rb @@ -45,7 +45,7 @@ it 'does not set a session cookie' do post saml_settings.idp_sso_target_url - new_cookies = response.header['Set-Cookie'].split("\n").map do |c| + new_cookies = response.header['set-cookie'].map do |c| cookie_regex.match(c)[:cookie] end diff --git a/spec/requests/secure_cookies_spec.rb b/spec/requests/secure_cookies_spec.rb index 7a72fa05cea..aa4699fe565 100644 --- a/spec/requests/secure_cookies_spec.rb +++ b/spec/requests/secure_cookies_spec.rb @@ -4,28 +4,30 @@ context 'with plain HTTP' do it 'flags all cookies sent by the application as HttpOnly and SameSite=Lax' do get root_url - cookie_count = response.headers['Set-Cookie'].split("\n").count + cookies = response.headers['Set-Cookie'] + cookie_count = cookies.count - expect(response.headers['Set-Cookie']).to_not include('; Secure') - expect(response.headers['Set-Cookie'].scan('; HttpOnly').size).to eq(cookie_count) - expect(response.headers['Set-Cookie'].scan('; SameSite=Lax').size).to eq(cookie_count) + expect(cookies.any? { |x| x.match?(SecureCookies::SECURE_REGEX) }).to eq(false) + expect(cookies.count { |x| x.match?(SecureCookies::HTTP_ONLY_REGEX) }).to eq(cookie_count) + expect(cookies.count { |x| x.match?(SecureCookies::SAME_SITE_REGEX) }).to eq(cookie_count) end end context 'with HTTPS' do it 'flags all cookies sent by the application as Secure, HttpOnly, and SameSite=Lax' do get root_url, headers: { 'HTTPS' => 'on' } - cookie_count = response.headers['Set-Cookie'].split("\n").count + cookie_count = response.headers['Set-Cookie'].count + cookies = response.headers['Set-Cookie'] - expect(response.headers['Set-Cookie'].scan('; Secure').size).to eq(cookie_count) - expect(response.headers['Set-Cookie'].scan('; HttpOnly').size).to eq(cookie_count) - expect(response.headers['Set-Cookie'].scan('; SameSite=Lax').size).to eq(cookie_count) + expect(cookies.count { |x| x.match?(SecureCookies::SECURE_REGEX) }).to eq(cookie_count) + expect(cookies.count { |x| x.match?(SecureCookies::HTTP_ONLY_REGEX) }).to eq(cookie_count) + expect(cookies.count { |x| x.match?(SecureCookies::SAME_SITE_REGEX) }).to eq(cookie_count) end end it 'does not set an expiration on the session cookie' do get root_url - cookies = response.headers['Set-Cookie'].split("\n") + cookies = response.headers['Set-Cookie'] session_cookie = cookies.find { |x| x.include?(APPLICATION_SESSION_COOKIE_KEY) } expect(session_cookie).to_not include('expires=') end diff --git a/spec/services/reporting/account_reuse_report_spec.rb b/spec/services/reporting/account_reuse_report_spec.rb index a5e08e1b4b3..a88817acf2f 100644 --- a/spec/services/reporting/account_reuse_report_spec.rb +++ b/spec/services/reporting/account_reuse_report_spec.rb @@ -102,6 +102,7 @@ def create_identity(id, provider, verified_time) ] aggregate_failures do + expect(report.account_reuse_emailable_report.title).to eq 'IDV app reuse rate Feb-2021' report.account_reuse_emailable_report.table.zip(expected_csv).each do |actual, expected| expect(actual).to eq(expected) end diff --git a/spec/support/features/webauthn_helper.rb b/spec/support/features/webauthn_helper.rb index 963f6b2dda3..2800a436c60 100644 --- a/spec/support/features/webauthn_helper.rb +++ b/spec/support/features/webauthn_helper.rb @@ -127,6 +127,15 @@ def set_hidden_field(id, value) end end + def simulate_platform_authenticator_available + page.evaluate_script(<<~JS) + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () => Promise.resolve(true); + JS + page.evaluate_script(<<~JS) + document.querySelectorAll('lg-webauthn-input').forEach((input) => input.connectedCallback()); + JS + end + def protocol 'http://' end diff --git a/spec/views/devise/sessions/new.html.erb_spec.rb b/spec/views/devise/sessions/new.html.erb_spec.rb index 3fe8664165b..039e2a616a6 100644 --- a/spec/views/devise/sessions/new.html.erb_spec.rb +++ b/spec/views/devise/sessions/new.html.erb_spec.rb @@ -75,6 +75,15 @@ ) end + it 'includes tracking script for no-JavaScript' do + render + + expect(rendered).to have_css( + "link[rel='stylesheet'][href='#{no_js_detect_css_path(location: :sign_in)}']", + visible: false, + ) + end + context 'when SP is present' do let(:sp) do build_stubbed(