diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ee9aa3037a..0ba01a76764 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -105,7 +105,7 @@ install: specs: stage: test - parallel: 7 + parallel: 11 cache: - <<: *ruby_cache - <<: *yarn_cache @@ -185,6 +185,17 @@ js_tests: - *yarn_install - yarn test + +pinpoint-check: + stage: test + cache: + - <<: *ruby_cache + - <<: *yarn_cache + script: + - *bundle_install + - *yarn_install + - make lint_country_dialing_codes + coverage: stage: after_test cache: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bed401f13ce..2fb2fc233c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,123 +55,6 @@ changelog categories. [changelog check script]: https://github.com/18F/identity-idp/blob/main/scripts/changelog_check.rb -### Style, Readability, and OO -- Rubocop or Reek offenses are not disabled unless they are false positives. -If you're not sure, please ask a teammate. - -- Related methods in the same class are in descending order of abstraction. -This is best explained through this video: https://youtu.be/0rsilnpU1DU?t=554 - -- Compound conditionals are replaced with more readable methods that describe -the business rule. For example, a conditional like -`user_session[:personal_key].nil? && current_user.personal_key.present?` could -be extracted into a method called -`current_user_has_already_confirmed_their_personal_key?`. -Another example is explained in this video: https://youtu.be/0rsilnpU1DU?t=40s - -- Service Objects should usually only have one public method, usually named -`call`. This mostly applies to classes that perform a specific task, unlike -Presenters, View Objects, and Value Objects, for example. Read -[7 Patterns to Refactor Fat ActiveRecord Models] for a good overview of the -different types of classes used in Rails. - - References: - - https://medium.com/selleo/essential-rubyonrails-patterns-part-1-service-objects-1af9f9573ca1 - - https://multithreaded.stitchfix.com/blog/2015/06/02/anatomy-of-service-objects-in-rails/ - - https://hackernoon.com/the-3-tenets-of-service-objects-c936b891b3c2 - - http://katafrakt.me//2018/07/04/writing-service-objects/ - - https://pawelurbanek.com/2018/02/12/ruby-on-rails-service-objects-and-testing-in-isolation/ - -[7 Patterns to Refactor Fat ActiveRecord Models]: https://codeclimate.com/blog/7-ways-to-decompose-fat-activerecord-models/ - -### RESTful controllers - -* Only use CRUD methods in controllers. - -* Prefer adding a new controller with one of the CRUD methods over creating a - custom method in an existing controller. For example, if your app allows a - user to update their email and their password on two different pages, instead of - using a single controller with methods called `update_email` and - `update_password`, create two controllers and name the methods `update`, i.e. - `EmailsController#update` and `PasswordsController#update`. See - http://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/ for more about - this design pattern. - -### Lean controllers -* Keep as much business logic as possible out of controllers. - -* Use specialized classes to handle the operations - * These will be Form Objects for the most part, since - most of what the app does is process user input via a form submission, or - clicking a link in email that contains a token. - -* Form Object rules: - - Should have a single public method called `submit` that returns a [FormResponse] object. - - Should use ActiveModel validations to validate the user input. - - Should be placed in `app/forms`. - -* Examples of Form/Service Objects: - - [EmailConfirmationTokenValidator] - - [PasswordForm] - -* The basic outline of how a controller interacts with this class is: -```ruby -result = Class.new(user).submit(params) # this returns a FormResponse object -# all the necessary analytics attributes are captured inside the Form Object -analytics.track_event('Some Event Name', result.to_h) - -if result.success? - handle_success -else - handle_failure -end -``` - -* Only make one call to `analytics.track_event` after submitting the form, as -opposed to one call when handling success and another when handling failure. The -Form Object, when used properly, will return a FormResponse object that already -tells us whether the action was successful or not. - -### Importance of the controller design for analytics - -This design pattern was the result of many iterations, and agreed upon by all -team members in early 2017. It keeps controllers clean and predictable. Having a -controller interact with a Form Object or some other specialized class is not a -new concept. Rails developers have been using them since at least 2012. What -might seem new is the `FormResponse` object. **The most important reason -controllers expect an object that responds to `success?` and `to_h` is to define -an analytics API, or a contract, if you will, between the analytics logs and the -folks who query them.** - -For example, if someone wants to look up all events that have failed, they would -run this query in Kibana: `properties.event_properties.success:false`. Now let's -say a developer introduces a new controller that doesn't adhere to our established -convention, and captures analytics in their own way, without adding `success` -and `errors` keys, which are expected to be included in all analytics events. -This means that any failures for this controller won't show up when running the -query above, and the person running the query might not realize data is missing. - -Deviating from the convention also causes confusion. The next developer to join -the team will not be sure which pattern to use, and might end up picking the -wrong pattern. As Sandi Metz says: - -> For better or for worse, the patterns you establish today will be replicated -forever. When the code lies you must be alert to programmers believing and then -propagating that lie. - -### Secure controllers -Rails by default is currently vulnerable to [cache poisoning attacks] -through modification of the `X-Forwarded-For` and `Host` headers. In -order to protect against the latter, there are two pieces that must be -in place. The first one is already taken care of by defining -`default_url_options` in `ApplicationController` with a `host` value -that we control. - -The other one is up to you when adding or modifying redirects: - -- Always use `_url` helpers (as opposed to `_path`) when calling -`redirect_to` in a controller. - ### Additional notes on pull requests and code reviews Please follow our [Code Review][review] guidelines. @@ -181,27 +64,10 @@ reading. [review]: https://engineering.18f.gov/code-review/ [thoughts]: http://glen.nu/ramblings/oncodereview.php -- Prioritize code reviews for the current sprint above your other work -- Review pull requests for the current sprint within 24 hours of being opened - Keep pull requests as small as possible, and focused on a single topic - Once a pull request is good to go, the person who opened it squashes related commits together, merges it, then deletes the branch. -### Recommended reading, viewing, and courses - -- [Practical Object-Oriented Design in Ruby](http://www.poodr.com/) -- [99 Bottles of OOP](https://sandimetz.dpdcart.com/) -- [Sandi Metz blog](https://www.sandimetz.com/blog/) -- [Sandi Metz talks](https://www.youtube.com/playlist?list=PLFQBiiaZoyrcTBYAGAUjvEUI6TUrp110W) -- [Learn Clean Code](https://thoughtbot.com/upcase/clean-code) -- [Ruby Science](https://gumroad.com/l/ruby-science) -- [Ruby Tapas](https://www.rubytapas.com/) -- [Master the Object-Oriented Mindset in Ruby and Rails](https://avdi.codes/moom/) -- [Refactoring Rails](https://www.refactoringrails.io/) -- [Growing Rails Applications in Practice](https://pragprog.com/book/d-kegrap/growing-rails-applications-in-practice) -- [The 30-Day Code Quality Challenge](https://www.codequalitychallenge.com/) -- [SourceMaking](https://sourcemaking.com/) - ## Public domain This project is in the public domain within the United States, and diff --git a/Gemfile b/Gemfile index 51b974ddc06..1933f5df2fe 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}.git" } ruby "~> #{File.read('.ruby-version').strip}" -gem 'rails', '~> 6.1.6.1' +gem 'rails', '~> 7.0.0' gem 'ahoy_matey', '~> 3.0' gem 'aws-sdk-kms', '~> 1.4' @@ -26,7 +26,7 @@ gem 'foundation_emails' gem 'good_job', '~> 2.99.0' gem 'hashie', '~> 4.1' gem 'http_accept_language' -gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v3.4.0' +gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v3.4.1' gem 'identity-logging', github: '18F/identity-logging', tag: 'v0.1.0' gem 'identity_validations', github: '18F/identity-validations', tag: 'v0.7.2' gem 'jsbundling-rails', '~> 1.0.0' @@ -60,6 +60,7 @@ gem 'safe_target_blank', '>= 1.0.2' gem 'saml_idp', github: '18F/saml_idp', tag: '0.17.0-18f' gem 'scrypt' gem 'simple_form', '>= 5.0.2' +gem 'sprockets-rails' gem 'stringex', require: false gem 'strong_migrations', '>= 0.4.2' gem 'subprocess', require: false @@ -89,7 +90,7 @@ end group :development, :test do gem 'aws-sdk-cloudwatchlogs', require: false gem 'brakeman', require: false - gem 'bullet', '>= 6.0.2' + gem 'bullet', '~> 7.0' gem 'capybara-webmock', git: 'https://github.com/hashrocket/capybara-webmock.git', ref: '63d790a0' gem 'data_uri', require: false gem 'erb_lint', '~> 0.1.0', require: false @@ -103,7 +104,7 @@ group :development, :test do gem 'pry-rails' gem 'psych' gem 'puma' - gem 'rspec-rails', '~> 4.0' + gem 'rspec-rails', '6.0.0.rc1' gem 'rubocop', '~> 1.29.1', require: false gem 'rubocop-performance', '~> 1.12.0', require: false gem 'rubocop-rails', '>= 2.5.2', require: false @@ -117,7 +118,7 @@ group :test do gem 'simplecov-cobertura' gem 'simplecov_json_formatter' gem 'email_spec' - gem 'factory_bot_rails', '>= 5.2.0' + gem 'factory_bot_rails', '>= 6.2.0' gem 'faker' gem 'rack_session_access', '>= 0.2.0' gem 'rack-test', '>= 1.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 42b8d6df000..bc44ebe8851 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,10 @@ GIT remote: https://github.com/18F/identity-hostdata.git - revision: c69cca28c5e9dd35c66c1bfbdb5c2218b560e14b - tag: v3.4.0 + revision: 25a7e98919b1eb0d61dbcce314807a412aff62ad + tag: v3.4.1 specs: - identity-hostdata (3.4.0) - activesupport (~> 6.1) + identity-hostdata (3.4.1) + activesupport (>= 6.1, < 8) aws-sdk-s3 (~> 1.8) GIT @@ -52,65 +52,71 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (6.1.6.1) - actionpack (= 6.1.6.1) - activesupport (= 6.1.6.1) + actioncable (7.0.3.1) + actionpack (= 7.0.3.1) + activesupport (= 7.0.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.6.1) - actionpack (= 6.1.6.1) - activejob (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + actionmailbox (7.0.3.1) + actionpack (= 7.0.3.1) + activejob (= 7.0.3.1) + activerecord (= 7.0.3.1) + activestorage (= 7.0.3.1) + activesupport (= 7.0.3.1) mail (>= 2.7.1) - actionmailer (6.1.6.1) - actionpack (= 6.1.6.1) - actionview (= 6.1.6.1) - activejob (= 6.1.6.1) - activesupport (= 6.1.6.1) + net-imap + net-pop + net-smtp + actionmailer (7.0.3.1) + actionpack (= 7.0.3.1) + actionview (= 7.0.3.1) + activejob (= 7.0.3.1) + activesupport (= 7.0.3.1) mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp rails-dom-testing (~> 2.0) - actionpack (6.1.6.1) - actionview (= 6.1.6.1) - activesupport (= 6.1.6.1) - rack (~> 2.0, >= 2.0.9) + actionpack (7.0.3.1) + actionview (= 7.0.3.1) + activesupport (= 7.0.3.1) + rack (~> 2.0, >= 2.2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.6.1) - actionpack (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + actiontext (7.0.3.1) + actionpack (= 7.0.3.1) + activerecord (= 7.0.3.1) + activestorage (= 7.0.3.1) + activesupport (= 7.0.3.1) + globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (6.1.6.1) - activesupport (= 6.1.6.1) + actionview (7.0.3.1) + activesupport (= 7.0.3.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.6.1) - activesupport (= 6.1.6.1) + activejob (7.0.3.1) + activesupport (= 7.0.3.1) globalid (>= 0.3.6) - activemodel (6.1.6.1) - activesupport (= 6.1.6.1) - activerecord (6.1.6.1) - activemodel (= 6.1.6.1) - activesupport (= 6.1.6.1) - activestorage (6.1.6.1) - actionpack (= 6.1.6.1) - activejob (= 6.1.6.1) - activerecord (= 6.1.6.1) - activesupport (= 6.1.6.1) + activemodel (7.0.3.1) + activesupport (= 7.0.3.1) + activerecord (7.0.3.1) + activemodel (= 7.0.3.1) + activesupport (= 7.0.3.1) + activestorage (7.0.3.1) + actionpack (= 7.0.3.1) + activejob (= 7.0.3.1) + activerecord (= 7.0.3.1) + activesupport (= 7.0.3.1) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.6.1) + activesupport (7.0.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - zeitwerk (~> 2.3) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) ahoy_matey (3.3.0) @@ -122,17 +128,17 @@ GEM ast (2.4.2) awrence (1.2.1) aws-eventstream (1.2.0) - aws-partitions (1.540.0) + aws-partitions (1.543.0) aws-sdk-cloudwatchlogs (1.49.0) aws-sdk-core (~> 3, >= 3.122.0) aws-sigv4 (~> 1.1) - aws-sdk-core (3.124.0) + aws-sdk-core (3.125.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.52.0) - aws-sdk-core (~> 3, >= 3.122.0) + aws-sdk-kms (1.53.0) + aws-sdk-core (~> 3, >= 3.125.0) aws-sigv4 (~> 1.1) aws-sdk-pinpoint (1.62.0) aws-sdk-core (~> 3, >= 3.122.0) @@ -140,10 +146,10 @@ GEM aws-sdk-pinpointsmsvoice (1.29.0) aws-sdk-core (~> 3, >= 3.122.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.87.0) - aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-s3 (1.110.0) + aws-sdk-core (~> 3, >= 3.125.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.1) + aws-sigv4 (~> 1.4) aws-sdk-ses (1.44.0) aws-sdk-core (~> 3, >= 3.122.0) aws-sigv4 (~> 1.1) @@ -185,18 +191,19 @@ GEM blueprinter (0.25.3) bootsnap (1.9.3) msgpack (~> 1.0) - brakeman (5.2.0) + brakeman (5.2.1) browser (5.3.1) builder (3.2.4) - bullet (6.1.5) + bullet (7.0.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.9.0.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) - capybara (3.35.3) + capybara (3.36.0) addressable + matrix mini_mime (>= 0.1.3) nokogiri (~> 1.8) rack (>= 1.6.0) @@ -246,7 +253,8 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - diff-lcs (1.4.4) + diff-lcs (1.5.0) + digest (3.1.0) docile (1.4.0) dotiw (5.3.2) activesupport @@ -269,7 +277,7 @@ GEM et-orbi (1.2.7) tzinfo execjs (2.8.1) - factory_bot (6.2.0) + factory_bot (6.2.1) activesupport (>= 5.0.0) factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) @@ -345,7 +353,7 @@ GEM http_accept_language (2.1.1) i18n (1.12.0) concurrent-ruby (~> 1.0) - i18n-tasks (0.9.35) + i18n-tasks (0.9.37) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -387,6 +395,7 @@ GEM mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.2) + matrix (0.4.2) maxminddb (0.1.22) memory_profiler (0.9.14) method_source (1.0.0) @@ -398,8 +407,22 @@ GEM multipart-post (2.1.1) multiset (0.5.3) nenv (0.3.0) + net-imap (0.2.3) + digest + net-protocol + strscan + net-pop (0.1.1) + digest + net-protocol + timeout + net-protocol (0.1.3) + timeout net-sftp (3.0.0) net-ssh (>= 5.0.0, < 7.0.0) + net-smtp (0.3.1) + digest + net-protocol + timeout net-ssh (6.1.0) newrelic_rpm (8.8.0) nio4r (2.5.8) @@ -468,21 +491,20 @@ GEM rack_session_access (0.2.0) builder (>= 2.0.0) rack (>= 1.0.0) - rails (6.1.6.1) - actioncable (= 6.1.6.1) - actionmailbox (= 6.1.6.1) - actionmailer (= 6.1.6.1) - actionpack (= 6.1.6.1) - actiontext (= 6.1.6.1) - actionview (= 6.1.6.1) - activejob (= 6.1.6.1) - activemodel (= 6.1.6.1) - activerecord (= 6.1.6.1) - activestorage (= 6.1.6.1) - activesupport (= 6.1.6.1) + rails (7.0.3.1) + actioncable (= 7.0.3.1) + actionmailbox (= 7.0.3.1) + actionmailer (= 7.0.3.1) + actionpack (= 7.0.3.1) + actiontext (= 7.0.3.1) + actionview (= 7.0.3.1) + activejob (= 7.0.3.1) + activemodel (= 7.0.3.1) + activerecord (= 7.0.3.1) + activestorage (= 7.0.3.1) + activesupport (= 7.0.3.1) bundler (>= 1.15.0) - railties (= 6.1.6.1) - sprockets-rails (>= 2.0.0) + railties (= 7.0.3.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -497,15 +519,16 @@ GEM ruby-graphviz (~> 1.2) rails-html-sanitizer (1.4.3) loofah (~> 2.3) - rails-i18n (6.0.0) + rails-i18n (7.0.3) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 7) - railties (6.1.6.1) - actionpack (= 6.1.6.1) - activesupport (= 6.1.6.1) + railties (>= 6.0.0, < 8) + railties (7.0.3.1) + actionpack (= 7.0.3.1) + activesupport (= 7.0.3.1) method_source rake (>= 12.2) thor (~> 1.0) + zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.0.6) rb-fsevent (0.10.4) @@ -533,29 +556,29 @@ GEM chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec (3.10.0) - rspec-core (~> 3.10.0) - rspec-expectations (~> 3.10.0) - rspec-mocks (~> 3.10.0) - rspec-core (3.10.1) - rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + rspec (3.11.0) + rspec-core (~> 3.11.0) + rspec-expectations (~> 3.11.0) + rspec-mocks (~> 3.11.0) + rspec-core (3.11.0) + rspec-support (~> 3.11.0) + rspec-expectations (3.11.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-mocks (3.10.2) + rspec-support (~> 3.11.0) + rspec-mocks (3.11.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.10.0) - rspec-rails (4.1.2) - actionpack (>= 4.2) - activesupport (>= 4.2) - railties (>= 4.2) - rspec-core (~> 3.10) - rspec-expectations (~> 3.10) - rspec-mocks (~> 3.10) - rspec-support (~> 3.10) + rspec-support (~> 3.11.0) + rspec-rails (6.0.0.rc1) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.11) + rspec-expectations (~> 3.11) + rspec-mocks (~> 3.11) + rspec-support (~> 3.11) rspec-retry (0.6.2) rspec-core (> 3.3) - rspec-support (3.10.3) + rspec-support (3.11.0) rubocop (1.29.1) parallel (~> 1.10) parser (>= 3.1.0.0) @@ -616,7 +639,7 @@ GEM simpleidn (0.2.1) unf (~> 0.1.4) smart_properties (1.17.0) - sprockets (4.1.1) + sprockets (4.0.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.4.2) @@ -624,14 +647,16 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) stringex (2.8.5) - strong_migrations (0.7.9) - activerecord (>= 5) + strong_migrations (0.8.0) + activerecord (>= 5.2) + strscan (3.0.1) subprocess (1.5.5) systemu (2.6.5) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) thor (1.2.1) thread_safe (0.3.6) + timeout (0.3.0) tpm-key_attestation (0.10.0) bindata (~> 2.4) openssl-signature_algorithm (~> 1.0) @@ -716,7 +741,7 @@ DEPENDENCIES bootsnap (~> 1.9.0) brakeman browser - bullet (>= 6.0.2) + bullet (~> 7.0) bundler-audit capybara-selenium (>= 0.0.6) capybara-webmock! @@ -728,7 +753,7 @@ DEPENDENCIES dotiw (>= 4.0.1) email_spec erb_lint (~> 0.1.0) - factory_bot_rails (>= 5.2.0) + factory_bot_rails (>= 6.2.0) faker faraday faraday_middleware @@ -773,7 +798,7 @@ DEPENDENCIES rack-test (>= 1.1.0) rack-timeout rack_session_access (>= 0.2.0) - rails (~> 6.1.6.1) + rails (~> 7.0.0) rails-controller-testing (>= 1.0.4) rails-erd (>= 1.6.0) redacted_struct @@ -783,7 +808,7 @@ DEPENDENCIES retries rotp (~> 6.1) rqrcode - rspec-rails (~> 4.0) + rspec-rails (= 6.0.0.rc1) rspec-retry rubocop (~> 1.29.1) rubocop-performance (~> 1.12.0) @@ -798,6 +823,7 @@ DEPENDENCIES simplecov (~> 0.21.0) simplecov-cobertura simplecov_json_formatter + sprockets-rails stringex strong_migrations (>= 0.4.2) subprocess diff --git a/Makefile b/Makefile index 27bb4dcb9e8..7c451433afc 100644 --- a/Makefile +++ b/Makefile @@ -62,8 +62,6 @@ lint: ## Runs all lint tests make lint_analytics_events @echo "--- brakeman ---" bundle exec brakeman - @echo "--- zeitwerk check ---" - bin/rails zeitwerk:check @echo "--- bundler-audit ---" bundle exec bundler-audit check --update # JavaScript diff --git a/app/assets/images/email/README.md b/app/assets/images/email/README.md new file mode 100644 index 00000000000..9796d3c69ac --- /dev/null +++ b/app/assets/images/email/README.md @@ -0,0 +1,3 @@ +# Email Images + +This folder contains images for exclusive use by mailer templates. This includes email-specific imagery, and also variants of existing assets. For example, since [SVG images are not well-supported](https://www.caniemail.com/features/image-svg/) in all email clients, this folder may include rasterized versions of common SVG images. diff --git a/app/assets/images/email/info.png b/app/assets/images/email/info.png new file mode 100644 index 00000000000..67da418b193 Binary files /dev/null and b/app/assets/images/email/info.png differ diff --git a/app/assets/images/email/user-signup-ial2.png b/app/assets/images/email/user-signup-ial2.png new file mode 100644 index 00000000000..9d8521fc12e Binary files /dev/null and b/app/assets/images/email/user-signup-ial2.png differ diff --git a/app/assets/stylesheets/components/_barcode.scss b/app/assets/stylesheets/components/_barcode.scss new file mode 100644 index 00000000000..e842cb94627 --- /dev/null +++ b/app/assets/stylesheets/components/_barcode.scss @@ -0,0 +1,14 @@ +.barcode.barby-barcode { + width: auto; + table-layout: fixed; + border-spacing: 0; +} + +.barcode .barby-cell { + width: 2px; + height: 96px; + + &.on { + background-color: #000; + } +} diff --git a/app/assets/stylesheets/components/_location-collection-item.scss b/app/assets/stylesheets/components/_location-collection-item.scss new file mode 100644 index 00000000000..d47b3ed69e9 --- /dev/null +++ b/app/assets/stylesheets/components/_location-collection-item.scss @@ -0,0 +1,38 @@ +.location-collection-item { + max-width: 64ex; + list-style-type: none; + padding-left: 0; + align-items: flex-start; + border-bottom-width: 1px; + border-bottom-style: solid; + display: flex; + margin-bottom: 1rem; + margin-top: 1rem; + padding-bottom: 1rem; + border-color: $border-color; +} + +@media screen { + .wrap-name { + overflow-wrap: break-word; + } +} + +@media screen and (min-width: 320px) { + .usa-button-mobile { + width: -webkit-fill-available; + margin-top: 8px; + } +} + +@media screen and (max-width: 480px) { + .usa-button-mobile-hidden { + display: none; + } +} + +@media screen and (min-width: 481px) { + .usa-button-desktop-hidden { + display: none; + } +} diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss index ee295a189d1..09ecaeaebff 100644 --- a/app/assets/stylesheets/components/all.scss +++ b/app/assets/stylesheets/components/all.scss @@ -1,5 +1,6 @@ @import 'account-header'; @import 'banner'; +@import 'barcode'; @import 'block-link'; @import 'block-submit-button'; @import 'btn'; @@ -27,3 +28,4 @@ @import 'troubleshooting-options'; @import 'validated-checkbox'; @import 'i18n-dropdown'; +@import 'location-collection-item'; diff --git a/app/assets/stylesheets/email.css.scss b/app/assets/stylesheets/email.css.scss index f1ae38d61e7..b9e1286126f 100644 --- a/app/assets/stylesheets/email.css.scss +++ b/app/assets/stylesheets/email.css.scss @@ -1,5 +1,9 @@ +@import 'required'; @import 'variables/email'; @import 'foundation-emails/scss/foundation-emails'; +@import 'identity-style-guide/dist/assets/scss/packages/required'; +@import 'identity-style-guide/dist/assets/scss/packages/utilities'; +@import './components/barcode'; .gray { &:active, @@ -28,6 +32,11 @@ line-height: 10px; } +.s16 { + font-size: 16px; + line-height: 24px; +} + .s30 { font-size: 30px; line-height: 30px; @@ -118,3 +127,37 @@ h4 { padding-right: $global-gutter-small !important; } } + +.info-alert { + background-color: color('info-lighter'); + padding: 0 units(0.5); + + td { + padding: units(1.5); + padding-right: units(1); + + & + td { + padding-left: 0; + padding-right: units(1.5); + } + } +} + +.process-list td { + padding-bottom: units(4); +} + +.process-list__circle { + border-radius: 50%; + width: units(3); + height: units(2.5); + background-color: color($theme-process-list-counter-background-color); + border: units($theme-process-list-counter-border-width) solid + color($theme-process-list-counter-border-color); + color: color('white'); + font-size: units(2); + font-weight: 700; + text-align: center; + padding-top: units(0.5); + margin-right: units(1.5); +} diff --git a/app/components/barcode_component.html.erb b/app/components/barcode_component.html.erb new file mode 100644 index 00000000000..c41cb0b82b7 --- /dev/null +++ b/app/components/barcode_component.html.erb @@ -0,0 +1,16 @@ +<%# Beware: This component is used in mailer content, so be mindful of email markup compatibility %> +<%= content_tag( + :div, + role: 'figure', + 'aria-labelledby': barcode_caption_id, + class: css_class, + **tag_options, + ) do %> + <%= barcode_html.html_safe %> +
+ <% if label.present? %> + <%= label %>: + <% end %> + <%= formatted_data %> +
+<% end %> diff --git a/app/components/barcode_component.rb b/app/components/barcode_component.rb new file mode 100644 index 00000000000..6316d940031 --- /dev/null +++ b/app/components/barcode_component.rb @@ -0,0 +1,38 @@ +require 'barby' +require 'barby/barcode/code_128' +require 'barby/outputter/html_outputter' + +class BarcodeComponent < BaseComponent + attr_reader :barcode_data, :label, :label_formatter, :tag_options + + def initialize(barcode_data:, label:, label_formatter: nil, **tag_options) + @barcode_data = barcode_data + @label = label + @label_formatter = label_formatter + @tag_options = tag_options + end + + def formatted_data + formatted_data = barcode_data + formatted_data = label_formatter.call(formatted_data) if label_formatter + formatted_data + end + + def barcode_html + html = Barby::Code128.new(barcode_data).to_html(class_name: 'barcode') + # The Barby gem doesn't provide much control over rendered output, so we need to manually slice + # in accessibility features (label as substitute to illegible inner content). + html.gsub( + '>', + %( aria-label="#{t('components.barcode.table_label')}">), + ) + end + + def barcode_caption_id + "barcode-caption-#{unique_id}" + end + + def css_class + [*tag_options[:class], 'display-inline-block margin-0'] + end +end diff --git a/app/controllers/api/verify/password_confirm_controller.rb b/app/controllers/api/verify/password_confirm_controller.rb index 1c728e0c7d4..3f312595121 100644 --- a/app/controllers/api/verify/password_confirm_controller.rb +++ b/app/controllers/api/verify/password_confirm_controller.rb @@ -10,7 +10,7 @@ def create user = User.find_by(uuid: result.extra[:user_uuid]) add_proofing_component(user) store_session_last_gpo_code(form.gpo_code) - save_in_person_enrollment(user, form.profile) + render json: { personal_key: personal_key, completion_url: completion_url(result, user), @@ -57,77 +57,8 @@ def completion_url(result, user) def in_person_enrollment?(user) return false unless IdentityConfig.store.in_person_proofing_enabled - # WILLFIX: After LG-6872 and we have enrollment saved, reference enrollment instead. ProofingComponent.find_by(user: user)&.document_check == Idp::Constants::Vendors::USPS end - - def usps_proofer - if IdentityConfig.store.usps_mock_fallback - UspsInPersonProofing::Mock::Proofer.new - else - UspsInPersonProofing::Proofer.new - end - end - - def create_usps_enrollment(enrollment) - pii = user_session[:idv][:pii] - applicant = UspsInPersonProofing::Applicant.new( - { - unique_id: enrollment.usps_unique_id, - first_name: pii.first_name, - last_name: pii.last_name, - address: pii.address1, - # do we need address2? - city: pii.city, - state: pii.state, - zip_code: pii.zipcode, - email: 'no-reply@login.gov', - }, - ) - proofer = usps_proofer - - response = proofer.request_enroll(applicant) - response['enrollmentCode'] - end - - def save_in_person_enrollment(user, profile) - return unless in_person_enrollment?(user) - - enrollment = InPersonEnrollment.create!( - profile: profile, - user: user, - current_address_matches_id: user_session.dig(:idv, :applicant, :same_address_as_id), - unique_id: InPersonEnrollment.generate_unique_id, - selected_location_details: { - 'name' => 'BALTIMORE — Post Office™', - 'streetAddress' => '900 E FAYETTE ST RM 118', - 'city' => 'BALTIMORE', - 'state' => 'MD', - 'zip5' => '21233', - 'zip4' => '9715', - 'phone' => '555-123-6409', - 'hours' => [ - { - 'weekdayHours' => '8:30 AM - 4:30 PM', - }, - { - 'saturdayHours' => '9:00 AM - 12:00 PM', - }, - { - 'sundayHours' => 'Closed', - }, - ], - }, - ) - - enrollment_code = create_usps_enrollment(enrollment) - return unless enrollment_code - - # update the enrollment to status pending - enrollment.enrollment_code = enrollment_code - enrollment.status = :pending - enrollment.save! - end end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 825f05a3f0c..ed114631686 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception + rescue_from ActionController::Redirecting::UnsafeRedirectError, with: :unsafe_redirect_error rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_auth_token rescue_from ActionController::UnknownFormat, with: :render_not_found rescue_from ActionView::MissingTemplate, with: :render_not_acceptable @@ -267,7 +268,24 @@ def invalid_auth_token(_exception) user_signed_in: user_signed_in?, ) flash[:error] = t('errors.general') - redirect_back fallback_location: new_user_session_url, allow_other_host: false + begin + redirect_back fallback_location: new_user_session_url, allow_other_host: false + rescue ActionController::Redirecting::UnsafeRedirectError => err + # Exceptions raised inside exception handlers are not propagated up, so we manually rescue + unsafe_redirect_error(err) + end + end + + def unsafe_redirect_error(_exception) + controller_info = "#{controller_path}##{action_name}" + analytics.unsafe_redirect_error( + controller: controller_info, + user_signed_in: user_signed_in?, + referer: request.referer, + ) + + flash[:error] = t('errors.general') + redirect_to new_user_session_url end def user_fully_authenticated? diff --git a/app/controllers/concerns/two_factor_authenticatable_methods.rb b/app/controllers/concerns/two_factor_authenticatable_methods.rb index a645776f312..9c70b576be4 100644 --- a/app/controllers/concerns/two_factor_authenticatable_methods.rb +++ b/app/controllers/concerns/two_factor_authenticatable_methods.rb @@ -161,7 +161,7 @@ def track_mfa_method_added mfa_user = MfaContext.new(current_user) mfa_count = mfa_user.enabled_mfa_methods_count analytics.multi_factor_auth_added_phone(enabled_mfa_methods_count: mfa_count) - Funnel::Registration::AddMfa.call(current_user.id, 'phone') + Funnel::Registration::AddMfa.call(current_user.id, 'phone', analytics) end def handle_valid_otp_for_authentication_context diff --git a/app/controllers/idv/capture_doc_status_controller.rb b/app/controllers/idv/capture_doc_status_controller.rb index f1a24ab502a..2503771a2a2 100644 --- a/app/controllers/idv/capture_doc_status_controller.rb +++ b/app/controllers/idv/capture_doc_status_controller.rb @@ -5,12 +5,7 @@ class CaptureDocStatusController < ApplicationController respond_to :json def show - render( - json: { - redirect: status == :too_many_requests ? idv_session_errors_throttled_url : nil, - }.compact, - status: status, - ) + render(json: { redirect: redirect_url }.compact, status: status) end private @@ -23,7 +18,7 @@ def status :gone elsif throttled? :too_many_requests - elsif confirmed_barcode_attention_result? + elsif confirmed_barcode_attention_result? || user_has_establishing_in_person_enrollment? :ok elsif session_result.blank? || pending_barcode_attention_confirmation? :accepted @@ -35,6 +30,16 @@ def status end end + def redirect_url + return unless flow_session && document_capture_session + + if throttled? + idv_session_errors_throttled_url + elsif user_has_establishing_in_person_enrollment? + idv_in_person_url + end + end + def flow_session user_session['idv/doc_auth'] end @@ -55,10 +60,16 @@ def document_capture_session_uuid end def throttled? - Throttle.new( - user: document_capture_session.user, - throttle_type: :idv_doc_auth, - ).throttled? + throttle.throttled? + end + + def throttle + @throttle ||= Throttle.new(user: document_capture_session.user, throttle_type: :idv_doc_auth) + end + + def user_has_establishing_in_person_enrollment? + return false unless IdentityConfig.store.in_person_proofing_enabled + current_user.establishing_in_person_enrollment.present? end def confirmed_barcode_attention_result? diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb index 45a937fc904..8dc73cea4f1 100644 --- a/app/controllers/idv/doc_auth_controller.rb +++ b/app/controllers/idv/doc_auth_controller.rb @@ -3,6 +3,7 @@ class DocAuthController < ApplicationController before_action :confirm_two_factor_authenticated before_action :redirect_if_mail_bounced before_action :redirect_if_pending_profile + before_action :redirect_if_pending_in_person_enrollment before_action :extend_timeout_using_meta_refresh_for_select_paths include IdvSession # remove if we retire the non docauth LOA3 flow @@ -36,6 +37,11 @@ def redirect_if_pending_profile redirect_to idv_gpo_verify_url if current_user.decorate.pending_profile_requires_verification? end + def redirect_if_pending_in_person_enrollment + return if !IdentityConfig.store.in_person_proofing_enabled + redirect_to idv_in_person_ready_to_verify_url if current_user.pending_in_person_enrollment + end + def update_if_skipping_upload return if params[:step] != 'upload' || !flow_session || !flow_session[:skip_upload_step] track_step_visited diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb index 384d18e05a6..351833e53fc 100644 --- a/app/controllers/idv/gpo_controller.rb +++ b/app/controllers/idv/gpo_controller.rb @@ -78,7 +78,10 @@ def pii(address_pii) def non_address_pii pii_to_h. slice('first_name', 'middle_name', 'last_name', 'dob', 'phone', 'ssn'). - merge(uuid_prefix: ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id) + merge( + uuid: current_user.uuid, + uuid_prefix: ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id, + ) end def pii_to_h diff --git a/app/controllers/idv/gpo_verify_controller.rb b/app/controllers/idv/gpo_verify_controller.rb index 94fa42750c5..eed5efd069f 100644 --- a/app/controllers/idv/gpo_verify_controller.rb +++ b/app/controllers/idv/gpo_verify_controller.rb @@ -9,7 +9,7 @@ def index analytics.idv_gpo_verification_visited gpo_mail = Idv::GpoMail.new(current_user) @mail_spammed = gpo_mail.mail_spammed? - @gpo_verify_form = GpoVerifyForm.new(user: current_user) + @gpo_verify_form = GpoVerifyForm.new(user: current_user, pii: pii) @code = session[:last_gpo_confirmation_code] if FeatureManagement.reveal_gpo_code? if throttle.throttled? @@ -19,6 +19,10 @@ def index end end + def pii + Pii::Cacher.new(current_user, user_session).fetch + end + def create @gpo_verify_form = build_gpo_verify_form @@ -29,15 +33,19 @@ def create analytics.idv_gpo_verification_submitted(**result.to_h) if result.success? - event = create_user_event_with_disavowal(:account_verified) - UserAlerts::AlertUserAboutAccountVerified.call( - user: current_user, - date_time: event.created_at, - sp_name: decorated_session.sp_name, - disavowal_token: event.disavowal_token, - ) - flash[:success] = t('account.index.verification.success') - redirect_to sign_up_completed_url + if result.extra[:pending_in_person_enrollment] + redirect_to idv_in_person_ready_to_verify_url + else + event = create_user_event_with_disavowal(:account_verified) + UserAlerts::AlertUserAboutAccountVerified.call( + user: current_user, + date_time: event.created_at, + sp_name: decorated_session.sp_name, + disavowal_token: event.disavowal_token, + ) + flash[:success] = t('account.index.verification.success') + redirect_to sign_up_completed_url + end else flash[:error] = @gpo_verify_form.errors.first.message redirect_to idv_gpo_verify_url @@ -66,6 +74,7 @@ def render_throttled def build_gpo_verify_form GpoVerifyForm.new( user: current_user, + pii: pii, otp: params_otp, ) end diff --git a/app/controllers/idv/in_person/usps_locations_controller.rb b/app/controllers/idv/in_person/usps_locations_controller.rb new file mode 100644 index 00000000000..c712c64dd9f --- /dev/null +++ b/app/controllers/idv/in_person/usps_locations_controller.rb @@ -0,0 +1,48 @@ +require 'json' + +module Idv + module InPerson + class UspsLocationsController < ApplicationController + include UspsInPersonProofing + include EffectiveUser + + # get the list of all pilot Post Office locations + def index + usps_response = [] + begin + usps_response = Proofer.new.request_pilot_facilities + rescue Faraday::ConnectionFailed => _error + nil + end + + render json: usps_response.to_json + end + + # save the Post Office location the user selected to an enrollment + def update + enrollment.update!(selected_location_details: permitted_params.as_json) + + render json: { success: true }, status: :ok + end + + protected + + def enrollment + UspsInPersonProofing::EnrollmentHelper. + establishing_in_person_enrollment_for_user(effective_user) + end + + def permitted_params + params.require(:usps_location).permit( + :formatted_city_state_zip, + :name, + :phone, + :saturday_hours, + :street_address, + :sunday_hours, + :weekday_hours, + ) + end + end + end +end diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 78fa290f07a..2f1c0e457cb 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -90,7 +90,7 @@ def init_profile idv_session.cache_encrypted_pii(password) idv_session.complete_session - if idv_session.phone_confirmed? + if idv_session.profile.active? event = create_user_event_with_disavowal(:account_verified) UserAlerts::AlertUserAboutAccountVerified.call( user: current_user, diff --git a/app/controllers/idv/session_errors_controller.rb b/app/controllers/idv/session_errors_controller.rb index b238169f511..4b4d9987347 100644 --- a/app/controllers/idv/session_errors_controller.rb +++ b/app/controllers/idv/session_errors_controller.rb @@ -5,6 +5,9 @@ class SessionErrorsController < ApplicationController before_action :confirm_two_factor_authenticated_or_user_id_in_session before_action :confirm_idv_session_step_needed + before_action :set_try_again_path, only: [:warning, :exception] + + def exception; end def warning @remaining_attempts = Throttle.new( @@ -51,5 +54,13 @@ def confirm_idv_session_step_needed return unless user_fully_authenticated? redirect_to idv_phone_url if idv_session.profile_confirmation == true end + + def set_try_again_path + if params[:from]&.starts_with? idv_in_person_path + @try_again_path = idv_in_person_path + else + @try_again_path = idv_doc_auth_path + end + end end end diff --git a/app/controllers/idv/sessions_controller.rb b/app/controllers/idv/sessions_controller.rb index 821d62d7566..51d17250806 100644 --- a/app/controllers/idv/sessions_controller.rb +++ b/app/controllers/idv/sessions_controller.rb @@ -6,11 +6,13 @@ class SessionsController < ApplicationController def destroy cancel_verification_attempt_if_pending_profile + cancel_in_person_enrollment_if_exists analytics.idv_start_over( step: location_params[:step], location: location_params[:location], ) user_session['idv/doc_auth'] = {} + user_session['idv/in_person'] = {} idv_session.clear Pii::Cacher.new(current_user, user_session).delete redirect_to idv_url @@ -19,10 +21,15 @@ def destroy private def cancel_verification_attempt_if_pending_profile - return if current_user.profiles.verification_pending.blank? + return if current_user.profiles.gpo_verification_pending.blank? Idv::CancelVerificationAttempt.new(user: current_user).call end + def cancel_in_person_enrollment_if_exists + return if !IdentityConfig.store.in_person_proofing_enabled + current_user.pending_in_person_enrollment&.update(status: :cancelled) + end + def location_params params.permit(:step, :location).to_h.symbolize_keys end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index ba2446c1dab..90202919ff2 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -66,7 +66,7 @@ def ial_context def handle_successful_handoff track_events SpHandoffBounce::AddHandoffTimeToSession.call(sp_session) - redirect_to @authorize_form.success_redirect_uri + redirect_to @authorize_form.success_redirect_uri, allow_other_host: true delete_branded_experience end @@ -113,7 +113,7 @@ def validate_authorize_form return if result.success? if (redirect_uri = result.extra[:redirect_uri]) - redirect_to redirect_uri + redirect_to redirect_uri, allow_other_host: true else render :error end diff --git a/app/controllers/openid_connect/logout_controller.rb b/app/controllers/openid_connect/logout_controller.rb index 0ec7dc359c7..b25132f0d9a 100644 --- a/app/controllers/openid_connect/logout_controller.rb +++ b/app/controllers/openid_connect/logout_controller.rb @@ -13,7 +13,12 @@ def index if result.success? && (redirect_uri = result.extra[:redirect_uri]) sign_out - redirect_to redirect_uri unless logout_params[:prevent_logout_redirect] == 'true' + unless logout_params[:prevent_logout_redirect] == 'true' + redirect_to( + redirect_uri, + allow_other_host: true, + ) + end else render :error end diff --git a/app/controllers/redirect/redirect_controller.rb b/app/controllers/redirect/redirect_controller.rb index d51f850a695..ad103661aef 100644 --- a/app/controllers/redirect/redirect_controller.rb +++ b/app/controllers/redirect/redirect_controller.rb @@ -15,7 +15,7 @@ def redirect_to_and_log(url, event: nil, tracker_method: analytics.method(:exter else tracker_method.call(redirect_url: url, **location_params) end - redirect_to(url) + redirect_to(url, allow_other_host: true) end end end diff --git a/app/controllers/redirect/return_to_sp_controller.rb b/app/controllers/redirect/return_to_sp_controller.rb index 119ec31cbd8..a1a18f0ca9a 100644 --- a/app/controllers/redirect/return_to_sp_controller.rb +++ b/app/controllers/redirect/return_to_sp_controller.rb @@ -5,14 +5,14 @@ class ReturnToSpController < Redirect::RedirectController def cancel redirect_url = sp_return_url_resolver.return_to_sp_url analytics.return_to_sp_cancelled(redirect_url: redirect_url, **location_params) - redirect_to(redirect_url) + redirect_to(redirect_url, allow_other_host: true) end def failure_to_proof redirect_url = sp_return_url_resolver.failure_to_proof_url analytics.return_to_sp_failure_to_proof(redirect_url: redirect_url, **location_params) - redirect_to(redirect_url) + redirect_to(redirect_url, allow_other_host: true) end private diff --git a/app/controllers/sign_out_controller.rb b/app/controllers/sign_out_controller.rb index 8eb656e5f08..95c2062b1a5 100644 --- a/app/controllers/sign_out_controller.rb +++ b/app/controllers/sign_out_controller.rb @@ -6,7 +6,7 @@ def destroy url_after_cancellation = decorated_session.cancel_link_url sign_out flash[:success] = t('devise.sessions.signed_out') - redirect_to url_after_cancellation + redirect_to(url_after_cancellation, allow_other_host: true) delete_branded_experience end end diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb index a48828f9e53..c6e77351c08 100644 --- a/app/controllers/sign_up/completions_controller.rb +++ b/app/controllers/sign_up/completions_controller.rb @@ -21,7 +21,10 @@ def update if decider.go_back_to_mobile_app? sign_user_out_and_instruct_to_go_back_to_mobile_app else - redirect_to(sp_session_request_url_with_updated_params || account_url) + redirect_to( + sp_session_request_url_with_updated_params || account_url, + allow_other_host: true, + ) end end diff --git a/app/controllers/two_factor_authentication/otp_verification_controller.rb b/app/controllers/two_factor_authentication/otp_verification_controller.rb index e3996c60f58..f8747294520 100644 --- a/app/controllers/two_factor_authentication/otp_verification_controller.rb +++ b/app/controllers/two_factor_authentication/otp_verification_controller.rb @@ -98,6 +98,8 @@ def analytics_properties phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), phone_configuration_id: user_session[:phone_id] || current_user.default_phone_configuration&.id, + in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, } end end diff --git a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb index 4ed00032819..4a44fa98e5a 100644 --- a/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb +++ b/app/controllers/two_factor_authentication/piv_cac_verification_controller.rb @@ -20,7 +20,7 @@ def redirect_to_piv_cac_service redirect_to PivCacService.piv_cac_service_link( nonce: piv_cac_nonce, redirect_uri: login_two_factor_piv_cac_url, - ) + ), allow_other_host: true end private diff --git a/app/controllers/users/additional_mfa_required_controller.rb b/app/controllers/users/additional_mfa_required_controller.rb index a451a659104..ab79d2998d5 100644 --- a/app/controllers/users/additional_mfa_required_controller.rb +++ b/app/controllers/users/additional_mfa_required_controller.rb @@ -19,6 +19,13 @@ def skip ).call end analytics.non_restricted_mfa_required_prompt_skipped + # should_count as complete as well + analytics.user_registration_mfa_setup_complete( + mfa_method_counts: mfa_context.enabled_two_factor_configuration_counts_hash, + enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, + pii_like_keypaths: [[:mfa_method_counts, :phone]], + success: true, + ) redirect_to after_sign_in_path_for(current_user) end @@ -28,6 +35,10 @@ def enforcement_date @enforcement_date ||= IdentityConfig.store.kantara_restriction_enforcement_date end + def mfa_context + @mfa_context ||= MfaContext.new(current_user) + end + def confirm_user_fully_authenticated unless user_fully_authenticated? return confirm_two_factor_authenticated(sp_session[:request_id]) diff --git a/app/controllers/users/authorization_confirmation_controller.rb b/app/controllers/users/authorization_confirmation_controller.rb index 9160b3e641a..7c674a6aa12 100644 --- a/app/controllers/users/authorization_confirmation_controller.rb +++ b/app/controllers/users/authorization_confirmation_controller.rb @@ -16,7 +16,7 @@ def new def create analytics.authentication_confirmation_continue - redirect_to sp_session_request_url_with_updated_params + redirect_to sp_session_request_url_with_updated_params, allow_other_host: true end def destroy diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index 8cd48955eba..43e6ee64f4e 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -29,6 +29,7 @@ def edit; end def continue flash[:success] = t('notices.backup_codes_configured') + analytics.multi_factor_auth_setup(**analytics_properties) redirect_to next_setup_path || after_mfa_setup_path end @@ -54,7 +55,7 @@ def track_backup_codes_created analytics.backup_code_created( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, ) - Funnel::Registration::AddMfa.call(current_user.id, 'backup_codes') + Funnel::Registration::AddMfa.call(current_user.id, 'backup_codes', analytics) end def mfa_user @@ -111,5 +112,14 @@ def authorize_backup_code_disable return if MfaPolicy.new(current_user).multiple_non_restricted_factors_enabled? redirect_to account_two_factor_authentication_path end + + def analytics_properties + { + success: true, + multi_factor_auth_method: 'backup_codes', + in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, + } + end end end diff --git a/app/controllers/users/mfa_selection_controller.rb b/app/controllers/users/mfa_selection_controller.rb index 5dd11276c2b..f655a934b49 100644 --- a/app/controllers/users/mfa_selection_controller.rb +++ b/app/controllers/users/mfa_selection_controller.rb @@ -9,7 +9,7 @@ class MfaSelectionController < ApplicationController before_action :multiple_factors_enabled? def index - @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + two_factor_options_form @after_setup_path = after_mfa_setup_path @presenter = two_factor_options_presenter analytics.user_registration_2fa_additional_setup_visit @@ -37,8 +37,7 @@ def update private def submit_form - @two_factor_options_form = TwoFactorOptionsForm.new(current_user) - @two_factor_options_form.submit(two_factor_options_form_params) + two_factor_options_form.submit(two_factor_options_form_params) end def two_factor_options_presenter @@ -50,6 +49,14 @@ def two_factor_options_presenter ) end + def two_factor_options_form + @two_factor_options_form ||= TwoFactorOptionsForm.new( + user: current_user, + aal3_required: service_provider_mfa_policy.aal3_required?, + piv_cac_required: service_provider_mfa_policy.piv_cac_required?, + ) + end + def process_valid_form user_session[:mfa_selections] = @two_factor_options_form.selection diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb index df32ed2db1e..ae88634539e 100644 --- a/app/controllers/users/passwords_controller.rb +++ b/app/controllers/users/passwords_controller.rb @@ -38,6 +38,7 @@ def user_params end def handle_valid_password + send_password_reset_risc_event create_event_and_notify_user_about_password_change bypass_sign_in current_user @@ -45,6 +46,11 @@ def handle_valid_password redirect_to account_url, flash: { info: t('notices.password_changed') } end + def send_password_reset_risc_event + event = PushNotification::PasswordResetEvent.new(user: current_user) + PushNotification::HttpPush.deliver(event) + end + def create_event_and_notify_user_about_password_change event = create_user_event_with_disavowal(:password_changed) UserAlerts::AlertUserAboutPasswordChange.call(current_user, event.disavowal_token) diff --git a/app/controllers/users/piv_cac_authentication_setup_controller.rb b/app/controllers/users/piv_cac_authentication_setup_controller.rb index 8741abefb08..e6b74b7aa17 100644 --- a/app/controllers/users/piv_cac_authentication_setup_controller.rb +++ b/app/controllers/users/piv_cac_authentication_setup_controller.rb @@ -44,7 +44,7 @@ def submit_new_piv_cac if good_nickname user_session[:piv_cac_nickname] = params[:name] create_piv_cac_nonce - redirect_to piv_cac_service_url_with_redirect + redirect_to piv_cac_service_url_with_redirect, allow_other_host: true else flash[:error] = I18n.t('errors.piv_cac_setup.unique_name') render_prompt @@ -84,7 +84,8 @@ def piv_cac_service_url_with_redirect def process_piv_cac_setup result = user_piv_cac_form.submit - analytics.multi_factor_auth_setup(**result.to_h) + properties = result.to_h.merge(analytics_properties) + analytics.multi_factor_auth_setup(**properties) if result.success? process_valid_submission else @@ -121,7 +122,7 @@ def track_mfa_method_added analytics.multi_factor_auth_added_piv_cac( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, ) - Funnel::Registration::AddMfa.call(current_user.id, 'piv_cac') + Funnel::Registration::AddMfa.call(current_user.id, 'piv_cac', analytics) end def piv_cac_enabled? @@ -150,6 +151,13 @@ def good_nickname name.present? && !PivCacConfiguration.exists?(user_id: current_user.id, name: name) end + def analytics_properties + { + in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + enabled_mfa_methods_count: mfa_context.enabled_mfa_methods_count, + } + end + def cap_piv_cac_count return unless IdentityConfig.store.max_piv_cac_per_account <= current_cac_count redirect_to account_two_factor_authentication_path diff --git a/app/controllers/users/piv_cac_login_controller.rb b/app/controllers/users/piv_cac_login_controller.rb index 6e8c945881c..2b3a8641d37 100644 --- a/app/controllers/users/piv_cac_login_controller.rb +++ b/app/controllers/users/piv_cac_login_controller.rb @@ -17,7 +17,7 @@ def redirect_to_piv_cac_service redirect_to PivCacService.piv_cac_service_link( nonce: piv_cac_nonce, redirect_uri: login_piv_cac_url, - ) + ), allow_other_host: true end def account_not_found; end diff --git a/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb b/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb index 3b8594bfdc3..19d3cb5eebc 100644 --- a/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb +++ b/app/controllers/users/piv_cac_setup_from_sign_in_controller.rb @@ -35,7 +35,8 @@ def render_prompt def process_piv_cac_setup result = user_piv_cac_form.submit - analytics.multi_factor_auth_setup(**result.to_h) + properties = result.to_h.merge(analytics_properties) + analytics.multi_factor_auth_setup(**properties) if result.success? process_valid_submission else @@ -66,5 +67,12 @@ def process_valid_submission create_user_event(:piv_cac_enabled) redirect_to login_add_piv_cac_success_url end + + def analytics_properties + { + in_multi_mfa_selection_flow: false, + enabled_mfa_methods_count: MfaContext.new(current_user).enabled_mfa_methods_count, + } + end end end diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb index 90eaf46b0b3..290c5b96b63 100644 --- a/app/controllers/users/reset_passwords_controller.rb +++ b/app/controllers/users/reset_passwords_controller.rb @@ -111,11 +111,17 @@ def build_user end def handle_successful_password_reset + send_password_reset_risc_event create_reset_event_and_send_notification flash[:info] = t('devise.passwords.updated_not_active') if is_flashing_format? redirect_to new_user_session_url end + def send_password_reset_risc_event + event = PushNotification::PasswordResetEvent.new(user: resource) + PushNotification::HttpPush.deliver(event) + end + def handle_unsuccessful_password_reset(result) reset_password_token_errors = result.errors[:reset_password_token] if reset_password_token_errors.present? diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index 993d772412b..9806097fdc8 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -40,6 +40,11 @@ def create def destroy analytics.logout_initiated(sp_initiated: false, oidc: false) + irs_attempts_api_tracker.logout_initiated( + user_uuid: current_user.uuid, + unique_session_id: current_user.unique_session_id, + success: true, + ) super end diff --git a/app/controllers/users/totp_setup_controller.rb b/app/controllers/users/totp_setup_controller.rb index 7abb664d231..0044f3d698c 100644 --- a/app/controllers/users/totp_setup_controller.rb +++ b/app/controllers/users/totp_setup_controller.rb @@ -24,7 +24,8 @@ def new def confirm result = totp_setup_form.submit - analytics.multi_factor_auth_setup(**result.to_h) + properties = result.to_h.merge(analytics_properties) + analytics.multi_factor_auth_setup(**properties) if result.success? process_valid_code @@ -97,7 +98,7 @@ def create_events analytics.multi_factor_auth_added_totp( enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, ) - Funnel::Registration::AddMfa.call(current_user.id, 'auth_app') + Funnel::Registration::AddMfa.call(current_user.id, 'auth_app', analytics) end def process_successful_disable @@ -140,5 +141,12 @@ def cap_auth_app_count def current_auth_app_count current_user.auth_app_configurations.count end + + def analytics_properties + { + in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + pii_like_keypaths: [[:mfa_method_counts, :phone]], + } + end end end diff --git a/app/controllers/users/two_factor_authentication_setup_controller.rb b/app/controllers/users/two_factor_authentication_setup_controller.rb index 32f3587c56f..15f9273c1de 100644 --- a/app/controllers/users/two_factor_authentication_setup_controller.rb +++ b/app/controllers/users/two_factor_authentication_setup_controller.rb @@ -8,7 +8,7 @@ class TwoFactorAuthenticationSetupController < ApplicationController before_action :confirm_user_needs_2fa_setup def index - @two_factor_options_form = TwoFactorOptionsForm.new(current_user) + two_factor_options_form @presenter = two_factor_options_presenter analytics.user_registration_2fa_setup_visit end @@ -24,6 +24,7 @@ def create flash[:phone_error] = t('errors.two_factor_auth_setup.must_select_additional_option') redirect_to authentication_methods_setup_path(anchor: 'select_phone') else + flash[:error] = t('errors.two_factor_auth_setup.must_select_option') @presenter = two_factor_options_presenter render :index end @@ -35,8 +36,7 @@ def create private def submit_form - @two_factor_options_form = TwoFactorOptionsForm.new(current_user) - @two_factor_options_form.submit(two_factor_options_form_params) + two_factor_options_form.submit(two_factor_options_form_params) end def two_factor_options_presenter @@ -48,6 +48,14 @@ def two_factor_options_presenter ) end + def two_factor_options_form + @two_factor_options_form ||= TwoFactorOptionsForm.new( + user: current_user, + aal3_required: service_provider_mfa_policy.aal3_required?, + piv_cac_required: service_provider_mfa_policy.piv_cac_required?, + ) + end + def process_valid_form user_session[:mfa_selections] = @two_factor_options_form.selection redirect_to confirmation_path(user_session[:mfa_selections].first) diff --git a/app/controllers/users/webauthn_setup_controller.rb b/app/controllers/users/webauthn_setup_controller.rb index 0ad642cda6a..b3771332ea7 100644 --- a/app/controllers/users/webauthn_setup_controller.rb +++ b/app/controllers/users/webauthn_setup_controller.rb @@ -39,7 +39,8 @@ def confirm remember_device_default: remember_device_default, platform_authenticator: @platform_authenticator, ) - analytics.multi_factor_auth_setup(**result.to_h) + properties = result.to_h.merge(analytics_properties) + analytics.multi_factor_auth_setup(**properties) if result.success? process_valid_webauthn(form) else @@ -135,7 +136,7 @@ def process_valid_webauthn(form) platform_authenticator: form.platform_authenticator?, enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, ) - Funnel::Registration::AddMfa.call(current_user.id, 'webauthn') + Funnel::Registration::AddMfa.call(current_user.id, 'webauthn', analytics) mark_user_as_fully_authenticated handle_remember_device if form.platform_authenticator? @@ -147,6 +148,12 @@ def process_valid_webauthn(form) redirect_to next_setup_path || after_mfa_setup_path end + def analytics_properties + { + in_multi_mfa_selection_flow: in_multi_mfa_selection_flow?, + } + end + def handle_remember_device save_user_opted_remember_device_pref save_remember_device_preference diff --git a/app/forms/api/profile_creation_form.rb b/app/forms/api/profile_creation_form.rb index 9a84ceb3dd4..9e2e41b3527 100644 --- a/app/forms/api/profile_creation_form.rb +++ b/app/forms/api/profile_creation_form.rb @@ -52,8 +52,29 @@ def cache_encrypted_pii end def complete_session - complete_profile if phone_confirmed? - create_gpo_entry if user_bundle.gpo_address_verification? + associate_in_person_enrollment_with_profile + + if user_bundle.gpo_address_verification? + profile.deactivate(:gpo_verification_pending) + create_gpo_entry + elsif phone_confirmed? + if pending_in_person_enrollment? + UspsInPersonProofing::EnrollmentHelper.schedule_in_person_enrollment(user, session[:pii]) + profile.deactivate(:in_person_verification_pending) + else + complete_profile + end + end + end + + def associate_in_person_enrollment_with_profile + return unless pending_in_person_enrollment? && user.establishing_in_person_enrollment + user.establishing_in_person_enrollment.update(profile: profile) + end + + def pending_in_person_enrollment? + return false unless IdentityConfig.store.in_person_proofing_enabled + ProofingComponent.find_by(user: user)&.document_check == Idp::Constants::Vendors::USPS end def phone_confirmed? @@ -61,7 +82,7 @@ def phone_confirmed? end def complete_profile - user.pending_profile&.activate + profile.activate move_pii_to_user_session end @@ -127,7 +148,7 @@ def form_valid? def extra_attributes if user.present? @extra_attributes ||= { - profile_pending: user.pending_profile?, + profile_pending: user_bundle.gpo_address_verification?, user_uuid: user.uuid, } else @@ -150,5 +171,10 @@ def public_key key end + + def in_person_enrollment? + return false unless IdentityConfig.store.in_person_proofing_enabled + ProofingComponent.find_by(user: user)&.document_check == Idp::Constants::Vendors::USPS + end end end diff --git a/app/forms/gpo_verify_form.rb b/app/forms/gpo_verify_form.rb index e5ca6a4a894..62b42cdbb02 100644 --- a/app/forms/gpo_verify_form.rb +++ b/app/forms/gpo_verify_form.rb @@ -6,18 +6,24 @@ class GpoVerifyForm validate :validate_otp_not_expired validate :validate_pending_profile - attr_accessor :otp, :pii_attributes + attr_accessor :otp, :pii, :pii_attributes attr_reader :user - def initialize(user:, otp: nil) + def initialize(user:, pii:, otp: nil) @user = user + @pii = pii @otp = otp end def submit result = valid? if result - activate_profile + if pending_in_person_enrollment? + UspsInPersonProofing::EnrollmentHelper.schedule_in_person_enrollment(user, pii) + pending_profile&.deactivate(:in_person_verification_pending) + else + activate_profile + end else reset_sensitive_fields end @@ -26,6 +32,7 @@ def submit errors: errors, extra: { pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], + pending_in_person_enrollment: pending_in_person_enrollment?, }, ) end @@ -65,6 +72,11 @@ def reset_sensitive_fields self.otp = nil end + def pending_in_person_enrollment? + return false unless IdentityConfig.store.in_person_proofing_enabled + pending_profile.proofing_components&.[]('document_check') == Idp::Constants::Vendors::USPS + end + def activate_profile user.pending_profile&.activate end diff --git a/app/forms/idv/api_document_verification_status_form.rb b/app/forms/idv/api_document_verification_status_form.rb index 08f675ccce7..414fa7e673b 100644 --- a/app/forms/idv/api_document_verification_status_form.rb +++ b/app/forms/idv/api_document_verification_status_form.rb @@ -18,6 +18,7 @@ def submit errors: errors, extra: { remaining_attempts: remaining_attempts, + doc_auth_result: @async_state&.result&.[](:doc_auth_result), }, ) end diff --git a/app/forms/idv/inherited_proofing/va/form.rb b/app/forms/idv/inherited_proofing/va/form.rb new file mode 100644 index 00000000000..712d0b8c978 --- /dev/null +++ b/app/forms/idv/inherited_proofing/va/form.rb @@ -0,0 +1,165 @@ +module Idv + module InheritedProofing + module Va + class Form + include ActiveModel::Model + + class << self + def model_name + ActiveModel::Name.new(self, nil, namespaced_model_name) + end + + def namespaced_model_name + self.to_s.gsub('::', '') + end + + # Returns the field names based on the validators we've set up. + def field_names + @field_names ||= fields.keys + end + + def fields + @fields ||= { + first_name: { required: true }, + last_name: { required: true }, + phone: { required: false }, + birth_date: { required: true }, + ssn: { required: true }, + address_street: { required: true }, + address_street2: { required: false }, + address_city: { required: false }, + address_state: { required: false }, + address_country: { required: false }, + address_zip: { required: true }, + } + end + + def required_fields + @required_fields ||= fields.filter_map do |field_name, options| + field_name if options[:required] + end + end + + def optional_fields + @optional_fields ||= fields.filter_map do |field_name, options| + field_name unless options[:required] + end + end + end + + private_class_method :namespaced_model_name, :required_fields, :optional_fields + + attr_reader :payload_hash + + validate :validate_field_names + + required_fields.each { |required_field| validates(required_field, presence: true) } + + # This must be performed after our validators are defined. + attr_accessor(*self.field_names) + + def initialize(payload_hash:) + raise ArgumentError, 'payload_hash is blank?' if payload_hash.blank? + raise ArgumentError, 'payload_hash is not a Hash' unless payload_hash.is_a? Hash + + @payload_hash = payload_hash.dup + + populate_field_data + end + + def submit + validate + + FormResponse.new( + success: valid?, + errors: errors, + extra: { + }, + ) + end + + private + + attr_writer :payload_hash + + # Populates our field data from the payload hash. + def populate_field_data + payload_field_info.each do |field_name, field_info| + # Ignore fields we're not interested in. + next unless respond_to? field_name + + value = payload_hash.dig( + *[field_info[:namespace], + field_info[:field_name]].flatten.compact, + ) + public_send("#{field_name}=", value) + end + end + + # Validator for field names. All fields (not the presence of data) are required. + def validate_field_names + self.class.field_names.each do |field_name| + next if payload_field_info.key? field_name + errors.add(field_name, 'field is missing', type: :missing_required_field) + end + end + + def payload_field_info + @payload_field_info ||= field_name_info_from payload_hash: payload_hash + end + + # This method simply navigates the payload hash received and creates qualified + # hash key names that can be used to verify/map to our field names in this model. + # This can be used to qualify nested hash fields and saves us some headaches + # if there are nested field names with the same name: + # + # given: + # + # payload_hash = { + # first_name: 'first_name', + # ... + # address: { + # street: '', + # ... + # } + # } + # + # field_name_info_from(payload_hash: payload_hash) #=> + # + # { + # :first_name=>{:field_name=>:first_name, :namespace=>[]}, + # ... + # :address_street=>{:field_name=>:street, :namespace=>[:address]}, + # ... + # } + # + # The generated, qualified field names expected to map to our model, because we named + # them as such. + # + # :field_name is the actual, unqualified field name found in the payload hash sent. + # :namespace is the hash key by which :field_name can be found in the payload hash + # if need be. + def field_name_info_from(payload_hash:, namespace: [], field_name_info: {}) + payload_hash.each do |key, value| + if value.is_a? Hash + field_name_info_from payload_hash: value, namespace: namespace << key, + field_name_info: field_name_info + namespace.pop + next + end + + namespace = namespace.dup + if namespace.blank? + field_name_info[key] = { field_name: key, namespace: namespace } + else + field_name_info["#{namespace.split.join('_')}_#{key}".to_sym] = + { field_name: key, namespace: namespace } + end + end + + field_name_info + end + end + end + end +end diff --git a/app/forms/totp_setup_form.rb b/app/forms/totp_setup_form.rb index fc024a9062e..5974b9fea06 100644 --- a/app/forms/totp_setup_form.rb +++ b/app/forms/totp_setup_form.rb @@ -42,14 +42,23 @@ def process_valid_submission user.save! end + def mfa_context + user + end + def extra_analytics_attributes { totp_secret_present: secret.present?, multi_factor_auth_method: 'totp', auth_app_configuration_id: @auth_app_config&.id, + enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, } end + def mfa_user + MfaContext.new(user) + end + def create_auth_app(user, secret, new_timestamp, name) @auth_app_config = Db::AuthAppConfiguration.create(user, secret, new_timestamp, name) end diff --git a/app/forms/two_factor_options_form.rb b/app/forms/two_factor_options_form.rb index c47da559bf5..4bc3a3bdbe5 100644 --- a/app/forms/two_factor_options_form.rb +++ b/app/forms/two_factor_options_form.rb @@ -1,18 +1,19 @@ class TwoFactorOptionsForm include ActiveModel::Model - attr_reader :selection - attr_reader :configuration_id + attr_accessor :selection, :user, :aal3_required, :piv_cac_required validates :selection, inclusion: { in: %w[phone sms voice auth_app piv_cac webauthn webauthn_platform backup_code] } - validates :selection, length: { minimum: 1 }, if: :has_no_configured_mfa? + validates :selection, length: { minimum: 1 }, if: :has_no_mfa_or_in_required_flow? validates :selection, length: { minimum: 2, message: 'phone' }, if: :phone_validations? - def initialize(user) + def initialize(user:, aal3_required:, piv_cac_required:) self.user = user + self.aal3_required = aal3_required + self.piv_cac_required = piv_cac_required end def submit(params) @@ -25,9 +26,6 @@ def submit(params) private - attr_accessor :user - attr_writer :selection - def mfa_user @mfa_user ||= MfaContext.new(user) end @@ -35,10 +33,15 @@ def mfa_user def extra_analytics_attributes { selection: selection, + selected_mfa_count: selection.count, enabled_mfa_methods_count: mfa_user.enabled_mfa_methods_count, } end + def in_aal3_or_piv_cac_required_flow? + aal3_required || piv_cac_required + end + def user_needs_updating? (%w[voice sms] & selection).present? && !selection.include?(user.otp_delivery_preference) @@ -58,6 +61,10 @@ def has_no_configured_mfa? mfa_user.enabled_mfa_methods_count == 0 end + def has_no_mfa_or_in_required_flow? + has_no_configured_mfa? || in_aal3_or_piv_cac_required_flow? + end + def kantara_2fa_phone_restricted? IdentityConfig.store.kantara_2fa_phone_restricted end diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 5c6b215a486..426b984c260 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -12,6 +12,8 @@ export { default as IconListItem } from './icon-list/icon-list-item'; export { default as IconListTitle } from './icon-list/icon-list-title'; export { default as Link } from './link'; export { default as PageFooter } from './page-footer'; +export { default as LocationCollection } from './location-collection'; +export { default as LocationCollectionItem } from './location-collection-item'; export { default as PageHeading } from './page-heading'; export { default as ProcessList } from './process-list/process-list'; export { default as ProcessListHeading } from './process-list/process-list-heading'; diff --git a/app/javascript/packages/components/location-collection-item.spec.tsx b/app/javascript/packages/components/location-collection-item.spec.tsx new file mode 100644 index 00000000000..8aa1431745d --- /dev/null +++ b/app/javascript/packages/components/location-collection-item.spec.tsx @@ -0,0 +1,62 @@ +import { render } from '@testing-library/react'; +import sinon from 'sinon'; +import LocationCollectionItem from './location-collection-item'; + +describe('LocationCollectionItem', () => { + it('renders the component with expected class and children', () => { + const onClick = sinon.stub(); + const { container } = render( + , + ); + + const wrapper = container.firstElementChild!; + expect(wrapper.classList.contains('location-collection-item')).to.be.true(); + const locationCollectionItem = wrapper.firstElementChild!; + expect(locationCollectionItem.classList.contains('usa-collection__body')).to.be.true(); + const display = locationCollectionItem.firstElementChild!; + expect(display.classList.contains('display-flex')).to.be.true(); + expect(display.classList.contains('flex-justify')).to.be.true(); + const heading = display.firstElementChild!; + expect(heading.classList.contains('usa-collection__heading')).to.be.true(); + }); + + it('renders the component with expected data', () => { + const onClick = sinon.stub(); + const { getByText } = render( + , + ); + + const addressParent = getByText('123 Test Address').parentElement!; + expect(addressParent.textContent).to.contain('test name'); + expect(addressParent.textContent).to.contain('123 Test Address'); + expect(addressParent.textContent).to.contain('City, State 12345-1234'); + const wkDayHours = getByText( + 'in_person_proofing.body.location.retail_hours_weekday 9 AM - 5 PM', + ).parentElement!; + expect(wkDayHours.textContent).to.contain('9 AM - 5 PM'); + const satHours = getByText('in_person_proofing.body.location.retail_hours_sat 9 AM - 6 PM') + .parentElement!; + expect(satHours.textContent).to.contain('9 AM - 6 PM'); + const sunHours = getByText('in_person_proofing.body.location.retail_hours_sun Closed') + .parentElement!; + expect(sunHours.textContent).to.contain('Closed'); + }); +}); diff --git a/app/javascript/packages/components/location-collection-item.tsx b/app/javascript/packages/components/location-collection-item.tsx new file mode 100644 index 00000000000..0d1491b2877 --- /dev/null +++ b/app/javascript/packages/components/location-collection-item.tsx @@ -0,0 +1,62 @@ +import { Button } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; + +interface LocationCollectionItemProps { + formattedCityStateZip: string; + handleSelect: (event: React.FormEvent, selection: number) => void; + name: string; + saturdayHours: string; + selectId: number; + streetAddress: string; + sundayHours: string; + weekdayHours: string; +} + +function LocationCollectionItem({ + formattedCityStateZip, + handleSelect, + name, + saturdayHours, + selectId, + streetAddress, + sundayHours, + weekdayHours, +}: LocationCollectionItemProps) { + const { t } = useI18n(); + + return ( +
  • +
    +
    +

    {name}

    + +
    +
    {streetAddress}
    +
    {formattedCityStateZip}
    +

    {t('in_person_proofing.body.location.retail_hours_heading')}

    +
    {`${t('in_person_proofing.body.location.retail_hours_weekday')} ${weekdayHours}`}
    +
    {`${t('in_person_proofing.body.location.retail_hours_sat')} ${saturdayHours}`}
    +
    {`${t('in_person_proofing.body.location.retail_hours_sun')} ${sundayHours}`}
    + +
    +
  • + ); +} + +export default LocationCollectionItem; diff --git a/app/javascript/packages/components/location-collection.spec.tsx b/app/javascript/packages/components/location-collection.spec.tsx new file mode 100644 index 00000000000..9f3a86d17a2 --- /dev/null +++ b/app/javascript/packages/components/location-collection.spec.tsx @@ -0,0 +1,31 @@ +import { render } from '@testing-library/react'; +import LocationCollection from './location-collection'; + +describe('LocationCollection', () => { + it('renders the component with expected class and children', () => { + const { getByText } = render( + +
    LCI
    +
    , + ); + + const child = getByText('LCI'); + const item = child.parentElement!; + + expect(item.classList.contains('usa-collection')).to.be.true(); + expect(item.textContent).to.equal('LCI'); + }); + + it('renders the component with custom class', () => { + const { getByText } = render( + +
    LCI
    +
    , + ); + + const child = getByText('LCI'); + const item = child.parentElement!; + + expect(item.classList.contains('custom-class')).to.be.true(); + }); +}); diff --git a/app/javascript/packages/components/location-collection.tsx b/app/javascript/packages/components/location-collection.tsx new file mode 100644 index 00000000000..d5747c2963f --- /dev/null +++ b/app/javascript/packages/components/location-collection.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +interface LocationCollectionProps { + className?: string; + + children?: ReactNode; +} + +function LocationCollection({ children, className }: LocationCollectionProps) { + const classes = ['usa-collection', className].filter(Boolean).join(' '); + return
      {children}
    ; +} + +export default LocationCollection; diff --git a/app/javascript/packages/document-capture-polling/index.js b/app/javascript/packages/document-capture-polling/index.ts similarity index 69% rename from app/javascript/packages/document-capture-polling/index.js rename to app/javascript/packages/document-capture-polling/index.ts index c4f93e1f1f6..9c12b6a8c94 100644 --- a/app/javascript/packages/document-capture-polling/index.js +++ b/app/javascript/packages/document-capture-polling/index.ts @@ -6,49 +6,49 @@ export const MAX_DOC_CAPTURE_POLL_ATTEMPTS = Math.floor( DOC_CAPTURE_TIMEOUT / DOC_CAPTURE_POLL_INTERVAL, ); -/** - * @typedef DocumentCapturePollingElements - * - * @prop {HTMLFormElement} form - * @prop {HTMLAnchorElement} backLink - */ +interface DocumentCapturePollingElements { + form: HTMLFormElement; -/** - * @typedef DocumentCapturePollingOptions - * - * @prop {string} statusEndpoint - * @prop {DocumentCapturePollingElements} elements - * @prop {typeof defaultTrackEvent=} trackEvent - */ + backLink: HTMLAnchorElement; +} -/** - * @enum {number} - */ -const StatusCodes = { - SUCCESS: 200, - GONE: 410, - TOO_MANY_REQUESTS: 429, -}; +interface DocumentCapturePollingOptions { + statusEndpoint: string; -/** - * @enum {string} - */ -const ResultType = { - SUCCESS: 'SUCCESS', - CANCELLED: 'CANCELLED', - THROTTLED: 'THROTTLED', -}; + elements: DocumentCapturePollingElements; + + trackEvent?: typeof defaultTrackEvent; +} + +enum StatusCodes { + SUCCESS = 200, + GONE = 410, + TOO_MANY_REQUESTS = 429, +} + +enum ResultType { + SUCCESS = 'SUCCESS', + CANCELLED = 'CANCELLED', + THROTTLED = 'THROTTLED', +} /** * Manages polling requests for document capture hybrid flow. */ export class DocumentCapturePolling { + elements: DocumentCapturePollingElements; + + statusEndpoint: string; + + trackEvent: typeof defaultTrackEvent; + pollAttempts = 0; - /** - * @param {DocumentCapturePollingOptions} options - */ - constructor({ elements, statusEndpoint, trackEvent = defaultTrackEvent }) { + constructor({ + elements, + statusEndpoint, + trackEvent = defaultTrackEvent, + }: DocumentCapturePollingOptions) { this.elements = elements; this.statusEndpoint = statusEndpoint; this.trackEvent = trackEvent; @@ -62,10 +62,7 @@ export class DocumentCapturePolling { this.elements.backLink.addEventListener('click', () => this.bindPromptOnNavigate(false)); } - /** - * @param {boolean} isVisible - */ - toggleFormVisible(isVisible) { + toggleFormVisible(isVisible: boolean) { this.elements.form.classList.toggle('display-none', !isVisible); } @@ -85,10 +82,7 @@ export class DocumentCapturePolling { this.toggleFormVisible(true); } - /** - * @param {{ result?: ResultType, redirect?: string }} params - */ - async onComplete({ result = ResultType.SUCCESS, redirect } = {}) { + async onComplete({ result, redirect }: { result: ResultType; redirect?: string }) { await this.trackEvent('IdV: Link sent capture doc polling complete', { isCancelled: result === ResultType.CANCELLED, isThrottled: result === ResultType.THROTTLED, @@ -112,10 +106,11 @@ export class DocumentCapturePolling { async poll() { const response = await window.fetch(this.statusEndpoint); + const { redirect } = (await response.json()) as { redirect?: string }; switch (response.status) { case StatusCodes.SUCCESS: - this.onComplete(); + this.onComplete({ result: ResultType.SUCCESS, redirect }); break; case StatusCodes.GONE: @@ -123,7 +118,6 @@ export class DocumentCapturePolling { break; case StatusCodes.TOO_MANY_REQUESTS: { - const { redirect } = await response.json(); this.onComplete({ result: ResultType.THROTTLED, redirect }); break; } diff --git a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx index 3c3d7d6198b..885321ae284 100644 --- a/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx +++ b/app/javascript/packages/document-capture/components/document-capture-troubleshooting-options.tsx @@ -22,6 +22,11 @@ interface DocumentCaptureTroubleshootingOptionsProps { */ showDocumentTips?: boolean; + /** + * Whether to include option to verify in person. + */ + showInPersonOption?: boolean; + /** * If there are any errors (toggles whether or not to show in person proofing option) */ @@ -32,6 +37,7 @@ function DocumentCaptureTroubleshootingOptions({ heading, location = 'document_capture_troubleshooting_options', showDocumentTips = true, + showInPersonOption = true, hasErrors, }: DocumentCaptureTroubleshootingOptionsProps) { const { t } = useI18n(); @@ -71,7 +77,7 @@ function DocumentCaptureTroubleshootingOptions({ ].filter(Boolean) as TroubleshootingOption[] } /> - {hasErrors && inPersonURL && ( + {hasErrors && inPersonURL && showInPersonOption && ( { + const response = await fetch(locationUrl).then((res) => + res.json().catch((error) => { + throw error; + }), + ); + return response; +}; + +const formatLocation = (postOffices: PostOffice[]) => { + const formattedLocations = [] as FormattedLocation[]; + postOffices.forEach((po: PostOffice, index) => { + const location = { + formattedCityStateZip: `${po.city}, ${po.state}, ${po.zip_code_5}-${po.zip_code_4}`, + id: index, + name: po.name, + phone: po.phone, + saturdayHours: po.saturday_hours, + streetAddress: po.address, + sundayHours: po.sunday_hours, + weekdayHours: po.weekday_hours, + } as FormattedLocation; + formattedLocations.push(location); + }); + return formattedLocations; +}; + +const snakeCase = (value: string) => + value + .split(/(?=[A-Z])/) + .join('_') + .toLowerCase(); + +// snake case the keys of the location +const prepToSend = (location: object) => { + const sendObject = {}; + Object.keys(location).forEach((key) => { + sendObject[snakeCase(key)] = location[key]; + }); + return sendObject; +}; + +function InPersonLocationStep({ onChange }) { const { t } = useI18n(); + const [locationData, setLocationData] = useState([] as FormattedLocation[]); + const [inProgress, setInProgress] = useState(false); + const [autoSubmit, setAutoSubmit] = useState(false); + const [isLoadingComplete, setIsLoadingComplete] = useState(false); + + // ref allows us to avoid a memory leak + const mountedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // useCallBack here prevents unnecessary rerenders due to changing function identity + const handleLocationSelect = useCallback( + async (e: any, id: number) => { + onChange({ selectedLocationName: locationData[id].name }); + if (autoSubmit) { + return; + } + // prevent navigation from continuing + e.preventDefault(); + if (inProgress) { + return; + } + const selected = prepToSend(locationData[id]); + const headers = { 'Content-Type': 'application/json' }; + const meta: HTMLMetaElement | null = document.querySelector('meta[name="csrf-token"]'); + const csrf = meta?.content; + if (csrf) { + headers['X-CSRF-Token'] = csrf; + } + setInProgress(true); + await fetch(locationUrl, { + method: 'PUT', + body: JSON.stringify(selected), + headers, + }) + .then(() => { + if (!mountedRef.current) { + return; + } + setAutoSubmit(true); + setImmediate(() => { + // continue with navigation + e.target.click(); + // allow process to be re-triggered in case submission did not work as expected + setAutoSubmit(false); + }); + }) + .finally(() => { + if (!mountedRef.current) { + return; + } + setInProgress(false); + }); + }, + [locationData, inProgress], + ); + + useEffect(() => { + let mounted = true; + (async () => { + try { + const fetchedLocations = await getResponse(); + if (mounted) { + const formattedLocations = formatLocation(fetchedLocations); + setLocationData(formattedLocations); + } + } finally { + if (mounted) { + setIsLoadingComplete(true); + } + } + })(); + return () => { + mounted = false; + }; + }, []); + + let locationItems: React.ReactNode; + if (!isLoadingComplete) { + locationItems = ; + } else if (locationData.length < 1) { + locationItems =

    {t('in_person_proofing.body.location.none_found')}

    ; + } else { + locationItems = locationData.map((item, index) => ( + + )); + } return ( <> {t('in_person_proofing.headings.location')} - + +

    {t('in_person_proofing.body.location.location_step_about')}

    + {locationItems} ); } diff --git a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx index 514e3c6383f..bc8c8a6593f 100644 --- a/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-prepare-step.tsx @@ -11,17 +11,26 @@ import { removeUnloadProtection } from '@18f/identity-url'; import { useContext } from 'react'; import { FlowContext } from '@18f/identity-verify-flow'; import { useI18n } from '@18f/identity-react-i18n'; -import InPersonTroubleshootingOptions from './in-person-troubleshooting-options'; +import { FormStepsButton } from '@18f/identity-form-steps'; +import UploadContext from '../context/upload'; +// WILLFIX: Hiding this component until help links are finalized; see LG-6875 +// import InPersonTroubleshootingOptions from './in-person-troubleshooting-options'; -function InPersonPrepareStep() { +function InPersonPrepareStep({ value }) { const { t } = useI18n(); const { inPersonURL } = useContext(FlowContext); + const { flowPath } = useContext(UploadContext); + const { selectedLocationName } = value; return ( <> - - {t('in_person_proofing.body.prepare.alert_selected_post_office', { name: 'EASTCHESTER' })} - + {selectedLocationName && ( + + {t('in_person_proofing.body.prepare.alert_selected_post_office', { + name: selectedLocationName, + })} + + )} {t('in_person_proofing.headings.prepare')}

    {t('in_person_proofing.body.prepare.verify_step_about')}

    @@ -74,14 +83,18 @@ function InPersonPrepareStep() { - {inPersonURL && ( + {flowPath === 'hybrid' && } + {inPersonURL && flowPath === 'standard' && (
    )} + {/* + WILLFIX: Hiding this component until help links are finalized; see LG-6875 + */} ); } diff --git a/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx b/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx new file mode 100644 index 00000000000..bcf11f094c0 --- /dev/null +++ b/app/javascript/packages/document-capture/components/in-person-switch-back-step.tsx @@ -0,0 +1,26 @@ +import { useLayoutEffect } from 'react'; +import { PageHeading } from '@18f/identity-components'; +import { getAssetPath } from '@18f/identity-assets'; +import { t } from '@18f/identity-i18n'; +import type { FormStepComponentProps } from '@18f/identity-form-steps'; + +function InPersonSwitchBackStep({ onChange }: FormStepComponentProps) { + // Resetting the value prevents the user from being prompted about unsaved changes when closing + // the tab. `useLayoutEffect` is used to avoid race conditions where the callback could occur at + // the same time as the change handler's `ifStillMounted` wrapping `useEffect`, which would treat + // it as unmounted and not update the value. + useLayoutEffect(() => onChange({}, { patch: false }), []); + + return ( + <> + {t('in_person_proofing.headings.switch_back')} + {t('doc_auth.instructions.switch_back_image')} + + ); +} + +export default InPersonSwitchBackStep; diff --git a/app/javascript/packages/document-capture/components/review-issues-step.tsx b/app/javascript/packages/document-capture/components/review-issues-step.tsx index ad71f384118..a981efc0d83 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.tsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.tsx @@ -50,6 +50,8 @@ interface ReviewIssuesStepValue { interface ReviewIssuesStepProps extends FormStepComponentProps { remainingAttempts: number; + isFailedResult: boolean; + captureHints: boolean; pii?: PII; @@ -70,6 +72,7 @@ function ReviewIssuesStep({ onError = () => {}, registerField = () => undefined, remainingAttempts = Infinity, + isFailedResult = false, pii, captureHints = false, }: ReviewIssuesStepProps) { @@ -110,6 +113,7 @@ function ReviewIssuesStep({ } > diff --git a/app/javascript/packages/document-capture/context/upload.tsx b/app/javascript/packages/document-capture/context/upload.tsx index b02fac344a1..b24b844284b 100644 --- a/app/javascript/packages/document-capture/context/upload.tsx +++ b/app/javascript/packages/document-capture/context/upload.tsx @@ -94,6 +94,11 @@ export interface UploadErrorResponse { * Personally-identifiable information from OCR analysis. */ ocr_pii?: PII; + + /** + * Whether the unsuccessful result was the failure type. + */ + result_failed: boolean; } export type UploadImplementation = ( diff --git a/app/javascript/packages/document-capture/services/upload.ts b/app/javascript/packages/document-capture/services/upload.ts index e49d4a6519f..84b98fd1935 100644 --- a/app/javascript/packages/document-capture/services/upload.ts +++ b/app/javascript/packages/document-capture/services/upload.ts @@ -36,6 +36,8 @@ export class UploadFormEntriesError extends FormError { remainingAttempts = Infinity; + isFailedResult = false; + pii?: PII; hints = false; @@ -111,6 +113,8 @@ const upload: UploadImplementation = async function (payload, { method = 'POST', error.hints = result.hints; } + error.isFailedResult = !!result.result_failed; + throw error; } diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index e6f8ef3983a..a562f057d76 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useCallback } from 'react'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/dom'; @@ -250,6 +250,43 @@ describe('FormSteps', () => { expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'one' }); }); + it('provides set onChange option for non-patch value change', async () => { + const steps = [ + { + name: 'first', + title: 'First Title', + form: ({ onChange, value }) => ( + <> + + {JSON.stringify(value)} + + ), + }, + ]; + + const { getByRole, getByTestId } = render(); + + const button = getByRole('button', { name: 'Change Value' }); + await userEvent.click(button); + await userEvent.click(button); + + const value = getByTestId('value'); + expect(value.textContent).to.equal('{"b":2}'); + }); + it('submits with form values', async () => { const onComplete = sinon.spy(); const { getByText, getByLabelText } = render( diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index 73057d23177..72486e91b8c 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -39,9 +39,9 @@ type FormValues = Record; export interface FormStepComponentProps { /** - * Update values, merging with existing values. + * Update values, merging with existing values if configured as a patch. */ - onChange: (nextValues: Partial) => void; + onChange: (nextValues: Partial, options?: { patch: boolean }) => void; /** * Trigger a field error. @@ -426,11 +426,15 @@ function FormSteps({ value={values} errors={activeErrors} unknownFieldErrors={unknownFieldErrors} - onChange={ifStillMounted((nextValuesPatch) => { + onChange={ifStillMounted((nextValues, { patch } = { patch: true }) => { setActiveErrors((prevActiveErrors) => - prevActiveErrors.filter(({ field }) => !field || !(field in nextValuesPatch)), + prevActiveErrors.filter(({ field }) => !field || !(field in nextValues)), ); - setPatchValues(nextValuesPatch); + if (patch) { + setPatchValues(nextValues); + } else { + setValues(nextValues); + } })} onError={ifStillMounted((error, { field } = {}) => { if (field) { diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb index 85450fb8bfc..8b3b78bd7dd 100644 --- a/app/jobs/get_usps_proofing_results_job.rb +++ b/app/jobs/get_usps_proofing_results_job.rb @@ -70,6 +70,7 @@ def handle_bad_request_error(err, enrollment) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( reason: 'Request exception', enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, exception_class: err.class.to_s, exception_message: err.message, ) @@ -80,6 +81,7 @@ def handle_standard_error(err, enrollment) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( reason: 'Request exception', enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, exception_class: err.class.to_s, exception_message: err.message, ) @@ -89,6 +91,7 @@ def handle_response_is_not_a_hash(enrollment) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_exception( reason: 'Bad response structure', enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, ) end @@ -96,6 +99,7 @@ def handle_unsupported_status(enrollment, status) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_failure( reason: 'Unsupported status', enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, status: status, ) end @@ -104,6 +108,7 @@ def handle_unsupported_id_type(enrollment, primary_id_type) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_failure( reason: 'Unsupported ID type', enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, primary_id_type: primary_id_type, ) end @@ -112,11 +117,10 @@ def handle_failed_status(enrollment, response) analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_failure( reason: 'Failed status', enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, failure_reason: response['failureReason'], fraud_suspected: response['fraudSuspected'], primary_id_type: response['primaryIdType'], - proofing_city: response['proofingCity'], - proofing_post_office: response['proofingPostOffice'], proofing_state: response['proofingState'], secondary_id_type: response['secondaryIdType'], transaction_end_date_time: response['transactionEndDateTime'], @@ -124,12 +128,22 @@ def handle_failed_status(enrollment, response) ) end + def handle_successful_status_update(enrollment) + analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_enrollment_success( + reason: 'Successful status update', + enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, + ) + end + def update_enrollment_status(enrollment, response) case response['status'] when IPP_STATUS_PASSED if SUPPORTED_ID_TYPES.include?(response['primaryIdType']) enrollment.profile.activate enrollment.update(status: :passed) + handle_successful_status_update(enrollment) + send_verified_email(enrollment.user, enrollment) else # Unsupported ID type enrollment.update(status: :failed) @@ -138,8 +152,29 @@ def update_enrollment_status(enrollment, response) when IPP_STATUS_FAILED enrollment.update(status: :failed) handle_failed_status(enrollment, response) + send_failed_email(enrollment.user, enrollment) else handle_unsupported_status(enrollment, response['status']) end end + + def send_verified_email(user, enrollment) + user.confirmed_email_addresses.each do |email_address| + UserMailer.in_person_verified( + user, + email_address, + enrollment: enrollment, + ).deliver_now_or_later + end + end + + def send_failed_email(user, enrollment) + user.confirmed_email_addresses.each do |email_address| + UserMailer.in_person_failed( + user, + email_address, + enrollment: enrollment, + ).deliver_now_or_later + end + end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 130bc7f05ad..ccb2bef93d6 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -224,6 +224,39 @@ def account_verified(user, email_address, date_time:, sp_name:, disavowal_token: end end + def in_person_ready_to_verify(user, email_address, first_name:, enrollment:) + with_user_locale(user) do + @header = t('in_person_proofing.headings.barcode') + @first_name = first_name + @presenter = Idv::InPerson::ReadyToVerifyPresenter.new(enrollment: enrollment) + mail( + to: email_address.email, + subject: t('user_mailer.in_person_ready_to_verify.subject', app_name: APP_NAME), + ) + end + end + + def in_person_verified(user, email_address, enrollment:) + with_user_locale(user) do + @hide_title = true + @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new(enrollment: enrollment) + mail( + to: email_address.email, + subject: t('user_mailer.in_person_verified.subject', app_name: APP_NAME), + ) + end + end + + def in_person_failed(user, email_address, enrollment:) + with_user_locale(user) do + @presenter = Idv::InPerson::VerificationResultsEmailPresenter.new(enrollment: enrollment) + mail( + to: email_address.email, + subject: t('user_mailer.in_person_failed.subject', app_name: APP_NAME), + ) + end + end + private def email_should_receive_nonessential_notifications?(email) diff --git a/app/models/backup_code_configuration.rb b/app/models/backup_code_configuration.rb index 05138a035e5..5db2cc84f40 100644 --- a/app/models/backup_code_configuration.rb +++ b/app/models/backup_code_configuration.rb @@ -14,7 +14,7 @@ def self.unused end def mfa_enabled? - user.backup_code_configurations.unused.any? if user + persisted? && used_at.nil? end def selection_presenters diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index df33c0c161f..f2f5fc192e6 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -9,7 +9,7 @@ class InPersonEnrollment < ApplicationRecord passed: 2, failed: 3, expired: 4, - canceled: 5, + cancelled: 5, } validate :profile_belongs_to_user @@ -51,7 +51,9 @@ def on_status_updated end def profile_belongs_to_user - unless profile&.user == user + return unless profile.present? + + unless profile.user == user errors.add :profile, I18n.t('idv.failure.exceptions.internal_error'), type: :in_person_enrollment_user_profile_mismatch end diff --git a/app/models/profile.rb b/app/models/profile.rb index 9ee54bc5cbe..8465f997a65 100644 --- a/app/models/profile.rb +++ b/app/models/profile.rb @@ -13,8 +13,9 @@ class Profile < ApplicationRecord enum deactivation_reason: { password_reset: 1, encryption_error: 2, - verification_pending: 3, + gpo_verification_pending: 3, verification_cancelled: 4, + in_person_verification_pending: 5, } attr_reader :personal_key diff --git a/app/models/user.rb b/app/models/user.rb index 56277bd34d5..d9ebc343577 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -45,7 +45,13 @@ class User < ApplicationRecord has_many :sign_in_restrictions, dependent: :destroy has_many :in_person_enrollments, dependent: :destroy - has_one :pending_in_person_enrollment, -> { where(status: :pending).order(created_at: :desc) }, + has_one :pending_in_person_enrollment, + -> { where(status: :pending).order(created_at: :desc) }, + class_name: 'InPersonEnrollment', foreign_key: :user_id, inverse_of: :user, + dependent: :destroy + + has_one :establishing_in_person_enrollment, + -> { where(status: :establishing).order(created_at: :desc) }, class_name: 'InPersonEnrollment', foreign_key: :user_id, inverse_of: :user, dependent: :destroy @@ -92,7 +98,7 @@ def pending_profile? end def pending_profile - profiles.verification_pending.order(created_at: :desc).first + profiles.gpo_verification_pending.order(created_at: :desc).first end def default_phone_configuration diff --git a/app/presenters/idv/in_person/ready_to_verify_presenter.rb b/app/presenters/idv/in_person/ready_to_verify_presenter.rb index e48fcdced0c..575ce7cbd0d 100644 --- a/app/presenters/idv/in_person/ready_to_verify_presenter.rb +++ b/app/presenters/idv/in_person/ready_to_verify_presenter.rb @@ -1,36 +1,23 @@ -require 'barby' -require 'barby/barcode/code_128' -require 'barby/outputter/png_outputter' - module Idv module InPerson class ReadyToVerifyPresenter # WILLFIX: With LG-6881, confirm timezone or use deadline from enrollment response. USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'] - delegate :selected_location_details, to: :enrollment + delegate :selected_location_details, :enrollment_code, to: :enrollment def initialize(enrollment:) @enrollment = enrollment end - def barcode_data_url - "data:image/png;base64,#{Base64.strict_encode64(barcode_image_data)}" - end - def formatted_due_date due_date.in_time_zone(USPS_SERVER_TIMEZONE).strftime(I18n.t('time.formats.event_date')) end - def formatted_enrollment_code - EnrollmentCodeFormatter.format(enrollment_code) - end - def selected_location_hours(prefix) - selected_location_details['hours'].each do |hours_candidate| - hours = hours_candidate["#{prefix}Hours"] - return localized_hours(hours) if hours - end + return unless selected_location_details + hours = selected_location_details["#{prefix}_hours"] + return localized_hours(hours) if hours end def needs_proof_of_address? @@ -40,7 +27,6 @@ def needs_proof_of_address? private attr_reader :enrollment - delegate :enrollment_code, to: :enrollment def barcode_image_data Barby::Code128C.new(enrollment_code).to_png(margin: 0, xdim: 2) diff --git a/app/presenters/idv/in_person/verification_results_email_presenter.rb b/app/presenters/idv/in_person/verification_results_email_presenter.rb new file mode 100644 index 00000000000..d8970ec9388 --- /dev/null +++ b/app/presenters/idv/in_person/verification_results_email_presenter.rb @@ -0,0 +1,22 @@ +module Idv + module InPerson + class VerificationResultsEmailPresenter + # update to user's time zone when out of pilot + USPS_SERVER_TIMEZONE = ActiveSupport::TimeZone['America/New_York'] + + def initialize(enrollment:) + @enrollment = enrollment + end + + def location_name + @enrollment.selected_location_details['name'] + end + + def formatted_verified_date + @enrollment.status_updated_at.in_time_zone(USPS_SERVER_TIMEZONE).strftime( + I18n.t('time.formats.event_date'), + ) + end + end + end +end diff --git a/app/presenters/image_upload_response_presenter.rb b/app/presenters/image_upload_response_presenter.rb index b79c5e8c821..9b319a45c08 100644 --- a/app/presenters/image_upload_response_presenter.rb +++ b/app/presenters/image_upload_response_presenter.rb @@ -38,6 +38,7 @@ def as_json(*) json[:redirect] = idv_session_errors_throttled_url if remaining_attempts&.zero? json[:hints] = true if show_hints? json[:ocr_pii] = ocr_pii + json[:result_failed] = doc_auth_result_failed? json end end @@ -48,6 +49,10 @@ def url_options private + def doc_auth_result_failed? + @form_response.to_h[:doc_auth_result] == DocAuth::Acuant::ResultCodes::FAILED.name + end + def show_hints? @form_response.errors[:hints].present? || attention_with_barcode? end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 36d87e45af6..45af378d4ee 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -997,6 +997,25 @@ def invalid_authenticity_token( ) end + # @param [String] controller + # @param [String] referer + # @param [Boolean] user_signed_in + # Redirect was almost sent to an invalid external host unexpectedly + def unsafe_redirect_error( + controller:, + referer:, + user_signed_in: nil, + **extra + ) + track_event( + 'Unsafe Redirect', + controller: controller, + referer: referer, + user_signed_in: user_signed_in, + **extra, + ) + end + # @param [Integer] acknowledged_event_count number of acknowledged events in the API call # @param [Integer] rendered_event_count how many events were rendered in the API response # @param [String] set_errors JSON encoded representation of SET errors from the client @@ -1325,10 +1344,16 @@ def multi_factor_auth_max_sends # Tracks when a user sets up a multi factor auth method # @param [String] multi_factor_auth_method - def multi_factor_auth_setup(multi_factor_auth_method:, **extra) + # @param [Boolean] in_multi_mfa_selection_flow + # @param [integer] enabled_mfa_methods_count + def multi_factor_auth_setup(multi_factor_auth_method:, + enabled_mfa_methods_count:, in_multi_mfa_selection_flow:, + **extra) track_event( 'Multi-Factor Authentication Setup', multi_factor_auth_method: multi_factor_auth_method, + in_multi_mfa_selection_flow: in_multi_mfa_selection_flow, + enabled_mfa_methods_count: enabled_mfa_methods_count, **extra, ) end @@ -2094,11 +2119,13 @@ def user_registration_2fa_additional_setup_visit # @param [Boolean] success # @param [Hash] errors # @param [Integer] enabled_mfa_methods_count + # @param [Integer] selected_mfa_count # @param ['voice', 'auth_app'] selection # Tracks when the the user has selected and submitted MFA auth methods on user registration def user_registration_2fa_setup( success:, errors: nil, + selected_mfa_count: nil, enabled_mfa_methods_count: nil, selection: nil, **extra @@ -2108,6 +2135,7 @@ def user_registration_2fa_setup( { success: success, errors: errors, + selected_mfa_count: selected_mfa_count, enabled_mfa_methods_count: enabled_mfa_methods_count, selection: selection, **extra, @@ -2115,6 +2143,21 @@ def user_registration_2fa_setup( ) end + # @param [String] mfa_method + # Tracks when the the user fully registered by submitting their first MFA method into the system + def user_registration_user_fully_registered( + mfa_method:, + **extra + ) + track_event( + 'User Registration: User Fully Registered', + { + mfa_method: mfa_method, + **extra, + }.compact, + ) + end + # @param [Boolean] success # @param [Hash] mfa_method_counts # @param [Integer] enabled_mfa_methods_count @@ -2350,5 +2393,17 @@ def idv_in_person_usps_proofing_results_job_enrollment_failure(reason:, enrollme **extra, ) end + + # Tracks individual enrollments that succeed during GetUspsProofingResultsJob + # @param [String] reason why did this enrollment pass? + # @param [String] enrollment_id + def idv_in_person_usps_proofing_results_job_enrollment_success(reason:, enrollment_id:, **extra) + track_event( + 'GetUspsProofingResultsJob: Enrollment passed proofing', + reason: reason, + enrollment_id: enrollment_id, + **extra, + ) + end end # rubocop:enable Metrics/ModuleLength diff --git a/app/services/doc_auth/acuant/responses/get_results_response.rb b/app/services/doc_auth/acuant/responses/get_results_response.rb index 9f77da3f06d..b301bd93feb 100644 --- a/app/services/doc_auth/acuant/responses/get_results_response.rb +++ b/app/services/doc_auth/acuant/responses/get_results_response.rb @@ -53,13 +53,14 @@ def response_info def create_response_info alerts = processed_alerts + log_alert_formatter = DocAuth::ProcessedAlertToLogAlertFormatter.new { vendor: 'Acuant', billed: result_code.billed, doc_auth_result: result_code.name, processed_alerts: alerts, alert_failure_count: alerts[:failed]&.count.to_i, - log_alert_results: log_alerts(alerts), + log_alert_results: log_alert_formatter.log_alerts(alerts), image_metrics: processed_image_metrics, tamper_result: tamper_result_code&.name, } @@ -95,19 +96,6 @@ def processed_alerts @processed_alerts ||= process_raw_alerts(raw_alerts) end - def log_alerts(alerts) - log_alert_results = {} - alerts.keys.each do |key| - alerts[key.to_sym].each do |alert| - side = alert[:side] || 'no_side' - log_alert_results[alert[:name]. - downcase. - parameterize(separator: '_').to_sym] = { "#{side}": alert[:result] } - end - end - log_alert_results - end - def processed_image_metrics @processed_image_metrics ||= raw_images_data.index_by do |image| image.delete('Uri') diff --git a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb index 7f544dcd5c7..20bff9ab194 100644 --- a/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb +++ b/app/services/doc_auth/lexis_nexis/responses/true_id_response.rb @@ -122,21 +122,9 @@ def response_info @response_info ||= create_response_info end - def log_alerts(alerts) - log_alert_results = {} - alerts.keys.each do |key| - alerts[key.to_sym].each do |alert| - side = alert[:side] || 'no_side' - log_alert_results[alert[:name]. - downcase. - parameterize(separator: '_').to_sym] = { "#{side}": alert[:result] } - end - end - log_alert_results - end - def create_response_info alerts = parsed_alerts + log_alert_formatter = DocAuth::ProcessedAlertToLogAlertFormatter.new { liveness_enabled: @liveness_checking_enabled, @@ -146,7 +134,7 @@ def create_response_info doc_auth_result: doc_auth_result, processed_alerts: alerts, alert_failure_count: alerts[:failed]&.count.to_i, - log_alert_results: log_alerts(alerts), + log_alert_results: log_alert_formatter.log_alerts(alerts), portrait_match_results: true_id_product[:PORTRAIT_MATCH_RESULT], image_metrics: parse_image_metrics, } @@ -260,9 +248,13 @@ def transform_metrics(img_metrics) end def parse_date(year:, month:, day:) - if year.to_i.positive? && month.to_i.positive? && day.to_i.positive? - Date.new(year.to_i, month.to_i, day.to_i).to_s - end + Date.new(year.to_i, month.to_i, day.to_i).to_s if year.to_i.positive? + rescue ArgumentError + message = { + event: 'Failure to parse TrueID date', + }.to_json + Rails.logger.info(message) + nil end end end diff --git a/app/services/doc_auth/mock/result_response.rb b/app/services/doc_auth/mock/result_response.rb index 23b3cdf102f..e7b3d6ada50 100644 --- a/app/services/doc_auth/mock/result_response.rb +++ b/app/services/doc_auth/mock/result_response.rb @@ -12,7 +12,7 @@ def initialize(uploaded_file, config, liveness_enabled) errors: errors, pii_from_doc: pii_from_doc, extra: { - doc_auth_result: success? ? 'Passed' : 'Caution', + doc_auth_result: doc_auth_result, billed: true, }, ) @@ -78,6 +78,22 @@ def parsed_data_from_uploaded_file @parsed_data_from_uploaded_file = parse_uri || parse_yaml end + def doc_auth_result + doc_auth_result_from_uploaded_file || doc_auth_result_from_success + end + + def doc_auth_result_from_uploaded_file + parsed_data_from_uploaded_file&.[]('doc_auth_result') + end + + def doc_auth_result_from_success + if success? + DocAuth::Acuant::ResultCodes::PASSED.name + else + DocAuth::Acuant::ResultCodes::CAUTION.name + end + end + def parse_uri uri = URI.parse(uploaded_file.chomp) if uri.scheme == 'data' diff --git a/app/services/doc_auth/processed_alert_to_log_alert_formatter.rb b/app/services/doc_auth/processed_alert_to_log_alert_formatter.rb new file mode 100644 index 00000000000..a21c09429a4 --- /dev/null +++ b/app/services/doc_auth/processed_alert_to_log_alert_formatter.rb @@ -0,0 +1,34 @@ +module DocAuth + class ProcessedAlertToLogAlertFormatter + def get_alert_result(log_alert_results, side, alert_name_key, result) + if log_alert_results.dig(alert_name_key, side.to_sym).present? + alert_value = log_alert_results[alert_name_key][side.to_sym] + Rails.logger. + info("ALERT ALREADY HAS A VALUE: #{alert_value}, #{result}") + end + result + end + + def log_alerts(alerts) + log_alert_results = {} + + alerts.keys.each do |key| + alerts[key.to_sym].each do |alert| + alert_name_key = alert[:name]. + downcase. + parameterize(separator: '_').to_sym + side = alert[:side] || 'no_side' + + log_alert_results[alert_name_key] = + { "#{side}": get_alert_result( + log_alert_results, + side, + alert_name_key, + alert[:result], + ) } + end + end + log_alert_results + end + end +end diff --git a/app/services/funnel/registration/add_mfa.rb b/app/services/funnel/registration/add_mfa.rb index 5dbfdea8b8d..8246c52e636 100644 --- a/app/services/funnel/registration/add_mfa.rb +++ b/app/services/funnel/registration/add_mfa.rb @@ -1,22 +1,23 @@ module Funnel module Registration class AddMfa - def self.call(user_id, mfa_method) + def self.call(user_id, mfa_method, analytics) now = Time.zone.now funnel = RegistrationLog.find_by(user_id: user_id) return if funnel.blank? || funnel.second_mfa.present? - params = if funnel.first_mfa.present? - { - second_mfa: mfa_method, - } - else - { - first_mfa: mfa_method, - first_mfa_at: now, - registered_at: now, - } - end + if funnel.first_mfa.present? + params = { + second_mfa: mfa_method, + } + else + params = { + first_mfa: mfa_method, + first_mfa_at: now, + registered_at: now, + } + analytics.user_registration_user_fully_registered(mfa_method: mfa_method) + end funnel.update!(params) end diff --git a/app/services/idv/actions/ipp/cancel_update_ssn_action.rb b/app/services/idv/actions/ipp/cancel_update_ssn_action.rb new file mode 100644 index 00000000000..6e37e6c9f04 --- /dev/null +++ b/app/services/idv/actions/ipp/cancel_update_ssn_action.rb @@ -0,0 +1,11 @@ +module Idv + module Actions + module Ipp + class CancelUpdateSsnAction < Idv::Steps::DocAuthBaseStep + def call + mark_step_complete(:ssn) if flow_session.dig(:pii_from_user, :ssn) + end + end + end + end +end diff --git a/app/services/idv/cancel_verification_attempt.rb b/app/services/idv/cancel_verification_attempt.rb index e9d760bb2a5..8df5c4210fe 100644 --- a/app/services/idv/cancel_verification_attempt.rb +++ b/app/services/idv/cancel_verification_attempt.rb @@ -7,7 +7,7 @@ def initialize(user:) end def call - user.profiles.verification_pending.each do |profile| + user.profiles.gpo_verification_pending.each do |profile| profile.update!( active: false, deactivation_reason: :verification_cancelled, diff --git a/app/services/idv/flows/in_person_flow.rb b/app/services/idv/flows/in_person_flow.rb index dff64ba9a19..07fc1d6e115 100644 --- a/app/services/idv/flows/in_person_flow.rb +++ b/app/services/idv/flows/in_person_flow.rb @@ -16,6 +16,7 @@ class InPersonFlow < Flow::BaseFlow }.freeze ACTIONS = { + cancel_update_ssn: Idv::Actions::Ipp::CancelUpdateSsnAction, redo_state_id: Idv::Actions::Ipp::RedoStateIdAction, redo_address: Idv::Actions::Ipp::RedoAddressAction, redo_ssn: Idv::Actions::RedoSsnAction, @@ -41,7 +42,10 @@ def initialize(controller, session, name) @idv_session = self.class.session_idv(session) super(controller, STEPS, ACTIONS, session[name]) @flow_session ||= {} - @flow_session[:pii_from_user] ||= {} + @flow_session[:pii_from_user] ||= { uuid: current_user.uuid } + # there may be data in @idv_session to copy to @flow_session + applicant = @idv_session['applicant'] || {} + @flow_session[:pii_from_user] = @flow_session[:pii_from_user].to_h.merge(applicant) end def self.session_idv(session) diff --git a/app/services/idv/profile_maker.rb b/app/services/idv/profile_maker.rb index 1c93d0a7170..70e780b58ce 100644 --- a/app/services/idv/profile_maker.rb +++ b/app/services/idv/profile_maker.rb @@ -9,10 +9,7 @@ def initialize(applicant:, user:, user_password:) end def save_profile - profile = Profile.new( - deactivation_reason: :verification_pending, - user: user, - ) + profile = Profile.new(user: user, active: false) profile.encrypt_pii(pii_attributes, user_password) profile.proofing_components = current_proofing_components profile.save! diff --git a/app/services/idv/session.rb b/app/services/idv/session.rb index 31220460131..c561d46a110 100644 --- a/app/services/idv/session.rb +++ b/app/services/idv/session.rb @@ -72,17 +72,41 @@ def clear user_session.delete(:idv) end + def pending_in_person_enrollment? + return false unless IdentityConfig.store.in_person_proofing_enabled + ProofingComponent.find_by(user: current_user)&.document_check == Idp::Constants::Vendors::USPS + end + def phone_confirmed? vendor_phone_confirmation == true && user_phone_confirmation == true end def complete_session - complete_profile if phone_confirmed? - create_gpo_entry if address_verification_mechanism == 'gpo' + associate_in_person_enrollment_with_profile + + if address_verification_mechanism == 'gpo' + profile.deactivate(:gpo_verification_pending) + create_gpo_entry + elsif phone_confirmed? + if pending_in_person_enrollment? + UspsInPersonProofing::EnrollmentHelper.schedule_in_person_enrollment( + current_user, + applicant, + ) + profile.deactivate(:in_person_verification_pending) + else + complete_profile + end + end + end + + def associate_in_person_enrollment_with_profile + return unless pending_in_person_enrollment? && current_user.establishing_in_person_enrollment + current_user.establishing_in_person_enrollment.update(profile: profile) end def complete_profile - current_user.pending_profile&.activate + profile.activate move_pii_to_user_session end @@ -146,5 +170,10 @@ def build_profile_maker(user_password) user_password: user_password, ) end + + def in_person_enrollment? + return false unless IdentityConfig.store.in_person_proofing_enabled + ProofingComponent.find_by(user: current_user)&.document_check == Idp::Constants::Vendors::USPS + end end end diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index cf34dcb0037..734c7bd0dce 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -27,13 +27,17 @@ def idv_failure(result) step_name: self.class.name, remaining_attempts: throttle.remaining_count, ) - redirect_to idv_session_errors_exception_url + redirect_to idv_session_errors_exception_url( + from: request.path, + ) else @flow.analytics.idv_doc_auth_warning_visited( step_name: self.class.name, remaining_attempts: throttle.remaining_count, ) - redirect_to idv_session_errors_warning_url + redirect_to idv_session_errors_warning_url( + from: request.path, + ) end result end diff --git a/app/services/idv/steps/welcome_step.rb b/app/services/idv/steps/welcome_step.rb index 4830ff6a054..92ff02bf433 100644 --- a/app/services/idv/steps/welcome_step.rb +++ b/app/services/idv/steps/welcome_step.rb @@ -6,6 +6,7 @@ class WelcomeStep < DocAuthBaseStep def call return no_camera_redirect if params[:no_camera] create_document_capture_session(document_capture_session_uuid_key) + cancel_previous_in_person_enrollments end private @@ -15,6 +16,12 @@ def no_camera_redirect msg = 'Doc Auth error: Javascript could not detect camera on mobile device.' failure(msg) end + + def cancel_previous_in_person_enrollments + return unless IdentityConfig.store.in_person_proofing_enabled + UspsInPersonProofing::EnrollmentHelper. + cancel_stale_establishing_enrollments_for_user(current_user) + end end end end diff --git a/app/services/inherited_proofing/va/service.rb b/app/services/inherited_proofing/va/service.rb new file mode 100644 index 00000000000..87a7c047190 --- /dev/null +++ b/app/services/inherited_proofing/va/service.rb @@ -0,0 +1,86 @@ +module InheritedProofing + module Va + # Encapsulates request, response, error handling, validation, etc. for calling + # the VA service to gain PII for a particular user that will be subsequently + # used to proof the user using inherited proofing. + class Service + BASE_URI = IdentityConfig.store.inherited_proofing_va_base_url + + attr_reader :auth_code + + def initialize(auth_code) + @auth_code = auth_code + end + + # Calls the endpoint and returns the decrypted response. + def execute + raise 'The provided auth_code is blank?' if auth_code.blank? + + response = request + payload_to_hash decrypt_payload(response) + end + + private + + def request + connection.get(request_uri) { |req| req.headers = request_headers } + end + + def connection + Faraday.new do |conn| + conn.options.timeout = request_timeout + conn.options.read_timeout = request_timeout + conn.options.open_timeout = request_timeout + conn.options.write_timeout = request_timeout + conn.request :instrumentation, name: 'inherited_proofing.va' + + # raises errors on 4XX or 5XX responses + conn.response :raise_error + end + end + + def request_timeout + @request_timeout ||= IdentityConfig.store.doc_auth_s3_request_timeout + end + + def request_uri + @request_uri ||= "#{ URI(BASE_URI) }/inherited_proofing/user_attributes" + end + + def request_headers + { Authorization: "Bearer #{jwt_token}" } + end + + def jwt_token + JWT.encode(jwt_payload, private_key, jwt_encryption) + end + + def jwt_payload + { inherited_proofing_auth: auth_code, exp: jwt_expires } + end + + def private_key + @private_key ||= AppArtifacts.store.oidc_private_key + end + + def jwt_encryption + 'RS256' + end + + def jwt_expires + 1.day.from_now.to_i + end + + def decrypt_payload(response) + payload = JSON.parse(response.body)['data'] + JWE.decrypt(payload, private_key) if payload + end + + def payload_to_hash(decrypted_payload, default: nil) + return default unless decrypted_payload.present? + + JSON.parse(decrypted_payload, symbolize_names: true) + end + end + end +end diff --git a/app/services/irs_attempts_api/tracker_events.rb b/app/services/irs_attempts_api/tracker_events.rb index c24dc1174a4..54e1eb6616b 100644 --- a/app/services/irs_attempts_api/tracker_events.rb +++ b/app/services/irs_attempts_api/tracker_events.rb @@ -10,5 +10,18 @@ def email_and_password_auth(email:, success:) success: success, ) end + + # @param [String] user_uuid The user's uuid + # @param [String] unique_session_id The unique session id + # @param [Boolean] success True if the email and password matched + # A user has initiated a logout event + def logout_initiated(user_uuid:, unique_session_id:, success:) + track_event( + :logout_initiated, + user_uuid: user_uuid, + unique_session_id: unique_session_id, + success: success, + ) + end end end diff --git a/app/services/pii/attributes.rb b/app/services/pii/attributes.rb index a248976d365..d8605821ec4 100644 --- a/app/services/pii/attributes.rb +++ b/app/services/pii/attributes.rb @@ -5,7 +5,7 @@ module Pii Attributes = RedactedStruct.new( :first_name, :middle_name, :last_name, - :address1, :address2, :city, :state, :zipcode, + :address1, :address2, :city, :state, :zipcode, :same_address_as_id, :ssn, :dob, :phone, :prev_address1, :prev_address2, :prev_city, :prev_state, :prev_zipcode, *DEPRECATED_PII_ATTRIBUTES diff --git a/app/services/proofing/mock/resolution_mock_client.rb b/app/services/proofing/mock/resolution_mock_client.rb index af6afe5103e..3abf893d3db 100644 --- a/app/services/proofing/mock/resolution_mock_client.rb +++ b/app/services/proofing/mock/resolution_mock_client.rb @@ -3,9 +3,17 @@ module Mock class ResolutionMockClient < Proofing::Base vendor_name 'ResolutionMock' - required_attributes :first_name, :ssn, :zipcode - - optional_attributes :uuid, :uuid_prefix + required_attributes :uuid, + :first_name, + :last_name, + :dob, + :ssn, + :address1, + :city, + :state, + :zipcode + + optional_attributes :address2, :uuid_prefix, :dob_year_only stage :resolution diff --git a/app/services/push_notification/password_reset_event.rb b/app/services/push_notification/password_reset_event.rb new file mode 100644 index 00000000000..b969b52f522 --- /dev/null +++ b/app/services/push_notification/password_reset_event.rb @@ -0,0 +1,17 @@ +module PushNotification + class PasswordResetEvent + include IssSubEvent + + EVENT_TYPE = 'https://schemas.login.gov/secevent/risc/event-type/password-reset'.freeze + + attr_reader :user + + def initialize(user:) + @user = user + end + + def event_type + EVENT_TYPE + end + end +end diff --git a/app/services/usps_in_person_proofing/enrollment_helper.rb b/app/services/usps_in_person_proofing/enrollment_helper.rb new file mode 100644 index 00000000000..62d8d4baf7d --- /dev/null +++ b/app/services/usps_in_person_proofing/enrollment_helper.rb @@ -0,0 +1,77 @@ +module UspsInPersonProofing + class EnrollmentHelper + class << self + def schedule_in_person_enrollment(user, pii) + enrollment = user.establishing_in_person_enrollment + return unless enrollment + + enrollment.current_address_matches_id = pii['same_address_as_id'] + enrollment.save! + + enrollment_code = create_usps_enrollment(enrollment, pii) + return unless enrollment_code + + # update the enrollment to status pending + enrollment.enrollment_code = enrollment_code + enrollment.status = :pending + enrollment.enrollment_established_at = Time.zone.now + enrollment.save! + + send_ready_to_verify_email(user, pii, enrollment) + end + + def send_ready_to_verify_email(user, pii, enrollment) + user.confirmed_email_addresses.each do |email_address| + UserMailer.in_person_ready_to_verify( + user, + email_address, + first_name: pii['first_name'], + enrollment: enrollment, + ).deliver_now_or_later + end + end + + def establishing_in_person_enrollment_for_user(user) + enrollment = user.establishing_in_person_enrollment + return enrollment if enrollment.present? + + InPersonEnrollment.create!(user: user, profile: nil) + end + + def usps_proofer + if IdentityConfig.store.usps_mock_fallback + UspsInPersonProofing::Mock::Proofer.new + else + UspsInPersonProofing::Proofer.new + end + end + + def create_usps_enrollment(enrollment, pii) + applicant = UspsInPersonProofing::Applicant.new( + { + unique_id: enrollment.usps_unique_id, + first_name: pii['first_name'], + last_name: pii['last_name'], + address: pii['address1'], + # do we need address2? + city: pii['city'], + state: pii['state'], + zip_code: pii['zipcode'], + email: 'no-reply@login.gov', + }, + ) + proofer = usps_proofer + + response = proofer.request_enroll(applicant) + response['enrollmentCode'] + end + + def cancel_stale_establishing_enrollments_for_user(user) + user. + in_person_enrollments. + where(status: :establishing). + each(&:cancelled!) + end + end + end +end diff --git a/app/services/usps_in_person_proofing/mock.rb b/app/services/usps_in_person_proofing/mock.rb index 6e20843562b..91a88adb54b 100644 --- a/app/services/usps_in_person_proofing/mock.rb +++ b/app/services/usps_in_person_proofing/mock.rb @@ -3,7 +3,32 @@ module Mock class Proofer def request_enroll(_applicant) JSON.load_file( - Rails.root.join('spec/fixtures/usps_ipp_responses/request_enroll_response.json'), + Rails.root.join( + 'spec', + 'fixtures', + 'usps_ipp_responses', + 'request_enroll_response.json', + ), + ) + end + + def request_facilities(_location) + JSON.load_file( + Rails.root.join( + 'spec', + 'fixtures', + 'usps_ipp_responses', + 'request_facilities_response.json', + ), + ) + end + + def request_pilot_facilities + JSON.load_file( + Rails.root.join( + 'config', + 'ipp_pilot_usps_facilities.json', + ), ) end end diff --git a/app/services/usps_in_person_proofing/post_office.rb b/app/services/usps_in_person_proofing/post_office.rb index 20ae83c5e4f..3febd3fccad 100644 --- a/app/services/usps_in_person_proofing/post_office.rb +++ b/app/services/usps_in_person_proofing/post_office.rb @@ -1,5 +1,16 @@ module UspsInPersonProofing PostOffice = Struct.new( - :distance, :address, :city, :phone, :name, :zip_code, :state, keyword_init: true + :address, + :city, + :distance, + :name, + :phone, + :saturday_hours, + :state, + :sunday_hours, + :weekday_hours, + :zip_code_4, + :zip_code_5, + keyword_init: true, ) end diff --git a/app/services/usps_in_person_proofing/proofer.rb b/app/services/usps_in_person_proofing/proofer.rb index e4579cf6a03..2c43d7452b5 100644 --- a/app/services/usps_in_person_proofing/proofer.rb +++ b/app/services/usps_in_person_proofing/proofer.rb @@ -16,21 +16,25 @@ def request_facilities(location) city: location.city, state: location.state, zipCode: location.zip_code, - } + }.to_json - resp = faraday.post(url, body, dynamic_headers) + headers = request_headers.merge( + 'Authorization' => @token, + 'RequestID' => request_id, + ) - resp.body['postOffices'].map do |post_office| - PostOffice.new( - distance: post_office['distance'], - address: post_office['streetAddress'], - city: post_office['city'], - phone: post_office['phone'], - name: post_office['name'], - zip_code: post_office['zip5'], - state: post_office['state'], - ) - end + parse_facilities( + faraday.post(url, body, headers) do |req| + req.options.context = { service_name: 'usps_facilities' } + end.body, + ) + end + + # Temporary function to return a static set of facilities + # @return [Array] Facility locations + def request_pilot_facilities + resp = File.read(Rails.root.join('config', 'ipp_pilot_usps_facilities.json')) + parse_facilities(JSON.parse(resp)) end # Makes HTTP request to enroll an applicant in in-person proofing. @@ -56,7 +60,9 @@ def request_enroll(applicant) IPPAssuranceLevel: '1.5', } - faraday.post(url, body, dynamic_headers).body + faraday.post(url, body, dynamic_headers) do |req| + req.options.context = { service_name: 'usps_enroll' } + end.body end # Makes HTTP request to retrieve proofing status @@ -75,7 +81,9 @@ def request_proofing_results(unique_id, enrollment_code) enrollmentCode: enrollment_code, } - faraday.post(url, body, dynamic_headers).body + faraday.post(url, body, dynamic_headers) do |req| + req.options.context = { service_name: 'usps_proofing_results' } + end.body end # Makes HTTP request to retrieve enrollment code @@ -92,7 +100,9 @@ def request_enrollment_code(unique_id) uniqueID: unique_id, } - faraday.post(url, body, dynamic_headers).body + faraday.post(url, body, dynamic_headers) do |req| + req.options.context = { service_name: 'usps_enrollment_code' } + end.body end # Makes a request to retrieve a new OAuth token @@ -118,6 +128,9 @@ def faraday conn.options.open_timeout = IdentityConfig.store.usps_ipp_request_timeout conn.options.write_timeout = IdentityConfig.store.usps_ipp_request_timeout + # Log request metrics + conn.request :instrumentation, name: 'request_metric.faraday' + # Raise an error subclassing Faraday::Error on 4xx, 5xx, and malformed responses # Note: The order of this matters for parsing the error response body. conn.response :raise_error @@ -160,7 +173,9 @@ def request_token scope: 'ivs.ippaas.apis', } - faraday.post(url, body).body + faraday.post(url, body) do |req| + req.options.context = { service_name: 'usps_token' } + end.body end def root_url @@ -178,5 +193,30 @@ def request_id def request_headers { 'Content-Type' => 'application/json; charset=utf-8' } end + + def parse_facilities(facilities) + facilities['postOffices'].map do |post_office| + hours = {} + post_office['hours'].each do |hour_details| + hour_details.keys.each do |key| + hours[key] = hour_details[key] + end + end + + PostOffice.new( + address: post_office['streetAddress'], + city: post_office['city'], + distance: post_office['distance'], + name: post_office['name'], + phone: post_office['phone'], + saturday_hours: hours['saturdayHours'], + state: post_office['state'], + sunday_hours: hours['sundayHours'], + weekday_hours: hours['weekdayHours'], + zip_code_4: post_office['zip4'], + zip_code_5: post_office['zip5'], + ) + end + end end end diff --git a/app/views/account_reset/pending/confirm.html.erb b/app/views/account_reset/pending/confirm.html.erb index 4ef46167b20..872066ff582 100644 --- a/app/views/account_reset/pending/confirm.html.erb +++ b/app/views/account_reset/pending/confirm.html.erb @@ -3,6 +3,7 @@ <%= button_to( account_reset_pending_cancel_path, class: 'usa-button usa-button--wide usa-button--big margin-bottom-2', + method: :post, ) { t('forms.buttons.continue') } %> <%= link_to(t('links.go_back'), account_reset_pending_path) %> diff --git a/app/views/account_reset/request/show.html.erb b/app/views/account_reset/request/show.html.erb index e324060b477..bfcceb2a086 100644 --- a/app/views/account_reset/request/show.html.erb +++ b/app/views/account_reset/request/show.html.erb @@ -23,6 +23,7 @@ <%= button_to( account_reset_request_path, class: 'usa-button usa-button--unstyled', + method: :post, ) { t('account_reset.request.yes_continue') } %> <%= render PageFooterComponent.new do %> diff --git a/app/views/idv/address/new.html.erb b/app/views/idv/address/new.html.erb index ab18bf14353..7739d880393 100644 --- a/app/views/idv/address/new.html.erb +++ b/app/views/idv/address/new.html.erb @@ -69,5 +69,5 @@ <% end %> <% end %> -<%= render 'idv/doc_auth/back', step: 'verify' %> +<%= render 'idv/shared/back', step: 'verify' %> <%= javascript_packs_tag_once('formatted-fields') %> diff --git a/app/views/idv/cancellations/destroy.html.erb b/app/views/idv/cancellations/destroy.html.erb index fba5e2911c1..84a641f32f7 100644 --- a/app/views/idv/cancellations/destroy.html.erb +++ b/app/views/idv/cancellations/destroy.html.erb @@ -4,5 +4,5 @@ <% c.header { t('idv.cancel.headings.confirmation.hybrid') } %>

    <%= t('doc_auth.instructions.switch_back') %>

    - <%= image_tag(asset_url('idv/switch.png'), width: 193) %> + <%= image_tag(asset_url('idv/switch.png'), width: 193, alt: t('doc_auth.instructions.switch_back_image')) %> <% end %> diff --git a/app/views/idv/capture_doc/capture_complete.html.erb b/app/views/idv/capture_doc/capture_complete.html.erb index 28c8ea90bcf..4199e3d5dcc 100644 --- a/app/views/idv/capture_doc/capture_complete.html.erb +++ b/app/views/idv/capture_doc/capture_complete.html.erb @@ -6,4 +6,4 @@ <%= render PageHeadingComponent.new.with_content(t('doc_auth.instructions.switch_back')) %> -<%= image_tag(asset_url('idv/switch.png'), width: 193) %> +<%= image_tag(asset_url('idv/switch.png'), width: 193, alt: t('doc_auth.instructions.switch_back_image')) %> diff --git a/app/views/idv/doc_auth/link_sent.html.erb b/app/views/idv/doc_auth/link_sent.html.erb index df657f97ec9..bb952544359 100644 --- a/app/views/idv/doc_auth/link_sent.html.erb +++ b/app/views/idv/doc_auth/link_sent.html.erb @@ -44,4 +44,4 @@ <%= javascript_packs_tag_once 'doc-capture-polling' %> <% end %> -<%= render 'idv/doc_auth/back', action: 'cancel_link_sent', class: 'link-sent-back-link' %> +<%= render 'idv/shared/back', action: 'cancel_link_sent', class: 'link-sent-back-link' %> diff --git a/app/views/idv/doc_auth/send_link.html.erb b/app/views/idv/doc_auth/send_link.html.erb index edc17672ddb..be1365ce6ce 100644 --- a/app/views/idv/doc_auth/send_link.html.erb +++ b/app/views/idv/doc_auth/send_link.html.erb @@ -32,4 +32,4 @@ <%= t('forms.buttons.continue') %> <% end %> -<%= render 'idv/doc_auth/back', action: 'cancel_send_link' %> +<%= render 'idv/shared/back', action: 'cancel_send_link' %> diff --git a/app/views/idv/gpo/index.html.erb b/app/views/idv/gpo/index.html.erb index 3c1b4b4adf0..f5a372195ea 100644 --- a/app/views/idv/gpo/index.html.erb +++ b/app/views/idv/gpo/index.html.erb @@ -41,4 +41,4 @@ ], ) %> -<%= render 'idv/doc_auth/back', fallback_path: @presenter.fallback_back_path %> +<%= render 'idv/shared/back', fallback_path: @presenter.fallback_back_path %> diff --git a/app/views/idv/in_person/address.html.erb b/app/views/idv/in_person/address.html.erb index b18482c7587..869d2e68844 100644 --- a/app/views/idv/in_person/address.html.erb +++ b/app/views/idv/in_person/address.html.erb @@ -8,7 +8,9 @@

    <%= t('in_person_proofing.body.address.info') %> - <%= new_window_link_to(t('in_person_proofing.body.address.learn_more'), MarketingSite.security_and_privacy_practices_url) %> + <%# WILLFIX: Hiding the link below until help links are finalized; see LG-6875 %> + <%# i18n-tasks-use t('in_person_proofing.body.address.learn_more') %> + <%# new_window_link_to(t('in_person_proofing.body.address.learn_more'), MarketingSite.security_and_privacy_practices_url) %>

    <%= simple_form_for( @@ -91,4 +93,6 @@ <% end %> <% end %> +<%= render 'idv/doc_auth/cancel', step: 'address' %> + <%= javascript_packs_tag_once('formatted-fields') %> diff --git a/app/views/idv/in_person/ready_to_verify/show.html.erb b/app/views/idv/in_person/ready_to_verify/show.html.erb index dff4908268f..6e93663230c 100644 --- a/app/views/idv/in_person/ready_to_verify/show.html.erb +++ b/app/views/idv/in_person/ready_to_verify/show.html.erb @@ -22,7 +22,7 @@ <% end %> <%= render AlertComponent.new(class: 'margin-y-4', text_tag: :div) do %> -

    <%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %>

    +

    <%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %>

    <%= t('in_person_proofing.body.barcode.deadline_restart') %>

    <% end %> @@ -32,18 +32,11 @@ <%= render ProcessListComponent.new(heading_level: :h3, class: 'margin-y-3') do |c| %> <% c.item(heading: t('in_person_proofing.process.barcode.heading')) do %>

    <%= t('in_person_proofing.process.barcode.info') %>

    -
    - <%= image_tag( - @presenter.barcode_data_url, - skip_pipeline: true, - alt: t('in_person_proofing.process.barcode.image_alt'), - class: 'display-block margin-bottom-1', - ) %> -
    - <%= t('in_person_proofing.process.barcode.caption_label') %>: - <%= @presenter.formatted_enrollment_code %> -
    -
    + <%= render BarcodeComponent.new( + barcode_data: @presenter.enrollment_code, + label: t('in_person_proofing.process.barcode.caption_label'), + label_formatter: Idv::InPerson::EnrollmentCodeFormatter.method(:format), + ) %> <% end %> <% c.item(heading: t('in_person_proofing.process.state_id.heading')) do %>

    <%= t('in_person_proofing.process.state_id.info') %>

    @@ -66,8 +59,9 @@ <% end %> <% end %>

    - <%= t('in_person_proofing.body.barcode.items_to_bring_questions') %> - <%= new_window_link_to( + <%# WILLFIX: Hiding this text and link until help links are finalized; see LG-6875 %> + <%# t('in_person_proofing.body.barcode.items_to_bring_questions') %> + <%# new_window_link_to( t('in_person_proofing.body.barcode.learn_more'), MarketingSite.help_center_article_url( category: 'verify-your-identity', @@ -77,29 +71,29 @@

    -
    -
    -

    <%= @presenter.selected_location_details['name'] %>

    -
    - <%= @presenter.selected_location_details['streetAddress'] %>
    - <%= @presenter.selected_location_details['city'] %>, - <%= @presenter.selected_location_details['state'] %> - <%= @presenter.selected_location_details['zip5'] %>-<%= @presenter.selected_location_details['zip4'] %> -
    -

    <%= t('in_person_proofing.body.barcode.retail_hours') %>

    -
    - <%= t('date.range', from: t('date.day_names')[0], to: t('date.day_names')[4]) %>: <%= @presenter.selected_location_hours(:weekday) %>
    - <%= t('date.day_names')[5] %>: <%= @presenter.selected_location_hours(:saturday) %>
    - <%= t('date.day_names')[6] %>: <%= @presenter.selected_location_hours(:sunday) %> -
    -
    - <%= t('in_person_proofing.body.barcode.retail_phone_label') %>: - <%= @presenter.selected_location_details['phone'] %> -
    -
    -
    +<% if @presenter.selected_location_details.present? %> +
    +
    +

    <%= @presenter.selected_location_details['name'] %>

    +
    + <%= @presenter.selected_location_details['street_address'] %>
    + <%= @presenter.selected_location_details['formatted_city_state_zip'] %> +
    +

    <%= t('in_person_proofing.body.barcode.retail_hours') %>

    +
    + <%= t('date.range', from: t('date.day_names')[0], to: t('date.day_names')[4]) %>: <%= @presenter.selected_location_hours(:weekday) %>
    + <%= t('date.day_names')[5] %>: <%= @presenter.selected_location_hours(:saturday) %>
    + <%= t('date.day_names')[6] %>: <%= @presenter.selected_location_hours(:sunday) %> +
    +
    + <%= t('in_person_proofing.body.barcode.retail_phone_label') %>: + <%= @presenter.selected_location_details['phone'] %> +
    +
    +
    -

    <%= t('in_person_proofing.body.barcode.speak_to_associate') %>

    +

    <%= t('in_person_proofing.body.barcode.speak_to_associate') %>

    +<% end %>

    <%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %> diff --git a/app/views/idv/in_person/state_id.html.erb b/app/views/idv/in_person/state_id.html.erb index 41bbf53f68d..40da7642b1f 100644 --- a/app/views/idv/in_person/state_id.html.erb +++ b/app/views/idv/in_person/state_id.html.erb @@ -89,3 +89,5 @@ <% end %> <% end %> <% end %> + +<%= render 'idv/doc_auth/cancel', step: 'state_id' %> diff --git a/app/views/idv/session_errors/exception.html.erb b/app/views/idv/session_errors/exception.html.erb index 9a88749d3b4..69dece31929 100644 --- a/app/views/idv/session_errors/exception.html.erb +++ b/app/views/idv/session_errors/exception.html.erb @@ -4,7 +4,7 @@ heading: t('idv.failure.sessions.exception'), action: { text: t('idv.failure.button.warning'), - url: idv_doc_auth_path, + url: @try_again_path, }, options: [ { diff --git a/app/views/idv/session_errors/warning.html.erb b/app/views/idv/session_errors/warning.html.erb index 34c7372d58d..34a91cf6760 100644 --- a/app/views/idv/session_errors/warning.html.erb +++ b/app/views/idv/session_errors/warning.html.erb @@ -7,7 +7,7 @@

    <%= t('idv.failure.attempts', count: @remaining_attempts) %>

    <% c.action_button( - action: ->(**tag_options, &block) { link_to(idv_doc_auth_path, **tag_options, &block) }, + action: ->(**tag_options, &block) { link_to(@try_again_path, **tag_options, &block) }, ) { t('idv.forgot_password.try_again') } %> <% c.troubleshooting_options do |tc| %> diff --git a/app/views/idv/doc_auth/_back.html.erb b/app/views/idv/shared/_back.html.erb similarity index 84% rename from app/views/idv/doc_auth/_back.html.erb rename to app/views/idv/shared/_back.html.erb index 3f5d0ef5b6f..88f45338b35 100644 --- a/app/views/idv/doc_auth/_back.html.erb +++ b/app/views/idv/shared/_back.html.erb @@ -5,13 +5,15 @@ path can be passed in case the HTTP header is not specified or is invalid. If no yield a useable URL, nothing will be rendered. locals: +* step_url: (Optional) Base target for flow step URL calls, falls back to @step_url. * action: (Optional) Flow action to call to return to the previous step. * step: (Optional) Name of step to which user should be returned. * fallback_path: (Optional) Path to redirect absent action, step, and HTTP referer. %> <% text = '‹ ' + t('forms.buttons.back') + step_url = local_assigns[:step_url] || @step_url step = local_assigns[:action] || local_assigns[:step] - path = step ? idv_doc_auth_step_path(step: step) : go_back_path + path = (step_url && step) ? send(step_url, step: step) : go_back_path path ||= local_assigns[:fallback_path] classes = [] classes << local_assigns[:class] if local_assigns[:class] %> diff --git a/app/views/idv/shared/_ssn.html.erb b/app/views/idv/shared/_ssn.html.erb index ffef9a165ce..a79f37fc4a4 100644 --- a/app/views/idv/shared/_ssn.html.erb +++ b/app/views/idv/shared/_ssn.html.erb @@ -60,7 +60,7 @@ locals: <% end %> <% if updating_ssn %> - <%= render 'idv/doc_auth/back', action: 'cancel_update_ssn' %> + <%= render 'idv/shared/back', action: 'cancel_update_ssn' %> <% else %> <%= render 'idv/doc_auth/cancel', step: 'ssn' %> <% end %> diff --git a/app/views/layouts/user_mailer.html.erb b/app/views/layouts/user_mailer.html.erb index f695ac2cfe6..243a7b28278 100644 --- a/app/views/layouts/user_mailer.html.erb +++ b/app/views/layouts/user_mailer.html.erb @@ -71,8 +71,11 @@ -

    <%= @header || message.subject %> -

    <%= yield %> + <% unless @hide_title %> +

    <%= @header || message.subject %> +

    + <% end %> + <%= yield %> diff --git a/app/views/shared/_in-person-verification-results-email-lower.html.erb b/app/views/shared/_in-person-verification-results-email-lower.html.erb new file mode 100644 index 00000000000..3ebe6dca242 --- /dev/null +++ b/app/views/shared/_in-person-verification-results-email-lower.html.erb @@ -0,0 +1,38 @@ +
    +
    + + + + + + +
    + + + + + + +
    + <%= link_to t('user_mailer.in_person_verified.sign_in'), + idv_url, + target: '_blank', + class: 'float-center', + rel: 'noopener' %> +
    +
    + + +

    + <%= link_to idv_url, idv_url, target: '_blank', rel: 'noopener' %> +

    + +
    +
    + <%= t( + 'user_mailer.in_person_verified.warning_contact_us_html', + contact_us_url: MarketingSite.contact_url, + sign_in_url: idv_url, + ) + %> +
    diff --git a/app/views/user_mailer/in_person_failed.html.erb b/app/views/user_mailer/in_person_failed.html.erb new file mode 100644 index 00000000000..f34e8718abb --- /dev/null +++ b/app/views/user_mailer/in_person_failed.html.erb @@ -0,0 +1,20 @@ +
    + <%= t('user_mailer.in_person_verified.greeting') %>
    +

    + <%= t( + 'user_mailer.in_person_failed.intro', + location: @presenter.location_name, + date: @presenter.formatted_verified_date, + ) %> +

    +

    + <%= t('user_mailer.in_person_failed.body', app_name: APP_NAME) %>

    +

    + <%= t('user_mailer.in_person_failed.verifying_identity') %>
    +
      +
    • <%= t('user_mailer.in_person_failed.verifying_step_not_expired') %>
    • +
    • <%= t('user_mailer.in_person_failed.verifying_step_proof_of_address') %>
    • +
    +
    + +<%= render 'shared/in-person-verification-results-email-lower' %> diff --git a/app/views/user_mailer/in_person_ready_to_verify.html.erb b/app/views/user_mailer/in_person_ready_to_verify.html.erb new file mode 100644 index 00000000000..f12c97a82b0 --- /dev/null +++ b/app/views/user_mailer/in_person_ready_to_verify.html.erb @@ -0,0 +1,101 @@ +

    + <%= t('user_mailer.in_person_ready_to_verify.greeting', name: @first_name) %>
    + <%= t('user_mailer.in_person_ready_to_verify.intro') %> +

    + + + + + + +
    + <%= image_tag('email/info.png', width: 16, height: 16, alt: '') %> + +

    <%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %>

    +

    <%= t('in_person_proofing.body.barcode.deadline_restart') %>

    +
    + +
    +

    + <%= t('in_person_proofing.body.barcode.items_to_bring') %> +

    + + + + + + + + + + <% if @presenter.needs_proof_of_address? %> + + + + + <% end %> +
    1
    +

    <%= t('in_person_proofing.process.barcode.heading') %>

    +

    <%= t('in_person_proofing.process.barcode.info') %>

    + <%= render BarcodeComponent.new( + barcode_data: @presenter.enrollment_code, + label: nil, + label_formatter: Idv::InPerson::EnrollmentCodeFormatter.method(:format), + ) %> +
    2
    +

    <%= t('in_person_proofing.process.state_id.heading') %>

    +

    <%= t('in_person_proofing.process.state_id.info') %>

    +
      + <% t('in_person_proofing.process.state_id.acceptable_documents').each do |document| %> +
    • <%= document %>
    • + <% end %> +
    +

    <%= t('in_person_proofing.process.state_id.no_other_documents') %>

    +
    3
    +

    <%= t('in_person_proofing.process.proof_of_address.heading') %>

    +

    <%= t('in_person_proofing.process.proof_of_address.info') %>

    +
      + <% t('in_person_proofing.process.proof_of_address.acceptable_proof').each do |proof| %> +
    • <%= proof %>
    • + <% end %> +
    +
    +

    + <%# WILLFIX: Hiding this text and link until help links are finalized; see LG-6875 %> + <%# i18n-tasks-use t('in_person_proofing.body.barcode.items_to_bring_questions') %> + <%# t('in_person_proofing.body.barcode.items_to_bring_questions') %> + <%# link_to( + t('in_person_proofing.body.barcode.learn_more'), + MarketingSite.help_center_article_url( + category: 'verify-your-identity', + article: 'how-to-verify-in-person', + ), + ) %> +

    +
    + +<% if @presenter.selected_location_details.present? %> +
    +

    <%= @presenter.selected_location_details['name'] %>

    +
    + <%= @presenter.selected_location_details['street_address'] %>
    + <%= @presenter.selected_location_details['formatted_city_state_zip'] %> +
    +
    <%= t('in_person_proofing.body.barcode.retail_hours') %>
    +
    + <%= t('date.range', from: t('date.day_names')[0], to: t('date.day_names')[4]) %>: <%= @presenter.selected_location_hours(:weekday) %>
    + <%= t('date.day_names')[5] %>: <%= @presenter.selected_location_hours(:saturday) %>
    + <%= t('date.day_names')[6] %>: <%= @presenter.selected_location_hours(:sunday) %> +
    +
    + <%= @presenter.selected_location_details[:phone] %> +
    +
    +<% end %> + +

    <%= t('in_person_proofing.body.barcode.speak_to_associate') %>

    + +

    + <%= t('in_person_proofing.body.barcode.deadline', deadline: @presenter.formatted_due_date) %> + <%= t('in_person_proofing.body.barcode.no_appointment_required') %> +

    diff --git a/app/views/user_mailer/in_person_verified.html.erb b/app/views/user_mailer/in_person_verified.html.erb new file mode 100644 index 00000000000..8ffd02a9cce --- /dev/null +++ b/app/views/user_mailer/in_person_verified.html.erb @@ -0,0 +1,19 @@ +<%= image_tag( + 'email/user-signup-ial2.png', + width: 140, + height: 177, + alt: '', + class: 'float-center padding-bottom-4', + ) %> +

    <%= message.subject %>

    +

    + <%= t('user_mailer.in_person_verified.greeting') %>
    + <%= t( + 'user_mailer.in_person_verified.intro', + location: @presenter.location_name, + date: @presenter.formatted_verified_date, + ) %>

    + <%= t('user_mailer.in_person_verified.next_sign_in', app_name: APP_NAME) %> +

    + +<%= render 'shared/in-person-verification-results-email-lower' %> diff --git a/app/views/users/authorization_confirmation/new.html.erb b/app/views/users/authorization_confirmation/new.html.erb index 8e4287bc166..d13ab57eb32 100644 --- a/app/views/users/authorization_confirmation/new.html.erb +++ b/app/views/users/authorization_confirmation/new.html.erb @@ -30,7 +30,7 @@
    - <%= button_to(user_authorization_confirmation_path, class: 'usa-button usa-button--big usa-button--wide') { t('user_authorization_confirmation.continue') } %> + <%= button_to(user_authorization_confirmation_path, class: 'usa-button usa-button--big usa-button--wide', method: :post) { t('user_authorization_confirmation.continue') } %>
    <%= t('user_authorization_confirmation.or') %> diff --git a/app/views/vendor_outage/show.html.erb b/app/views/vendor_outage/show.html.erb index 99317898f20..f204a6f3a56 100644 --- a/app/views/vendor_outage/show.html.erb +++ b/app/views/vendor_outage/show.html.erb @@ -21,4 +21,4 @@

    <%= @specific_message %>

    <% end %> -<%= render('idv/doc_auth/back') %> +<%= render('idv/shared/back') %> diff --git a/config/application.rb b/config/application.rb index 21855158294..703d4ac6ce7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -48,7 +48,9 @@ class Application < Rails::Application end end - config.load_defaults '6.1' + config.load_defaults '7.0' + # Delete after deploying once + config.active_support.cache_format_version = 6.1 config.active_record.belongs_to_required_by_default = false config.active_record.legacy_connection_handling = false config.assets.unknown_asset_fallback = true @@ -78,12 +80,6 @@ class Application < Rails::Application config.time_zone = 'UTC' - # Generate CSRF tokens that are encoded in URL-safe Base64. - # - # This change is not backwards compatible with earlier Rails versions. - # It's best enabled when your entire app is migrated and stable on 6.1. - Rails.application.config.action_controller.urlsafe_csrf_tokens = false - config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{yml}')] config.i18n.available_locales = %w[en es fr] config.i18n.default_locale = :en diff --git a/config/application.yml.default b/config/application.yml.default index d0705f770a4..c8f835fddda 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -141,6 +141,15 @@ lexisnexis_trueid_liveness_nocropping_workflow: customers.gsa.trueid.workflow lexisnexis_trueid_noliveness_cropping_workflow: customers.gsa.trueid.workflow lexisnexis_trueid_noliveness_nocropping_workflow: customers.gsa.trueid.workflow ################################################################### +# LexisNexis ThreatMetrix ########################################## +lexisnexis_threatmetrix_base_url: https://www.example.com +lexisnexis_threatmetrix_request_mode: testing +lexisnexis_threatmetrix_account_id: test_account +lexisnexis_threatmetrix_username: test_username +lexisnexis_threatmetrix_password: test_password +lexisnexis_threatmetrix_instant_verify_timeout: 1.0 +lexisnexis_threatmetrix_instant_verify_workflow: customers.gsa.instant.verify.workflow +################################################################### lockout_period_in_minutes: 10 log_to_stdout: false logins_per_email_and_ip_bantime: 60 @@ -187,6 +196,8 @@ piv_cac_verify_token_url: https://localhost:8443/ platform_authentication_enabled: true poll_rate_for_verify_in_seconds: 3 proofer_mock_fallback: true +proofing_device_profiling_collecting_enabled: false +proofing_device_profiling_decisioning_enabled: false proofing_send_partial_dob: false proof_address_max_attempts: 5 proof_address_max_attempt_window_in_minutes: 360 @@ -272,6 +283,7 @@ voice_otp_speech_rate: 'slow' voip_check: true voip_block: true voip_allowed_phones: '[]' +inherited_proofing_va_base_url: 'https://staging-api.va.gov' development: aamva_private_key: 123abc @@ -371,8 +383,6 @@ production: doc_auth_vendor_randomize_percent: 0 doc_auth_vendor_randomize_alternate_vendor: '' domain_name: login.gov - enable_numeric_authentication_otp: false - enable_numeric_authentication_otp_input: false enable_test_routes: false enable_usps_verification: false hmac_fingerprinter_key: diff --git a/config/environments/test.rb b/config/environments/test.rb index 19b5f8d9c59..711fe6db86a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -55,6 +55,7 @@ :webauthn_configurations, :email_addresses, :proofing_component, + :account_reset_request, ].each do |association| Bullet.add_safelist(type: :n_plus_one_query, class_name: 'User', association: association) end diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index 8ce946d7589..8f9769790f4 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -5,7 +5,7 @@ } config.action_dispatch.default_headers.merge!( - 'X-Frame-Options' => 'DENY', + 'X-Frame-Options' => IdentityConfig.store.rails_mailer_previews_enabled ? 'SAMEORIGIN' : 'DENY', 'X-XSS-Protection' => '1; mode=block', 'X-Download-Options' => 'noopen', ) diff --git a/config/ipp_pilot_usps_facilities.json b/config/ipp_pilot_usps_facilities.json new file mode 100644 index 00000000000..249d09ca2bc --- /dev/null +++ b/config/ipp_pilot_usps_facilities.json @@ -0,0 +1,158 @@ +{ + "postOffices": [ + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "8:30 AM - 7:00 PM" + }, + { + "saturdayHours": "8:30 AM - 5:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.0 mi", + "streetAddress": "900 E FAYETTE ST RM 118", + "city": "BALTIMORE", + "phone": "410-347-4202", + "name": "BALTIMORE", + "zip4": "9715", + "state": "MD", + "zip5": "21233" + }, + { + "parking": "Street", + "hours": [ + { + "weekdayHours": "9:00 AM - 5:00 PM" + }, + { + "saturdayHours": "9:00 AM - 4:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.0 mi", + "streetAddress": "6900 WISCONSIN AVE STE 100", + "city": "CHEVY CHASE", + "phone": "301-941-2670", + "name": "BETHESDA", + "zip4": "9996", + "state": "MD", + "zip5": "20815" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "8:00 AM - 6:00 PM" + }, + { + "saturdayHours": "8:00 AM - 4:00 PM" + }, + { + "sundayHours": "10:00 AM - 4:00 PM" + } + ], + "distance": "0.0 mi", + "streetAddress": "4005 WISCONSIN AVE NW", + "city": "WASHINGTON", + "phone": "202-842-3332", + "name": "FRIENDSHIP", + "zip4": "9997", + "state": "DC", + "zip5": "20016" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 5:00 PM" + }, + { + "saturdayHours": "9:00 AM - 4:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.0 mi", + "streetAddress": "900 BRENTWOOD RD NE", + "city": "WASHINGTON", + "phone": "202-636-1259", + "name": "WASHINGTON", + "zip4": "9998", + "state": "DC", + "zip5": "20066" + }, + { + "parking": "Street", + "hours": [ + { + "weekdayHours": "9:00 AM - 5:00 PM" + }, + { + "saturdayHours": "9:00 AM - 1:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.0 mi", + "streetAddress": "3118 WASHINGTON BLVD", + "city": "ARLINGTON", + "phone": "703-993-0072", + "name": "ARLINGTON", + "zip4": "9998", + "state": "VA", + "zip5": "22201" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 1:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.0 mi", + "streetAddress": "44715 PRENTICE DR", + "city": "DULLES", + "phone": "703-406-6291", + "name": "ASHBURN", + "zip4": "9996", + "state": "VA", + "zip5": "20101" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 8:00 PM" + }, + { + "saturdayHours": "9:00 AM - 5:00 PM" + }, + { + "sundayHours": "9:00 AM - 5:00 PM" + } + ], + "distance": "0.0 mi", + "streetAddress": "8409 LEE HWY", + "city": "MERRIFIELD", + "phone": "703-698-6377", + "name": "MERRIFIELD", + "zip4": "9998", + "state": "VA", + "zip5": "22116" + } + ] +} diff --git a/config/locales/components/en.yml b/config/locales/components/en.yml index d197121dd90..94915725810 100644 --- a/config/locales/components/en.yml +++ b/config/locales/components/en.yml @@ -1,6 +1,8 @@ --- en: components: + barcode: + table_label: Barcode clipboard_button: label: Copy javascript_required: diff --git a/config/locales/components/es.yml b/config/locales/components/es.yml index 11f564c2f78..cec7ec4d61c 100644 --- a/config/locales/components/es.yml +++ b/config/locales/components/es.yml @@ -1,6 +1,8 @@ --- es: components: + barcode: + table_label: Código de barras clipboard_button: label: Copiar javascript_required: diff --git a/config/locales/components/fr.yml b/config/locales/components/fr.yml index 5011d86f66f..62f05681bf4 100644 --- a/config/locales/components/fr.yml +++ b/config/locales/components/fr.yml @@ -1,6 +1,8 @@ --- fr: components: + barcode: + table_label: Code-barres clipboard_button: label: Copier javascript_required: diff --git a/config/locales/doc_auth/en.yml b/config/locales/doc_auth/en.yml index a53767be631..07f9ad760f7 100644 --- a/config/locales/doc_auth/en.yml +++ b/config/locales/doc_auth/en.yml @@ -216,6 +216,7 @@ en: send_sms: We’ll send a text message to your device with a link. Follow that link to your browser to take photos of the front and back of your ID. switch_back: Switch back to your computer to finish verifying your identity. + switch_back_image: Arrow pointing from phone to computer test_ssn: In the test environment only SSNs that begin with “900-” or “666-” are considered valid. Do not enter real PII in this field. text1a: such as a phone or computer. diff --git a/config/locales/doc_auth/es.yml b/config/locales/doc_auth/es.yml index 37571454714..d1801b37386 100644 --- a/config/locales/doc_auth/es.yml +++ b/config/locales/doc_auth/es.yml @@ -252,6 +252,7 @@ es: trasera de su identificación. switch_back: Regrese a su computadora para continuar con la verificación de su identidad. + switch_back_image: Flecha que apunta del teléfono a la computadora test_ssn: En el entorno de prueba solo los SSN que comienzan con “900-” o “666-” se consideran válidos. No ingrese PII real en este campo. text1a: como un teléfono o una computadora. diff --git a/config/locales/doc_auth/fr.yml b/config/locales/doc_auth/fr.yml index 49511596b52..63fda376067 100644 --- a/config/locales/doc_auth/fr.yml +++ b/config/locales/doc_auth/fr.yml @@ -267,6 +267,7 @@ fr: ce lien vers votre navigateur pour prendre des photos du recto et du verso de votre identifiant. switch_back: Retournez sur votre ordinateur pour continuer à vérifier votre identité. + switch_back_image: Flèche pointant du téléphone vers l’ordinateur test_ssn: Dans l’environnement de test seuls les SSN commençant par “900-” ou “900-” sont considérés comme valides. N’entrez pas de vrais PII dans ce champ. diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 76750bd4c96..74460ccaee2 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -6,10 +6,10 @@ es: %{last_number} buttons: cancel: Cancele y regrese a su perfil - change_address_label: Cambiar dirección - change_label: Cambio - change_ssn_label: Cambiar número de Seguridad Social - change_state_id_label: Cambiar ID de estado + change_address_label: Actualizar su dirección actual + change_label: Actualizar + change_ssn_label: Actualizar su número de la Seguridad Social + change_state_id_label: Actualizar la información de su cédula de identidad continue_plain: Continuar mail: resend: Enviar otra carta @@ -106,7 +106,7 @@ es: city: Ciudad dob: Fecha de nacimiento first_name: Nombre de pila - id_number: Número de identificación + id_number: Número de cédula last_name: Apellido password: Contraseña ssn: Número de seguridad social @@ -190,7 +190,7 @@ es: review: Vuelve a ingresar tu contraseña de %{app_name} para encriptar tus datos troubleshooting: headings: - are_you_near: '¿Se encuentra cerca de Washington D. C.?' + are_you_near: '¿Se encuentra cerca de la ciudad de Washington?' missing_required_items: '¿Le falta alguno de estos puntos?' need_assistance: '¿Necesita ayuda inmediata? Así es como puede obtener ayuda:' still_having_trouble: '¿Sigue teniendo dificultades?' @@ -208,8 +208,7 @@ es: supported_documents: Vea la lista de documentos de identidad emitidos por el estado que son aceptados verify_by_mail: Verifique su dirección por correo - verify_in_person: Verifique su identificación de manera presencial en una - oficina de correos + verify_in_person: Verifique su cédula de identidad en persona en una oficina de correos welcome: no_js_header: Debe habilitar JavaScript para verificar su identidad. no_js_intro: '%{sp_name} requiere que usted verifique su identidad. Debe diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index 2d9db2c6a10..1ab7443d94c 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -6,10 +6,10 @@ fr: %{last_number} buttons: cancel: Annuler et retourner à votre profil - change_address_label: Changement d’adresse - change_label: Changement - change_ssn_label: Changement de numéro de sécurité sociale - change_state_id_label: Modifier l’ID d’état + change_address_label: Mettre à jour votre adresse actuelle + change_label: Mettre à jour + change_ssn_label: Mettre à jour votre numéro de Sécurité Sociale + change_state_id_label: Mettre à jour les informations figurant sur votre document d’identité continue_plain: Continuer mail: resend: Envoyer une autre lettre @@ -117,7 +117,7 @@ fr: ssn: Numéro de sécurité sociale ssn_label_html: Numéro de sécurité sociale state: État - zipcode: Code ZIP + zipcode: Code postal index: id: need_html: Si vous créez un compte, vous aurez besoin d’un identifiant @@ -203,7 +203,7 @@ fr: review: Entrez à nouveau votre mot de passe %{app_name} pour crypter vos données troubleshooting: headings: - are_you_near: Êtes-vous près de Washington, D.C.? + are_you_near: Êtes-vous situé près de Washington, D.C.? missing_required_items: Est-ce qu’il vous manque un de ces éléments? need_assistance: 'Avez-vous besoin d’une assistance immédiate? Voici comment obtenir de l’aide:' @@ -221,7 +221,7 @@ fr: learn_more_verify_in_person: En savoir plus sur la vérification en personne supported_documents: Voir la liste des pièces d’identité acceptées et délivrées par l’État verify_by_mail: Vérifiez plutôt votre adresse par courrier - verify_in_person: Vérifiez votre identité en personne dans un bureau de poste + verify_in_person: Vérifier votre identité en personne dans un bureau de poste welcome: no_js_header: Vous devez activer JavaScript pour vérifier votre identité. no_js_intro: '%{sp_name} a besoin de vous pour vérifier votre identité. Vous diff --git a/config/locales/in_person_proofing/en.yml b/config/locales/in_person_proofing/en.yml index faf2ce8e0e4..ae4b35e8de7 100644 --- a/config/locales/in_person_proofing/en.yml +++ b/config/locales/in_person_proofing/en.yml @@ -21,6 +21,17 @@ en: retail_phone_label: Phone number speak_to_associate: You can speak with any retail associate at this Post Office to verify your identity. + location: + location_button: Select + location_step_about: If you are having trouble adding your ID, you may be able + to verify in person at a local United States Post Office in select + locations. + none_found: No locations found. + post_office: 'Post Office ™' + retail_hours_heading: Retail Hours + retail_hours_sat: 'Sat:' + retail_hours_sun: 'Sun:' + retail_hours_weekday: 'Monday to Friday:' prepare: alert_selected_post_office: 'You’ve selected the %{name} Post Office.' bring_barcode_header: A copy of your barcode @@ -77,13 +88,13 @@ en: location: Select a location to verify your ID prepare: Verify your identity in person state_id: Enter the information on your ID + switch_back: Switch back to your computer to prepare to verify your identity in person update_address: Update your current address update_state_id: Update the information on your ID process: barcode: caption_label: Enrollment code heading: A copy of your barcode - image_alt: Barcode info: Print or scan from your mobile device. proof_of_address: acceptable_proof: diff --git a/config/locales/in_person_proofing/es.yml b/config/locales/in_person_proofing/es.yml index 2546488f7e5..e1df11062d8 100644 --- a/config/locales/in_person_proofing/es.yml +++ b/config/locales/in_person_proofing/es.yml @@ -18,11 +18,22 @@ es: learn_more: Más información location_details: Detalles de la ubicación no_appointment_required: No es necesario pedir cita. - retail_hours: Heures de vente au détail + retail_hours: Horario de atención al público retail_hours_closed: Cerrado retail_phone_label: Número de teléfono speak_to_associate: Puedes hablar con cualquier socio de ventas en esta oficina de correos para verificar tu identidad. + location: + location_button: Seleccionar + location_step_about: Si tiene problemas para añadir su cédula de identidad, es + posible que pueda realizar la verificación en persona en una oficina + de correos local de los Estados Unidos en determinadas localidades. + none_found: No se encontraron localidades. + post_office: Oficina de Correos + retail_hours_heading: Horario de atención al público + retail_hours_sat: 'Sáb:' + retail_hours_sun: 'Dom:' + retail_hours_weekday: 'De Lunes a Viernes:' prepare: alert_selected_post_office: 'Ha seleccionado la oficina de correos %{name}.' bring_barcode_header: Una copia de su código de barras @@ -81,13 +92,14 @@ es: location: Seleccione un lugar para verificar su cédula prepare: Verifique su identidad en persona state_id: Ingrese la información de su cédula + switch_back: Vuelva a su computadora para prepararse para verificar su identidad + en persona update_address: Actualizar su dirección actual update_state_id: Actualizar la información de su cédula de identidad process: barcode: caption_label: Código de registro heading: Una copia de su código de barras - image_alt: Código de barras info: Imprima o escanee desde su dispositivo móvil. proof_of_address: acceptable_proof: diff --git a/config/locales/in_person_proofing/fr.yml b/config/locales/in_person_proofing/fr.yml index 0e333ff59f4..7db2b9364fa 100644 --- a/config/locales/in_person_proofing/fr.yml +++ b/config/locales/in_person_proofing/fr.yml @@ -24,6 +24,17 @@ fr: retail_phone_label: Numéro de téléphone speak_to_associate: Vous pouvez vous adresser à n’importe quel employé de ce bureau de poste pour faire vérifier votre identité. + location: + location_button: Sélectionner + location_step_about: Si vous ne parvenez pas à ajouter votre pièce d’identité, + vous pourrez peut-être vérifier en personne dans un bureau de poste + américain local dans certains endroits. + none_found: Aucun endroit trouvé. + post_office: Bureau de Poste + retail_hours_heading: Heures de vente au détail + retail_hours_sat: 'Sam:' + retail_hours_sun: 'Dim:' + retail_hours_weekday: 'Lundi à Vendredi:' prepare: alert_selected_post_office: 'Vous avez sélectionné le bureau de poste de %{name}.' bring_barcode_header: Une copie de votre code-barres @@ -85,13 +96,14 @@ fr: location: Sélectionnez un lieu pour vérifier votre identité prepare: Vérifiez votre identité en personne state_id: Saisissez les informations figurant sur votre document d’identité + switch_back: Retournez sur votre ordinateur pour vous préparer à vérifier votre + identité en personne update_address: Mettre à jour votre adresse actuelle update_state_id: Mettre à jour les informations figurant sur votre document d’identité process: barcode: caption_label: Code d’inscription heading: Une copie de votre code-barres - image_alt: Code-barres info: Imprimez ou numérisez depuis votre appareil mobile. proof_of_address: acceptable_proof: diff --git a/config/locales/step_indicator/es.yml b/config/locales/step_indicator/es.yml index 2cf963a9360..ba1363dce93 100644 --- a/config/locales/step_indicator/es.yml +++ b/config/locales/step_indicator/es.yml @@ -4,9 +4,9 @@ es: accessible_label: Progreso por pasos flows: idv: - find_a_post_office: Encontrar una Oficina de Correos + find_a_post_office: Encontrar una oficina de correos getting_started: Inicio - go_to_the_post_office: Ir a la Oficina de Correos + go_to_the_post_office: Ir a la oficina de correos secure_account: Proteje tu cuenta verify_id: Verifica tu identificación verify_info: Verifica tus datos personales diff --git a/config/locales/step_indicator/fr.yml b/config/locales/step_indicator/fr.yml index 2b6af4f389f..fa93938adeb 100644 --- a/config/locales/step_indicator/fr.yml +++ b/config/locales/step_indicator/fr.yml @@ -4,9 +4,9 @@ fr: accessible_label: Progression par étapes flows: idv: - find_a_post_office: Trouver un Bureau de Poste + find_a_post_office: Trouver un bureau de poste getting_started: Démarrer - go_to_the_post_office: Aller au Bureau de Poste + go_to_the_post_office: Se rendre au bureau de poste secure_account: Sécurisez votre compte verify_id: Vérifier votre identité verify_info: Vérifier vos données personnelles diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 7b6f7f33011..634f1ad81aa 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -100,6 +100,35 @@ en: %{app_name} %{help_link} or %{contact_link}. subject: Email address deleted help_link_text: Help Center + in_person_failed: + body: Click the button or copy the link below to try verifying your identity + online again through %{app_name}. If you are still experiencing issues, + please contact the agency you are trying to access. + intro: Your identity could not be verified at the %{location} Post Office on + %{date}. + subject: Your identity could not be verified in person + verifying_identity: 'When verifying your identity:' + verifying_step_not_expired: 'Your state-issued ID or driver’s license must not + be expired. We do not currently accept any other forms of + identification, such as passports and military IDs.' + verifying_step_proof_of_address: 'If you try to verify your identity in person + again, you need to bring a valid proof of address if your current + address is different than the address on your ID.' + in_person_ready_to_verify: + greeting: Hi %{name}, + intro: Here are the details to verify your identity in person at a United States + Post Office near you. + subject: You’re ready to verify your identity with %{app_name} in person + in_person_verified: + greeting: Hello, + intro: You successfully verified your identity at the %{location} Post Office on + %{date}. + next_sign_in: Next, click the button or copy the link below to sign in to %{app_name}. + sign_in: Sign in + subject: You successfully verified your identity with %{app_name} + warning_contact_us_html: If you did not attempt to verify your identity in + person, please contact us and sign in to change your password. letter_reminder: info_html: The letter you are about to receive will contain a confirmation code that helps us verify your address. You can complete the identity diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 9bcc9d62f61..9f0634ed76a 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -106,6 +106,38 @@ es: %{app_name} %{help_link} o el %{contact_link}. subject: Dirección de correo electrónico eliminada help_link_text: Centro de Ayuda + in_person_failed: + body: Haga clic en el botón o copie el enlace siguiente para volver a intentar + verificar su identidad en línea con %{app_name}. Si sigue teniendo + problemas, póngase en contacto con la agencia a la que intenta acceder. + intro: El %{date}, no se pudo verificar su identidad en la oficina de correos de + %{location}. + subject: No se pudo verificar su identidad en persona + verifying_identity: 'Al verificar su identidad:' + verifying_step_not_expired: Su documento de identidad o permiso de conducir + emitido por el estado debe estar vigente. Por el momento, no aceptamos + otras formas de identificación, como pasaportes o cartillas militares. + verifying_step_proof_of_address: Si vuelve a intentar verificar su identidad en + persona, deberá llevar un comprobante de domicilio vigente si su + dirección actual es distinta de la que aparece en su documento de + identidad. + in_person_ready_to_verify: + greeting: 'Hola, %{name}:' + intro: Estos son los detalles para verificar su identidad en persona en una + oficina de correos de los Estados Unidos cercana a usted. + subject: Está listo para verificar su identidad con %{app_name} en persona + in_person_verified: + greeting: Hola, + intro: El %{date}, verificó correctamente su identidad en la oficina de correos + de %{location}. + next_sign_in: Luego, haga clic en el botón o copie el enlace que aparece a + continuación para iniciar sesión en %{app_name}. + sign_in: Iniciar sesión + subject: Verificó correctamente su identidad con %{app_name} + warning_contact_us_html: Si usted no intentó verificar su identidad en persona, + por favor, póngase en contacto con + nosotros e inicie sesión para cambiar + su contraseña. letter_reminder: info_html: La carta que está a punto de recibir contendrá un código de confirmación que nos ayudará a verificar su dirección. Puede completar diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 616a7302c7f..1e72e5204ce 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -109,6 +109,40 @@ fr: veuillez visiter le %{help_link} de %{app_name} ou %{contact_link}. subject: Adresse email supprimée help_link_text: Centre d’aide + in_person_failed: + body: Cliquez sur le bouton ou copiez le lien ci-dessous pour essayer de + vérifier à nouveau votre identité en ligne par le biais de %{app_name}. + Si vous rencontrez toujours des problèmes, veuillez contacter l’agence à + laquelle vous essayez d’accéder. + intro: Votre identité n’a pas pu être vérifiée au bureau de poste de %{location} + le %{date}. + subject: Votre identité n’a pas pu être vérifiée en personne + verifying_identity: 'Lors de la vérification de votre identité :' + verifying_step_not_expired: Votre carte d’identité ou votre permis de conduire + délivré par l’État ne doit pas être périmé. Nous n’acceptons + actuellement aucune autre forme d’identification, comme les passeports + et les cartes d’identité militaires. + verifying_step_proof_of_address: Si vous tentez à nouveau de vérifier votre + identité en personne, vous devez apporter un justificatif de domicile + valable si votre adresse actuelle est différente de celle figurant sur + votre pièce d’identité. + in_person_ready_to_verify: + greeting: Bonjour %{name}, + intro: Voici les détails pour vérifier votre identité en personne dans un bureau + de poste des États-Unis près de chez vous. + subject: Vous êtes prêt à vérifier votre identité avec %{app_name} en personne + in_person_verified: + greeting: Bonjour, + intro: Vous avez vérifié avec succès votre identité au bureau de poste de + %{location} le %{date}. + next_sign_in: Ensuite, cliquez sur le bouton ou copiez le lien ci-dessous pour + vous connecter à %{app_name}. + sign_in: Se connecter + subject: Vous avez vérifié avec succès votre identité avec %{app_name} + warning_contact_us_html: Si vous n’avez pas essayé de vérifier votre identité en + personne, veuillez nous contacter et + vous connecter pour changer votre mot de + passe. letter_reminder: info_html: La lettre que vous êtes sur le point de recevoir contiendra un code de confirmation nous permettant de vérifier votre adresse. Vous pouvez diff --git a/config/routes.rb b/config/routes.rb index aaf5302755d..c4ece5930fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -329,6 +329,8 @@ get '/in_person' => 'in_person#index' get '/in_person/ready_to_verify' => 'in_person/ready_to_verify#show', as: :in_person_ready_to_verify + get '/in_person/usps_locations' => 'in_person/usps_locations#index' + put '/in_person/usps_locations' => 'in_person/usps_locations#update' get '/in_person/:step' => 'in_person#show', as: :in_person_step put '/in_person/:step' => 'in_person#update' diff --git a/config/service_providers.localdev.yml b/config/service_providers.localdev.yml index 8a693224a19..5e22299273b 100644 --- a/config/service_providers.localdev.yml +++ b/config/service_providers.localdev.yml @@ -419,6 +419,7 @@ development: redirect_uris: - 'http://localhost:3001/auth/logindotgov/callback' - 'http://localhost:3001' + push_notification_url: http://localhost:3001/api/security_events 'urn:gov:gsa:openidconnect:development': redirect_uris: diff --git a/db/primary_migrate/20220728223809_allow_null_profiles_on_in_person_enrollments.rb b/db/primary_migrate/20220728223809_allow_null_profiles_on_in_person_enrollments.rb new file mode 100644 index 00000000000..1ae58ee4cee --- /dev/null +++ b/db/primary_migrate/20220728223809_allow_null_profiles_on_in_person_enrollments.rb @@ -0,0 +1,5 @@ +class AllowNullProfilesOnInPersonEnrollments < ActiveRecord::Migration[7.0] + def change + change_column_null :in_person_enrollments, :profile_id, true + end +end diff --git a/db/primary_migrate/20220728223843_add_enrollment_established_at_to_in_person_enrollments.rb b/db/primary_migrate/20220728223843_add_enrollment_established_at_to_in_person_enrollments.rb new file mode 100644 index 00000000000..3fc06fb5439 --- /dev/null +++ b/db/primary_migrate/20220728223843_add_enrollment_established_at_to_in_person_enrollments.rb @@ -0,0 +1,5 @@ +class AddEnrollmentEstablishedAtToInPersonEnrollments < ActiveRecord::Migration[7.0] + def change + add_column :in_person_enrollments, :enrollment_established_at, :datetime, null: true, comment: "When the enrollment was successfully established" + end +end diff --git a/db/schema.rb b/db/schema.rb index e5ea41ec1ef..bacb570dbb6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_21_170157) do - +ActiveRecord::Schema[7.0].define(version: 2022_07_28_223843) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -19,14 +18,14 @@ create_table "account_reset_requests", force: :cascade do |t| t.integer "user_id", null: false - t.datetime "requested_at" + t.datetime "requested_at", precision: nil t.string "request_token" - t.datetime "cancelled_at" - t.datetime "reported_fraud_at" - t.datetime "granted_at" + t.datetime "cancelled_at", precision: nil + t.datetime "reported_fraud_at", precision: nil + t.datetime "granted_at", precision: nil t.string "granted_token" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["cancelled_at", "granted_at", "requested_at"], name: "index_account_reset_requests_on_timestamps" t.index ["granted_token"], name: "index_account_reset_requests_on_granted_token", unique: true t.index ["request_token"], name: "index_account_reset_requests_on_request_token", unique: true @@ -54,17 +53,17 @@ t.string "encrypted_otp_secret_key", null: false t.string "name", null: false t.integer "totp_timestamp" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["user_id", "created_at"], name: "index_auth_app_configurations_on_user_id_and_created_at", unique: true t.index ["user_id", "name"], name: "index_auth_app_configurations_on_user_id_and_name", unique: true end create_table "backup_code_configurations", force: :cascade do |t| t.integer "user_id", null: false - t.datetime "used_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "used_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "salted_code_fingerprint" t.string "code_salt" t.string "code_cost" @@ -75,8 +74,8 @@ create_table "deleted_users", force: :cascade do |t| t.integer "user_id", null: false t.string "uuid", null: false - t.datetime "user_created_at", null: false - t.datetime "deleted_at", null: false + t.datetime "user_created_at", precision: nil, null: false + t.datetime "deleted_at", precision: nil, null: false t.index ["user_id"], name: "index_deleted_users_on_user_id", unique: true t.index ["uuid"], name: "index_deleted_users_on_uuid", unique: true end @@ -85,98 +84,98 @@ t.integer "user_id", null: false t.string "cookie_uuid", null: false t.string "user_agent", null: false - t.datetime "last_used_at", null: false + t.datetime "last_used_at", precision: nil, null: false t.string "last_ip", limit: 255, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["cookie_uuid"], name: "index_devices_on_cookie_uuid" t.index ["user_id", "last_used_at"], name: "index_device_user_id_last_used_at" end create_table "doc_auth_logs", force: :cascade do |t| t.integer "user_id", null: false - t.datetime "welcome_view_at" + t.datetime "welcome_view_at", precision: nil t.integer "welcome_view_count", default: 0 - t.datetime "upload_view_at" + t.datetime "upload_view_at", precision: nil t.integer "upload_view_count", default: 0 - t.datetime "send_link_view_at" + t.datetime "send_link_view_at", precision: nil t.integer "send_link_view_count", default: 0 - t.datetime "link_sent_view_at" + t.datetime "link_sent_view_at", precision: nil t.integer "link_sent_view_count", default: 0 - t.datetime "email_sent_view_at" + t.datetime "email_sent_view_at", precision: nil t.integer "email_sent_view_count", default: 0 - t.datetime "front_image_view_at" + t.datetime "front_image_view_at", precision: nil t.integer "front_image_view_count", default: 0 t.integer "front_image_submit_count", default: 0 t.integer "front_image_error_count", default: 0 - t.datetime "back_image_view_at" + t.datetime "back_image_view_at", precision: nil t.integer "back_image_view_count", default: 0 t.integer "back_image_submit_count", default: 0 t.integer "back_image_error_count", default: 0 - t.datetime "mobile_front_image_view_at" + t.datetime "mobile_front_image_view_at", precision: nil t.integer "mobile_front_image_view_count", default: 0 - t.datetime "mobile_back_image_view_at" + t.datetime "mobile_back_image_view_at", precision: nil t.integer "mobile_back_image_view_count", default: 0 - t.datetime "ssn_view_at" + t.datetime "ssn_view_at", precision: nil t.integer "ssn_view_count", default: 0 - t.datetime "verify_view_at" + t.datetime "verify_view_at", precision: nil t.integer "verify_view_count", default: 0 t.integer "verify_submit_count", default: 0 t.integer "verify_error_count", default: 0 - t.datetime "verify_phone_view_at" + t.datetime "verify_phone_view_at", precision: nil t.integer "verify_phone_view_count", default: 0 - t.datetime "usps_address_view_at" + t.datetime "usps_address_view_at", precision: nil t.integer "usps_address_view_count", default: 0 - t.datetime "encrypt_view_at" + t.datetime "encrypt_view_at", precision: nil t.integer "encrypt_view_count", default: 0 - t.datetime "verified_view_at" + t.datetime "verified_view_at", precision: nil t.integer "verified_view_count", default: 0 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "mobile_front_image_submit_count", default: 0 t.integer "mobile_front_image_error_count", default: 0 t.integer "mobile_back_image_submit_count", default: 0 t.integer "mobile_back_image_error_count", default: 0 t.integer "usps_letter_sent_submit_count", default: 0 t.integer "usps_letter_sent_error_count", default: 0 - t.datetime "capture_mobile_back_image_view_at" + t.datetime "capture_mobile_back_image_view_at", precision: nil t.integer "capture_mobile_back_image_view_count", default: 0 - t.datetime "capture_complete_view_at" + t.datetime "capture_complete_view_at", precision: nil t.integer "capture_complete_view_count", default: 0 t.integer "capture_mobile_back_image_submit_count", default: 0 t.integer "capture_mobile_back_image_error_count", default: 0 - t.datetime "no_sp_session_started_at" - t.datetime "choose_method_view_at" + t.datetime "no_sp_session_started_at", precision: nil + t.datetime "choose_method_view_at", precision: nil t.integer "choose_method_view_count", default: 0 - t.datetime "present_cac_view_at" + t.datetime "present_cac_view_at", precision: nil t.integer "present_cac_view_count", default: 0 t.integer "present_cac_submit_count", default: 0 t.integer "present_cac_error_count", default: 0 - t.datetime "enter_info_view_at" + t.datetime "enter_info_view_at", precision: nil t.integer "enter_info_view_count", default: 0 - t.datetime "success_view_at" + t.datetime "success_view_at", precision: nil t.integer "success_view_count", default: 0 - t.datetime "selfie_view_at" + t.datetime "selfie_view_at", precision: nil t.integer "selfie_view_count", default: 0 t.integer "selfie_submit_count", default: 0 t.integer "selfie_error_count", default: 0 t.string "issuer" t.string "last_document_error" - t.datetime "document_capture_view_at" + t.datetime "document_capture_view_at", precision: nil t.integer "document_capture_view_count", default: 0 t.integer "document_capture_submit_count", default: 0 t.integer "document_capture_error_count", default: 0 - t.datetime "agreement_view_at" + t.datetime "agreement_view_at", precision: nil t.integer "agreement_view_count", default: 0 t.string "state" t.boolean "aamva" - t.datetime "verify_submit_at" + t.datetime "verify_submit_at", precision: nil t.integer "verify_phone_submit_count", default: 0 - t.datetime "verify_phone_submit_at" - t.datetime "document_capture_submit_at" - t.datetime "back_image_submit_at" - t.datetime "capture_mobile_back_image_submit_at" - t.datetime "mobile_back_image_submit_at" + t.datetime "verify_phone_submit_at", precision: nil + t.datetime "document_capture_submit_at", precision: nil + t.datetime "back_image_submit_at", precision: nil + t.datetime "capture_mobile_back_image_submit_at", precision: nil + t.datetime "mobile_back_image_submit_at", precision: nil t.index ["issuer"], name: "index_doc_auth_logs_on_issuer" t.index ["user_id"], name: "index_doc_auth_logs_on_user_id", unique: true t.index ["verified_view_at"], name: "index_doc_auth_logs_on_verified_view_at" @@ -186,12 +185,12 @@ t.string "uuid" t.string "result_id" t.bigint "user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "requested_at" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.datetime "requested_at", precision: nil t.boolean "ial2_strict" t.string "issuer" - t.datetime "cancelled_at" + t.datetime "cancelled_at", precision: nil t.boolean "ocr_confirmation_pending", default: false t.index ["result_id"], name: "index_document_capture_sessions_on_result_id" t.index ["user_id"], name: "index_document_capture_sessions_on_user_id" @@ -201,13 +200,13 @@ create_table "email_addresses", force: :cascade do |t| t.bigint "user_id" t.string "confirmation_token", limit: 255 - t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" + t.datetime "confirmed_at", precision: nil + t.datetime "confirmation_sent_at", precision: nil t.string "email_fingerprint", default: "", null: false t.string "encrypted_email", default: "", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "last_sign_in_at" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.datetime "last_sign_in_at", precision: nil t.index ["confirmation_token"], name: "index_email_addresses_on_confirmation_token", unique: true t.index ["email_fingerprint", "user_id"], name: "index_email_addresses_on_email_fingerprint_and_user_id", unique: true t.index ["email_fingerprint"], name: "index_email_addresses_on_email_fingerprint", unique: true, where: "(confirmed_at IS NOT NULL)" @@ -217,11 +216,11 @@ create_table "events", id: :serial, force: :cascade do |t| t.integer "user_id", null: false t.integer "event_type", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "device_id" t.string "ip" - t.datetime "disavowed_at" + t.datetime "disavowed_at", precision: nil t.string "disavowal_token_fingerprint" t.index ["device_id", "created_at"], name: "index_events_on_device_id_and_created_at" t.index ["disavowal_token_fingerprint"], name: "index_events_on_disavowal_token_fingerprint" @@ -257,10 +256,10 @@ create_table "identities", id: :serial, force: :cascade do |t| t.string "service_provider", limit: 255 - t.datetime "last_authenticated_at" + t.datetime "last_authenticated_at", precision: nil t.integer "user_id" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.string "session_uuid", limit: 255 t.string "uuid", null: false t.string "nonce" @@ -270,11 +269,11 @@ t.string "code_challenge" t.string "rails_session_id" t.json "verified_attributes" - t.datetime "verified_at" - t.datetime "last_consented_at" - t.datetime "last_ial1_authenticated_at" - t.datetime "last_ial2_authenticated_at" - t.datetime "deleted_at" + t.datetime "verified_at", precision: nil + t.datetime "last_consented_at", precision: nil + t.datetime "last_ial1_authenticated_at", precision: nil + t.datetime "last_ial2_authenticated_at", precision: nil + t.datetime "deleted_at", precision: nil t.index ["access_token"], name: "index_identities_on_access_token", unique: true t.index ["session_uuid"], name: "index_identities_on_session_uuid", unique: true t.index ["user_id", "service_provider"], name: "index_identities_on_user_id_and_service_provider", unique: true @@ -283,16 +282,17 @@ create_table "in_person_enrollments", comment: "Details and status of an in-person proofing enrollment for one user and profile", force: :cascade do |t| t.bigint "user_id", null: false, comment: "Foreign key to the user this enrollment belongs to" - t.bigint "profile_id", null: false, comment: "Foreign key to the profile this enrollment belongs to" + t.bigint "profile_id", comment: "Foreign key to the profile this enrollment belongs to" t.string "enrollment_code", comment: "The code returned by the USPS service" - t.datetime "status_check_attempted_at", comment: "The last time a status check was attempted" - t.datetime "status_updated_at", comment: "The last time the status was successfully updated with a value from the USPS API" + t.datetime "status_check_attempted_at", precision: nil, comment: "The last time a status check was attempted" + t.datetime "status_updated_at", precision: nil, comment: "The last time the status was successfully updated with a value from the USPS API" t.integer "status", default: 0, comment: "The status of the enrollment" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.boolean "current_address_matches_id", comment: "True if the user indicates that their current address matches the address on the ID they're bringing to the Post Office." t.jsonb "selected_location_details", comment: "The location details of the Post Office the user selected (including title, address, hours of operation)" t.string "unique_id", comment: "Unique ID to use with the USPS service" + t.datetime "enrollment_established_at", comment: "When the enrollment was successfully established" t.index ["profile_id"], name: "index_in_person_enrollments_on_profile_id" t.index ["unique_id"], name: "index_in_person_enrollments_on_unique_id", unique: true t.index ["user_id", "status"], name: "index_in_person_enrollments_on_user_id_and_status", unique: true, where: "(status = 1)" @@ -330,7 +330,7 @@ end create_table "letter_requests_to_usps_ftp_logs", force: :cascade do |t| - t.datetime "ftp_at", null: false + t.datetime "ftp_at", precision: nil, null: false t.integer "letter_requests_count", null: false t.index ["ftp_at"], name: "index_letter_requests_to_usps_ftp_logs_on_ftp_at" end @@ -353,12 +353,12 @@ end create_table "otp_requests_trackers", id: :serial, force: :cascade do |t| - t.datetime "otp_last_sent_at" + t.datetime "otp_last_sent_at", precision: nil t.integer "otp_send_count", default: 0 t.string "attribute_cost" t.string "phone_fingerprint", default: "", null: false - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.boolean "phone_confirmed", default: false t.index ["phone_fingerprint", "phone_confirmed"], name: "index_on_phone_and_confirmed", unique: true end @@ -390,11 +390,11 @@ t.text "encrypted_phone", null: false t.integer "delivery_preference", default: 0, null: false t.boolean "mfa_enabled", default: true, null: false - t.datetime "confirmation_sent_at" - t.datetime "confirmed_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "made_default_at" + t.datetime "confirmation_sent_at", precision: nil + t.datetime "confirmed_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.datetime "made_default_at", precision: nil t.index ["user_id", "made_default_at", "created_at"], name: "index_phone_configurations_on_made_default_at" end @@ -402,8 +402,8 @@ t.string "encrypted_phone" t.string "phone_fingerprint", null: false t.string "uuid" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["phone_fingerprint"], name: "index_phone_number_opt_outs_on_phone_fingerprint", unique: true t.index ["uuid"], name: "index_phone_number_opt_outs_on_uuid", unique: true end @@ -412,8 +412,8 @@ t.integer "user_id", null: false t.string "x509_dn_uuid", null: false t.string "name", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "x509_issuer" t.index ["user_id", "created_at"], name: "index_piv_cac_configurations_on_user_id_and_created_at", unique: true t.index ["user_id", "name"], name: "index_piv_cac_configurations_on_user_id_and_name", unique: true @@ -423,10 +423,10 @@ create_table "profiles", id: :serial, force: :cascade do |t| t.integer "user_id", null: false t.boolean "active", default: false, null: false - t.datetime "verified_at" - t.datetime "activated_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "verified_at", precision: nil + t.datetime "activated_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.text "encrypted_pii" t.string "ssn_signature", limit: 64 t.text "encrypted_pii_recovery" @@ -449,9 +449,9 @@ t.string "source_check" t.string "resolution_check" t.string "address_check" - t.datetime "verified_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "verified_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "liveness_check" t.index ["user_id"], name: "index_proofing_components_on_user_id", unique: true t.index ["verified_at"], name: "index_proofing_components_on_verified_at" @@ -466,8 +466,8 @@ t.integer "lexis_nexis_address_count", default: 0 t.integer "gpo_letter_count", default: 0 t.integer "phone_otp_count", default: 0 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "acuant_result_count", default: 0 t.integer "acuant_selfie_count", default: 0 t.index ["user_id"], name: "index_proofing_costs_on_user_id", unique: true @@ -475,13 +475,13 @@ create_table "registration_logs", force: :cascade do |t| t.integer "user_id", null: false - t.datetime "submitted_at", null: false - t.datetime "confirmed_at" - t.datetime "password_at" + t.datetime "submitted_at", precision: nil, null: false + t.datetime "confirmed_at", precision: nil + t.datetime "password_at", precision: nil t.string "first_mfa" - t.datetime "first_mfa_at" + t.datetime "first_mfa_at", precision: nil t.string "second_mfa" - t.datetime "registered_at" + t.datetime "registered_at", precision: nil t.index ["registered_at"], name: "index_registration_logs_on_registered_at" t.index ["submitted_at"], name: "index_registration_logs_on_submitted_at" t.index ["user_id"], name: "index_registration_logs_on_user_id", unique: true @@ -492,9 +492,9 @@ t.string "event_type", null: false t.string "jti" t.string "issuer" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "occurred_at" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.datetime "occurred_at", precision: nil t.index ["jti", "user_id", "issuer"], name: "index_security_events_on_jti_and_user_id_and_issuer", unique: true t.index ["user_id"], name: "index_security_events_on_user_id" end @@ -519,8 +519,8 @@ t.text "sp_initiated_login_url" t.text "return_to_sp_url" t.json "attribute_bundle" - t.datetime "created_at" - t.datetime "updated_at" + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil t.boolean "active", default: false, null: false t.boolean "approved", default: false, null: false t.boolean "native", default: false, null: false @@ -553,8 +553,8 @@ create_table "sign_in_restrictions", force: :cascade do |t| t.integer "user_id", null: false t.string "service_provider" - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.index ["user_id", "service_provider"], name: "index_sign_in_restrictions_on_user_id_and_service_provider", unique: true end @@ -562,20 +562,20 @@ t.string "issuer", null: false t.integer "agency_id", null: false t.string "cost_type", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "ial" t.string "transaction_id" t.index ["created_at"], name: "index_sp_costs_on_created_at" end create_table "sp_return_logs", force: :cascade do |t| - t.datetime "requested_at", null: false + t.datetime "requested_at", precision: nil, null: false t.string "request_id", null: false t.integer "ial", null: false t.string "issuer", null: false t.integer "user_id" - t.datetime "returned_at" + t.datetime "returned_at", precision: nil t.boolean "billable" t.index "((requested_at)::date), issuer", name: "index_sp_return_logs_on_requested_at_date_issuer", where: "(returned_at IS NOT NULL)" t.index ["request_id"], name: "index_sp_return_logs_on_request_id", unique: true @@ -583,25 +583,25 @@ create_table "users", id: :serial, force: :cascade do |t| t.string "reset_password_token", limit: 255 - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" - t.datetime "created_at" - t.datetime "updated_at" - t.datetime "confirmed_at" + t.datetime "reset_password_sent_at", precision: nil + t.datetime "remember_created_at", precision: nil + t.datetime "created_at", precision: nil + t.datetime "updated_at", precision: nil + t.datetime "confirmed_at", precision: nil t.integer "second_factor_attempts_count", default: 0 t.string "uuid", limit: 255, null: false - t.datetime "second_factor_locked_at" - t.datetime "phone_confirmed_at" + t.datetime "second_factor_locked_at", precision: nil + t.datetime "phone_confirmed_at", precision: nil t.string "direct_otp" - t.datetime "direct_otp_sent_at" + t.datetime "direct_otp_sent_at", precision: nil t.string "unique_session_id" t.integer "otp_delivery_preference", default: 0, null: false t.string "encrypted_password_digest", default: "" t.string "encrypted_recovery_code_digest", default: "" - t.datetime "remember_device_revoked_at" + t.datetime "remember_device_revoked_at", precision: nil t.string "email_language", limit: 10 - t.datetime "accepted_terms_at" - t.datetime "encrypted_recovery_code_digest_generated_at" + t.datetime "accepted_terms_at", precision: nil + t.datetime "encrypted_recovery_code_digest_generated_at", precision: nil t.date "non_restricted_mfa_required_prompt_skip_date" t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["uuid"], name: "index_users_on_uuid", unique: true @@ -610,18 +610,18 @@ create_table "usps_confirmation_codes", force: :cascade do |t| t.integer "profile_id", null: false t.string "otp_fingerprint", null: false - t.datetime "code_sent_at", default: -> { "CURRENT_TIMESTAMP" }, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "bounced_at" + t.datetime "code_sent_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.datetime "bounced_at", precision: nil t.index ["otp_fingerprint"], name: "index_usps_confirmation_codes_on_otp_fingerprint" t.index ["profile_id"], name: "index_usps_confirmation_codes_on_profile_id" end create_table "usps_confirmations", id: :serial, force: :cascade do |t| t.text "entry", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "webauthn_configurations", force: :cascade do |t| @@ -629,8 +629,8 @@ t.string "name", null: false t.text "credential_id", null: false t.text "credential_public_key", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.boolean "platform_authenticator" t.index ["user_id"], name: "index_webauthn_configurations_on_user_id" end diff --git a/db/worker_jobs_schema.rb b/db/worker_jobs_schema.rb index 597bfbcc5a8..5677efb08d4 100644 --- a/db/worker_jobs_schema.rb +++ b/db/worker_jobs_schema.rb @@ -10,15 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_20_185043) do - +ActiveRecord::Schema[7.0].define(version: 2022_07_20_185043) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", precision: 6, null: false - t.datetime "updated_at", precision: 6, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.jsonb "state" end @@ -26,17 +25,17 @@ t.text "queue_name" t.integer "priority" t.jsonb "serialized_params" - t.datetime "scheduled_at" - t.datetime "performed_at" - t.datetime "finished_at" + t.datetime "scheduled_at", precision: nil + t.datetime "performed_at", precision: nil + t.datetime "finished_at", precision: nil t.text "error" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.uuid "active_job_id" t.text "concurrency_key" t.text "cron_key" t.uuid "retried_good_job_id" - t.datetime "cron_at" + t.datetime "cron_at", precision: nil t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" t.index ["active_job_id"], name: "index_good_jobs_on_active_job_id" t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" diff --git a/docs/backend.md b/docs/backend.md index c177e9f03b7..e3329a88cc4 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -35,7 +35,7 @@ We use `ActiveModel::Model` validations to help build useful error structures. Forms should have a `#submit` method that returns a `FormResponse`. - `success:` is usually `#valid?` from ActiveModel -- `errors:` is usually `errors` from ActiveModel +- `errors:` is usually `#errors` from ActiveModel - `extra:` is, by convention, a method called `extra_analytics_attributes` that returns a Hash @@ -66,19 +66,34 @@ with YARD so that we can auto-generate ### Controllers -These tie everything together +These tie everything together! We aim for lean, "RESTful" controllers -```ruby -def index - form = MyForm.new(params) +* Keep as much business logic as possible out of controllers moving that logic + into Forms or Services + +* Prefer adding a new controller with one of the CRUD methods over creating a + custom method in an existing controller. For example, if your app allows a + user to update their email and their password on two different pages, instead of + using a single controller with methods called `update_email` and + `update_password`, create two controllers and name the methods `update`, i.e. + `EmailsController#update` and `PasswordsController#update`. See + http://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/ for more about + this design pattern. - result = form.submit - analytics.my_event(**result.to_h) - if result.success? - do_something(form.sensitive_value_here) - else - do_something_else +```ruby +class MyController < ApplicationController + def update + form = MyForm.new(params) + + result = form.submit + analytics.my_event(**result.to_h) + + if result.success? + do_something(form.sensitive_value_here) + else + do_something_else + end end end ``` diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 3487bf8eeb2..bbeeed761d2 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -210,6 +210,13 @@ def self.build_store(config_map) config.add(:lexisnexis_trueid_noliveness_cropping_workflow, type: :string) config.add(:lexisnexis_trueid_noliveness_nocropping_workflow, type: :string) config.add(:lexisnexis_trueid_timeout, type: :float) + config.add(:lexisnexis_threatmetrix_base_url, type: :string) + config.add(:lexisnexis_threatmetrix_request_mode, type: :string) + config.add(:lexisnexis_threatmetrix_account_id, type: :string) + config.add(:lexisnexis_threatmetrix_username, type: :string) + config.add(:lexisnexis_threatmetrix_password, type: :string) + config.add(:lexisnexis_threatmetrix_instant_verify_timeout, type: :float) + config.add(:lexisnexis_threatmetrix_instant_verify_workflow, type: :string) config.add(:liveness_checking_enabled, type: :boolean) config.add(:lockout_period_in_minutes, type: :integer) config.add(:log_to_stdout, type: :boolean) @@ -271,6 +278,8 @@ def self.build_store(config_map) config.add(:platform_authentication_enabled, type: :boolean) config.add(:poll_rate_for_verify_in_seconds, type: :integer) config.add(:proofer_mock_fallback, type: :boolean) + config.add(:proofing_device_profiling_collecting_enabled, type: :boolean) + config.add(:proofing_device_profiling_decisioning_enabled, type: :boolean) config.add(:proofing_send_partial_dob, type: :boolean) config.add(:proof_address_max_attempts, type: :integer) config.add(:proof_address_max_attempt_window_in_minutes, type: :integer) @@ -366,6 +375,7 @@ def self.build_store(config_map) config.add(:voip_allowed_phones, type: :json) config.add(:voip_block, type: :boolean) config.add(:voip_check, type: :boolean) + config.add(:inherited_proofing_va_base_url, type: :string) @store = RedactedStruct.new('IdentityConfig', *config.written_env.keys, keyword_init: true). new(**config.written_env) diff --git a/lib/linters/errors_add_linter.rb b/lib/linters/errors_add_linter.rb index 73c81e9bbfe..d6c9c9043e1 100644 --- a/lib/linters/errors_add_linter.rb +++ b/lib/linters/errors_add_linter.rb @@ -1,16 +1,16 @@ module RuboCop module Cop module IdentityIdp - # This lint ensures `redirect_back` is called with - # the fallback_location option and allow_other_host set to false. - # This is to prevent open redirects via the Referer header. + # This lint helps make sure that we have language-agnostic identifiers + # for errors that we log. The error strings are different in each locale + # so this helps us compare them more directly. # # @example # #bad # errors.add(:iss, 'invalid issuer') # # #good - # rrors.add(:iss, 'invalid issuer', type: issue) + # errors.add(:iss, 'invalid issuer', type: :invalid_issuer) # class ErrorsAddLinter < RuboCop::Cop::Cop MSG = 'Please set a unique key for this error'.freeze diff --git a/lib/session_encryptor.rb b/lib/session_encryptor.rb index b225a369588..397b7e46a09 100644 --- a/lib/session_encryptor.rb +++ b/lib/session_encryptor.rb @@ -5,9 +5,9 @@ class SensitiveValueError < StandardError; end NEW_CIPHERTEXT_HEADER = 'v2' SENSITIVE_KEYS = [ 'first_name', 'middle_name', 'last_name', 'address1', 'address2', 'city', 'state', 'zipcode', - 'zip_code', 'dob', 'phone_number', 'phone', 'ssn', 'prev_address1', 'prev_address2', - 'prev_city', 'prev_state', 'prev_zipcode', 'pii', 'pii_from_doc', 'pii_from_user', 'password', - 'personal_key', 'email', 'email_address', 'unconfirmed_phone' + 'zip_code', 'same_address_as_id', 'dob', 'phone_number', 'phone', 'ssn', 'prev_address1', + 'prev_address2', 'prev_city', 'prev_state', 'prev_zipcode', 'pii', 'pii_from_doc', + 'pii_from_user', 'password', 'personal_key', 'email', 'email_address', 'unconfirmed_phone' ].to_set.freeze # 'idv/doc_auth' and 'idv' are used during the proofing process and can contain PII diff --git a/spec/components/barcode_component_spec.rb b/spec/components/barcode_component_spec.rb new file mode 100644 index 00000000000..44e171fc434 --- /dev/null +++ b/spec/components/barcode_component_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe BarcodeComponent, type: :component do + it 'renders expected content' do + rendered = render_inline BarcodeComponent.new(barcode_data: '1234', label: 'Code') + + caption = page.find_css('table + div', text: 'Code: 1234').first + + expect(rendered).to have_css("table.barcode[aria-label=#{t('components.barcode.table_label')}]") + expect(rendered).to have_css("[role=figure][aria-labelledby=#{caption.attr(:id)}]") + expect(rendered).to have_css('table tbody[aria-hidden=true]') + end + + context 'with tag options' do + it 'renders with attributes' do + rendered = render_inline( + BarcodeComponent.new( + barcode_data: '1234', + label: '', + data: { foo: 'bar' }, + aria: { hidden: 'false' }, + class: 'example', + ), + ) + + expect(rendered).to have_css( + '.example[role=figure][aria-labelledby][data-foo=bar][aria-hidden=false]', + ) + end + end + + context 'with empty label' do + it 'renders label without prefix' do + rendered = render_inline BarcodeComponent.new(barcode_data: '1234', label: '') + + expect(rendered).to have_css('table + div', text: '1234') + end + end + + context 'with label formatter' do + it 'renders formatted label' do + rendered = render_inline BarcodeComponent.new( + barcode_data: '1234', + label: '', + label_formatter: ->(barcode_data) { barcode_data + '5678' }, + ) + + expect(rendered).to have_css('table + div', text: '12345678') + end + end +end diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index 7bbc6ba4160..ff263a19a8c 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -63,7 +63,7 @@ user = create( :user, :signed_up, - profiles: [build(:profile, deactivation_reason: :verification_pending)], + profiles: [build(:profile, deactivation_reason: :gpo_verification_pending)], ) sign_in user diff --git a/spec/controllers/api/verify/document_capture_controller_spec.rb b/spec/controllers/api/verify/document_capture_controller_spec.rb index 48faa73128f..573fae40621 100644 --- a/spec/controllers/api/verify/document_capture_controller_spec.rb +++ b/spec/controllers/api/verify/document_capture_controller_spec.rb @@ -75,16 +75,18 @@ it 'returns inprogress status when create is called' do agent = instance_double(Idv::Agent) allow(Idv::Agent).to receive(:new).with( - user_uuid: user.uuid, - uuid_prefix: nil, - document_arguments: { - 'encryption_key' => encryption_key, - 'front_image_iv' => front_image_iv, - 'back_image_iv' => back_image_iv, - 'selfie_image_iv' => selfie_image_iv, - 'front_image_url' => front_image_url, - 'back_image_url' => back_image_url, - 'selfie_image_url' => selfie_image_url, + { + user_uuid: user.uuid, + uuid_prefix: nil, + document_arguments: { + 'encryption_key' => encryption_key, + 'front_image_iv' => front_image_iv, + 'back_image_iv' => back_image_iv, + 'selfie_image_iv' => selfie_image_iv, + 'front_image_url' => front_image_url, + 'back_image_url' => back_image_url, + 'selfie_image_url' => selfie_image_url, + }, }, ).and_return(agent) diff --git a/spec/controllers/api/verify/password_confirm_controller_spec.rb b/spec/controllers/api/verify/password_confirm_controller_spec.rb index 8b235e1e3b4..8812e08e235 100644 --- a/spec/controllers/api/verify/password_confirm_controller_spec.rb +++ b/spec/controllers/api/verify/password_confirm_controller_spec.rb @@ -60,6 +60,14 @@ def stub_idv_session end context 'with in person profile' do + let(:applicant) { + Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE.merge(same_address_as_id: true) + } + let(:stub_idv_session) do + stub_user_with_applicant_data(user, applicant) + end + let!(:enrollment) { create(:in_person_enrollment, :establishing, user: user, profile: nil) } + before do ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) @@ -106,31 +114,48 @@ def stub_idv_session post :create, params: { password: password, user_bundle_token: jwt } end - it 'creates an in-person enrollment record' do - expect(InPersonEnrollment.count).to be(0) + it 'updates in-person enrollment record to associate profile' do post :create, params: { password: password, user_bundle_token: jwt } - expect(InPersonEnrollment.count).to be(1) - enrollment = InPersonEnrollment.where(user_id: user.id).first + enrollment.reload + expect(enrollment.status).to eq('pending') expect(enrollment.user_id).to eq(user.id) expect(enrollment.enrollment_code).to be_a(String) + expect(enrollment.profile).to eq(user.profiles.last) + expect(enrollment.profile.deactivation_reason).to eq('in_person_verification_pending') end it 'leaves the enrollment in establishing when no enrollment code is returned' do proofer = UspsInPersonProofing::Mock::Proofer.new expect(UspsInPersonProofing::Mock::Proofer).to receive(:new).and_return(proofer) expect(proofer).to receive(:request_enroll).and_return({}) - expect(InPersonEnrollment.count).to be(0) post :create, params: { password: password, user_bundle_token: jwt } + enrollment.reload + expect(InPersonEnrollment.count).to be(1) - enrollment = InPersonEnrollment.where(user_id: user.id).first expect(enrollment.status).to eq('establishing') - expect(enrollment.user_id).to eq(user.id) + expect(enrollment.user_id).to be(user.id) expect(enrollment.enrollment_code).to be_nil end + + it 'sends ready to verify email' do + mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) + user.email_addresses.each do |email_address| + expect(UserMailer).to receive(:in_person_ready_to_verify). + with( + user, + email_address, + enrollment: instance_of(InPersonEnrollment), + first_name: kind_of(String), + ). + and_return(mailer) + end + + post :create, params: { password: password, user_bundle_token: jwt } + end end context 'with associated sp session' do @@ -146,7 +171,13 @@ def stub_idv_session end context 'with pending profile' do - let(:jwt_metadata) { { vendor_phone_confirmation: false, user_phone_confirmation: false } } + let(:jwt_metadata) do + { + vendor_phone_confirmation: false, + user_phone_confirmation: false, + address_verification_mechanism: 'gpo', + } + end it 'creates a profile and returns completion url' do post :create, params: { password: password, user_bundle_token: jwt } @@ -155,6 +186,13 @@ def stub_idv_session end context 'with in person profile' do + let(:applicant) { + Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE.merge(same_address_as_id: true) + } + let(:stub_idv_session) do + stub_user_with_applicant_data(user, applicant) + end + before do ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 70d74a5c73a..c39d5da3144 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -134,6 +134,48 @@ def index expect(response).to redirect_to(new_user_session_url) end + + it 'redirects back to home page if present and referer is invalid' do + referer = '@@ABC' + + request.env['HTTP_REFERER'] = referer + + get :index + + expect(response).to redirect_to(new_user_session_url) + end + end + + describe 'handling UnsafeRedirectError exceptions' do + controller do + def index + raise ActionController::Redirecting::UnsafeRedirectError + end + end + + it 'tracks the Unsafe Redirect event and does not sign the user out' do + referer = '@@ABC' + request.env['HTTP_REFERER'] = referer + sign_in_as_user + expect(subject.current_user).to be_present + + stub_analytics + event_properties = { controller: 'anonymous#index', user_signed_in: true, referer: referer } + expect(@analytics).to receive(:track_event). + with('Unsafe Redirect', event_properties) + + get :index + + expect(flash[:error]).to eq t('errors.general') + expect(response).to redirect_to(root_url) + expect(subject.current_user).to be_present + end + + it 'redirects back to home page' do + get :index + + expect(response).to redirect_to(new_user_session_url) + end end describe '#append_info_to_payload' do diff --git a/spec/controllers/idv/capture_doc_status_controller_spec.rb b/spec/controllers/idv/capture_doc_status_controller_spec.rb index 27721c9a4c7..035a8e29413 100644 --- a/spec/controllers/idv/capture_doc_status_controller_spec.rb +++ b/spec/controllers/idv/capture_doc_status_controller_spec.rb @@ -234,5 +234,19 @@ end end end + + context 'when user opted for in-person proofing' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + create(:in_person_enrollment, :establishing, user: user, profile: nil) + end + + it 'returns success with redirect' do + get :show + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)).to include('redirect' => idv_in_person_url) + end + end end end diff --git a/spec/controllers/idv/doc_auth_controller_spec.rb b/spec/controllers/idv/doc_auth_controller_spec.rb index d4b0ae65d6a..56ae560a832 100644 --- a/spec/controllers/idv/doc_auth_controller_spec.rb +++ b/spec/controllers/idv/doc_auth_controller_spec.rb @@ -235,6 +235,16 @@ attention_with_barcode: false, } end + let(:hard_fail_result) do + { + pii_from_doc: {}, + success: false, + errors: { front: 'Wrong document' }, + messages: ['message'], + attention_with_barcode: false, + doc_auth_result: 'Failed', + } + end it 'returns status of success' do set_up_document_capture_result( @@ -284,14 +294,15 @@ errors: [{ field: 'front', message: 'Wrong document' }], remaining_attempts: IdentityConfig.store.doc_auth_max_attempts, ocr_pii: nil, + result_failed: false, }.to_json, ) end - it 'returns status of fail with incomplete PII from doc auth' do + it 'returns status of hard fail' do set_up_document_capture_result( uuid: document_capture_session_uuid, - idv_result: bad_pii_result, + idv_result: hard_fail_result, ) put :update, params: { @@ -303,15 +314,40 @@ expect(response.body).to eq( { success: false, - errors: [{ field: 'pii', - message: I18n.t('doc_auth.errors.general.no_liveness') }], + errors: [{ field: 'front', message: 'Wrong document' }], remaining_attempts: IdentityConfig.store.doc_auth_max_attempts, ocr_pii: nil, + result_failed: true, }.to_json, ) - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} verify_document_status submitted".downcase, { - errors: { pii: [I18n.t('doc_auth.errors.general.no_liveness')] }, + end + + it 'returns status of fail with incomplete PII from doc auth' do + set_up_document_capture_result( + uuid: document_capture_session_uuid, + idv_result: bad_pii_result, + ) + + expect(@analytics).to receive(:track_event).with( + "IdV: #{Analytics::DOC_AUTH.downcase} image upload vendor pii validation", include( + errors: include( + pii: [I18n.t('doc_auth.errors.general.no_liveness')], + ), + error_details: { pii: [I18n.t('doc_auth.errors.general.no_liveness')] }, + attention_with_barcode: false, + success: false, + remaining_attempts: IdentityConfig.store.doc_auth_max_attempts, + flow_path: 'standard', + pii_like_keypaths: [[:pii]], + user_id: nil, + ) + ) + + expect(@analytics).to receive(:track_event).with( + "IdV: #{Analytics::DOC_AUTH.downcase} verify_document_status submitted", include( + errors: include( + pii: [I18n.t('doc_auth.errors.general.no_liveness')], + ), error_details: { pii: [I18n.t('doc_auth.errors.general.no_liveness')] }, attention_with_barcode: false, success: false, @@ -320,7 +356,26 @@ flow_path: 'standard', step_count: 1, pii_like_keypaths: [[:pii]], - } + doc_auth_result: nil, + ) + ) + + put :update, + params: { + step: 'verify_document_status', + document_capture_session_uuid: document_capture_session_uuid, + } + + expect(response.status).to eq(400) + expect(response.body).to eq( + { + success: false, + errors: [{ field: 'pii', + message: I18n.t('doc_auth.errors.general.no_liveness') }], + remaining_attempts: IdentityConfig.store.doc_auth_max_attempts, + ocr_pii: nil, + result_failed: false, + }.to_json, ) end end diff --git a/spec/controllers/idv/gpo_verify_controller_spec.rb b/spec/controllers/idv/gpo_verify_controller_spec.rb index 7aefd6ae7c6..fa192bff695 100644 --- a/spec/controllers/idv/gpo_verify_controller_spec.rb +++ b/spec/controllers/idv/gpo_verify_controller_spec.rb @@ -5,7 +5,15 @@ let(:success) { true } let(:otp) { 'ABC123' } let(:submitted_otp) { otp } - let(:pending_profile) { build(:profile) } + let(:pending_profile) { + create( + :profile, + :with_pii, + user: user, + proofing_components: proofing_components, + ) + } + let(:proofing_components) { nil } let(:user) { create(:user) } before do @@ -96,6 +104,7 @@ 'IdV: GPO verification submitted', success: true, errors: {}, + pending_in_person_enrollment: false, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) @@ -106,6 +115,44 @@ expect(disavowal_event_count).to eq 1 expect(response).to redirect_to(sign_up_completed_url) end + + it 'dispatches account verified alert' do + expect(UserAlerts::AlertUserAboutAccountVerified).to receive(:call) + + action + end + + context 'with establishing in person enrollment' do + let(:proofing_components) { + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + } + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + allow(controller).to receive(:pii). + and_return(user.pending_profile.decrypt_pii(user.password).to_h) + end + + it 'redirects to ready to verify screen' do + expect(@analytics).to receive(:track_event).with( + 'IdV: GPO verification submitted', + success: true, + errors: {}, + pending_in_person_enrollment: true, + pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], + ) + + action + + expect(response).to redirect_to(idv_in_person_ready_to_verify_url) + end + + it 'does not dispatch account verified alert' do + expect(UserAlerts::AlertUserAboutAccountVerified).not_to receive(:call) + + action + end + end end context 'with an invalid form' do @@ -116,6 +163,7 @@ 'IdV: GPO verification submitted', success: false, errors: { otp: [t('errors.messages.confirmation_code_incorrect')] }, + pending_in_person_enrollment: false, error_details: { otp: [:confirmation_code_incorrect] }, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ) @@ -142,6 +190,7 @@ 'IdV: GPO verification submitted', success: false, errors: { otp: [t('errors.messages.confirmation_code_incorrect')] }, + pending_in_person_enrollment: false, error_details: { otp: [:confirmation_code_incorrect] }, pii_like_keypaths: [[:errors, :otp], [:error_details, :otp]], ).exactly(max_attempts).times diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 1691be5c6a0..cb60cad2084 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -159,6 +159,7 @@ success: false, errors: [{ field: 'front', message: 'Please fill in this field.' }], remaining_attempts: Throttle.max_attempts(:idv_doc_auth) - 2, + result_failed: false, ocr_pii: nil, }, ) @@ -176,6 +177,7 @@ errors: [{ field: 'limit', message: 'We could not verify your ID' }], redirect: idv_session_errors_throttled_url, remaining_attempts: 0, + result_failed: false, ocr_pii: nil, }, ) diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb new file mode 100644 index 00000000000..7c4cfaf4804 --- /dev/null +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +describe Idv::InPerson::UspsLocationsController do + include IdvHelper + + let(:user) { create(:user) } + let(:in_person_proofing_enabled) { false } + let(:selected_location) do + { + usps_location: { + formatted_city_state_zip: 'BALTIMORE, MD, 21233-9715', + name: 'BALTIMORE', + phone: '410-555-1212', + saturday_hours: '8:30 AM - 5:00 PM', + street_address: '123 Fake St.', + sunday_hours: 'Closed', + weekday_hours: '8:30 AM - 7:00 PM', + }, + } + end + + before do + stub_analytics + stub_sign_in(user) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled). + and_return(in_person_proofing_enabled) + end + + describe '#index' do + let(:proofer) { double('Proofer') } + let(:locations) { + [ + { name: 'Location 1' }, + { name: 'Location 2' }, + { name: 'Location 3' }, + { name: 'Location 4' }, + ] + } + subject(:response) { get :index } + + before do + allow(UspsInPersonProofing::Proofer).to receive(:new).and_return(proofer) + end + + context 'with successful fetch' do + before do + allow(proofer).to receive(:request_pilot_facilities).and_return(locations) + end + + it 'gets successful pilot response' do + response = get :index + json = response.body + facilities = JSON.parse(json) + expect(facilities.length).to eq 4 + end + end + + context 'with unsuccessful fetch' do + before do + exception = Faraday::ConnectionFailed.new('error') + allow(proofer).to receive(:request_pilot_facilities).and_raise(exception) + end + + it 'gets an empty pilot response' do + response = get :index + json = response.body + facilities = JSON.parse(json) + expect(facilities.length).to eq 0 + end + end + end + + describe '#update' do + it 'writes the passed location to in-person enrollment' do + put :update, params: selected_location + + expect(user.reload.establishing_in_person_enrollment.selected_location_details).to eq( + selected_location[:usps_location].as_json, + ) + end + + context 'with hybrid user' do + let(:user) { nil } + let(:effective_user) { create(:user) } + + before do + session[:doc_capture_user_id] = effective_user.id + end + + it 'writes the passed location to in-person enrollment associated with effective user' do + put :update, params: selected_location + + expect( + effective_user.reload.establishing_in_person_enrollment.selected_location_details, + ).to eq(selected_location[:usps_location].as_json) + end + end + end +end diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index e80f5700f48..7bc08e7106c 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -389,17 +389,18 @@ end it 'tracks throttled event' do - put :create, params: { idv_phone_form: { phone: bad_phone } } - stub_analytics allow(@analytics).to receive(:track_event) expect(@analytics).to receive(:track_event).with( 'Throttler Rate Limit Triggered', - throttle_type: :proof_address, - step_name: a_kind_of(Symbol), + { + throttle_type: :proof_address, + step_name: :phone, + }, ) + put :create, params: { idv_phone_form: { phone: bad_phone } } get :new end end diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index efc94e05bd9..bc3a856c817 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -207,7 +207,10 @@ def show expect(flash.now[:success]).to eq( t( 'idv.messages.review.info_verified_html', - phone_message: "#{t('idv.messages.phone.phone_of_record')}", + phone_message: ActionController::Base.helpers.content_tag( + :strong, + t('idv.messages.phone.phone_of_record'), + ), ), ) end @@ -355,6 +358,12 @@ def show expect(profile).to be_active end + it 'dispatches account verified alert' do + expect(UserAlerts::AlertUserAboutAccountVerified).to receive(:call) + + put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } + end + it 'creates an `account_verified` event once per confirmation' do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } disavowal_event_count = user.events.where(event_type: :account_verified, ip: '0.0.0.0'). @@ -374,6 +383,22 @@ def show expect(response).to redirect_to idv_app_url end end + + context 'with in person enrollment' do + before do + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + idv_session.applicant = Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE.merge( + same_address_as_id: true, + ).as_json + end + + it 'does not dispatch account verified alert' do + expect(UserAlerts::AlertUserAboutAccountVerified).not_to receive(:call) + + put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } + end + end end context 'user picked GPO confirmation' do diff --git a/spec/controllers/idv/session_errors_controller_spec.rb b/spec/controllers/idv/session_errors_controller_spec.rb index beea0a52cea..9927ff69e3c 100644 --- a/spec/controllers/idv/session_errors_controller_spec.rb +++ b/spec/controllers/idv/session_errors_controller_spec.rb @@ -77,6 +77,20 @@ expect(assigns(:remaining_attempts)).to be_kind_of(Numeric) end + + it 'assigns URL to try again' do + get action + + expect(assigns(:try_again_path)).to eq(idv_doc_auth_path) + end + + context 'referrer is page from In-Person Proofing flow' do + it 'assigns URL to try again' do + get action, params: { from: idv_in_person_ready_to_verify_path } + + expect(assigns(:try_again_path)).to eq(idv_in_person_path) + end + end end end diff --git a/spec/controllers/idv/sessions_controller_spec.rb b/spec/controllers/idv/sessions_controller_spec.rb index f4a5fc265fe..6d0f7d567b1 100644 --- a/spec/controllers/idv/sessions_controller_spec.rb +++ b/spec/controllers/idv/sessions_controller_spec.rb @@ -17,6 +17,7 @@ allow(idv_session).to receive(:clear) allow(subject).to receive(:idv_session).and_return(idv_session) controller.user_session['idv/doc_auth'] = flow_session + controller.user_session['idv/in_person'] = flow_session controller.user_session[:decrypted_pii] = pii end @@ -26,6 +27,7 @@ delete :destroy expect(controller.user_session['idv/doc_auth']).to be_blank + expect(controller.user_session['idv/in_person']).to be_blank expect(controller.user_session[:decrypted_pii]).to be_blank end @@ -49,7 +51,7 @@ let(:user) do create( :user, - profiles: [create(:profile, deactivation_reason: :verification_pending)], + profiles: [create(:profile, deactivation_reason: :gpo_verification_pending)], ) end diff --git a/spec/controllers/openid_connect/token_controller_spec.rb b/spec/controllers/openid_connect/token_controller_spec.rb index 03be4f8b7a8..c1ca44c68f7 100644 --- a/spec/controllers/openid_connect/token_controller_spec.rb +++ b/spec/controllers/openid_connect/token_controller_spec.rb @@ -53,11 +53,12 @@ it 'tracks a successful event in analytics' do stub_analytics expect(@analytics).to receive(:track_event). - with('OpenID Connect: token', - success: true, - client_id: client_id, - user_id: user.uuid, - errors: {}) + with('OpenID Connect: token', { + success: true, + client_id: client_id, + user_id: user.uuid, + errors: {}, + }) action end end @@ -77,12 +78,13 @@ it 'tracks an unsuccessful event in analytics' do stub_analytics expect(@analytics).to receive(:track_event). - with('OpenID Connect: token', - success: false, - client_id: client_id, - user_id: user.uuid, - errors: hash_including(:grant_type), - error_details: hash_including(:grant_type)) + with('OpenID Connect: token', { + success: false, + client_id: client_id, + user_id: user.uuid, + errors: hash_including(:grant_type), + error_details: hash_including(:grant_type), + }) action end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 89b45b4876a..9eda5cf5a2f 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -537,27 +537,28 @@ def name_id_version(format_urn) it 'tracks IAL2 authentication events' do stub_analytics expect(@analytics).to receive(:track_event). - with('SAML Auth Request', - requested_ial: authn_context, - service_provider: sp1_issuer) + with('SAML Auth Request', { + requested_ial: authn_context, + service_provider: sp1_issuer, + }) expect(@analytics).to receive(:track_event). - with('SAML Auth', - success: true, - errors: {}, - nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, - authn_context: [authn_context], - authn_context_comparison: 'exact', - requested_ial: authn_context, - service_provider: sp1_issuer, - endpoint: '/api/saml/auth2022', - idv: false, - finish_profile: false) + with('SAML Auth', { + success: true, + errors: {}, + nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + authn_context: [authn_context], + authn_context_comparison: 'exact', + requested_ial: authn_context, + service_provider: sp1_issuer, + endpoint: '/api/saml/auth2022', + idv: false, + finish_profile: false, + }) expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', + with('SP redirect initiated', { ial: ial, billed_ial: [ial, 2].min, - ) + }) allow(controller).to receive(:identity_needs_verification?).and_return(false) saml_get_auth(ial2_settings) @@ -694,27 +695,25 @@ def name_id_version(format_urn) it 'tracks IAL2 authentication events' do stub_analytics expect(@analytics).to receive(:track_event). - with('SAML Auth Request', - requested_ial: 'ialmax', - service_provider: sp1_issuer) + with('SAML Auth Request', { + requested_ial: 'ialmax', + service_provider: sp1_issuer, + }) expect(@analytics).to receive(:track_event). - with('SAML Auth', - success: true, - errors: {}, - nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, - authn_context: ['http://idmanagement.gov/ns/assurance/ial/1'], - authn_context_comparison: 'minimum', - requested_ial: 'ialmax', - service_provider: sp1_issuer, - endpoint: '/api/saml/auth2022', - idv: false, - finish_profile: false) + with('SAML Auth', { + success: true, + errors: {}, + nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, + authn_context: ['http://idmanagement.gov/ns/assurance/ial/1'], + authn_context_comparison: 'minimum', + requested_ial: 'ialmax', + service_provider: sp1_issuer, + endpoint: '/api/saml/auth2022', + idv: false, + finish_profile: false, + }) expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', - ial: 0, - billed_ial: 2, - ) + with('SP redirect initiated', { ial: 0, billed_ial: 2 }) allow(controller).to receive(:identity_needs_verification?).and_return(false) saml_get_auth(ialmax_settings) @@ -1380,9 +1379,10 @@ def name_id_version(format_urn) it 'logs SAML Auth Request but does not log SAML Auth' do stub_analytics expect(@analytics).to receive(:track_event). - with('SAML Auth Request', - requested_ial: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - service_provider: 'http://localhost:3000') + with('SAML Auth Request', { + requested_ial: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + service_provider: 'http://localhost:3000', + }) saml_get_auth(saml_settings) end @@ -1828,9 +1828,10 @@ def stub_auth } expect(@analytics).to receive(:track_event). - with('SAML Auth Request', - requested_ial: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, - service_provider: 'http://localhost:3000') + with('SAML Auth Request', { + requested_ial: Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF, + service_provider: 'http://localhost:3000', + }) expect(@analytics).to receive(:track_event). with('SAML Auth', analytics_hash) @@ -1870,16 +1871,13 @@ def stub_requested_attributes } expect(@analytics).to receive(:track_event). - with('SAML Auth Request', - requested_ial: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - service_provider: 'http://localhost:3000') + with('SAML Auth Request', { + requested_ial: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + service_provider: 'http://localhost:3000', + }) expect(@analytics).to receive(:track_event).with('SAML Auth', analytics_hash) expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', - ial: 1, - billed_ial: 1, - ) + with('SP redirect initiated', { ial: 1, billed_ial: 1 }) generate_saml_response(user) end @@ -1907,16 +1905,16 @@ def stub_requested_attributes } expect(@analytics).to receive(:track_event). - with('SAML Auth Request', - requested_ial: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, - service_provider: 'http://localhost:3000') + with('SAML Auth Request', { + requested_ial: Saml::Idp::Constants::IAL1_AUTHN_CONTEXT_CLASSREF, + service_provider: 'http://localhost:3000', + }) expect(@analytics).to receive(:track_event).with('SAML Auth', analytics_hash) expect(@analytics).to receive(:track_event). - with( - 'SP redirect initiated', + with('SP redirect initiated', { ial: 1, billed_ial: 1, - ) + }) generate_saml_response(user) end diff --git a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb index a1b8c2e1308..5e796cd5155 100644 --- a/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb +++ b/spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb @@ -56,6 +56,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, } expect(@analytics).to receive(:track_event). @@ -102,6 +104,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, } stub_analytics expect(@analytics).to receive(:track_mfa_submit_event). @@ -151,6 +155,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, } stub_analytics @@ -206,6 +212,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, } stub_analytics @@ -334,6 +342,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, } expect(@analytics).to receive(:track_event). @@ -400,6 +410,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, } expect(@analytics).to have_received(:track_event). @@ -448,6 +460,8 @@ area_code: parsed_phone.area_code, country_code: parsed_phone.country, phone_fingerprint: Pii::Fingerprinter.fingerprint(parsed_phone.e164), + enabled_mfa_methods_count: 0, + in_multi_mfa_selection_flow: false, } expect(@analytics).to have_received(:track_event). diff --git a/spec/controllers/users/mfa_selection_controller_spec.rb b/spec/controllers/users/mfa_selection_controller_spec.rb index 0535b0fb8b0..d1660277bef 100644 --- a/spec/controllers/users/mfa_selection_controller_spec.rb +++ b/spec/controllers/users/mfa_selection_controller_spec.rb @@ -36,8 +36,9 @@ params = ActionController::Parameters.new(voice_params) response = FormResponse.new(success: true, errors: {}, extra: { selection: ['voice'] }) + form_params = { user: user, aal3_required: false, piv_cac_required: nil } form = instance_double(TwoFactorOptionsForm) - allow(TwoFactorOptionsForm).to receive(:new).with(user).and_return(form) + allow(TwoFactorOptionsForm).to receive(:new).with(form_params).and_return(form) expect(form).to receive(:submit). with(params.require(:two_factor_options_form).permit(:selection)). and_return(response) diff --git a/spec/controllers/users/passwords_controller_spec.rb b/spec/controllers/users/passwords_controller_spec.rb index bbf18c82d6a..c869f2048d7 100644 --- a/spec/controllers/users/passwords_controller_spec.rb +++ b/spec/controllers/users/passwords_controller_spec.rb @@ -47,6 +47,16 @@ patch :update, params: { update_user_password_form: params } end + it 'sends a security event' do + user = create(:user) + stub_sign_in(user) + security_event = PushNotification::PasswordResetEvent.new(user: user) + expect(PushNotification::HttpPush).to receive(:deliver).with(security_event) + + params = { password: 'salty new password' } + patch :update, params: { update_user_password_form: params } + end + it 'sends the user an email' do user = create(:user) mail = double diff --git a/spec/controllers/users/reset_passwords_controller_spec.rb b/spec/controllers/users/reset_passwords_controller_spec.rb index 4de4f5a4f23..8127559f321 100644 --- a/spec/controllers/users/reset_passwords_controller_spec.rb +++ b/spec/controllers/users/reset_passwords_controller_spec.rb @@ -194,6 +194,9 @@ old_confirmed_at = user.reload.confirmed_at allow(user).to receive(:active_profile).and_return(nil) + security_event = PushNotification::PasswordResetEvent.new(user: user) + expect(PushNotification::HttpPush).to receive(:deliver).with(security_event) + stub_user_mailer(user) password = 'a really long passw0rd' @@ -235,6 +238,9 @@ ) _profile = create(:profile, :active, :verified, user: user) + security_event = PushNotification::PasswordResetEvent.new(user: user) + expect(PushNotification::HttpPush).to receive(:deliver).with(security_event) + stub_user_mailer(user) get :edit, params: { reset_password_token: raw_reset_token } @@ -274,6 +280,9 @@ reset_password_sent_at: Time.zone.now, ) + security_event = PushNotification::PasswordResetEvent.new(user: user) + expect(PushNotification::HttpPush).to receive(:deliver).with(security_event) + stub_user_mailer(user) password = 'a really long passw0rd' diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb index c77f2240f8f..56080339b47 100644 --- a/spec/controllers/users/sessions_controller_spec.rb +++ b/spec/controllers/users/sessions_controller_spec.rb @@ -128,6 +128,12 @@ sign_in_as_user + expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:logout_initiated).with( + user_uuid: controller.current_user.uuid, + unique_session_id: controller.current_user.unique_session_id, + success: true, + ) + get :destroy expect(controller.current_user).to be nil end @@ -146,6 +152,12 @@ sign_in_as_user + expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:logout_initiated).with( + user_uuid: controller.current_user.uuid, + unique_session_id: controller.current_user.unique_session_id, + success: true, + ) + delete :destroy expect(controller.current_user).to be nil end @@ -316,7 +328,7 @@ user = create(:user, :signed_up) create( :profile, - deactivation_reason: :verification_pending, + deactivation_reason: :gpo_verification_pending, user: user, pii: { ssn: '1234' } ) @@ -564,7 +576,7 @@ it 'redirects to the verify profile page' do profile = create( :profile, - deactivation_reason: :verification_pending, + deactivation_reason: :gpo_verification_pending, pii: { ssn: '6666', dob: '1920-01-01' }, ) user = profile.user diff --git a/spec/controllers/users/totp_setup_controller_spec.rb b/spec/controllers/users/totp_setup_controller_spec.rb index cc4095b742c..3c68d10f6a2 100644 --- a/spec/controllers/users/totp_setup_controller_spec.rb +++ b/spec/controllers/users/totp_setup_controller_spec.rb @@ -108,6 +108,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, + enabled_mfa_methods_count: 0, + in_multi_mfa_selection_flow: false, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -137,6 +140,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, + enabled_mfa_methods_count: 2, + in_multi_mfa_selection_flow: false, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -167,6 +173,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -198,6 +207,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: false, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -228,6 +240,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, + enabled_mfa_methods_count: 0, + in_multi_mfa_selection_flow: false, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). with('Multi-Factor Authentication Setup', result) @@ -259,6 +274,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: true, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -269,7 +287,7 @@ context 'when user has multiple MFA methods left in user session' do let(:mfa_selections) { ['auth_app', 'voice'] } - it 'redirects to mfa confirmation path with a success message and still logs analytics' do + it 'redirects to next mfa path with a success message and still logs analytics' do expect(response).to redirect_to(phone_setup_url) result = { @@ -278,6 +296,9 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, + enabled_mfa_methods_count: 1, + in_multi_mfa_selection_flow: true, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). @@ -306,6 +327,9 @@ totp_secret_present: false, multi_factor_auth_method: 'totp', auth_app_configuration_id: nil, + enabled_mfa_methods_count: 0, + in_multi_mfa_selection_flow: false, + pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to have_received(:track_event). diff --git a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb index f66ae7a4436..366a5b1d62c 100644 --- a/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb +++ b/spec/controllers/users/two_factor_authentication_setup_controller_spec.rb @@ -67,8 +67,9 @@ params = ActionController::Parameters.new(voice_params) response = FormResponse.new(success: true, errors: {}, extra: { selection: ['voice'] }) + form_params = { user: user, aal3_required: false, piv_cac_required: nil } form = instance_double(TwoFactorOptionsForm) - allow(TwoFactorOptionsForm).to receive(:new).with(user).and_return(form) + allow(TwoFactorOptionsForm).to receive(:new).with(form_params).and_return(form) expect(form).to receive(:submit). with(params.require(:two_factor_options_form).permit(:selection)). and_return(response) @@ -87,6 +88,7 @@ enabled_mfa_methods_count: 0, selection: ['voice', 'auth_app'], success: true, + selected_mfa_count: 2, errors: {}, } diff --git a/spec/controllers/users/webauthn_setup_controller_spec.rb b/spec/controllers/users/webauthn_setup_controller_spec.rb index 29ed30cf21a..a33a5545666 100644 --- a/spec/controllers/users/webauthn_setup_controller_spec.rb +++ b/spec/controllers/users/webauthn_setup_controller_spec.rb @@ -68,6 +68,7 @@ before do allow(IdentityConfig.store).to receive(:domain_name).and_return('localhost:3000') + request.host = 'localhost:3000' controller.user_session[:webauthn_challenge] = webauthn_challenge end @@ -80,27 +81,26 @@ multi_factor_auth_method: 'webauthn', success: true, errors: {}, + in_multi_mfa_selection_flow: false, pii_like_keypaths: [[:mfa_method_counts, :phone]], } expect(@analytics).to receive(:track_event). with('Multi-Factor Authentication Setup', result) expect(@analytics).to receive(:track_event). - with( - 'Multi-Factor Authentication: Added webauthn', + with('Multi-Factor Authentication: Added webauthn', { enabled_mfa_methods_count: 3, method_name: :webauthn, platform_authenticator: false, - ) + }) expect(@analytics).to receive(:track_event). - with( - 'User Registration: MFA Setup Complete', + with('User Registration: MFA Setup Complete', { enabled_mfa_methods_count: 3, mfa_method_counts: { auth_app: 1, phone: 1, webauthn: 1 }, pii_like_keypaths: [[:mfa_method_counts, :phone]], success: true, - ) + }) patch :confirm, params: params end @@ -172,6 +172,7 @@ stub_analytics stub_sign_in(user) allow(IdentityConfig.store).to receive(:domain_name).and_return('localhost:3000') + request.host = 'localhost:3000' controller.user_session[:webauthn_challenge] = webauthn_challenge end context ' Multiple MFA options turned on' do diff --git a/spec/features/idv/doc_auth/verify_step_spec.rb b/spec/features/idv/doc_auth/verify_step_spec.rb index d4f7fb6043c..faa38652603 100644 --- a/spec/features/idv/doc_auth/verify_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_step_spec.rb @@ -99,7 +99,9 @@ step_name: 'Idv::Steps::VerifyWaitStepShow', remaining_attempts: 4, ) - expect(page).to have_current_path(idv_session_errors_warning_path) + expect(page).to have_current_path( + idv_session_errors_warning_path(from: idv_doc_auth_step_path(step: :verify_wait)), + ) click_on t('idv.failure.button.warning') @@ -118,7 +120,9 @@ step_name: 'Idv::Steps::VerifyWaitStepShow', remaining_attempts: 5, ) - expect(page).to have_current_path(idv_session_errors_exception_path) + expect(page).to have_current_path( + idv_session_errors_exception_path(from: idv_doc_auth_step_path(step: :verify_wait)), + ) click_on t('idv.failure.button.warning') @@ -132,7 +136,9 @@ click_idv_continue (max_attempts - 1).times do click_idv_continue - expect(page).to have_current_path(idv_session_errors_warning_path) + expect(page).to have_current_path( + idv_session_errors_warning_path(from: idv_doc_auth_step_path(step: :verify_wait)), + ) visit idv_doc_auth_verify_step end click_idv_continue diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index bab09da2b80..2210daa2d13 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -17,11 +17,13 @@ it 'works for a happy path', allow_browser_log: true do user = user_with_2fa + sign_in_and_2fa_user(user) begin_in_person_proofing(user) # location page expect(page).to have_content(t('in_person_proofing.headings.location')) - complete_location_step(user) + bethesda_location = page.find_all('.location-collection-item')[1] + bethesda_location.click_button(t('in_person_proofing.body.location.location_button')) # prepare page expect(page).to have_content(t('in_person_proofing.headings.prepare')) @@ -95,10 +97,87 @@ expect(page).to have_content(t('in_person_proofing.headings.barcode')) expect(page).to have_content(Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code)) expect(page).to have_content(t('in_person_proofing.body.barcode.deadline', deadline: deadline)) + expect(page).to have_content('BETHESDA') + expect(page).to have_content( + "#{t('date.day_names')[6]}: #{t('in_person_proofing.body.barcode.retail_hours_closed')}", + ) + + # signing in again before completing in-person proofing at a post office + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_welcome_step + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + end + + it 'allows the user to cancel and start over from the beginning', allow_browser_log: true do + sign_in_and_2fa_user + begin_in_person_proofing + complete_all_in_person_proofing_steps + + click_link t('links.cancel') + click_on t('idv.cancel.actions.start_over') + + expect(page).to have_current_path(idv_doc_auth_welcome_step) + begin_in_person_proofing + complete_all_in_person_proofing_steps + end + + context 'with hybrid document capture' do + before do + allow(FeatureManagement).to receive(:doc_capture_polling_enabled?).and_return(true) + allow(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| + @sms_link = config[:link] + impl.call(**config) + end + end + + it 'resumes desktop session with in-person proofing', allow_browser_log: true do + user = nil + + perform_in_browser(:desktop) do + user = sign_in_and_2fa_user + complete_doc_auth_steps_before_send_link_step + fill_in :doc_auth_phone, with: '415-555-0199' + click_idv_continue + end + + perform_in_browser(:mobile) do + visit @sms_link + mock_doc_auth_attention_with_barcode + attach_and_submit_images + + click_button t('idv.troubleshooting.options.verify_in_person') + + bethesda_location = page.find_all('.location-collection-item')[1] + bethesda_location.click_button(t('in_person_proofing.body.location.location_button')) + + click_idv_continue + + expect(page).to have_content(t('in_person_proofing.headings.switch_back')) + end + + perform_in_browser(:desktop) do + expect(page).to have_current_path(idv_in_person_step_path(step: :state_id), wait: 10) + + complete_state_id_step(user) + complete_address_step(user) + complete_ssn_step(user) + complete_verify_step(user) + complete_phone_step(user) + complete_review_step(user) + acknowledge_and_confirm_personal_key + + expect(page).to have_content('BETHESDA') + end + end end context 'verify address by mail (GPO letter)' do + before do + allow(FeatureManagement).to receive(:reveal_gpo_code?).and_return(true) + end + it 'requires address verification before showing instructions', allow_browser_log: true do + sign_in_and_2fa_user begin_in_person_proofing complete_all_in_person_proofing_steps click_on t('idv.troubleshooting.options.verify_by_mail') @@ -109,7 +188,29 @@ expect(page).to have_content(t('idv.titles.come_back_later')) expect(page).to have_current_path(idv_come_back_later_path) - # WILLFIX: After LG-6897, assert that "Ready to Verify" content is shown after code entry. + click_idv_continue + expect(page).to have_current_path(account_path) + expect(page).not_to have_content(t('headings.account.verified_account')) + click_on t('account.index.verification.reactivate_button') + click_button t('forms.verify_profile.submit') + + expect(page).to have_current_path(idv_in_person_ready_to_verify_path) + expect(page).not_to have_content(t('account.index.verification.success')) + end + + it 'lets the user clear and start over from gpo confirmation', allow_browser_log: true do + sign_in_and_2fa_user + begin_in_person_proofing + complete_all_in_person_proofing_steps + click_on t('idv.troubleshooting.options.verify_by_mail') + click_on t('idv.buttons.mail.send') + complete_review_step + acknowledge_and_confirm_personal_key + click_idv_continue + click_on t('account.index.verification.reactivate_button') + click_on t('idv.messages.clear_and_start_over') + + expect(page).to have_current_path(idv_doc_auth_welcome_step) end end end diff --git a/spec/features/idv/steps/gpo_step_spec.rb b/spec/features/idv/steps/gpo_step_spec.rb index a57e024c93c..6e061cc639c 100644 --- a/spec/features/idv/steps/gpo_step_spec.rb +++ b/spec/features/idv/steps/gpo_step_spec.rb @@ -64,7 +64,7 @@ def expect_user_to_be_unverified(user) profile = user.profiles.first expect(profile.active?).to eq false - expect(profile.deactivation_reason).to eq 'verification_pending' + expect(profile.deactivation_reason).to eq 'gpo_verification_pending' end end end diff --git a/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb index 60f8137e511..a8adef0377d 100644 --- a/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb +++ b/spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb @@ -51,7 +51,7 @@ it 'does not prompt a pending user for a mailed code' do user = create( :profile, - deactivation_reason: :verification_pending, + deactivation_reason: :gpo_verification_pending, pii: { first_name: 'John', ssn: '111223333' }, ).user diff --git a/spec/features/openid_connect/aal3_required_spec.rb b/spec/features/openid_connect/aal3_required_spec.rb index 1d7e77734c5..5bf1a2930d2 100644 --- a/spec/features/openid_connect/aal3_required_spec.rb +++ b/spec/features/openid_connect/aal3_required_spec.rb @@ -47,6 +47,21 @@ expect(page).to have_content(t('two_factor_authentication.two_factor_aal3_choice')) expect(page).to have_xpath("//img[@alt='important alert icon']") end + + it 'throws an error if user doesnt select AAL3 auth' do + user = user_with_2fa + + visit_idp_from_ial1_oidc_sp_defaulting_to_aal3(prompt: 'select_account') + sign_in_live_with_2fa(user) + + expect(current_url).to eq(authentication_methods_setup_url) + expect(page).to have_content(t('two_factor_authentication.two_factor_aal3_choice')) + expect(page).to have_xpath("//img[@alt='important alert icon']") + + click_continue + + expect(page).to have_content(t('errors.two_factor_auth_setup.must_select_option')) + end end context 'user has aal3 auth configured' do diff --git a/spec/features/saml/ial2_sso_spec.rb b/spec/features/saml/ial2_sso_spec.rb index bc925fb60bf..830bfbbe9b2 100644 --- a/spec/features/saml/ial2_sso_spec.rb +++ b/spec/features/saml/ial2_sso_spec.rb @@ -85,7 +85,7 @@ def sign_out_user let(:profile) do create( :profile, - deactivation_reason: :verification_pending, + deactivation_reason: :gpo_verification_pending, pii: { ssn: '6666', dob: '1920-01-01' }, ) end diff --git a/spec/features/users/verify_profile_spec.rb b/spec/features/users/verify_profile_spec.rb index 0b246e23abb..a979c0f966b 100644 --- a/spec/features/users/verify_profile_spec.rb +++ b/spec/features/users/verify_profile_spec.rb @@ -7,7 +7,7 @@ before do profile = create( :profile, - deactivation_reason: :verification_pending, + deactivation_reason: :gpo_verification_pending, pii: { ssn: '666-66-1234', dob: '1920-01-01', phone: '+1 703-555-9999' }, user: user, ) diff --git a/spec/fixtures/usps_ipp_responses/enrollment_selected_location_details.json b/spec/fixtures/usps_ipp_responses/enrollment_selected_location_details.json new file mode 100644 index 00000000000..76ef1d31cd7 --- /dev/null +++ b/spec/fixtures/usps_ipp_responses/enrollment_selected_location_details.json @@ -0,0 +1,10 @@ +{ + "formatted_city_state_zip": "ARLINGTON, VA, 22201-9998", + "id": 4, + "name": "ARLINGTON", + "phone": "703-993-0072", + "saturday_hours": "9:00 AM - 1:00 PM", + "street_address": "3118 WASHINGTON BLVD", + "sunday_hours": "Closed", + "weekday_hours": "9:00 AM - 5:00 PM" +} diff --git a/spec/fixtures/usps_ipp_responses/request_show_usps_location_response.json b/spec/fixtures/usps_ipp_responses/request_show_usps_location_response.json new file mode 100644 index 00000000000..9356efbf6ca --- /dev/null +++ b/spec/fixtures/usps_ipp_responses/request_show_usps_location_response.json @@ -0,0 +1,10 @@ +{ + "formattedCityStateZip": "ARLINGTON, VA, 22201-9998", + "id": 4, + "name": "ARLINGTON", + "phone": "703-993-0072", + "saturdayHours": "9:00 AM - 1:00 PM", + "streetAddress": "3118 WASHINGTON BLVD", + "sundayHours": "Closed", + "weekdayHours": "9:00 AM - 5:00 PM" +} diff --git a/spec/forms/api/profile_creation_form_spec.rb b/spec/forms/api/profile_creation_form_spec.rb index bc351f67790..7c1330ca326 100644 --- a/spec/forms/api/profile_creation_form_spec.rb +++ b/spec/forms/api/profile_creation_form_spec.rb @@ -78,6 +78,35 @@ expect(stored_pii['first_name']).to eq 'Ada' end + + context 'with establishing in person enrollment' do + let!(:enrollment) do + create(:in_person_enrollment, :establishing, user: user, profile: nil) + end + + before do + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + end + + it 'sets profile to pending in person verification' do + subject.submit + profile = user.profiles.first + + expect(profile.active?).to be false + expect(profile.deactivation_reason).to eq('in_person_verification_pending') + end + + it 'saves in person enrollment' do + expect(UspsInPersonProofing::EnrollmentHelper). + to receive(:schedule_in_person_enrollment). + with(user, Pii::Attributes.new_from_hash(pii)) + + subject.submit + + expect(enrollment.reload.profile).to eq(user.profiles.last) + end + end end context 'with the user having verified their address via GPO letter' do @@ -92,6 +121,7 @@ profile = user.profiles.first expect(profile.active?).to be false + expect(profile.deactivation_reason).to eq('gpo_verification_pending') end it 'moves the pii to the user_session' do @@ -121,6 +151,21 @@ expect(subject.gpo_code).to eq(gpo_code) end end + + context 'with establishing in person enrollment' do + before do + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + end + + it 'does not activate the user profile' do + subject.submit + profile = user.profiles.first + + expect(profile.active?).to be false + expect(profile.deactivation_reason).to eq('gpo_verification_pending') + end + end end end diff --git a/spec/forms/gpo_verify_form_spec.rb b/spec/forms/gpo_verify_form_spec.rb index 2032ba1cd8a..4881c8d1627 100644 --- a/spec/forms/gpo_verify_form_spec.rb +++ b/spec/forms/gpo_verify_form_spec.rb @@ -2,14 +2,23 @@ describe GpoVerifyForm do subject(:form) do - GpoVerifyForm.new(user: user, otp: entered_otp) + GpoVerifyForm.new(user: user, pii: applicant, otp: entered_otp) end - let(:user) { pending_profile.user } + let(:user) { create(:user, :signed_up) } + let(:applicant) { Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE.merge(same_address_as_id: true) } let(:entered_otp) { otp } let(:otp) { 'ABC123' } let(:code_sent_at) { Time.zone.now } - let(:pending_profile) { create(:profile, deactivation_reason: :verification_pending) } + let(:pending_profile) { + create( + :profile, + user: user, + deactivation_reason: :gpo_verification_pending, + proofing_components: proofing_components, + ) + } + let(:proofing_components) { nil } before do next if pending_profile.blank? @@ -101,6 +110,36 @@ expect(pending_profile.reload).to be_active end + + context 'pending in person enrollment' do + let!(:enrollment) do + create(:in_person_enrollment, :establishing, profile: pending_profile, user: user) + end + let(:proofing_components) { + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + } + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + end + + it 'sets profile to pending in person verification' do + subject.submit + pending_profile.reload + + expect(pending_profile).not_to be_active + expect(pending_profile.deactivation_reason).to eq('in_person_verification_pending') + end + + it 'updates establishing in-person enrollment to pending' do + subject.submit + + enrollment.reload + + expect(enrollment.status).to eq('pending') + expect(enrollment.user_id).to eq(user.id) + expect(enrollment.enrollment_code).to be_a(String) + end + end end context 'incorrect OTP' do diff --git a/spec/forms/idv/api_document_verification_status_form_spec.rb b/spec/forms/idv/api_document_verification_status_form_spec.rb index 431f481d1ec..402d02817d9 100644 --- a/spec/forms/idv/api_document_verification_status_form_spec.rb +++ b/spec/forms/idv/api_document_verification_status_form_spec.rb @@ -86,5 +86,18 @@ response = form.submit expect(response.extra[:remaining_attempts]).to be_a_kind_of(Numeric) end + + it 'includes doc_auth_result' do + response = form.submit + expect(response.extra[:doc_auth_result]).to be_nil + + expect(async_state).to receive(:result).and_return(doc_auth_result: nil) + response = form.submit + expect(response.extra[:doc_auth_result]).to be_nil + + expect(async_state).to receive(:result).and_return(doc_auth_result: 'Failed') + response = form.submit + expect(response.extra[:doc_auth_result]).to eq('Failed') + end end end diff --git a/spec/forms/idv/inherited_proofing/va/form_spec.rb b/spec/forms/idv/inherited_proofing/va/form_spec.rb new file mode 100644 index 00000000000..ba74559849d --- /dev/null +++ b/spec/forms/idv/inherited_proofing/va/form_spec.rb @@ -0,0 +1,216 @@ +require 'rails_helper' + +RSpec.shared_examples 'the hash is blank?' do + it 'raises an error' do + expect { subject }.to raise_error 'payload_hash is blank?' + end +end + +RSpec.describe Idv::InheritedProofing::Va::Form do + subject(:form) { described_class.new payload_hash: payload_hash } + + let(:payload_hash) do + { + first_name: 'Henry', + last_name: 'Ford', + phone: '12222222222', + birth_date: '2000-01-01', + ssn: '111223333', + address: { + street: '1234 Model Street', + street2: 'Suite A', + city: 'Detroit', + state: 'MI', + country: 'United States', + zip: '12345', + }, + } + end + + describe 'class methods' do + describe '.model_name' do + it 'returns the right model name' do + expect(described_class.model_name).to eq 'IdvInheritedProofingVaForm' + end + end + + describe '.field_names' do + let(:expected_field_names) do + [ + :address_city, + :address_country, + :address_state, + :address_street, + :address_street2, + :address_zip, + :birth_date, + :first_name, + :last_name, + :phone, + :ssn, + ].sort + end + + it 'returns the right model name' do + expect(described_class.field_names).to match_array expected_field_names + end + end + end + + describe '#initialize' do + context 'when passing an invalid payload hash' do + context 'when not a Hash' do + let(:payload_hash) { :x } + + it 'raises an error' do + expect { subject }.to raise_error 'payload_hash is not a Hash' + end + end + + context 'when nil?' do + let(:payload_hash) { nil } + + it_behaves_like 'the hash is blank?' + end + + context 'when empty?' do + let(:payload_hash) { {} } + + it_behaves_like 'the hash is blank?' + end + end + + context 'when passing a valid payload hash' do + it 'raises no errors' do + expect { subject }.to_not raise_error + end + end + end + + describe '#validate' do + context 'with valid payload data' do + it 'returns true' do + expect(subject.validate).to eq true + end + end + + context 'with invalid payload data' do + context 'when the payload has missing fields' do + let(:payload_hash) do + { + xfirst_name: 'Henry', + xlast_name: 'Ford', + xphone: '12222222222', + xbirth_date: '2000-01-01', + xssn: '111223333', + xaddress: { + xstreet: '1234 Model Street', + xstreet2: 'Suite A', + xcity: 'Detroit', + xstate: 'MI', + xcountry: 'United States', + xzip: '12345', + }, + } + end + + let(:expected_error_messages) do + [ + # Required field presence + 'First name field is missing', + 'Last name field is missing', + 'Phone field is missing', + 'Birth date field is missing', + 'Ssn field is missing', + 'Address street field is missing', + 'Address street2 field is missing', + 'Address city field is missing', + 'Address state field is missing', + 'Address country field is missing', + 'Address zip field is missing', + ] + end + + it 'returns false' do + expect(subject.validate).to eq false + end + + it 'adds the correct error messages for missing fields' do + subject.validate + expect( + expected_error_messages.all? do |error_message| + subject.errors.full_messages.include? error_message + end, + ).to eq true + end + end + + context 'when the payload has missing required field data' do + let(:payload_hash) do + { + first_name: nil, + last_name: '', + phone: nil, + birth_date: '', + ssn: nil, + address: { + street: '', + street2: nil, + city: '', + state: nil, + country: '', + zip: nil, + }, + } + end + + let(:expected_error_messages) do + [ + # Required field data presence + 'First name Please fill in this field.', + 'Last name Please fill in this field.', + 'Birth date Please fill in this field.', + 'Ssn Please fill in this field.', + 'Address street Please fill in this field.', + 'Address zip Please fill in this field.', + ] + end + + it 'returns false' do + expect(subject.validate).to eq false + end + + it 'adds the correct error messages for required fields that are missing data' do + subject.validate + expect(subject.errors.full_messages).to match_array expected_error_messages + end + end + end + end + + describe '#submit' do + it 'returns a FormResponse object' do + expect(subject.submit).to be_kind_of FormResponse + end + + describe 'before returning' do + after do + subject.submit + end + + it 'calls #validate' do + expect(subject).to receive(:validate).once + end + end + + context 'with an invalid payload' do + context 'when the payload has missing fields' do + it 'returns a FormResponse indicating errors' + end + + context 'when the payload has invalid field data' do + it 'returns a FormResponse indicating errors' + end + end + end +end diff --git a/spec/forms/totp_setup_form_spec.rb b/spec/forms/totp_setup_form_spec.rb index ec01fd29ee4..a7d0bdc78fe 100644 --- a/spec/forms/totp_setup_form_spec.rb +++ b/spec/forms/totp_setup_form_spec.rb @@ -14,6 +14,7 @@ totp_secret_present: true, multi_factor_auth_method: 'totp', auth_app_configuration_id: next_auth_app_id, + enabled_mfa_methods_count: 1, } expect(form.submit.to_h).to eq( diff --git a/spec/forms/two_factor_options_form_spec.rb b/spec/forms/two_factor_options_form_spec.rb index 928a2edced2..5e345d10627 100644 --- a/spec/forms/two_factor_options_form_spec.rb +++ b/spec/forms/two_factor_options_form_spec.rb @@ -2,7 +2,15 @@ describe TwoFactorOptionsForm do let(:user) { build(:user) } - subject { described_class.new(user) } + let(:aal3_required) { false } + let(:piv_cac_required) { false } + subject { + described_class.new( + user: user, + aal3_required: aal3_required, + piv_cac_required: piv_cac_required, + ) + } describe '#submit' do let(:submit_phone) { subject.submit(selection: 'phone') } @@ -120,6 +128,37 @@ end end + context 'when a user wants to is required to add piv_cac on sign in' do + let(:user) { build(:user, :with_authentication_app) } + let(:enabled_mfa_methods_count) { 1 } + let(:mfa_selection) { ['phone'] } + let(:aal3_required) { true } + let(:piv_cac_required) { false } + + before do + allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) + end + + context 'when user is didnt select an mfa' do + let(:mfa_selection) { nil } + + it 'does not submits the form' do + submission = subject.submit(selection: mfa_selection) + expect(submission.success?).to be_falsey + end + end + + context 'when user selects an mfa' do + it 'submits the form' do + submission = subject.submit(selection: mfa_selection) + expect(submission.success?).to be_truthy + end + end + end + + context 'when user doesnt select mfa selection with existing account' do + end + context 'when the feature flag toggle for 2FA phone restriction is off' do before do allow(IdentityConfig.store).to receive(:select_multiple_mfa_options).and_return(true) diff --git a/spec/helpers/link_helper_spec.rb b/spec/helpers/link_helper_spec.rb index 52fddb42a47..b19b8d8dccf 100644 --- a/spec/helpers/link_helper_spec.rb +++ b/spec/helpers/link_helper_spec.rb @@ -39,7 +39,8 @@ it 'renders a form' do expect(subject).to have_selector("form[action='#{url}']") expect(subject).to have_selector("input[name='_method'][value='#{method}']", visible: :all) - expect(subject).to have_selector("input.#{css_class}[type='submit'][value='#{text}']") + expect(subject).to have_selector("button.#{css_class}[type='submit']") + expect(subject).to have_text(text) end end end diff --git a/spec/helpers/script_helper_spec.rb b/spec/helpers/script_helper_spec.rb index 57173e5a241..1483415be93 100644 --- a/spec/helpers/script_helper_spec.rb +++ b/spec/helpers/script_helper_spec.rb @@ -43,9 +43,9 @@ 'script[type="application/json"][data-asset-map]', visible: :all, text: { - 'clock.svg' => '/clock.svg', + 'clock.svg' => 'http://test.host/clock.svg', 'identity-style-guide/dist/assets/img/sprite.svg' => - '/identity-style-guide/dist/assets/img/sprite.svg', + 'http://test.host/identity-style-guide/dist/assets/img/sprite.svg', }.to_json, ) end diff --git a/spec/javascripts/packages/document-capture-polling/index-spec.js b/spec/javascripts/packages/document-capture-polling/index-spec.js index 293e7b0f135..598480cf85b 100644 --- a/spec/javascripts/packages/document-capture-polling/index-spec.js +++ b/spec/javascripts/packages/document-capture-polling/index-spec.js @@ -49,12 +49,16 @@ describe('DocumentCapturePolling', () => { }); it('polls', async () => { - sandbox.stub(window, 'fetch').withArgs('/status').resolves({ status: 202 }); + sandbox + .stub(window, 'fetch') + .withArgs('/status') + .resolves({ status: 202, json: () => Promise.resolve({}) }); sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); expect(window.fetch).to.have.been.calledOnce(); - await flushPromises(); + await flushPromises(); // Flush `fetch` + await flushPromises(); // Flush `json` sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); expect(window.fetch).to.have.been.calledTwice(); @@ -64,11 +68,15 @@ describe('DocumentCapturePolling', () => { it('submits when done', async () => { sandbox.stub(subject.elements.form, 'submit'); - sandbox.stub(window, 'fetch').withArgs('/status').resolves({ status: 200 }); + sandbox + .stub(window, 'fetch') + .withArgs('/status') + .resolves({ status: 200, json: () => Promise.resolve({}) }); subject.bind(); sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); - await flushPromises(); + await flushPromises(); // Flush `fetch` + await flushPromises(); // Flush `json` expect(subject.elements.form.submit).to.have.been.called(); expect(trackEvent).to.have.been.calledWith('IdV: Link sent capture doc polling started'); @@ -78,12 +86,31 @@ describe('DocumentCapturePolling', () => { }); }); + it('redirects if given redirect URL on success', async () => { + sandbox.stub(subject.elements.form, 'submit'); + sandbox + .stub(window, 'fetch') + .withArgs('/status') + .resolves({ status: 200, json: () => Promise.resolve({ redirect: '#redirect' }) }); + + sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); + await flushPromises(); // Flush `fetch` + await flushPromises(); // Flush `json` + + expect(window.location.hash).to.equal('#redirect'); + expect(subject.elements.form.submit).not.to.have.been.called(); + }); + it('submits when cancelled', async () => { sandbox.stub(subject.elements.form, 'submit'); - sandbox.stub(window, 'fetch').withArgs('/status').resolves({ status: 410 }); + sandbox + .stub(window, 'fetch') + .withArgs('/status') + .resolves({ status: 410, json: () => Promise.resolve({}) }); sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); - await flushPromises(); + await flushPromises(); // Flush `fetch` + await flushPromises(); // Flush `json` expect(trackEvent).to.have.been.calledWith('IdV: Link sent capture doc polling started'); expect(trackEvent).to.have.been.calledWith('IdV: Link sent capture doc polling complete', { @@ -100,7 +127,8 @@ describe('DocumentCapturePolling', () => { .resolves({ status: 429, json: () => Promise.resolve({ redirect: '#throttled' }) }); sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); - await flushPromises(); + await flushPromises(); // Flush `fetch` + await flushPromises(); // Flush `json` expect(trackEvent).to.have.been.calledWith('IdV: Link sent capture doc polling started'); expect(trackEvent).to.have.been.calledWith('IdV: Link sent capture doc polling complete', { @@ -111,12 +139,17 @@ describe('DocumentCapturePolling', () => { }); it('polls until max, then showing form to submit', async () => { - sandbox.stub(window, 'fetch').withArgs('/status').resolves({ status: 202 }); + sandbox + .stub(window, 'fetch') + .withArgs('/status') + .resolves({ status: 202, json: () => Promise.resolve({}) }); for (let i = MAX_DOC_CAPTURE_POLL_ATTEMPTS; i; i--) { sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); // eslint-disable-next-line no-await-in-loop - await flushPromises(); + await flushPromises(); // Flush `fetch` + // eslint-disable-next-line no-await-in-loop + await flushPromises(); // Flush `json` } expect(screen.getByText('Submit').closest('.display-none')).to.not.be.ok(); @@ -143,10 +176,14 @@ describe('DocumentCapturePolling', () => { it('does not prompt by navigating away via form submission', async () => { sandbox.stub(subject.elements.form, 'submit'); - sandbox.stub(window, 'fetch').withArgs('/status').resolves({ status: 200 }); + sandbox + .stub(window, 'fetch') + .withArgs('/status') + .resolves({ status: 200, json: () => Promise.resolve({}) }); subject.bind(); sandbox.clock.tick(DOC_CAPTURE_POLL_INTERVAL); - await flushPromises(); + await flushPromises(); // Flush `fetch` + await flushPromises(); // Flush `json` window.dispatchEvent(event); expect(event.defaultPrevented).to.be.false(); diff --git a/spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx b/spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx index 508402580de..2e5c889ebf3 100644 --- a/spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/document-capture-troubleshooting-options-spec.jsx @@ -155,6 +155,22 @@ describe('DocumentCaptureTroubleshootingOptions', () => { expect(ippButton).to.exist(); }); }); + + context('hasErrors and inPersonURL but showInPersonOption is false', () => { + const wrapper = ({ children }) => ( + {children} + ); + + it('does not have link to IPP flow', () => { + const { queryAllByText, queryAllByRole } = render( + , + { wrapper }, + ); + + expect(queryAllByText('components.troubleshooting_options.new_feature').length).to.equal(0); + expect(queryAllByRole('button').length).to.equal(0); + }); + }); }); context('with document tips hidden', () => { diff --git a/spec/javascripts/packages/document-capture/services/upload-spec.js b/spec/javascripts/packages/document-capture/services/upload-spec.js index 379b9b4a94a..38eb6ab24a5 100644 --- a/spec/javascripts/packages/document-capture/services/upload-spec.js +++ b/spec/javascripts/packages/document-capture/services/upload-spec.js @@ -169,6 +169,7 @@ describe('document-capture/services/upload', () => { ], remaining_attempts: 3, hints: true, + result_failed: true, ocr_pii: { first_name: 'Fakey', last_name: 'McFakerson', dob: '1938-10-06' }, }), }), @@ -187,6 +188,7 @@ describe('document-capture/services/upload', () => { last_name: 'McFakerson', dob: '1938-10-06', }); + expect(error.isFailedResult).to.be.true(); expect(error.formEntryErrors[0]).to.be.instanceOf(UploadFormEntryError); expect(error.formEntryErrors[0].field).to.equal('front'); expect(error.formEntryErrors[0].message).to.equal('Please fill in this field'); diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index 017f7b0824d..5500a41e826 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -17,16 +17,36 @@ let!(:passed_enrollment) { create(:in_person_enrollment, :passed) } let!(:pending_enrollment) do - create(:in_person_enrollment, status: :pending, enrollment_code: SecureRandom.hex(16)) + create( + :in_person_enrollment, + status: :pending, + enrollment_code: SecureRandom.hex(16), + selected_location_details: { name: 'FRIENDSHIP' }, + ) end let!(:pending_enrollment_2) do - create(:in_person_enrollment, status: :pending, enrollment_code: SecureRandom.hex(16)) + create( + :in_person_enrollment, + status: :pending, + enrollment_code: SecureRandom.hex(16), + selected_location_details: { name: 'BALTIMORE' }, + ) end let!(:pending_enrollment_3) do - create(:in_person_enrollment, status: :pending, enrollment_code: SecureRandom.hex(16)) + create( + :in_person_enrollment, + status: :pending, + enrollment_code: SecureRandom.hex(16), + selected_location_details: { name: 'WASHINGTON' }, + ) end let!(:pending_enrollment_4) do - create(:in_person_enrollment, status: :pending, enrollment_code: SecureRandom.hex(16)) + create( + :in_person_enrollment, + status: :pending, + enrollment_code: SecureRandom.hex(16), + selected_location_details: { name: 'ARLINGTON' }, + ) end let(:pending_enrollments) do [ @@ -93,7 +113,7 @@ ) end - it 'logs details about failed requests' do + it 'logs details about a failed proofing' do stub_request_token stub_request_failed_proofing_results @@ -110,11 +130,10 @@ 'GetUspsProofingResultsJob: Enrollment failed proofing', reason: 'Failed status', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, failure_reason: 'Clerk indicates that ID name or address does not match source data.', fraud_suspected: false, primary_id_type: 'Uniformed Services identification card', - proofing_city: 'WILKES BARRE', - proofing_post_office: 'WILKES BARRE', proofing_state: 'PA', secondary_id_type: 'Deed of Trust', transaction_end_date_time: '12/17/2020 034055', @@ -122,7 +141,29 @@ ) end - it 'updates enrollment records and activates profiles on 2xx responses with valid JSON' do + it 'sends proofing failed email on response with failed status' do + stub_request_token + stub_request_failed_proofing_results + + allow(InPersonEnrollment).to receive(:needs_usps_status_check). + and_return([pending_enrollment]) + + mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) + user = pending_enrollment.user + user.email_addresses.each do |email_address| + expect(UserMailer).to receive(:in_person_failed). + with( + user, + email_address, + enrollment: instance_of(InPersonEnrollment), + ). + and_return(mailer) + end + + job.perform(Time.zone.now) + end + + it 'updates enrollment records and activates profiles on response with passed status' do stub_request_token stub_request_passed_proofing_results @@ -139,7 +180,36 @@ expected_range.cover?(timestamp) end expect(enrollment.profile.active).to be(true) + + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment passed proofing', + reason: 'Successful status update', + enrollment_id: enrollment.id, + enrollment_code: enrollment.enrollment_code, + ) + end + end + + it 'sends verifed email on 2xx responses with valid JSON' do + stub_request_token + stub_request_passed_proofing_results + + allow(InPersonEnrollment).to receive(:needs_usps_status_check). + and_return([pending_enrollment]) + + mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) + user = pending_enrollment.user + user.email_addresses.each do |email_address| + expect(UserMailer).to receive(:in_person_verified). + with( + user, + email_address, + enrollment: instance_of(InPersonEnrollment), + ). + and_return(mailer) end + + job.perform(Time.zone.now) end it 'receives a non-hash value' do @@ -152,6 +222,7 @@ 'GetUspsProofingResultsJob: Exception raised', reason: 'Bad response structure', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, ) end @@ -172,6 +243,7 @@ 'GetUspsProofingResultsJob: Enrollment failed proofing', reason: 'Unsupported status', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, status: 'Not supported', ) end @@ -193,6 +265,7 @@ 'GetUspsProofingResultsJob: Exception raised', reason: 'Request exception', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, ) end @@ -213,6 +286,7 @@ 'GetUspsProofingResultsJob: Exception raised', reason: 'Request exception', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, ) end @@ -257,6 +331,7 @@ 'GetUspsProofingResultsJob: Exception raised', reason: 'Request exception', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, ) end @@ -277,6 +352,7 @@ 'GetUspsProofingResultsJob: Enrollment failed proofing', reason: 'Unsupported ID type', enrollment_id: pending_enrollment.id, + enrollment_code: pending_enrollment.enrollment_code, primary_id_type: 'Not supported', ) end diff --git a/spec/jobs/reports/sp_user_counts_report_spec.rb b/spec/jobs/reports/sp_user_counts_report_spec.rb index 55721544347..4d818298ad1 100644 --- a/spec/jobs/reports/sp_user_counts_report_spec.rb +++ b/spec/jobs/reports/sp_user_counts_report_spec.rb @@ -18,28 +18,36 @@ freeze_time do timestamp = Time.zone.now.iso8601 expect(subject).to receive(:write_hash_to_reports_log).with( - app_id: app_id, - ial1_user_total: 3, - ial2_user_total: 0, - issuer: issuer, - name: 'Report SP User Counts', - time: timestamp, - user_total: 3, + { + app_id: app_id, + ial1_user_total: 3, + ial2_user_total: 0, + issuer: issuer, + name: 'Report SP User Counts', + time: timestamp, + user_total: 3, + }, ) expect(subject).to receive(:write_hash_to_reports_log).with( - name: 'Report Registered Users Count', - time: timestamp, - count: 0, + { + name: 'Report Registered Users Count', + time: timestamp, + count: 0, + }, ) expect(subject).to receive(:write_hash_to_reports_log).with( - name: 'Report IAL1 Users Linked to SPs Count', - time: timestamp, - count: 2, + { + name: 'Report IAL1 Users Linked to SPs Count', + time: timestamp, + count: 2, + }, ) expect(subject).to receive(:write_hash_to_reports_log).with( - name: 'Report IAL2 Users Linked to SPs Count', - time: timestamp, - count: 1, + { + name: 'Report IAL2 Users Linked to SPs Count', + time: timestamp, + count: 1, + }, ) subject.perform(Time.zone.today) end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 461a28822b7..d4535666b41 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -134,6 +134,31 @@ def account_verified ) end + def in_person_ready_to_verify + UserMailer.in_person_ready_to_verify( + user, + email_address_record, + first_name: 'Michael', + enrollment: in_person_enrollment, + ) + end + + def in_person_verified + UserMailer.in_person_verified( + user, + email_address_record, + enrollment: in_person_enrollment, + ) + end + + def in_person_failed + UserMailer.in_person_failed( + user, + email_address_record, + enrollment: in_person_enrollment, + ) + end + private def user @@ -148,6 +173,28 @@ def email_address_record unsaveable(EmailAddress.new(email: email_address)) end + def in_person_enrollment + unsaveable( + InPersonEnrollment.new( + user: user, + profile: unsaveable(Profile.new(user: user)), + enrollment_code: '2048702198804358', + created_at: Time.zone.now - 2.hours, + status_updated_at: Time.zone.now - 1.hour, + current_address_matches_id: true, + selected_location_details: { + 'name' => 'BALTIMORE', + 'street_address' => '900 E FAYETTE ST RM 118', + 'formatted_city_state_zip' => 'BALTIMORE, MD 21233-9715', + 'phone' => '555-123-6409', + 'weekday_hours' => '8:30 AM - 4:30 PM', + 'saturday_hours' => '9:00 AM - 12:00 PM', + 'sunday_hours' => 'Closed', + }, + ), + ) + end + # Remove #save and #save! to make sure we can't write these made-up records def unsaveable(record) class << record diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 878879d46c9..feb9d2188fe 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -520,6 +520,72 @@ def expect_email_body_to_have_help_and_contact_links end end + describe '#in_person_ready_to_verify' do + let(:first_name) { 'Michael' } + let!(:enrollment) { + create( + :in_person_enrollment, + :pending, + selected_location_details: { name: 'FRIENDSHIP' }, + status_updated_at: Time.zone.now - 2.hours, + ) + } + + let(:mail) do + UserMailer.in_person_ready_to_verify( + user, + user.email_addresses.first, + first_name: first_name, + enrollment: enrollment, + ) + end + + it_behaves_like 'a system email' + it_behaves_like 'an email that respects user email locale preference' + end + + describe '#in_person_verified' do + let(:enrollment) { + create( + :in_person_enrollment, + selected_location_details: { name: 'FRIENDSHIP' }, + status_updated_at: Time.zone.now - 2.hours, + ) + } + + let(:mail) do + UserMailer.in_person_verified( + user, + user.email_addresses.first, + enrollment: enrollment, + ) + end + + it_behaves_like 'a system email' + it_behaves_like 'an email that respects user email locale preference' + end + + describe '#in_person_failed' do + let(:enrollment) { + create( + :in_person_enrollment, + selected_location_details: { name: 'FRIENDSHIP' }, + status_updated_at: Time.zone.now - 2.hours, + ) + } + + let(:mail) do + UserMailer.in_person_failed( + user, + user.email_addresses.first, + enrollment: enrollment, + ) + end + + it_behaves_like 'a system email' + it_behaves_like 'an email that respects user email locale preference' + end + def strip_tags(str) ActionController::Base.helpers.strip_tags(str) end diff --git a/spec/models/backup_code_configuration_spec.rb b/spec/models/backup_code_configuration_spec.rb index d6a14ad1773..9a346dd7278 100644 --- a/spec/models/backup_code_configuration_spec.rb +++ b/spec/models/backup_code_configuration_spec.rb @@ -25,9 +25,10 @@ user = User.new user.save BackupCodeGenerator.new(user).create - backup_code_config = BackupCodeConfiguration.new(user_id: user.id) - expect(backup_code_config.mfa_enabled?).to be_truthy + user.backup_code_configurations.each do |backup_code_config| + expect(backup_code_config.mfa_enabled?).to be_truthy + end end end diff --git a/spec/models/in_person_enrollment_spec.rb b/spec/models/in_person_enrollment_spec.rb index 25e78b231c7..df0298f09f9 100644 --- a/spec/models/in_person_enrollment_spec.rb +++ b/spec/models/in_person_enrollment_spec.rb @@ -9,7 +9,7 @@ describe 'Status' do it 'defines enum correctly' do should define_enum_for(:status). - with_values([:establishing, :pending, :passed, :failed, :expired, :canceled]) + with_values([:establishing, :pending, :passed, :failed, :expired, :cancelled]) end end @@ -17,7 +17,7 @@ it 'requires the profile to be associated with the user' do user1 = create(:user) user2 = create(:user) - profile2 = create(:profile, :verification_pending, user: user2) + profile2 = create(:profile, :gpo_verification_pending, user: user2) expect { create(:in_person_enrollment, user: user1, profile: profile2) }. to raise_error ActiveRecord::RecordInvalid expect(InPersonEnrollment.count).to eq 0 @@ -25,8 +25,8 @@ it 'does not allow more than one pending enrollment per user' do user = create(:user) - profile = create(:profile, :verification_pending, user: user) - profile2 = create(:profile, :verification_pending, user: user) + profile = create(:profile, :gpo_verification_pending, user: user) + profile2 = create(:profile, :gpo_verification_pending, user: user) create(:in_person_enrollment, user: user, profile: profile, status: :pending) expect(InPersonEnrollment.pending.count).to eq 1 expect { create(:in_person_enrollment, user: user, profile: profile2, status: :pending) }. @@ -36,7 +36,7 @@ it 'does not allow duplicate unique ids' do user = create(:user) - profile = create(:profile, :verification_pending, user: user) + profile = create(:profile, :gpo_verification_pending, user: user) unique_id = InPersonEnrollment.generate_unique_id create(:in_person_enrollment, user: user, profile: profile, unique_id: unique_id) expect { create(:in_person_enrollment, user: user, profile: profile, unique_id: unique_id) }. @@ -49,13 +49,13 @@ expect { InPersonEnrollment.statuses.each do |key,| status = InPersonEnrollment.statuses[key] - profile = create(:profile, :verification_pending, user: user) + profile = create(:profile, :gpo_verification_pending, user: user) create(:in_person_enrollment, user: user, profile: profile, status: status) end InPersonEnrollment.statuses.each do |key,| status = InPersonEnrollment.statuses[key] if status != InPersonEnrollment.statuses[:pending] - profile = create(:profile, :verification_pending, user: user) + profile = create(:profile, :gpo_verification_pending, user: user) create(:in_person_enrollment, user: user, profile: profile, status: status) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index aa0dc753537..98c37c38449 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -197,7 +197,7 @@ create(:profile, :verification_cancelled, user: user, pii: { first_name: 'Jane' }) } let(:profile2) { - create(:profile, :verification_pending, user: user, pii: { first_name: 'Susan' }) + create(:profile, :gpo_verification_pending, user: user, pii: { first_name: 'Susan' }) } let!(:enrollment1) { create(:in_person_enrollment, :failed, user: user, profile: profile1) } @@ -425,18 +425,18 @@ end describe '#pending_profile' do - context 'when a profile with a verification_pending deactivation_reason exists' do + context 'when a profile with a gpo_verification_pending deactivation_reason exists' do it 'returns the most recent profile' do user = User.new _old_profile = create( :profile, - :verification_pending, + :gpo_verification_pending, created_at: 1.day.ago, user: user, ) new_profile = create( :profile, - :verification_pending, + :gpo_verification_pending, user: user, ) @@ -444,7 +444,7 @@ end end - context 'when a verification_pending profile does not exist' do + context 'when a gpo_verification_pending profile does not exist' do it 'returns nil' do user = User.new create( diff --git a/spec/presenters/idv/gpo_presenter_spec.rb b/spec/presenters/idv/gpo_presenter_spec.rb index e9015aa25e4..fcafc1ea2d4 100644 --- a/spec/presenters/idv/gpo_presenter_spec.rb +++ b/spec/presenters/idv/gpo_presenter_spec.rb @@ -49,7 +49,7 @@ describe '#fallback_back_path' do context 'when the user has a pending profile' do it 'returns the verify account path' do - create(:profile, user: user, deactivation_reason: :verification_pending) + create(:profile, user: user, deactivation_reason: :gpo_verification_pending) expect(subject.fallback_back_path).to eq('/account/verify') end end 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 1155d09a966..5b604ac051a 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 @@ -6,6 +6,9 @@ let(:enrollment_code) { '2048702198804358' } let(:current_address_matches_id) { true } let(:created_at) { described_class::USPS_SERVER_TIMEZONE.parse('2022-07-14T00:00:00Z') } + let(:enrollment_selected_location_details) do + JSON.parse(UspsIppFixtures.enrollment_selected_location_details) + end let(:enrollment) do InPersonEnrollment.new( user: user, @@ -14,21 +17,12 @@ unique_id: InPersonEnrollment.generate_unique_id, created_at: created_at, current_address_matches_id: current_address_matches_id, - selected_location_details: - JSON.parse(UspsIppFixtures.request_facilities_response)['postOffices'].first, + selected_location_details: enrollment_selected_location_details, ) end subject(:presenter) { described_class.new(enrollment: enrollment) } - describe '#barcode_data_url' do - subject(:barcode_data_url) { presenter.barcode_data_url } - - it 'returns a valid data URL' do - expect(barcode_data_url).to match URI::DEFAULT_PARSER.make_regexp('data') - end - end - describe '#formatted_due_date' do subject(:formatted_due_date) { presenter.formatted_due_date } @@ -41,35 +35,28 @@ end end - describe '#formatted_enrollment_code' do - subject(:formatted_enrollment_code) { presenter.formatted_enrollment_code } - - it 'returns a formatted enrollment code' do - expect(formatted_enrollment_code).to eq( - Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code), - ) - end - end - describe '#selected_location_details' do subject(:selected_location_details) { presenter.selected_location_details } it 'returns a hash of location details associated with the enrollment' do expect(selected_location_details).to include( + 'formatted_city_state_zip' => kind_of(String), 'name' => kind_of(String), - 'streetAddress' => kind_of(String), - 'city' => kind_of(String), - 'state' => kind_of(String), - 'zip5' => kind_of(String), - 'zip4' => kind_of(String), 'phone' => kind_of(String), - 'hours' => array_including( - hash_including('weekdayHours' => kind_of(String)), - hash_including('saturdayHours' => kind_of(String)), - hash_including('sundayHours' => kind_of(String)), - ), + 'saturday_hours' => kind_of(String), + 'street_address' => kind_of(String), + 'sunday_hours' => kind_of(String), + 'weekday_hours' => kind_of(String), ) end + + context 'with blank selected_location_details' do + let(:enrollment_selected_location_details) { nil } + + it 'returns nil' do + expect(selected_location_details).to be_nil + end + end end describe '#selected_location_hours' do @@ -78,11 +65,11 @@ before do allow(presenter).to receive(:selected_location_details).and_return( - 'hours' => [ - { 'weekdayHours' => hours_open }, - { 'saturdayHours' => hours_open }, - { 'sundayHours' => hours_closed }, - ], + { + 'weekday_hours' => hours_open, + 'saturday_hours' => hours_open, + 'sunday_hours' => hours_closed, + }, ) end diff --git a/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb b/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb new file mode 100644 index 00000000000..8d05000ea47 --- /dev/null +++ b/spec/presenters/idv/in_person/verification_results_email_presenter_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe Idv::InPerson::VerificationResultsEmailPresenter do + let(:location_name) { 'FRIENDSHIP' } + let(:status_updated_at) { described_class::USPS_SERVER_TIMEZONE.parse('2022-07-14T00:00:00Z') } + let!(:enrollment) do + create( + :in_person_enrollment, + :pending, + selected_location_details: { name: location_name }, + ) + end + + subject(:presenter) { described_class.new(enrollment: enrollment) } + + describe '#location_name' do + it 'returns the enrollment location name' do + expect(presenter.location_name).to eq(location_name) + end + end + + describe '#formatted_verified_date' do + around do |example| + Time.use_zone('UTC') { example.run } + end + + it 'returns a formatted verified date' do + enrollment.update(status_updated_at: status_updated_at) + expect(presenter.formatted_verified_date).to eq 'July 13, 2022' + end + end +end diff --git a/spec/presenters/image_upload_response_presenter_spec.rb b/spec/presenters/image_upload_response_presenter_spec.rb index 439e0f990f5..760f052d253 100644 --- a/spec/presenters/image_upload_response_presenter_spec.rb +++ b/spec/presenters/image_upload_response_presenter_spec.rb @@ -115,6 +115,7 @@ it 'returns hash of properties' do expected = { success: false, + result_failed: false, errors: [{ field: :limit, message: t('errors.doc_auth.throttled_heading') }], redirect: idv_session_errors_throttled_url, remaining_attempts: 0, @@ -140,6 +141,7 @@ it 'returns hash of properties' do expected = { success: false, + result_failed: false, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, remaining_attempts: 3, @@ -149,6 +151,32 @@ expect(presenter.as_json).to eq expected end + context 'hard fail' do + let(:form_response) do + FormResponse.new( + success: false, + errors: { + front: t('doc_auth.errors.not_a_file'), + hints: true, + }, + extra: { doc_auth_result: 'Failed', remaining_attempts: 3 }, + ) + end + + it 'returns hash of properties' do + expected = { + success: false, + result_failed: true, + errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], + hints: true, + remaining_attempts: 3, + ocr_pii: nil, + } + + expect(presenter.as_json).to eq expected + end + end + context 'no remaining attempts' do let(:form_response) do FormResponse.new( @@ -164,6 +192,7 @@ it 'returns hash of properties' do expected = { success: false, + result_failed: false, errors: [{ field: :front, message: t('doc_auth.errors.not_a_file') }], hints: true, redirect: idv_session_errors_throttled_url, @@ -190,6 +219,7 @@ it 'returns hash of properties' do expected = { success: false, + result_failed: false, errors: [], hints: true, remaining_attempts: 3, diff --git a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb index 25afbc384b9..8e96f5b4510 100644 --- a/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb +++ b/spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb @@ -50,7 +50,7 @@ it 'specifies when the code will expire' do text = t( 'instructions.mfa.sms.number_message_html', - number: "#{data[:phone_number]}", + number: ActionController::Base.helpers.content_tag(:strong, data[:phone_number]), expiration: TwoFactorAuthenticatable::DIRECT_OTP_VALID_FOR_MINUTES, ) expect(presenter.phone_number_message).to eq text diff --git a/spec/services/doc_auth/acuant/responses/get_results_response_spec.rb b/spec/services/doc_auth/acuant/responses/get_results_response_spec.rb index 275e187537f..092feac1edd 100644 --- a/spec/services/doc_auth/acuant/responses/get_results_response_spec.rb +++ b/spec/services/doc_auth/acuant/responses/get_results_response_spec.rb @@ -162,6 +162,40 @@ expect(response.result_code.billed?).to eq(false) end + context 'when visiual_pattern passes and fails' do + let(:http_response) do + [1, 2].each do |index| + parsed_response_body['Alerts'] << { + Key: 'Visible Pattern', + Name: 'Visible Pattern', + RegionReferences: [], + Result: index, + } + end + + instance_double( + Faraday::Response, + body: parsed_response_body.to_json, + ) + end + + it 'returns log_alert_results for visible_pattern with multiple results comma separated' do + expect(response.to_h[:processed_alerts]).to eq( + passed: [{ name: 'Visible Pattern', result: 'Passed' }], + failed: + [{ name: 'Document Classification', + result: 'Failed' }, + { name: 'Visible Pattern', + result: 'Failed' }], + ) + + expect(response.to_h[:log_alert_results]).to eq( + { visible_pattern: { no_side: 'Failed' }, + document_classification: { no_side: 'Failed' } }, + ) + end + end + context 'when with an acuant error message' do let(:http_response) do parsed_response_body['Alerts'].first['Disposition'] = 'This message does not have a key' diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index f5bc8c60b9e..ca903dec728 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -102,7 +102,7 @@ transaction_reason_code: 'trueid_pass', product_status: 'pass', doc_auth_result: 'Passed', - processed_alerts: a_hash_including(:passed, :failed), + processed_alerts: a_hash_including(:failed), alert_failure_count: a_kind_of(Numeric), portrait_match_results: nil, image_metrics: a_hash_including(:front, :back), @@ -185,6 +185,12 @@ expect(errors[:hints]).to eq(true) end + it 'returns Failed for visible_pattern when it gets passed and failed value ' do + output = described_class.new(failure_response_no_liveness, false, config).to_h + expect(output.to_h[:log_alert_results]). + to match(a_hash_including(visible_pattern: { no_side: 'Failed' })) + end + it 'produces appropriate errors with liveness' do output = described_class.new(failure_response_with_liveness, true, config).to_h errors = output[:errors] @@ -367,6 +373,34 @@ end end + describe '#parse_date' do + let(:response) { described_class.new(success_response, false, config) } + + it 'handles an invalid month' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with( + { event: 'Failure to parse TrueID date' }.to_json, + ).once + expect(response.send(:parse_date, year: 2022, month: 13, day: 1)).to eq(nil) + end + + it 'handles an invalid leap day' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with( + { event: 'Failure to parse TrueID date' }.to_json, + ).once + expect(response.send(:parse_date, year: 2022, month: 2, day: 29)).to eq(nil) + end + + it 'handles a day past the end of the month' do + allow(Rails.logger).to receive(:info) + expect(Rails.logger).to receive(:info).with( + { event: 'Failure to parse TrueID date' }.to_json, + ).once + expect(response.send(:parse_date, year: 2022, month: 4, day: 31)).to eq(nil) + end + end + describe '#attention_with_barcode?' do let(:response) { described_class.new(success_response, false, config) } subject(:attention_with_barcode) { response.attention_with_barcode? } diff --git a/spec/services/doc_auth/mock/result_response_spec.rb b/spec/services/doc_auth/mock/result_response_spec.rb index 81138cbd64b..721ab88dbe9 100644 --- a/spec/services/doc_auth/mock/result_response_spec.rb +++ b/spec/services/doc_auth/mock/result_response_spec.rb @@ -274,6 +274,34 @@ expect(response.exception).to eq(nil) expect(response.pii_from_doc).to eq({}) expect(response.attention_with_barcode?).to eq(false) + expect(response.extra).to eq( + doc_auth_result: DocAuth::Acuant::ResultCodes::PASSED.name, + billed: true, + ) + end + end + + context 'with a yaml file containing a failed result' do + let(:input) do + <<~YAML + doc_auth_result: Failed + YAML + end + + it 'returns a failed result' do + expect(response.success?).to eq(false) + expect(response.errors).to eq( + general: [DocAuth::Errors::BARCODE_READ_CHECK], + back: [DocAuth::Errors::FALLBACK_FIELD_LEVEL], + hints: true, + ) + expect(response.exception).to eq(nil) + expect(response.pii_from_doc).to eq({}) + expect(response.attention_with_barcode?).to eq(false) + expect(response.extra).to eq( + doc_auth_result: DocAuth::Acuant::ResultCodes::FAILED.name, + billed: true, + ) end end @@ -315,6 +343,10 @@ state_id_type: 'drivers_license', ) expect(response.attention_with_barcode?).to eq(false) + expect(response.extra).to eq( + doc_auth_result: DocAuth::Acuant::ResultCodes::PASSED.name, + billed: true, + ) end end end diff --git a/spec/services/doc_auth/processed_alert_to_log_alert_formatter_spec.rb b/spec/services/doc_auth/processed_alert_to_log_alert_formatter_spec.rb new file mode 100644 index 00000000000..ce00c1fe573 --- /dev/null +++ b/spec/services/doc_auth/processed_alert_to_log_alert_formatter_spec.rb @@ -0,0 +1,37 @@ +# Take the proccessed alerts and reformat them in a hash so that its easier to search and collect +# stats through cloudwatch. + +require 'rails_helper' + +RSpec.describe DocAuth::ProcessedAlertToLogAlertFormatter do + let(:alerts) do + { passed: [{ alert: 'Alert_1', name: 'Visible Pattern', result: 'Passed' }], + failed: + [ + { alert: 'Alert_1', name: '2D Barcode Read', result: 'Failed' }, + { alert: 'Alert_2', name: 'Layout Valid', result: 'Attention' }, + { alert: 'Alert_3', name: '2D Barcode Read', result: 'Failed' }, + { alert: 'Alert_4', name: 'Visible Pattern', result: 'Failed' }, + { alert: 'Alert_5', name: 'Visible Photo Characteristics', result: 'Failed' }, + ] } + end + + context('when ProcessedAlertToLogAlertFormatter is called') do + subject { + DocAuth::ProcessedAlertToLogAlertFormatter.new.log_alerts(alerts) + } + + it('returns failed if both passed and failed are returned by the alert') do + expect(subject).to match(a_hash_including(visible_pattern: { no_side: 'Failed' })) + end + + it('returns the formatted log hash') do + expect(subject).to eq( + { '2d_barcode_read': { no_side: 'Failed' }, + layout_valid: { no_side: 'Attention' }, + visible_pattern: { no_side: 'Failed' }, + visible_photo_characteristics: { no_side: 'Failed' } }, + ) + end + end +end diff --git a/spec/services/encryption/encryptors/pii_encryptor_spec.rb b/spec/services/encryption/encryptors/pii_encryptor_spec.rb index fba039cb9d8..1c68f366901 100644 --- a/spec/services/encryption/encryptors/pii_encryptor_spec.rb +++ b/spec/services/encryption/encryptors/pii_encryptor_spec.rb @@ -44,7 +44,7 @@ and_return('aes_ciphertext') expect(subject.send(:kms_client)).to receive(:encrypt). - with('aes_ciphertext', 'context' => 'pii-encryption', 'user_uuid' => 'uuid-123-abc'). + with('aes_ciphertext', { 'context' => 'pii-encryption', 'user_uuid' => 'uuid-123-abc' }). and_return('kms_ciphertext') expected_ciphertext = Base64.strict_encode64('kms_ciphertext') diff --git a/spec/services/encryption/password_verifier_spec.rb b/spec/services/encryption/password_verifier_spec.rb index a5f25e230d9..36a1f2d34d2 100644 --- a/spec/services/encryption/password_verifier_spec.rb +++ b/spec/services/encryption/password_verifier_spec.rb @@ -43,7 +43,7 @@ kms_client = Encryption::KmsClient.new expect(kms_client).to receive(:encrypt).with( encoded_scrypt_password, - 'user_uuid' => user_uuid, 'context' => 'password-digest', + { 'user_uuid' => user_uuid, 'context' => 'password-digest' }, ).and_return('kms_ciphertext') expect(Encryption::KmsClient).to receive(:new).and_return(kms_client) diff --git a/spec/services/funnel/registration/add_mfa_spec.rb b/spec/services/funnel/registration/add_mfa_spec.rb index a31f79a13d7..375a3a596c0 100644 --- a/spec/services/funnel/registration/add_mfa_spec.rb +++ b/spec/services/funnel/registration/add_mfa_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe Funnel::Registration::AddMfa do + let(:analytics) { FakeAnalytics.new } subject { described_class } let(:user_id) do @@ -12,15 +13,15 @@ let(:funnel) { RegistrationLog.all.first } it 'adds an 1st mfa' do - subject.call(user_id, 'phone') + subject.call(user_id, 'phone', analytics) expect(funnel.first_mfa).to eq('phone') expect(funnel.first_mfa_at).to be_present end it 'adds a 2nd mfa' do - subject.call(user_id, 'phone') - subject.call(user_id, 'backup_codes') + subject.call(user_id, 'phone', analytics) + subject.call(user_id, 'backup_codes', analytics) expect(funnel.first_mfa).to eq('phone') expect(funnel.first_mfa_at).to be_present @@ -28,9 +29,9 @@ end it 'does not add a 3rd mfa' do - subject.call(user_id, 'phone') - subject.call(user_id, 'backup_codes') - subject.call(user_id, 'auth_app') + subject.call(user_id, 'phone', analytics) + subject.call(user_id, 'backup_codes', analytics) + subject.call(user_id, 'auth_app', analytics) expect(funnel.first_mfa).to eq('phone') expect(funnel.second_mfa).to eq('backup_codes') diff --git a/spec/services/funnel/registration/range_registered_count_spec.rb b/spec/services/funnel/registration/range_registered_count_spec.rb index eaf520bf821..259f473a8ed 100644 --- a/spec/services/funnel/registration/range_registered_count_spec.rb +++ b/spec/services/funnel/registration/range_registered_count_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe Funnel::Registration::RangeRegisteredCount do + let(:analytics) { FakeAnalytics.new } subject { described_class } let(:start) { '2019-01-01 00:00:00' } @@ -53,7 +54,7 @@ def register_user(year, month, day) user_id = user.id Funnel::Registration::Create.call(user_id) Funnel::Registration::AddPassword.call(user_id) - Funnel::Registration::AddMfa.call(user_id, 'backup_codes') + Funnel::Registration::AddMfa.call(user_id, 'backup_codes', analytics) end end end diff --git a/spec/services/funnel/registration/total_registered_count_spec.rb b/spec/services/funnel/registration/total_registered_count_spec.rb index 0af532d6791..03641f68a88 100644 --- a/spec/services/funnel/registration/total_registered_count_spec.rb +++ b/spec/services/funnel/registration/total_registered_count_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe Funnel::Registration::TotalRegisteredCount do + let(:analytics) { FakeAnalytics.new } subject { described_class } it 'returns 0' do @@ -18,7 +19,7 @@ expect(Funnel::Registration::TotalRegisteredCount.call).to eq(0) - Funnel::Registration::AddMfa.call(user_id, 'phone') + Funnel::Registration::AddMfa.call(user_id, 'phone', analytics) expect(Funnel::Registration::TotalRegisteredCount.call).to eq(1) end @@ -41,6 +42,6 @@ def register_user user_id = user.id Funnel::Registration::Create.call(user_id) Funnel::Registration::AddPassword.call(user_id) - Funnel::Registration::AddMfa.call(user_id, 'backup_codes') + Funnel::Registration::AddMfa.call(user_id, 'backup_codes', analytics) end end diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 0debecd563f..2b941f7767e 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -4,6 +4,8 @@ describe Idv::Agent do include IdvHelper + let(:user) { build(:user) } + let(:bad_phone) do Proofing::Mock::AddressMockClient::UNVERIFIABLE_PHONE_NUMBER end @@ -20,8 +22,7 @@ context 'proofing state_id enabled' do it 'does not proof state_id if resolution fails' do agent = Idv::Agent.new( - { ssn: '444-55-6666', first_name: Faker::Name.first_name, - zipcode: '11111' }, + Idp::Constants::MOCK_IDV_APPLICANT.merge(uuid: user.uuid, ssn: '444-55-6666'), ) agent.proof_resolution( document_capture_session, should_proof_state_id: true, trace_id: trace_id @@ -33,14 +34,7 @@ end it 'does proof state_id if resolution succeeds' do - agent = Idv::Agent.new( - ssn: '900-55-8888', - first_name: Faker::Name.first_name, - zipcode: '11111', - state_id_number: '123456789', - state_id_type: 'drivers_license', - state_id_jurisdiction: 'MD', - ) + agent = Idv::Agent.new(Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge(uuid: user.uuid)) agent.proof_resolution( document_capture_session, should_proof_state_id: true, trace_id: trace_id ) @@ -55,8 +49,7 @@ context 'proofing state_id disabled' do it 'does not proof state_id if resolution fails' do agent = Idv::Agent.new( - { ssn: '444-55-6666', first_name: Faker::Name.first_name, - zipcode: '11111' }, + Idp::Constants::MOCK_IDV_APPLICANT.merge(uuid: user.uuid, ssn: '444-55-6666'), ) agent.proof_resolution( document_capture_session, should_proof_state_id: true, trace_id: trace_id @@ -67,10 +60,7 @@ end it 'does not proof state_id if resolution succeeds' do - agent = Idv::Agent.new( - { ssn: '900-55-8888', first_name: Faker::Name.first_name, - zipcode: '11111' }, - ) + agent = Idv::Agent.new(Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge(uuid: user.uuid)) agent.proof_resolution( document_capture_session, should_proof_state_id: false, trace_id: trace_id ) @@ -84,8 +74,7 @@ it 'returns a successful result if SSN does not start with 900 but is in SSN allowlist' do agent = Idv::Agent.new( - ssn: '999-99-9999', first_name: Faker::Name.first_name, - zipcode: '11111' + Idp::Constants::MOCK_IDV_APPLICANT.merge(uuid: user.uuid, ssn: '999-99-9999'), ) agent.proof_resolution( @@ -101,8 +90,10 @@ it 'returns an unsuccessful result and notifies exception trackers if an exception occurs' do agent = Idv::Agent.new( - ssn: '900-55-8888', first_name: 'Time Exception', - zipcode: '11111' + Idp::Constants::MOCK_IDV_APPLICANT_WITH_SSN.merge( + uuid: user.uuid, + first_name: 'Time Exception', + ), ) agent.proof_resolution( diff --git a/spec/services/idv/cancel_verification_attempt_spec.rb b/spec/services/idv/cancel_verification_attempt_spec.rb index 3cfa7262a00..848c820f4ae 100644 --- a/spec/services/idv/cancel_verification_attempt_spec.rb +++ b/spec/services/idv/cancel_verification_attempt_spec.rb @@ -2,7 +2,7 @@ describe Idv::CancelVerificationAttempt do let(:user) { create(:user, profiles: profiles) } - let(:profiles) { [create(:profile, deactivation_reason: :verification_pending)] } + let(:profiles) { [create(:profile, deactivation_reason: :gpo_verification_pending)] } subject { described_class.new(user: user) } @@ -12,13 +12,13 @@ expect(profiles[0].active).to eq(false) expect(profiles[0].reload.deactivation_reason).to eq('verification_cancelled') - expect(user.reload.profiles.verification_pending).to be_empty + expect(user.reload.profiles.gpo_verification_pending).to be_empty end end context 'the user has multiple pending profiles' do let(:profiles) do - super().push(create(:profile, deactivation_reason: :verification_pending)) + super().push(create(:profile, deactivation_reason: :gpo_verification_pending)) end it 'deactivates both profiles' do @@ -28,7 +28,7 @@ expect(profiles[0].reload.deactivation_reason).to eq('verification_cancelled') expect(profiles[1].active).to eq(false) expect(profiles[1].reload.deactivation_reason).to eq('verification_cancelled') - expect(user.reload.profiles.verification_pending).to be_empty + expect(user.reload.profiles.gpo_verification_pending).to be_empty end end @@ -44,20 +44,20 @@ expect(profiles[0].reload.deactivation_reason).to eq('verification_cancelled') expect(profiles[1].active).to eq(true) expect(profiles[1].reload.deactivation_reason).to be_nil - expect(user.reload.profiles.verification_pending).to be_empty + expect(user.reload.profiles.gpo_verification_pending).to be_empty end end context 'when there are pending profiles for other users' do it 'only updates profiles for the specificed user' do - other_profile = create(:profile, deactivation_reason: :verification_pending) + other_profile = create(:profile, deactivation_reason: :gpo_verification_pending) subject.call expect(profiles[0].active).to eq(false) expect(profiles[0].reload.deactivation_reason).to eq('verification_cancelled') expect(other_profile.active).to eq(false) - expect(other_profile.reload.deactivation_reason).to eq('verification_pending') + expect(other_profile.reload.deactivation_reason).to eq('gpo_verification_pending') end end end diff --git a/spec/services/idv/profile_maker_spec.rb b/spec/services/idv/profile_maker_spec.rb index 5fb57840654..f996ad8c2bc 100644 --- a/spec/services/idv/profile_maker_spec.rb +++ b/spec/services/idv/profile_maker_spec.rb @@ -13,7 +13,8 @@ user_password: user_password, ) end - it 'creates a Profile with encrypted PII' do + + it 'creates an inactive Profile with encrypted PII' do proofing_component = ProofingComponent.create(user_id: user.id, document_check: 'acuant') profile = subject.save_profile pii = subject.pii_attributes @@ -23,6 +24,8 @@ expect(profile.encrypted_pii).to_not be_nil expect(profile.encrypted_pii).to_not match 'Some' expect(profile.proofing_components).to match proofing_component.as_json + expect(profile.active).to eq false + expect(profile.deactivation_reason).to be_nil expect(pii).to be_a Pii::Attributes expect(pii.first_name).to eq 'Some' diff --git a/spec/services/idv/session_spec.rb b/spec/services/idv/session_spec.rb index f39d7cd3c20..db0887dc64e 100644 --- a/spec/services/idv/session_spec.rb +++ b/spec/services/idv/session_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe Idv::Session do - let(:user) { build(:user) } + let(:user) { create(:user) } let(:user_session) { {} } subject { @@ -39,7 +39,7 @@ describe '#complete_session' do context 'with phone verifed by vendor' do before do - subject.address_verification_mechanism = :phone + subject.address_verification_mechanism = 'phone' subject.vendor_phone_confirmation = true allow(subject).to receive(:complete_profile) end @@ -57,11 +57,61 @@ expect(subject).not_to have_received(:complete_profile) end + + context 'with establishing in person enrollment' do + let!(:enrollment) do + create(:in_person_enrollment, :establishing, user: user, profile: nil) + end + + before do + ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + subject.user_phone_confirmation = true + subject.applicant = Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE.merge( + same_address_as_id: true, + ).with_indifferent_access + subject.create_profile_from_applicant_with_password(user.password) + end + + it 'sets profile to pending in person verification' do + subject.complete_session + + expect(subject).not_to have_received(:complete_profile) + expect(subject.profile.deactivation_reason).to eq('in_person_verification_pending') + end + + it 'creates a USPS enrollment' do + expect(UspsInPersonProofing::EnrollmentHelper). + to receive(:schedule_in_person_enrollment). + with(user, subject.applicant.transform_keys(&:to_s)) + + subject.complete_session + + expect(enrollment.reload.profile).to eq(user.profiles.last) + end + end + end + + context 'with gpo address verification' do + before do + subject.address_verification_mechanism = 'gpo' + subject.vendor_phone_confirmation = false + allow(subject).to receive(:complete_profile) + end + + it 'sets profile to pending gpo verification' do + subject.applicant = {} + subject.create_profile_from_applicant_with_password(user.password) + subject.complete_session + + expect(subject).not_to have_received(:complete_profile) + expect(subject.profile.deactivation_reason).to eq('gpo_verification_pending') + end end context 'without a confirmed phone number' do before do - subject.address_verification_mechanism = :phone + subject.address_verification_mechanism = 'phone' subject.vendor_phone_confirmation = false end diff --git a/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb b/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb index 7dcc87490af..87feefcb16e 100644 --- a/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb +++ b/spec/services/idv/steps/ipp/verify_wait_step_show_spec.rb @@ -7,6 +7,8 @@ let(:issuer) { 'test_issuer' } let(:service_provider) { build(:service_provider, issuer: issuer) } + let(:request) { FakeRequest.new } + let(:controller) do instance_double( 'controller', @@ -16,6 +18,7 @@ flash: {}, poll_with_meta_refresh: nil, url_options: {}, + request: request, ) end @@ -126,7 +129,35 @@ } end - it 'marks the verify step incomplete' do + it 'marks the verify step incomplete and redirects to the warning page' do + expect(step).to receive(:redirect_to).with( + idv_session_errors_warning_url( + from: request.path, + ), + ) + expect(flow.flow_session['Idv::Steps::Ipp::VerifyStep']).to be true + step.call + + expect(flow.flow_session['Idv::Steps::Ipp::VerifyStep']).to be_nil + end + end + + context 'when verification encounters an exception' do + let(:idv_result) do + { + context: { stages: { resolution: {} } }, + errors: {}, + exception: StandardError.new('testing'), + success: false, + } + end + + it 'marks the verify step incomplete and redirects to the exception page' do + expect(step).to receive(:redirect_to).with( + idv_session_errors_exception_url( + from: request.path, + ), + ) expect(flow.flow_session['Idv::Steps::Ipp::VerifyStep']).to be true step.call diff --git a/spec/services/idv/steps/welcome_step_spec.rb b/spec/services/idv/steps/welcome_step_spec.rb new file mode 100644 index 00000000000..9603863b623 --- /dev/null +++ b/spec/services/idv/steps/welcome_step_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Idv::Steps::WelcomeStep do + include Rails.application.routes.url_helpers + + let(:user) { build(:user) } + let(:params) { {} } + let(:controller) do + instance_double('controller', current_user: user, params: params, session: {}, url_options: {}) + end + let(:flow) do + Idv::Flows::DocAuthFlow.new(controller, {}, 'idv/doc_auth').tap do |flow| + flow.flow_session = {} + end + end + + subject(:step) { Idv::Steps::WelcomeStep.new(flow) } + + describe '#call' do + context 'without camera' do + let(:params) { { no_camera: true } } + + it 'redirects to no camera error page' do + result = step.call + + expect(redirect).to eq(idv_doc_auth_errors_no_camera_url) + expect(result.success?).to eq(false) + expect(result.errors).to eq( + message: 'Doc Auth error: Javascript could not detect camera on mobile device.', + ) + end + end + + context 'with previous establishing in-person enrollments' do + let!(:enrollment) { create(:in_person_enrollment, :establishing, user: user, profile: nil) } + + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + end + + it 'cancels all previous establishing enrollments' do + step.call + + expect(enrollment.reload.status).to eq('cancelled') + expect(user.establishing_in_person_enrollment).to be_blank + end + end + end + + def redirect + step.instance_variable_get(:@flow).instance_variable_get(:@redirect) + end +end diff --git a/spec/services/inherited_proofing/va/service_spec.rb b/spec/services/inherited_proofing/va/service_spec.rb new file mode 100644 index 00000000000..11bf20689aa --- /dev/null +++ b/spec/services/inherited_proofing/va/service_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +RSpec.shared_examples 'an invalid auth code error is raised' do + it 'raises an error' do + expect { subject.execute }.to raise_error 'The provided auth_code is blank?' + end +end + +RSpec.describe InheritedProofing::Va::Service do + include_context 'va_api_context' + include_context 'va_user_context' + + subject(:service) { described_class.new auth_code } + + before do + allow(service).to receive(:private_key).and_return(private_key) + end + + it { respond_to :execute } + + it do + expect(service.send(:private_key)).to eq private_key + end + + describe '#execute' do + context 'when the auth code is valid' do + let(:auth_code) { 'mocked-auth-code-for-testing' } + + it 'makes an authenticated request' do + stub = stub_request(:get, request_uri). + with(headers: request_headers). + to_return(status: 200, body: '{}', headers: {}) + + service.execute + + expect(stub).to have_been_requested.once + end + + it 'decrypts the response' do + stub_request(:get, request_uri). + with(headers: request_headers). + to_return(status: 200, body: encrypted_user_attributes, headers: {}) + + expect(service.execute).to eq user_attributes + end + end + + context 'when the auth code is invalid' do + context 'when an empty? string' do + let(:auth_code) { '' } + + it_behaves_like 'an invalid auth code error is raised' + end + + context 'when an nil?' do + let(:auth_code) { nil } + + it_behaves_like 'an invalid auth code error is raised' + end + end + end +end diff --git a/spec/services/push_notification/password_reset_event_spec.rb b/spec/services/push_notification/password_reset_event_spec.rb new file mode 100644 index 00000000000..fde556d5d9f --- /dev/null +++ b/spec/services/push_notification/password_reset_event_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe PushNotification::PasswordResetEvent do + include Rails.application.routes.url_helpers + + subject(:event) do + described_class.new(user: user) + end + + let(:user) { build(:user) } + + describe '#event_type' do + it 'is the RISC event type' do + expect(event.event_type).to eq(described_class::EVENT_TYPE) + end + end + + describe '#payload' do + let(:iss_sub) { SecureRandom.uuid } + + subject(:payload) { event.payload(iss_sub: iss_sub) } + + it 'is a subject with the provided iss_sub ' do + expect(payload).to eq( + subject: { + subject_type: 'iss-sub', + sub: iss_sub, + iss: root_url, + }, + ) + end + end +end diff --git a/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb b/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb new file mode 100644 index 00000000000..293d1186fbc --- /dev/null +++ b/spec/services/usps_in_person_proofing/enrollment_helper_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe UspsInPersonProofing::EnrollmentHelper do + let(:user) { build(:user) } + let(:current_address_matches_id) { false } + let(:pii) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE. + merge(same_address_as_id: current_address_matches_id). + transform_keys(&:to_s) + end + let(:subject) { described_class } + + describe '#schedule_in_person_enrollment' do + context 'an establishing enrollment record exists for the user' do + let!(:enrollment) do + create(:in_person_enrollment, user: user, status: :establishing, profile: nil) + end + + it 'updates the existing enrollment record' do + expect(user.in_person_enrollments.length).to eq(1) + + subject.schedule_in_person_enrollment(user, pii) + enrollment.reload + + expect(enrollment.current_address_matches_id).to eq(current_address_matches_id) + end + + it 'creates usps enrollment' do + proofer = UspsInPersonProofing::Mock::Proofer.new + mock = double + + expect(UspsInPersonProofing::Mock::Proofer).to receive(:new).and_return(mock) + expect(mock).to receive(:request_enroll) do |applicant| + expect(applicant.first_name).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:first_name]) + expect(applicant.last_name).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:last_name]) + expect(applicant.address).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:address1]) + expect(applicant.city).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:city]) + expect(applicant.state).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:state]) + expect(applicant.zip_code).to eq(Idp::Constants::MOCK_IDV_APPLICANT[:zipcode]) + expect(applicant.email).to eq('no-reply@login.gov') + expect(applicant.unique_id).to be_a(String) + + proofer.request_enroll(applicant) + end + + subject.schedule_in_person_enrollment(user, pii) + end + + it 'sets enrollment status to pending and sets enrollment established at date' do + subject.schedule_in_person_enrollment(user, pii) + + expect(user.in_person_enrollments.first.status).to eq('pending') + expect(user.in_person_enrollments.first.enrollment_established_at).to_not be_nil + end + + it 'sends verification emails' do + mailer = instance_double(ActionMailer::MessageDelivery, deliver_now_or_later: true) + user.email_addresses.each do |email_address| + expect(UserMailer).to receive(:in_person_ready_to_verify). + with( + user, + email_address, + enrollment: instance_of(InPersonEnrollment), + first_name: Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE[:first_name], + ). + and_return(mailer) + end + + subject.schedule_in_person_enrollment(user, pii) + end + end + end +end diff --git a/spec/services/usps_in_person_proofer_spec.rb b/spec/services/usps_in_person_proofing/proofer_spec.rb similarity index 79% rename from spec/services/usps_in_person_proofer_spec.rb rename to spec/services/usps_in_person_proofing/proofer_spec.rb index cb242644f77..f73cd103af0 100644 --- a/spec/services/usps_in_person_proofer_spec.rb +++ b/spec/services/usps_in_person_proofing/proofer_spec.rb @@ -15,6 +15,20 @@ end end + def check_facility(facility) + expect(facility.address).to be_present + expect(facility.city).to be_present + expect(facility.distance).to be_present + expect(facility.name).to be_present + expect(facility.phone).to be_present + expect(facility.saturday_hours).to be_present + expect(facility.state).to be_present + expect(facility.sunday_hours).to be_present + expect(facility.weekday_hours).to be_present + expect(facility.zip_code_4).to be_present + expect(facility.zip_code_5).to be_present + end + describe '#request_facilities' do it 'returns facilities' do stub_request_token @@ -29,14 +43,16 @@ facilities = subject.request_facilities(location) - facility = facilities[0] - expect(facility.distance).to be_present - expect(facility.address).to be_present - expect(facility.city).to be_present - expect(facility.phone).to be_present - expect(facility.name).to be_present - expect(facility.zip_code).to be_present - expect(facility.state).to be_present + check_facility(facilities[0]) + end + end + + describe '#request_pilot_facilities' do + it 'returns facilities' do + facilities = subject.request_pilot_facilities + expect(facilities.length).to eq(7) + + check_facility(facilities[0]) end end @@ -109,14 +125,12 @@ enrollment_code: '123456789', ) - expect( - -> do - subject.request_proofing_results( - applicant.unique_id, - applicant.enrollment_code, - ) - end, - ).to raise_error( + expect do + subject.request_proofing_results( + applicant.unique_id, + applicant.enrollment_code, + ) + end.to raise_error( an_instance_of(Faraday::BadRequestError). and(having_attributes( response: include( diff --git a/spec/support/controller_helper.rb b/spec/support/controller_helper.rb index f3863129499..6ee1590361f 100644 --- a/spec/support/controller_helper.rb +++ b/spec/support/controller_helper.rb @@ -47,6 +47,20 @@ def stub_verify_steps_one_and_two(user) allow(subject).to receive(:user_session).and_return(user_session) end + def stub_user_with_applicant_data(user, applicant) + user_session = {} + stub_sign_in(user) + idv_session = Idv::Session.new( + user_session: user_session, current_user: user, + service_provider: nil + ) + idv_session.applicant = applicant.with_indifferent_access + idv_session.profile_confirmation = true + allow(subject).to receive(:confirm_idv_session_started).and_return(true) + allow(subject).to receive(:idv_session).and_return(idv_session) + allow(subject).to receive(:user_session).and_return(user_session) + end + def stub_decorated_user_with_pending_profile(user) decorated_user = instance_double(UserDecorator) allow(user).to receive(:decorate).and_return(decorated_user) diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index 08550106ffa..b31fbc39c10 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -30,6 +30,10 @@ def click_idv_continue click_spinner_button_and_wait t('forms.buttons.continue') end + def click_idv_select + click_select_button_and_wait t('in_person_proofing.body.location.location_button') + end + def choose_idv_otp_delivery_method_sms page.find( 'label', diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index e2490ae8c92..4d028801b0a 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -34,8 +34,7 @@ def fill_out_address_form_ok choose t('in_person_proofing.form.address.same_address_choice_yes') end - def begin_in_person_proofing(user = user_with_2fa) - sign_in_and_2fa_user(user) + def begin_in_person_proofing(_user = user_with_2fa) complete_doc_auth_steps_before_document_capture_step mock_doc_auth_attention_with_barcode attach_and_submit_images @@ -44,7 +43,8 @@ def begin_in_person_proofing(user = user_with_2fa) end def complete_location_step(_user = user_with_2fa) - click_idv_continue + first('.location-collection-item'). + click_button(t('in_person_proofing.body.location.location_button')) end def complete_prepare_step(_user = user_with_2fa) diff --git a/spec/support/features/interaction_helper.rb b/spec/support/features/interaction_helper.rb index c6d57ade971..a2d7da1f9fc 100644 --- a/spec/support/features/interaction_helper.rb +++ b/spec/support/features/interaction_helper.rb @@ -1,6 +1,11 @@ module InteractionHelper def click_spinner_button_and_wait(...) - click_button(...) + click_on(...) expect(page).to have_no_css('lg-spinner-button.spinner-button--spinner-active', wait: 10) end + + def click_select_button_and_wait(...) + click_button(...) + expect(page).to have_no_css('button.usa-button', wait: 10) + end end diff --git a/spec/support/idv_examples/gpo_otp_verification_step.rb b/spec/support/idv_examples/gpo_otp_verification_step.rb index 12bdfb4e431..f2a6a8f1485 100644 --- a/spec/support/idv_examples/gpo_otp_verification_step.rb +++ b/spec/support/idv_examples/gpo_otp_verification_step.rb @@ -3,7 +3,7 @@ let(:profile) do create( :profile, - deactivation_reason: :verification_pending, + deactivation_reason: :gpo_verification_pending, pii: { ssn: '123-45-6789', dob: '1970-01-01' }, ) end diff --git a/spec/support/matchers/have_actions.rb b/spec/support/matchers/have_actions.rb index e349af88807..70583f08a51 100644 --- a/spec/support/matchers/have_actions.rb +++ b/spec/support/matchers/have_actions.rb @@ -38,7 +38,7 @@ callbacks = controller._process_action_callbacks.select { |callback| callback.kind == kind } actions = callbacks.each_with_object([]) do |f, result| - result << f.filter unless action_has_only_option?(f) || action_has_except_option?(f) + result << f.filter result << [f.filter, only: parsed_only_action(f)] if action_has_only_option?(f) result << [f.filter, if: parsed_only_action(f)] if action_has_only_option?(f) result << [f.filter, except: parsed_except_action(f)] if action_has_except_option?(f) @@ -67,9 +67,7 @@ def unless_option_for(action) end def parsed_only_action(action) - only_option = if_option_for(action)[0] - - "#{only_option.class}OptionParser".constantize.new(only_option).parse + if_option_for(action)[0] end def parsed_except_action(action) diff --git a/spec/support/private_key_file_helper.rb b/spec/support/private_key_file_helper.rb new file mode 100644 index 00000000000..94a4aaaaf5e --- /dev/null +++ b/spec/support/private_key_file_helper.rb @@ -0,0 +1,27 @@ +module PrivateKeyFileHelper + # Returns the private key in AppArtifacts.store.oidc_private_key if + # Identity::Hostdata.in_datacenter? or if the private key file does + # not exist; otherwise, the private key from the file is returned. + def private_key_from_store_or(file_name:) + file_name = force_tmp_private_key_file_name file_name: file_name + + if Rails.env.test? && !File.exist?(file_name) + puts "WARNING: Private key file '#{file_name}' not found!" + end + + if File.exist?(file_name) + OpenSSL::PKey::RSA.new(File.read(file_name)) + else + return AppArtifacts.store.oidc_private_key + end + end + + # Always ensure we're referencing files in the /tmp/ folder! + def force_tmp_private_key_file_name(file_name:) + "#{Rails.root}/tmp/#{File.basename(file_name)}" + end +end + +RSpec.configure do |config| + config.include PrivateKeyFileHelper +end diff --git a/spec/support/shared_contexts/inherited_proofing/encrypted_user_attributes.json b/spec/support/shared_contexts/inherited_proofing/encrypted_user_attributes.json new file mode 100644 index 00000000000..af29a512873 --- /dev/null +++ b/spec/support/shared_contexts/inherited_proofing/encrypted_user_attributes.json @@ -0,0 +1 @@ +{"data":"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkExMjhHQ00ifQ.IL_uTLpwR3ZoDQKuRY_clxK1AmrEnf3rCREIj8XGQ-iA7NxCiYfZ2CxuXFOTIzFbKXjcNYT1F56bCUuPwSmHNt88AGumB3RcskR6POfBu8EcjK2CI6myycGuQwm_1Dp9Vi55TQpSFRy5Bld7IR0gbk4ju0qTVSeH59-AyBGr0w07vojdHcPe-SDWEC1pG0_4iyVg0x2wOFAh6kIjMJ04sJYB4e7uW8hEI7lSwDLpiW-8KsjGhwCVIkUGPw7XKtLiWo1U_nXSragpG-E6XRx0Hn3YckSwEAMTATeZZPJr0TAAMO_jtukL0e7_ApwsCI-sEdI035_4befLlDnuz1QFJg.oLmsRlZKFlL_3Th4.YumiTPq6y8jyCpVwuSpqsd8iWQ_AqEN81v8pV9lB2dPb6po03aj05K361IWmWfB3gXir--L3nPpUdlFFkxF1X12QVkpfmH03kj01Zoaq9hZcQvY7d4QoOkMNkdONNFZ3_sp-4-11m5ki2TpD1AidkLe7AIaSvBvhYOq0TC-0veLwRvp5234-XyDq9o5hLogzUa3G1BxcZO_TxpS5IhV4CzJ2a-o_ymSgUULDjrAty23XMiqXxTMFbVCpMDrvgGTX2TYOYx0PngjySlir6Zf4WjKhvFBOd34hvx2MUYTEGPw.UcPA0owzraT7ckc1cRDzeg"} diff --git a/spec/support/shared_contexts/inherited_proofing/va_api_context.rb b/spec/support/shared_contexts/inherited_proofing/va_api_context.rb new file mode 100644 index 00000000000..230a5b8307b --- /dev/null +++ b/spec/support/shared_contexts/inherited_proofing/va_api_context.rb @@ -0,0 +1,15 @@ +RSpec.shared_context 'va_api_context' do + # Sample mocked API call: + # stub_request(:get, request_uri). + # with(headers: request_headers). + # to_return(status: 200, body: '{}', headers: {}) + + let(:auth_code) { 'mocked-auth-code-for-testing' } + let(:private_key) { private_key_from_store_or(file_name: 'empty.key') } + let(:payload) { { inherited_proofing_auth: auth_code, exp: 1.day.from_now.to_i } } + let(:jwt_token) { JWT.encode(payload, private_key, 'RS256') } + let(:request_uri) { + "#{InheritedProofing::Va::Service::BASE_URI}/inherited_proofing/user_attributes" + } + let(:request_headers) { { Authorization: "Bearer #{jwt_token}" } } +end diff --git a/spec/support/shared_contexts/inherited_proofing/va_user_context.rb b/spec/support/shared_contexts/inherited_proofing/va_user_context.rb new file mode 100644 index 00000000000..4510ccb1fa5 --- /dev/null +++ b/spec/support/shared_contexts/inherited_proofing/va_user_context.rb @@ -0,0 +1,18 @@ +RSpec.shared_context 'va_user_context' do + # As given to us from VA + let(:user_attributes) { + { first_name: 'Fakey', + last_name: 'Fakerson', + address: { street: '123 Fake St', + street2: 'Apt 235', + city: 'Faketown', + state: 'WA', + country: nil, + zip: '98037' }, + phone: '2063119187', + birth_date: '2022-1-31', + ssn: '123456789' } + } + # Encrypted with AppArtifacts.store.oidc_private_key for testing + let(:encrypted_user_attributes) { File.read("#{__dir__}/encrypted_user_attributes.json") } +end diff --git a/spec/support/usps_ipp_fixtures.rb b/spec/support/usps_ipp_fixtures.rb index d4121f6fb49..78603c9f292 100644 --- a/spec/support/usps_ipp_fixtures.rb +++ b/spec/support/usps_ipp_fixtures.rb @@ -7,6 +7,14 @@ def self.request_facilities_response load_response_fixture('request_facilities_response.json') end + def self.request_show_usps_location_response + load_response_fixture('request_show_usps_location_response.json') + end + + def self.enrollment_selected_location_details + load_response_fixture('enrollment_selected_location_details.json') + end + def self.request_enroll_response load_response_fixture('request_enroll_response.json') end diff --git a/spec/views/idv/doc_auth/_back.html.erb_spec.rb b/spec/views/idv/doc_auth/_back.html.erb_spec.rb deleted file mode 100644 index c6dd601f161..00000000000 --- a/spec/views/idv/doc_auth/_back.html.erb_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -require 'rails_helper' - -describe 'idv/doc_auth/_back.html.erb' do - let(:action) { nil } - let(:step) { nil } - let(:classes) { nil } - let(:fallback_path) { nil } - - subject do - render 'idv/doc_auth/back', { - action: action, - step: step, - class: classes, - fallback_path: fallback_path, - } - end - - shared_examples 'back link with class' do - let(:classes) { 'example-class' } - - it 'renders with class' do - expect(subject).to have_css('.example-class') - end - end - - context 'with action' do - let(:action) { 'redo_ssn' } - - it 'renders' do - expect(subject).to have_selector("form[action='#{idv_doc_auth_step_path(step: 'redo_ssn')}']") - expect(subject).to have_selector('input[name="_method"][value="put"]', visible: false) - expect(subject).to have_selector("[type='submit']") - expect(subject).to have_selector('button', text: '‹ ' + t('forms.buttons.back')) - end - - it_behaves_like 'back link with class' - end - - context 'with step' do - let(:step) { 'verify' } - - it 'renders' do - expect(subject).to have_selector("a[href='#{idv_doc_auth_step_path(step: 'verify')}']") - expect(subject).to have_content('‹ ' + t('forms.buttons.back')) - end - - it_behaves_like 'back link with class' - end - - context 'with back path' do - before do - allow(view).to receive(:go_back_path).and_return('/example') - end - - it 'renders with back path' do - expect(subject).to have_selector('a[href="/example"]') - expect(subject).to have_content('‹ ' + t('forms.buttons.back')) - end - end - - context 'with fallback link' do - let(:fallback_path) { '/example' } - - it 'renders' do - expect(subject).to have_selector('a[href="/example"]') - expect(subject).to have_content('‹ ' + t('forms.buttons.back')) - end - - it_behaves_like 'back link with class' - end - - context 'no back path' do - it 'renders nothing' do - render 'idv/doc_auth/back' - - expect(subject).to be_empty - end - end -end diff --git a/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb b/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb index 7661da3eadd..c46f8cb537b 100644 --- a/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb +++ b/spec/views/idv/in_person/ready_to_verify/show.html.erb_spec.rb @@ -7,6 +7,9 @@ let(:profile) { build(:profile, user: user) } let(:enrollment_code) { '2048702198804358' } let(:current_address_matches_id) { true } + let(:selected_location_details) { + JSON.parse(UspsIppFixtures.enrollment_selected_location_details) + } let(:created_at) { Time.zone.parse('2022-07-13') } let(:enrollment) do InPersonEnrollment.new( @@ -16,8 +19,7 @@ unique_id: InPersonEnrollment.generate_unique_id, created_at: created_at, current_address_matches_id: current_address_matches_id, - selected_location_details: - JSON.parse(UspsIppFixtures.request_facilities_response)['postOffices'].first, + selected_location_details: selected_location_details, ) end let(:presenter) { Idv::InPerson::ReadyToVerifyPresenter.new(enrollment: enrollment) } @@ -45,4 +47,22 @@ expect(rendered).to have_content(t('in_person_proofing.process.proof_of_address.heading')) end end + + context 'with enrollment where selected_location_details is present' do + it 'renders a location' do + render + + expect(rendered).to have_content(t('in_person_proofing.body.barcode.speak_to_associate')) + end + end + + context 'with enrollment where selected_location_details is not present' do + let(:selected_location_details) { nil } + + it 'does not render a location' do + render + + expect(rendered).not_to have_content(t('in_person_proofing.body.barcode.speak_to_associate')) + end + end end diff --git a/spec/views/idv/session_errors/exception.html.erb_spec.rb b/spec/views/idv/session_errors/exception.html.erb_spec.rb index a575741e774..0a78c990215 100644 --- a/spec/views/idv/session_errors/exception.html.erb_spec.rb +++ b/spec/views/idv/session_errors/exception.html.erb_spec.rb @@ -5,6 +5,7 @@ let(:sp_issuer) { nil } let(:in_person_proofing_enabled) { false } let(:in_person_proofing_enabled_issuers) { [] } + let(:try_again_path) { '/example/path' } before do decorated_session = instance_double( @@ -18,11 +19,13 @@ allow(IdentityConfig.store).to receive(:in_person_proofing_enabled_issuers). and_return(in_person_proofing_enabled_issuers) + assign(:try_again_path, try_again_path) + render end it 'shows a primary action' do - expect(rendered).to have_link(t('idv.failure.button.warning'), href: idv_doc_auth_path) + expect(rendered).to have_link(t('idv.failure.button.warning'), href: try_again_path) end it 'renders a list of troubleshooting options' do diff --git a/spec/views/idv/session_errors/warning.html.erb_spec.rb b/spec/views/idv/session_errors/warning.html.erb_spec.rb index f1a3dd05516..c567b408b2f 100644 --- a/spec/views/idv/session_errors/warning.html.erb_spec.rb +++ b/spec/views/idv/session_errors/warning.html.erb_spec.rb @@ -2,6 +2,7 @@ describe 'idv/session_errors/warning.html.erb' do let(:sp_name) { nil } + let(:try_again_path) { '/example/path' } let(:remaining_attempts) { 5 } let(:user_session) { {} } @@ -11,12 +12,13 @@ allow(view).to receive(:user_session).and_return(user_session) assign(:remaining_attempts, remaining_attempts) + assign(:try_again_path, try_again_path) render end it 'shows a primary action' do - expect(rendered).to have_link(t('idv.failure.button.warning'), href: idv_doc_auth_path) + expect(rendered).to have_link(t('idv.failure.button.warning'), href: try_again_path) end it 'shows remaining attempts' do diff --git a/spec/views/idv/shared/_back.html.erb_spec.rb b/spec/views/idv/shared/_back.html.erb_spec.rb new file mode 100644 index 00000000000..83d004fef13 --- /dev/null +++ b/spec/views/idv/shared/_back.html.erb_spec.rb @@ -0,0 +1,129 @@ +require 'rails_helper' + +describe 'idv/doc_auth/_back.html.erb' do + let(:step_url) { nil } + let(:action) { nil } + let(:step) { nil } + let(:classes) { nil } + let(:fallback_path) { nil } + + subject do + render 'idv/shared/back', { + step_url: step_url, + action: action, + step: step, + class: classes, + fallback_path: fallback_path, + } + end + + shared_examples 'back link with class' do + let(:classes) { 'example-class' } + + it 'renders with class' do + expect(subject).to have_css('.example-class') + end + end + + context 'with step URL in locals' do + let(:step_url) { :idv_doc_auth_step_url } + + context 'with action' do + let(:action) { 'redo_ssn' } + + it 'renders' do + expect(subject).to have_selector( + "form[action='#{send(:idv_doc_auth_step_url, step: 'redo_ssn')}']", + ) + expect(subject).to have_selector('input[name="_method"][value="put"]', visible: false) + expect(subject).to have_selector("[type='submit']") + expect(subject).to have_selector('button', text: '‹ ' + t('forms.buttons.back')) + end + + it_behaves_like 'back link with class' + end + + context 'with step' do + let(:step) { 'verify' } + + it 'renders' do + expect(subject).to have_selector( + "a[href='#{send( + :idv_doc_auth_step_url, + step: 'verify', + )}']", + ) + expect(subject).to have_content('‹ ' + t('forms.buttons.back')) + end + + it_behaves_like 'back link with class' + end + end + + context 'with step URL in instance variable' do + before do + assign(:step_url, :idv_doc_auth_step_url) + end + + context 'with action' do + let(:action) { 'redo_ssn' } + + it 'renders' do + expect(subject).to have_selector( + "form[action='#{send(:idv_doc_auth_step_url, step: 'redo_ssn')}']", + ) + expect(subject).to have_selector('input[name="_method"][value="put"]', visible: false) + expect(subject).to have_selector("[type='submit']") + expect(subject).to have_selector('button', text: '‹ ' + t('forms.buttons.back')) + end + + it_behaves_like 'back link with class' + end + + context 'with step' do + let(:step) { 'verify' } + + it 'renders' do + expect(subject).to have_selector( + "a[href='#{send( + :idv_doc_auth_step_url, + step: 'verify', + )}']", + ) + expect(subject).to have_content('‹ ' + t('forms.buttons.back')) + end + + it_behaves_like 'back link with class' + end + end + + context 'with back path' do + before do + allow(view).to receive(:go_back_path).and_return('/example') + end + + it 'renders with back path' do + expect(subject).to have_selector('a[href="/example"]') + expect(subject).to have_content('‹ ' + t('forms.buttons.back')) + end + end + + context 'with fallback link' do + let(:fallback_path) { '/example' } + + it 'renders' do + expect(subject).to have_selector('a[href="/example"]') + expect(subject).to have_content('‹ ' + t('forms.buttons.back')) + end + + it_behaves_like 'back link with class' + end + + context 'no back path' do + it 'renders nothing' do + render 'idv/shared/back' + + expect(subject).to be_empty + end + end +end diff --git a/spec/views/shared/_one_time_code_input.html.erb_spec.rb b/spec/views/shared/_one_time_code_input.html.erb_spec.rb index 39e5286e464..92b97abda0d 100644 --- a/spec/views/shared/_one_time_code_input.html.erb_spec.rb +++ b/spec/views/shared/_one_time_code_input.html.erb_spec.rb @@ -102,7 +102,6 @@ describe 'maxlength' do context 'no maxlength given' do it 'renders input maxlength DIRECT_OTP_LENGTH' do - puts rendered expect(rendered).to have_selector( "[maxlength=\"#{TwoFactorAuthenticatable::DIRECT_OTP_LENGTH}\"]", )