diff --git a/Gemfile b/Gemfile
index e8c19b918b9..b17c88e726e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -96,7 +96,7 @@ group :development, :test do
gem 'erb_lint', '~> 0.1.0', require: false
gem 'i18n-tasks', '>= 0.9.31'
gem 'knapsack'
- gem 'nokogiri', '~> 1.13.6'
+ gem 'nokogiri', '~> 1.13.9'
gem 'parallel_tests'
gem 'pg_query', require: false
gem 'pry-byebug'
@@ -113,7 +113,6 @@ end
group :test do
gem 'axe-core-rspec', '~> 4.2'
gem 'bundler-audit', require: false
- gem 'capybara-selenium', '>= 0.0.6'
gem 'simplecov', '~> 0.21.0', require: false
gem 'simplecov-cobertura'
gem 'simplecov_json_formatter'
diff --git a/Gemfile.lock b/Gemfile.lock
index 489960208c7..4c2a7d65776 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -210,9 +210,6 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
- capybara-selenium (0.0.6)
- capybara
- selenium-webdriver
cbor (0.5.9.6)
childprocess (4.1.0)
choice (0.2.0)
@@ -425,7 +422,7 @@ GEM
net-ssh (6.1.0)
newrelic_rpm (8.8.0)
nio4r (2.5.8)
- nokogiri (1.13.8)
+ nokogiri (1.13.9)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
notiffany (0.1.3)
@@ -742,7 +739,6 @@ DEPENDENCIES
browser
bullet (~> 7.0)
bundler-audit
- capybara-selenium (>= 0.0.6)
capybara-webmock!
connection_pool
cssbundling-rails
@@ -776,7 +772,7 @@ DEPENDENCIES
multiset
net-sftp
newrelic_rpm (~> 8.0)
- nokogiri (~> 1.13.6)
+ nokogiri (~> 1.13.9)
octokit (>= 4.25.0)
parallel_tests
pg
diff --git a/README.md b/README.md
index cd8bb6ce0b1..0ef762048cb 100644
--- a/README.md
+++ b/README.md
@@ -198,11 +198,6 @@ $ bundle install
$ yarn install
```
-#### I am receiving errors related to Capybara in feature tests
-You may need to install _chromedriver_ or your chromedriver may be the wrong version (`$ which chromedriver && chromedriver --version`).
-
-chromedriver can be installed using [Homebrew](https://formulae.brew.sh/cask/chromedriver) or [direct download](https://chromedriver.chromium.org/downloads). The version of chromedriver should correspond to the version of Chrome you have installed `(Chrome > About Google Chrome)`; if installing via Homebrew, make sure the versions match up.
-
#### I am receiving errors when creating the development and test databases
If you receive the following error (where _whoami_ == _your username_):
@@ -222,12 +217,23 @@ $ createdb `whoami`
$ make test_serial
```
-##### Errors related to too many _open files_
+##### Errors related to Capybara in feature tests
+You may need to install _chromedriver_ or your chromedriver may be the wrong version (`$ which chromedriver && chromedriver --version`).
+
+chromedriver can be installed using [Homebrew](https://formulae.brew.sh/cask/chromedriver) or [direct download](https://chromedriver.chromium.org/downloads). The version of chromedriver should correspond to the version of Chrome you have installed `(Chrome > About Google Chrome)`; if installing via Homebrew, make sure the versions match up. After your system recieves an automatic Chrome browser update you may have to upgrade (or reinstall) chromedriver.
+
+If `chromedriver -v` does not work you may have to [allow it](https://stackoverflow.com/questions/60362018/macos-catalinav-10-15-3-error-chromedriver-cannot-be-opened-because-the-de) with `xattr`.
+
+##### Errors related to _too many open files_
You may receive connection errors similar to the following:
`Failed to open TCP connection to 127.0.0.1:9515 (Too many open files - socket(2) for "127.0.0.1" port 9515)`
-Running the following, _prior_ to running tests, may solve the problem:
+You are encountering you OS's [limits on allowed file descriptors](https://wilsonmar.github.io/maximum-limits/). Check the limits with both:
+* `ulimit -n`
+* `launchctl limit maxfiles`
+
+Try this to increase the user limit:
```
$ ulimit -Sn 65536 && make test
```
@@ -235,3 +241,36 @@ To set this _permanently_, add the following to your `~/.zshrc` or `~/.bash_prof
```
ulimit -Sn 65536
```
+
+If you are running MacOS, you may find it is not taking your revised ulimit seriously. [You must insist.](https://medium.com/mindful-technology/too-many-open-files-limit-ulimit-on-mac-os-x-add0f1bfddde) Run this command to edit a property list file:
+```
+sudo nano /Library/LaunchDaemons/limit.maxfiles.plist
+```
+Paste the following contents into the text editor:
+```
+
+
+
+
+ Label
+ limit.maxfiles
+ ProgramArguments
+
+ launchctl
+ limit
+ maxfiles
+ 524288
+ 524288
+
+ RunAtLoad
+
+ ServiceIPC
+
+
+
+
+```
+Use Control+X to save the file.
+
+Restart your Mac to cause the .plist to take effect. Check the limits again and you should see both `ulimit -n` and `launchctl limit maxfiles` return a limit of 524288.
diff --git a/app/assets/stylesheets/components/_click-observer.scss b/app/assets/stylesheets/components/_click-observer.scss
new file mode 100644
index 00000000000..0d9671bba46
--- /dev/null
+++ b/app/assets/stylesheets/components/_click-observer.scss
@@ -0,0 +1,3 @@
+lg-click-observer {
+ display: contents;
+}
diff --git a/app/assets/stylesheets/components/all.scss b/app/assets/stylesheets/components/all.scss
index 53caced834b..924a3234fd9 100644
--- a/app/assets/stylesheets/components/all.scss
+++ b/app/assets/stylesheets/components/all.scss
@@ -4,6 +4,7 @@
@import 'block-link';
@import 'btn';
@import 'card';
+@import 'click-observer';
@import 'container';
@import 'file-input';
@import 'form-steps';
diff --git a/app/components/click_observer_component.rb b/app/components/click_observer_component.rb
new file mode 100644
index 00000000000..a9e4523be3a
--- /dev/null
+++ b/app/components/click_observer_component.rb
@@ -0,0 +1,12 @@
+class ClickObserverComponent < BaseComponent
+ attr_reader :event_name, :tag_options
+
+ def initialize(event_name:, **tag_options)
+ @event_name = event_name
+ @tag_options = tag_options
+ end
+
+ def call
+ content_tag(:'lg-click-observer', content, 'event-name': @event_name, **tag_options)
+ end
+end
diff --git a/app/components/click_observer_component.ts b/app/components/click_observer_component.ts
new file mode 100644
index 00000000000..ae5fb8eda0b
--- /dev/null
+++ b/app/components/click_observer_component.ts
@@ -0,0 +1 @@
+import '@18f/identity-analytics/click-observer-element';
diff --git a/app/components/download_button_component.rb b/app/components/download_button_component.rb
new file mode 100644
index 00000000000..f4e09f17a2f
--- /dev/null
+++ b/app/components/download_button_component.rb
@@ -0,0 +1,29 @@
+class DownloadButtonComponent < ButtonComponent
+ attr_reader :file_data, :file_name, :tag_options
+
+ def initialize(file_data:, file_name:, **tag_options)
+ super(
+ icon: :file_download,
+ action: ->(**tag_options, &block) do
+ link_to(
+ "data:text/plain;charset=utf-8,#{CGI.escape(file_data)}",
+ download: file_name,
+ **tag_options,
+ &block
+ )
+ end,
+ **tag_options,
+ )
+
+ @file_data = file_data
+ @file_name = file_name
+ end
+
+ def call
+ content_tag(:'lg-download-button', super)
+ end
+
+ def content
+ super || t('components.download_button.label')
+ end
+end
diff --git a/app/components/download_button_component.ts b/app/components/download_button_component.ts
new file mode 100644
index 00000000000..99726c1b32a
--- /dev/null
+++ b/app/components/download_button_component.ts
@@ -0,0 +1 @@
+import '@18f/identity-download-button/download-button-element';
diff --git a/app/controllers/concerns/idv_session.rb b/app/controllers/concerns/idv_session.rb
index 054d8ea5332..e4197f3c5e4 100644
--- a/app/controllers/concerns/idv_session.rb
+++ b/app/controllers/concerns/idv_session.rb
@@ -14,6 +14,7 @@ def confirm_idv_session_started
def confirm_idv_needed
return if effective_user.active_profile.blank? ||
decorated_session.requested_more_recent_verification? ||
+ effective_user.decorate.reproof_for_irs?(service_provider: current_sp) ||
strict_ial2_upgrade_required?
redirect_to idv_activated_url
diff --git a/app/controllers/concerns/saml_idp_auth_concern.rb b/app/controllers/concerns/saml_idp_auth_concern.rb
index 3d655883ef0..5acf7eb9b33 100644
--- a/app/controllers/concerns/saml_idp_auth_concern.rb
+++ b/app/controllers/concerns/saml_idp_auth_concern.rb
@@ -115,7 +115,9 @@ def link_identity_from_session_data
end
def identity_needs_verification?
- ial2_requested? && current_user.decorate.identity_not_verified?
+ ial2_requested? &&
+ (current_user.decorate.identity_not_verified? ||
+ current_user.decorate.reproof_for_irs?(service_provider: current_sp))
end
def_delegators :ial_context, :ial2_requested?
diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb
index 3cae4dc8967..9f3f53c5300 100644
--- a/app/controllers/frontend_log_controller.rb
+++ b/app/controllers/frontend_log_controller.rb
@@ -17,6 +17,7 @@ class FrontendLogController < ApplicationController
'IdV: Native camera forced after failed attempts' => :idv_native_camera_forced,
'Multi-Factor Authentication: download backup code' => :multi_factor_auth_backup_code_download,
'Show Password button clicked' => :show_password_button_clicked,
+ 'IdV: personal key acknowledgment toggled' => :idv_personal_key_acknowledgment_toggled,
}.transform_values { |method| AnalyticsEvents.instance_method(method) }.freeze
# rubocop:enable Layout/LineLength
diff --git a/app/controllers/idv/gpo_controller.rb b/app/controllers/idv/gpo_controller.rb
index 8c49bd98f31..dbb41fbec35 100644
--- a/app/controllers/idv/gpo_controller.rb
+++ b/app/controllers/idv/gpo_controller.rb
@@ -50,7 +50,7 @@ def update_tracking
irs_attempts_api_tracker.idv_gpo_letter_requested(resend: resend_requested?)
create_user_event(:gpo_mail_sent, current_user)
- ProofingComponent.create_or_find_by(user: current_user).update(address_check: 'gpo_letter')
+ ProofingComponent.find_or_create_by(user: current_user).update(address_check: 'gpo_letter')
end
def resend_requested?
diff --git a/app/controllers/idv/inherited_proofing_controller.rb b/app/controllers/idv/inherited_proofing_controller.rb
index 0d76f6d4728..e16d55545f9 100644
--- a/app/controllers/idv/inherited_proofing_controller.rb
+++ b/app/controllers/idv/inherited_proofing_controller.rb
@@ -1,5 +1,7 @@
module Idv
class InheritedProofingController < ApplicationController
+ before_action :confirm_two_factor_authenticated
+
include Flow::FlowStateMachine
include IdvSession
include InheritedProofing404Concern
@@ -15,5 +17,9 @@ class InheritedProofingController < ApplicationController
def return_to_sp
redirect_to return_to_sp_failure_to_proof_url(step: next_step, location: params[:location])
end
+
+ # for errors/no_information
+ def no_information
+ end
end
end
diff --git a/app/controllers/idv/personal_key_controller.rb b/app/controllers/idv/personal_key_controller.rb
index c0da6b28a8b..a0faafbcf9b 100644
--- a/app/controllers/idv/personal_key_controller.rb
+++ b/app/controllers/idv/personal_key_controller.rb
@@ -43,7 +43,7 @@ def confirm_profile_has_been_created
end
def add_proofing_component
- ProofingComponent.create_or_find_by(user: current_user).update(verified_at: Time.zone.now)
+ ProofingComponent.find_or_create_by(user: current_user).update(verified_at: Time.zone.now)
end
def finish_idv_session
diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb
index e31ef3450b2..a8b1cc27259 100644
--- a/app/controllers/idv_controller.rb
+++ b/app/controllers/idv_controller.rb
@@ -7,7 +7,8 @@ class IdvController < ApplicationController
before_action :profile_needs_reactivation?, only: [:index]
def index
- if decorated_session.requested_more_recent_verification?
+ if decorated_session.requested_more_recent_verification? ||
+ current_user.decorate.reproof_for_irs?(service_provider: current_sp)
verify_identity
elsif active_profile? && !strict_ial2_upgrade_required?
redirect_to idv_activated_url
diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb
index 7cc78654249..8a203fe3d70 100644
--- a/app/controllers/openid_connect/authorization_controller.rb
+++ b/app/controllers/openid_connect/authorization_controller.rb
@@ -100,6 +100,7 @@ def identity_needs_verification?
((@authorize_form.ial2_requested? || @authorize_form.ial2_strict_requested?) &&
(current_user.decorate.identity_not_verified? ||
decorated_session.requested_more_recent_verification?)) ||
+ current_user.decorate.reproof_for_irs?(service_provider: current_sp) ||
identity_needs_strict_ial2_verification?
end
diff --git a/app/decorators/user_decorator.rb b/app/decorators/user_decorator.rb
index d24713f5904..d477dfa105f 100644
--- a/app/decorators/user_decorator.rb
+++ b/app/decorators/user_decorator.rb
@@ -57,8 +57,13 @@ def identity_not_verified?
!identity_verified?
end
- def identity_verified?
- user.active_profile.present?
+ def identity_verified?(service_provider: nil)
+ user.active_profile.present? && !reproof_for_irs?(service_provider: service_provider)
+ end
+
+ def reproof_for_irs?(service_provider:)
+ service_provider&.irs_attempts_api_enabled &&
+ !user.active_profile&.initiating_service_provider&.irs_attempts_api_enabled
end
def active_profile_newer_than_pending_profile?
diff --git a/app/helpers/csp_helper.rb b/app/helpers/csp_helper.rb
new file mode 100644
index 00000000000..8c790226aad
--- /dev/null
+++ b/app/helpers/csp_helper.rb
@@ -0,0 +1,11 @@
+module CspHelper
+ def add_document_capture_image_urls_to_csp(request, urls)
+ cleaned_urls = urls.compact.map do |url|
+ URI(url).tap { |uri| uri.query = nil }.to_s
+ end
+
+ policy = request.content_security_policy.clone
+ policy.connect_src(*policy.connect_src, *cleaned_urls)
+ request.content_security_policy = policy
+ end
+end
diff --git a/app/helpers/secure_headers_helper.rb b/app/helpers/secure_headers_helper.rb
deleted file mode 100644
index 7b3cc6b8c3c..00000000000
--- a/app/helpers/secure_headers_helper.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module SecureHeadersHelper
- def add_document_capture_image_urls_to_csp(request, urls)
- cleaned_urls = urls.compact.map do |url|
- URI(url).tap { |uri| uri.query = nil }.to_s
- end
-
- add_document_capture_image_urls_to_csp_with_rails_csp_tooling(request, cleaned_urls)
- end
-
- def add_document_capture_image_urls_to_csp_with_secure_headers(request, urls)
- SecureHeaders.append_content_security_policy_directives(
- request,
- connect_src: urls,
- )
- end
-
- def add_document_capture_image_urls_to_csp_with_rails_csp_tooling(request, urls)
- policy = request.content_security_policy.clone
- policy.connect_src(*policy.connect_src, *urls)
- request.content_security_policy = policy
- end
-end
diff --git a/app/javascript/app/components/index.js b/app/javascript/app/components/index.js
index 582377f5e28..31b356d8633 100644
--- a/app/javascript/app/components/index.js
+++ b/app/javascript/app/components/index.js
@@ -1,8 +1,8 @@
-import { accordion, banner, navigation, skipnav } from 'identity-style-guide';
+import { accordion, banner, skipnav } from 'identity-style-guide';
import Modal from './modal';
window.LoginGov = window.LoginGov || {};
window.LoginGov.Modal = Modal;
-const components = [accordion, banner, navigation, skipnav];
+const components = [accordion, banner, skipnav];
components.forEach((component) => component.on());
diff --git a/app/javascript/app/i18n-dropdown.js b/app/javascript/app/i18n-dropdown.js
index a024c9c1d3d..27f647b1ae8 100644
--- a/app/javascript/app/i18n-dropdown.js
+++ b/app/javascript/app/i18n-dropdown.js
@@ -1,69 +1,36 @@
-export function setUp() {
- const mobileLink = document.querySelector('.i18n-mobile-toggle > button');
- const mobileDropdown = document.querySelector('.i18n-mobile-dropdown');
- const desktopLink = document.querySelector('.i18n-desktop-toggle > button');
- const desktopDropdown = document.querySelector('.i18n-desktop-dropdown');
+const mobileLink = document.querySelector('.i18n-mobile-toggle > button');
+const mobileDropdown = document.querySelector('.i18n-mobile-dropdown');
+const desktopLink = document.querySelector('.i18n-desktop-toggle > button');
+const desktopDropdown = document.querySelector('.i18n-desktop-dropdown');
- function addListenerMulti(el, s, fn) {
- s.split(' ').forEach((e) => el.addEventListener(e, fn, false));
- }
-
- function toggleAriaExpanded(element) {
- if (element.getAttribute('aria-expanded') === 'true') {
- element.setAttribute('aria-expanded', 'false');
- } else {
- element.setAttribute('aria-expanded', 'true');
- }
- }
-
- function languagePicker(trigger, dropdown) {
- addListenerMulti(trigger, 'click keypress', function (event) {
- const eventType = event.type;
-
- event.preventDefault();
- if (eventType === 'click' || (eventType === 'keypress' && event.which === 13)) {
- this.parentNode.classList.toggle('focused');
- dropdown.classList.toggle('display-none');
- toggleAriaExpanded(this);
- }
- });
- }
-
- if (desktopLink) {
- languagePicker(desktopLink, desktopDropdown);
- }
- if (mobileLink) {
- languagePicker(mobileLink, mobileDropdown);
- }
+function addListenerMulti(el, s, fn) {
+ s.split(' ').forEach((e) => el.addEventListener(e, fn, false));
+}
- /**
- * Loops through all of the language links in the dropdown and updates their target url
- * to reflect the correct route
- */
- function syncLanguageLinkURLs() {
- const links = document.querySelectorAll('.i18n-dropdown a[lang]');
- links.forEach((link) => {
- const linkLang = link.getAttribute('lang');
- const prefix = linkLang === 'en' ? '' : `/${linkLang}`;
- const url = new URL(window.location.href);
- const { lang } = document.documentElement;
- const barePath = url.pathname.replace(new RegExp(`^/${lang}`), '');
- url.pathname = prefix + barePath;
- link.setAttribute('href', url.toString());
- });
+function toggleAriaExpanded(element) {
+ if (element.getAttribute('aria-expanded') === 'true') {
+ element.setAttribute('aria-expanded', 'false');
+ } else {
+ element.setAttribute('aria-expanded', 'true');
}
+}
- syncLanguageLinkURLs();
+function languagePicker(trigger, dropdown) {
+ addListenerMulti(trigger, 'click keypress', function (event) {
+ const eventType = event.type;
- window.addEventListener('lg:url-change', syncLanguageLinkURLs);
- return () => {
- window.removeEventListener('lg:url-change', syncLanguageLinkURLs);
- };
+ event.preventDefault();
+ if (eventType === 'click' || (eventType === 'keypress' && event.which === 13)) {
+ this.parentNode.classList.toggle('focused');
+ dropdown.classList.toggle('display-none');
+ toggleAriaExpanded(this);
+ }
+ });
}
-/**
- * used to mock this behavior for testing purposes
- */
-if (process.env.NODE_ENV !== 'test') {
- setUp();
+if (desktopLink) {
+ languagePicker(desktopLink, desktopDropdown);
+}
+if (mobileLink) {
+ languagePicker(mobileLink, mobileDropdown);
}
diff --git a/app/javascript/packages/analytics/README.md b/app/javascript/packages/analytics/README.md
index 372d2939966..9aac752a12c 100644
--- a/app/javascript/packages/analytics/README.md
+++ b/app/javascript/packages/analytics/README.md
@@ -1,6 +1,6 @@
# `@18f/identity-analytics`
-Utilities for logging events and errors in the application.
+Utilities and custom elements for logging events and errors in the application.
By default, events logged from the frontend will have their names prefixed with "Frontend:". This
behavior occurs in [`FrontendLogController`][frontend_log_controller.rb]. You can avoid the prefix
@@ -8,7 +8,11 @@ by assigning an event mapping method in the controller's `EVENT_MAP` constant.
[frontend_log_controller.rb]: https://github.com/18F/identity-idp/blob/main/app/controllers/frontend_log_controller.rb
-## Example
+## Usage
+
+### Programmatic Event Tracking
+
+Track an event or error from your code using exported function members.
```ts
import { trackEvent, trackError } from '@18f/identity-analytics';
@@ -23,3 +27,19 @@ try {
trackError(error);
}
```
+
+### HTML Element Click Tracking
+
+Use the `` custom element to record clicks within the element.
+
+```ts
+import '@18f/identity-analytics/click-observer-element';
+```
+
+The custom element will implement the analytics logging behavior, but all markup must already exist.
+
+```html
+
+ Click me!
+
+```
diff --git a/app/javascript/packages/analytics/click-observer-element.spec.ts b/app/javascript/packages/analytics/click-observer-element.spec.ts
new file mode 100644
index 00000000000..4633846c4bf
--- /dev/null
+++ b/app/javascript/packages/analytics/click-observer-element.spec.ts
@@ -0,0 +1,71 @@
+import sinon from 'sinon';
+import { getByRole, getByText } from '@testing-library/dom';
+import userEvent from '@testing-library/user-event';
+import './click-observer-element';
+
+describe('ClickObserverElement', () => {
+ context('with an event name', () => {
+ it('tracks event on click', async () => {
+ document.body.innerHTML = `
+
+ Click me!
+ `;
+ const observer = document.body.querySelector('lg-click-observer')!;
+ const trackEvent = sinon.stub(observer, 'trackEvent');
+
+ const button = getByRole(document.body, 'button', { name: 'Click me!' });
+ await userEvent.click(button);
+
+ expect(trackEvent).to.have.been.calledWith('Button clicked');
+ });
+
+ context('for a checkbox', () => {
+ it('includes checked state in event payload', async () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const observer = document.body.querySelector('lg-click-observer')!;
+ const trackEvent = sinon.stub(observer, 'trackEvent');
+
+ const button = getByRole(document.body, 'checkbox', { name: 'Toggle' });
+ await userEvent.click(button);
+
+ expect(trackEvent).to.have.been.calledWith('Checkbox toggled', { checked: true });
+ });
+
+ context('clicking on associated label', () => {
+ it('logs a single event', async () => {
+ document.body.innerHTML = `
+
+
+ Toggle
+ `;
+ const observer = document.body.querySelector('lg-click-observer')!;
+ const trackEvent = sinon.stub(observer, 'trackEvent');
+
+ const label = getByText(document.body, 'Toggle');
+ await userEvent.click(label);
+
+ expect(trackEvent).to.have.been.calledOnceWith('Checkbox toggled', { checked: true });
+ });
+ });
+ });
+ });
+
+ context('without an event name', () => {
+ it('does nothing on click', async () => {
+ document.body.innerHTML = `
+
+ Click me!
+ `;
+ const observer = document.body.querySelector('lg-click-observer')!;
+ const trackEvent = sinon.stub(observer, 'trackEvent');
+
+ const button = getByRole(document.body, 'button', { name: 'Click me!' });
+ await userEvent.click(button);
+
+ expect(trackEvent).not.to.have.been.called();
+ });
+ });
+});
diff --git a/app/javascript/packages/analytics/click-observer-element.ts b/app/javascript/packages/analytics/click-observer-element.ts
new file mode 100644
index 00000000000..dd611b0e897
--- /dev/null
+++ b/app/javascript/packages/analytics/click-observer-element.ts
@@ -0,0 +1,45 @@
+import { trackEvent } from '.';
+
+class ClickObserverElement extends HTMLElement {
+ trackEvent: typeof trackEvent = trackEvent;
+
+ connectedCallback() {
+ this.addEventListener('click', (event) => this.handleEvent(event), true);
+ this.addEventListener('change', (event) => this.handleEvent(event), true);
+ }
+
+ get eventName(): string | null {
+ return this.getAttribute('event-name');
+ }
+
+ /**
+ * Whether event handling should handle target as a checkbox.
+ */
+ get isHandledAsCheckbox(): boolean {
+ return !!this.querySelector('[type=checkbox]');
+ }
+
+ handleEvent(event: Event) {
+ if (!this.eventName) {
+ return;
+ }
+
+ if (event.type === 'change' && this.isHandledAsCheckbox) {
+ this.trackEvent(this.eventName, { checked: (event.target as HTMLInputElement).checked });
+ } else if (event.type === 'click' && !this.isHandledAsCheckbox) {
+ this.trackEvent(this.eventName);
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'lg-click-observer': ClickObserverElement;
+ }
+}
+
+if (!customElements.get('lg-click-observer')) {
+ customElements.define('lg-click-observer', ClickObserverElement);
+}
+
+export default ClickObserverElement;
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 f20577f8689..5194da8af16 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
@@ -49,6 +49,22 @@ function DocumentCaptureTroubleshootingOptions({
return (
<>
+ {hasErrors && inPersonURL && showInPersonOption && (
+ trackEvent('IdV: verify in person troubleshooting option clicked'),
+ },
+ ]}
+ />
+ )}
- {hasErrors && inPersonURL && showInPersonOption && (
- trackEvent('IdV: verify in person troubleshooting option clicked'),
- },
- ]}
- />
- )}
>
);
}
diff --git a/app/javascript/packages/download-button/README.md b/app/javascript/packages/download-button/README.md
new file mode 100644
index 00000000000..2bea9377e3f
--- /dev/null
+++ b/app/javascript/packages/download-button/README.md
@@ -0,0 +1,19 @@
+# `@18f/identity-download-button`
+
+Custom element for a download button component.
+
+## Usage
+
+Importing the element will register the `` custom element:
+
+```ts
+import '@18f/identity-download-button/download-button-element';
+```
+
+The custom element will implement the copying behavior, but all markup must already exist.
+
+```html
+
+ Download
+
+```
diff --git a/app/javascript/packages/download-button/download-button-element.spec.ts b/app/javascript/packages/download-button/download-button-element.spec.ts
new file mode 100644
index 00000000000..6696f00ac92
--- /dev/null
+++ b/app/javascript/packages/download-button/download-button-element.spec.ts
@@ -0,0 +1,71 @@
+import sinon from 'sinon';
+import { screen, fireEvent } from '@testing-library/dom';
+import { useDefineProperty } from '@18f/identity-test-helpers';
+import { dataURIToBlob } from './download-button-element';
+
+describe('dataURIToBlob', () => {
+ it('converts a data URI to equivalent Blob', async () => {
+ const blob = dataURIToBlob('data:text/plain;charset=utf-8,hello%20world');
+
+ const result = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => resolve(reader.result as string));
+ reader.readAsText(blob);
+ });
+
+ expect(result).to.equal('hello world');
+ });
+});
+
+describe('DownloadButtonElement', () => {
+ it('does not interfere with the click event', () => {
+ document.body.innerHTML = `
+
+ Download
+
+ `;
+
+ const link = screen.getByRole('link', { name: 'Download' });
+
+ const onClick = sinon.stub().callsFake((event: MouseEvent) => {
+ expect(event.defaultPrevented).to.be.false();
+
+ // Prevent default behavior, since JSDOM will otherwise throw an error on navigation.
+ event.preventDefault();
+ });
+ window.addEventListener('click', onClick);
+ fireEvent.click(link);
+ window.removeEventListener('click', onClick);
+
+ expect(onClick).to.have.been.called();
+ });
+
+ context('with legacy Microsoft proprietary download', () => {
+ const defineProperty = useDefineProperty();
+
+ it('prevents default and calls msSaveBlob', () => {
+ defineProperty(window.navigator, 'msSaveBlob', { value: sinon.stub(), configurable: true });
+
+ document.body.innerHTML = `
+
+ Download
+
+ `;
+
+ const link = screen.getByRole('link', { name: 'Download' });
+
+ const onClick = sinon.stub().callsFake((event: MouseEvent) => {
+ expect(event.defaultPrevented).to.be.true();
+
+ // Prevent default behavior, since JSDOM will otherwise throw an error on navigation.
+ event.preventDefault();
+ });
+ window.addEventListener('click', onClick);
+ fireEvent.click(link);
+ window.removeEventListener('click', onClick);
+
+ expect(onClick).to.have.been.called();
+ expect(window.navigator.msSaveBlob).to.have.been.called();
+ });
+ });
+});
diff --git a/app/javascript/packages/download-button/download-button-element.ts b/app/javascript/packages/download-button/download-button-element.ts
new file mode 100644
index 00000000000..ac9a9c2ccd1
--- /dev/null
+++ b/app/javascript/packages/download-button/download-button-element.ts
@@ -0,0 +1,56 @@
+declare global {
+ interface Navigator {
+ msSaveBlob?: (blob: Blob, filename: string) => void;
+ }
+}
+
+/**
+ * Converts a data URI to a Blob. The given URI data must be URI-encoded and have a MIME type of
+ * `text/plain`.
+ *
+ * @param uri URI string to convert.
+ *
+ * @return Blob instance.
+ */
+export function dataURIToBlob(uri: string) {
+ const data = decodeURIComponent(uri.split(',')[1]);
+ const bytes = Uint8Array.from(data, (char) => char.charCodeAt(0));
+ return new Blob([bytes], { type: 'text/plain' });
+}
+
+class DownloadButtonElement extends HTMLElement {
+ link: HTMLAnchorElement;
+
+ connectedCallback() {
+ this.link = this.querySelector('a')!;
+
+ if (window.navigator.msSaveBlob) {
+ this.link.addEventListener('click', this.triggerInternetExplorerDownload);
+ }
+ }
+
+ /**
+ * Click handler to trigger download for legacy Microsoft proprietary download.
+ */
+ triggerInternetExplorerDownload = (event: MouseEvent) => {
+ event.preventDefault();
+
+ const filename = this.link.getAttribute('download')!;
+ const uri = this.link.getAttribute('href')!;
+ const blob = new Blob([dataURIToBlob(uri)], { type: 'text/plain' });
+
+ window.navigator.msSaveBlob?.(blob, filename);
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'lg-download-button': DownloadButtonElement;
+ }
+}
+
+if (!customElements.get('lg-download-button')) {
+ customElements.define('lg-download-button', DownloadButtonElement);
+}
+
+export default DownloadButtonElement;
diff --git a/app/javascript/packages/download-button/package.json b/app/javascript/packages/download-button/package.json
new file mode 100644
index 00000000000..b6efa66946e
--- /dev/null
+++ b/app/javascript/packages/download-button/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "@18f/identity-download-button",
+ "version": "1.0.0",
+ "private": true
+}
diff --git a/app/javascript/packages/form-link/form-link-element.spec.ts b/app/javascript/packages/form-link/form-link-element.spec.ts
index 02c80c4d82d..5727f3e416f 100644
--- a/app/javascript/packages/form-link/form-link-element.spec.ts
+++ b/app/javascript/packages/form-link/form-link-element.spec.ts
@@ -20,8 +20,8 @@ describe('FormLinkElement', () => {
const onSubmit = sinon.stub().callsFake((event) => event.preventDefault());
window.addEventListener('submit', onSubmit);
-
const didPreventDefault = !fireEvent.click(link);
+ window.removeEventListener('submit', onSubmit);
expect(onSubmit).to.have.been.called();
expect(didPreventDefault).to.be.true();
diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx
index 87e6af1bd84..c1ac8ac689f 100644
--- a/app/javascript/packages/form-steps/form-steps.tsx
+++ b/app/javascript/packages/form-steps/form-steps.tsx
@@ -165,12 +165,6 @@ interface FormStepsProps {
*/
promptOnNavigate?: boolean;
- /**
- * When using path fragments for maintaining history, the base path to which the current step name
- * is appended.
- */
- basePath?: string;
-
/**
* Format string for page title, interpolated with step title as `%{step}` parameter.
*/
@@ -236,13 +230,12 @@ function FormSteps({
initialActiveErrors = [],
autoFocus,
promptOnNavigate = true,
- basePath,
titleFormat,
}: FormStepsProps) {
const [values, setValues] = useState(initialValues);
const [activeErrors, setActiveErrors] = useState(initialActiveErrors);
const formRef = useRef(null as HTMLFormElement | null);
- const [stepName, setStepName] = useHistoryParam(initialStep, { basePath });
+ const [stepName, setStepName] = useHistoryParam(initialStep);
const [stepErrors, setStepErrors] = useState([] as Error[]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [stepCanComplete, setStepCanComplete] = useState(undefined);
diff --git a/app/javascript/packages/form-steps/index.ts b/app/javascript/packages/form-steps/index.ts
index 2b643b5aeb1..43f5a161ae5 100644
--- a/app/javascript/packages/form-steps/index.ts
+++ b/app/javascript/packages/form-steps/index.ts
@@ -4,7 +4,7 @@ export { default as RequiredValueMissingError } from './required-value-missing-e
export { default as FormStepsContext } from './form-steps-context';
export { default as FormStepsButton } from './form-steps-button';
export { default as PromptOnNavigate } from './prompt-on-navigate';
-export { default as useHistoryParam, getStepParam, getParamURL } from './use-history-param';
+export { default as useHistoryParam } from './use-history-param';
export type {
FormStepError,
diff --git a/app/javascript/packages/form-steps/use-history-param.spec.tsx b/app/javascript/packages/form-steps/use-history-param.spec.tsx
index a54e0cf1b4d..fb843acc3a1 100644
--- a/app/javascript/packages/form-steps/use-history-param.spec.tsx
+++ b/app/javascript/packages/form-steps/use-history-param.spec.tsx
@@ -1,7 +1,6 @@
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import userEvent from '@testing-library/user-event';
-import { useDefineProperty, useSandbox } from '@18f/identity-test-helpers';
import useHistoryParam, { getStepParam } from './use-history-param';
describe('getStepParam', () => {
@@ -11,32 +10,11 @@ describe('getStepParam', () => {
expect(result).to.equal('step');
});
-
- context('with subpath', () => {
- it('returns step', () => {
- const path = 'step/subpath';
- const result = getStepParam(path);
-
- expect(result).to.equal('step');
- });
- });
-
- context('with trailing or leading slashes', () => {
- it('returns step', () => {
- const path = '/step/';
- const result = getStepParam(path);
-
- expect(result).to.equal('step');
- });
- });
});
describe('useHistoryParam', () => {
- const sandbox = useSandbox();
- const defineProperty = useDefineProperty();
-
- function TestComponent({ initialValue, basePath }: { initialValue?: string; basePath?: string }) {
- const [count = 0, setCount] = useHistoryParam(initialValue, { basePath });
+ function TestComponent({ initialValue }: { initialValue?: string }) {
+ const [count = 0, setCount] = useHistoryParam(initialValue);
return (
<>
@@ -53,17 +31,13 @@ describe('useHistoryParam', () => {
}
let originalHash;
- let onURLChange;
beforeEach(() => {
originalHash = window.location.hash;
- onURLChange = sandbox.stub();
- window.addEventListener('lg:url-change', onURLChange);
});
afterEach(() => {
window.location.hash = originalHash;
- window.removeEventListener('lg:url-change', onURLChange);
});
it('returns undefined value if absent from initial URL', () => {
@@ -86,12 +60,10 @@ describe('useHistoryParam', () => {
expect(getByDisplayValue('1')).to.be.ok();
expect(window.location.hash).to.equal('#1');
- expect(onURLChange).to.have.been.calledOnce();
await userEvent.click(getByText('Increment'));
expect(getByDisplayValue('2')).to.be.ok();
expect(window.location.hash).to.equal('#2');
- expect(onURLChange).to.have.been.calledTwice();
});
it('scrolls to top on programmatic history manipulation', async () => {
@@ -117,33 +89,25 @@ describe('useHistoryParam', () => {
it('syncs by history events', async () => {
const { getByText, getByDisplayValue, findByDisplayValue } = render( );
- onURLChange.callsFake(() => expect(window.location.hash).to.equal('#1'));
await userEvent.click(getByText('Increment'));
- onURLChange.resetBehavior();
expect(getByDisplayValue('1')).to.be.ok();
expect(window.location.hash).to.equal('#1');
- expect(onURLChange).to.have.been.calledOnce();
await userEvent.click(getByText('Increment'));
expect(getByDisplayValue('2')).to.be.ok();
expect(window.location.hash).to.equal('#2');
- expect(onURLChange).to.have.been.calledTwice();
- onURLChange.callsFake(() => expect(window.location.hash).to.equal('#1'));
window.history.back();
- onURLChange.resetBehavior();
expect(await findByDisplayValue('1')).to.be.ok();
expect(window.location.hash).to.equal('#1');
- expect(onURLChange).to.have.been.calledThrice();
window.history.back();
expect(await findByDisplayValue('0')).to.be.ok();
expect(window.location.hash).to.equal('');
- expect(onURLChange).to.have.callCount(4);
});
it('encodes parameter names and values', async () => {
@@ -158,158 +122,11 @@ describe('useHistoryParam', () => {
it('syncs across instances', () => {
const inst1 = renderHook(() => useHistoryParam());
const inst2 = renderHook(() => useHistoryParam());
- const inst3 = renderHook(() => useHistoryParam(undefined, { basePath: '/base' }));
const [, setPath1] = inst1.result.current;
setPath1('root');
const [path2] = inst2.result.current;
- const [path3] = inst3.result.current;
expect(path2).to.equal('root');
- expect(path3).to.be.undefined();
- expect(onURLChange).to.have.been.calledOnce();
- });
-
- Object.entries({
- 'with basePath': '/base/',
- 'with basePath, no trailing slash': '/base',
- }).forEach(([description, basePath]) => {
- context(description, () => {
- context('without initial value', () => {
- beforeEach(() => {
- const history: string[] = [basePath];
- defineProperty(window, 'location', {
- value: {
- get pathname() {
- return history[history.length - 1];
- },
- },
- });
-
- sandbox.stub(window, 'history').value(
- Object.assign(history, {
- pushState(_data, _unused, url: string) {
- history.push(url as string);
- },
- replaceState(_data, _unused, url: string) {
- history[history.length - 1] = url as string;
- },
- back() {
- history.pop();
- window.dispatchEvent(new CustomEvent('popstate'));
- },
- }),
- );
- });
-
- it('returns undefined value', () => {
- const { getByDisplayValue } = render( );
-
- expect(getByDisplayValue('0')).to.be.ok();
- });
-
- it('syncs by setter', async () => {
- const { getByText, getByDisplayValue } = render( );
-
- await userEvent.click(getByText('Increment'));
-
- expect(getByDisplayValue('1')).to.be.ok();
- expect(window.location.pathname).to.equal('/base/1');
- expect(onURLChange).to.have.been.calledOnce();
-
- await userEvent.click(getByText('Increment'));
-
- expect(getByDisplayValue('2')).to.be.ok();
- expect(window.location.pathname).to.equal('/base/2');
- expect(onURLChange).to.have.been.calledTwice();
- });
-
- it('syncs by history events', async () => {
- const { getByText, getByDisplayValue, findByDisplayValue } = render(
- ,
- );
-
- await userEvent.click(getByText('Increment'));
-
- expect(getByDisplayValue('1')).to.be.ok();
- expect(window.location.pathname).to.equal('/base/1');
- expect(onURLChange).to.have.been.calledOnce();
-
- await userEvent.click(getByText('Increment'));
-
- expect(getByDisplayValue('2')).to.be.ok();
- expect(window.location.pathname).to.equal('/base/2');
- expect(onURLChange).to.have.been.calledTwice();
-
- window.history.back();
-
- expect(await findByDisplayValue('1')).to.be.ok();
- expect(window.location.pathname).to.equal('/base/1');
- expect(onURLChange).to.have.been.calledThrice();
-
- window.history.back();
-
- expect(await findByDisplayValue('0')).to.be.ok();
- expect(window.location.pathname).to.equal(basePath);
- expect(onURLChange).to.have.callCount(4);
- });
-
- context('with initial provided value', () => {
- it('returns initial value', () => {
- const { getByDisplayValue } = render(
- ,
- );
-
- expect(getByDisplayValue('1')).to.be.ok();
- });
-
- it('syncs to URL', () => {
- onURLChange.callsFake(() => expect(window.location.pathname).to.equal('/base/1'));
- render( );
- onURLChange.resetBehavior();
-
- expect(window.location.pathname).to.equal('/base/1');
- expect(window.history.length).to.equal(1);
- expect(onURLChange).to.have.been.calledOnce();
- });
- });
- });
-
- context('with initial URL value', () => {
- beforeEach(() => {
- defineProperty(window, 'location', {
- value: {
- get pathname() {
- return '/base/5/';
- },
- },
- });
- });
-
- it('returns initial value', () => {
- const { getByDisplayValue } = render( );
-
- expect(getByDisplayValue('5')).to.be.ok();
- });
- });
-
- context('with initial URL value, no trailing slash', () => {
- beforeEach(() => {
- defineProperty(window, 'location', {
- value: {
- get pathname() {
- return '/base/5';
- },
- },
- });
- });
-
- it('returns initial value', () => {
- const { getByDisplayValue } = render( );
-
- expect(getByDisplayValue('5')).to.be.ok();
- });
- });
- });
});
});
diff --git a/app/javascript/packages/form-steps/use-history-param.ts b/app/javascript/packages/form-steps/use-history-param.ts
index 40365106f01..1839840924d 100644
--- a/app/javascript/packages/form-steps/use-history-param.ts
+++ b/app/javascript/packages/form-steps/use-history-param.ts
@@ -2,10 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
export type ParamValue = string | undefined;
-interface HistoryOptions {
- basePath?: string;
-}
-
/**
* Returns the step name from a given path, ignoring any subpaths or leading or trailing slashes.
*
@@ -13,19 +9,9 @@ interface HistoryOptions {
*
* @return Step name.
*/
-export const getStepParam = (path: string): string =>
- decodeURIComponent(path.split('/').filter(Boolean)[0]);
-
-export function getParamURL(value: ParamValue, { basePath }: HistoryOptions): string {
- let prefix = typeof basePath === 'string' ? basePath.replace(/\/$/, '') : '#';
- if (value && basePath) {
- prefix += '/';
- }
-
- return [prefix, encodeURIComponent(value || '')].filter(Boolean).join('');
-}
+export const getStepParam = (path: string): string => decodeURIComponent(path.replace(/^#/, ''));
-const onURLChange = () => window.dispatchEvent(new window.CustomEvent('lg:url-change'));
+const getParamURL = (value: ParamValue) => `#${encodeURIComponent(value || '')}`;
const subscribers: Array<() => void> = [];
@@ -43,13 +29,9 @@ const subscribers: Array<() => void> = [];
*/
function useHistoryParam(
initialValue?: string,
- { basePath }: HistoryOptions = {},
): [string | undefined, (nextParamValue: ParamValue) => void] {
function getCurrentValue(): ParamValue {
- const path =
- typeof basePath === 'string'
- ? window.location.pathname.split(basePath)[1]
- : window.location.hash.slice(1);
+ const path = window.location.hash.slice(1);
if (path) {
return getStepParam(path);
@@ -63,8 +45,7 @@ function useHistoryParam(
// Push the next value to history, both to update the URL, and to allow the user to return to
// an earlier value (see `popstate` sync behavior).
if (nextValue !== value) {
- window.history.pushState(null, '', getParamURL(nextValue, { basePath }));
- onURLChange();
+ window.history.pushState(null, '', getParamURL(nextValue));
subscribers.forEach((sync) => sync());
}
@@ -75,15 +56,12 @@ function useHistoryParam(
useEffect(() => {
if (initialValue && initialValue !== getCurrentValue()) {
- window.history.replaceState(null, '', getParamURL(initialValue, { basePath }));
- onURLChange();
+ window.history.replaceState(null, '', getParamURL(initialValue));
}
window.addEventListener('popstate', syncValue);
- window.addEventListener('popstate', onURLChange);
return () => {
window.removeEventListener('popstate', syncValue);
- window.removeEventListener('popstate', onURLChange);
};
}, []);
diff --git a/app/javascript/packages/modal/README.md b/app/javascript/packages/modal/README.md
deleted file mode 100644
index 035956efe97..00000000000
--- a/app/javascript/packages/modal/README.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# `@18f/identity-modal`
-
-Custom element and React implementation for a print button component.
-
-## Usage
-
-### Custom Element
-
-_The modal custom element is not currently implemented._
-
-### React
-
-The package exports a named `Modal` component, which includes several helper components available as properties of the top-level component:
-
-- `Modal`: The top-level wrapper component.
-- `Modal.Heading`: Modal heading.
-- `Modal.Description`: Optional modal description.
-
-```tsx
-import { Button } from '@18f/identity-components';
-import { Modal } from '@18f/identity-modal';
-
-export function Example() {
- return (
-
- Are you sure you want to continue?
- You have unsaved changes that will be lost.
- Continue
- Go Back
-
- );
-}
-```
diff --git a/app/javascript/packages/modal/index.ts b/app/javascript/packages/modal/index.ts
deleted file mode 100644
index b04ab42f995..00000000000
--- a/app/javascript/packages/modal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default as Modal } from './modal';
diff --git a/app/javascript/packages/modal/modal.tsx b/app/javascript/packages/modal/modal.tsx
deleted file mode 100644
index a153696afc0..00000000000
--- a/app/javascript/packages/modal/modal.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { createContext, useContext } from 'react';
-import type { ReactNode } from 'react';
-import { FullScreen } from '@18f/identity-components';
-import { useInstanceId } from '@18f/identity-react-hooks';
-
-const ModalContext = createContext('');
-
-interface ModalProps {
- /**
- * Callback invoked in response to user interaction indicating a request to close the modal.
- */
- onRequestClose?: () => void;
-
- /**
- * Modal content.
- */
- children: ReactNode;
-}
-
-interface ModalHeadingProps {
- /**
- * Heading text.
- */
- children: ReactNode;
-}
-
-interface ModalDescriptionProps {
- /**
- * Description text.
- */
- children: ReactNode;
-}
-
-function Modal({ children, onRequestClose }: ModalProps) {
- const instanceId = useInstanceId();
-
- return (
-
-
-
-
-
- );
-}
-
-Modal.Heading = ({ children }: ModalHeadingProps) => {
- const instanceId = useContext(ModalContext);
-
- return (
-
- {children}
-
- );
-};
-
-Modal.Description = ({ children }: ModalDescriptionProps) => {
- const instanceId = useContext(ModalContext);
-
- return (
-
- {children}
-
- );
-};
-
-export default Modal;
diff --git a/app/javascript/packages/modal/package.json b/app/javascript/packages/modal/package.json
deleted file mode 100644
index 1293755c91a..00000000000
--- a/app/javascript/packages/modal/package.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "name": "@18f/identity-modal",
- "version": "1.0.0",
- "private": true,
- "peerDependencies": {
- "react": "*"
- },
- "peerDependenciesMeta": {
- "react": {
- "optional": true
- }
- }
-}
diff --git a/app/javascript/packs/backup-code-analytics.ts b/app/javascript/packs/backup-code-analytics.ts
deleted file mode 100644
index 3d6ec1ec3f9..00000000000
--- a/app/javascript/packs/backup-code-analytics.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { trackEvent } from '@18f/identity-analytics';
-
-const downloadLink = document.querySelector('a[download]');
-
-function trackDownload() {
- trackEvent('Multi-Factor Authentication: download backup code');
-}
-
-downloadLink?.addEventListener('click', trackDownload);
diff --git a/app/javascript/packs/navigation.ts b/app/javascript/packs/navigation.ts
new file mode 100644
index 00000000000..7bf8dffa242
--- /dev/null
+++ b/app/javascript/packs/navigation.ts
@@ -0,0 +1,3 @@
+import { navigation } from 'identity-style-guide';
+
+navigation.on();
diff --git a/app/javascript/packs/personal-key-page-controller.js b/app/javascript/packs/personal-key-page-controller.js
deleted file mode 100644
index 36d052bda10..00000000000
--- a/app/javascript/packs/personal-key-page-controller.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import { encodeInput } from '@18f/identity-personal-key-input';
-import { trackEvent } from '@18f/identity-analytics';
-import { t } from '@18f/identity-i18n';
-
-const modalSelector = '#personal-key-confirm';
-const modal = new window.LoginGov.Modal({ el: modalSelector });
-
-const personalKeyWords = [].slice.call(document.querySelectorAll('[data-personal-key]'));
-const formEl = document.getElementById('confirm-key');
-const input = formEl.querySelector('input[type="text"]');
-const modalTrigger = document.querySelector('[data-toggle="modal"]');
-const modalDismiss = document.querySelector('[data-dismiss="personal-key-confirm"]');
-const downloadLink = document.querySelector('a[download]');
-
-function scrapePersonalKey() {
- const keywords = [];
-
- personalKeyWords.forEach((keyword) => {
- keywords.push(keyword.innerHTML);
- });
-
- return keywords.join('-').toUpperCase();
-}
-
-const personalKey = scrapePersonalKey();
-
-function resetForm() {
- formEl.reset();
- input.setCustomValidity('');
-}
-
-function validateInput() {
- let isValid = false;
- try {
- const value = encodeInput(input.value);
- isValid = value === personalKey;
- } catch {}
-
- input.setCustomValidity(isValid ? '' : t('users.personal_key.confirmation_error'));
-}
-
-function show(event) {
- event.preventDefault();
-
- modal.on('show', function () {
- input.focus();
- });
-
- trackEvent('IdV: show personal key modal');
- modal.show();
-}
-
-function hide() {
- modal.on('hide', function () {
- resetForm();
- });
-
- trackEvent('IdV: hide personal key modal');
- modal.hide();
-}
-
-function downloadForIE(event) {
- event.preventDefault();
-
- const filename = downloadLink.getAttribute('download');
- const data = scrapePersonalKey();
- const blob = new Blob([data], { type: 'text/plain' });
-
- window.navigator.msSaveBlob(blob, filename);
-}
-
-function trackDownload() {
- trackEvent('IdV: download personal key');
-}
-
-if (modalTrigger) {
- modalTrigger.addEventListener('click', show);
-}
-modalDismiss.addEventListener('click', hide);
-input.addEventListener('input', validateInput);
-downloadLink.addEventListener('click', trackDownload);
-
-if (window.navigator.msSaveBlob) {
- downloadLink.addEventListener('click', downloadForIE);
-}
diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb
index a8742e91a8d..a9554c74a36 100644
--- a/app/jobs/get_usps_proofing_results_job.rb
+++ b/app/jobs/get_usps_proofing_results_job.rb
@@ -19,6 +19,15 @@ class GetUspsProofingResultsJob < ApplicationJob
discard_on GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError
+ def email_analytics_attributes(enrollment)
+ {
+ timestamp: Time.zone.now,
+ user_id: enrollment.user_id,
+ service_provider: enrollment.issuer,
+ delay_time_seconds: mail_delivery_params[:wait],
+ }
+ end
+
def enrollment_analytics_attributes(enrollment, complete:)
{
enrollment_code: enrollment.enrollment_code,
@@ -220,8 +229,16 @@ def handle_failed_status(enrollment, response)
enrollment.update(status: :failed)
if response['fraudSuspected']
send_failed_fraud_email(enrollment.user, enrollment)
+ analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated(
+ **email_analytics_attributes(enrollment),
+ email_type: 'Failed fraud suspected',
+ )
else
send_failed_email(enrollment.user, enrollment)
+ analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated(
+ **email_analytics_attributes(enrollment),
+ email_type: 'Failed',
+ )
end
end
@@ -234,6 +251,10 @@ def handle_successful_status_update(enrollment, response)
passed: true,
reason: 'Successful status update',
)
+ analytics(user: enrollment.user).idv_in_person_usps_proofing_results_job_email_initiated(
+ **email_analytics_attributes(enrollment),
+ email_type: 'Success',
+ )
enrollment.profile.activate
enrollment.update(status: :passed)
send_verified_email(enrollment.user, enrollment)
diff --git a/app/jobs/reports/deleted_user_accounts_report.rb b/app/jobs/reports/deleted_user_accounts_report.rb
index 53b8fc391df..214a4db1e50 100644
--- a/app/jobs/reports/deleted_user_accounts_report.rb
+++ b/app/jobs/reports/deleted_user_accounts_report.rb
@@ -19,13 +19,15 @@ def perform(_date)
emails = report_hash['emails']
issuers = report_hash['issuers']
report = deleted_user_accounts_data_for_issuers(issuers)
+ # rubocop:disable IdentityIdp/MailLaterLinter
emails.each do |email|
ReportMailer.deleted_user_accounts_report(
email: email,
name: name,
issuers: issuers,
data: report,
- ).deliver_now_or_later
+ ).deliver_now
+ # rubocop:enable IdentityIdp/MailLaterLinter
end
end
end
diff --git a/app/jobs/resolution_proofing_job.rb b/app/jobs/resolution_proofing_job.rb
index 55c85d9ce3b..8429f04d586 100644
--- a/app/jobs/resolution_proofing_job.rb
+++ b/app/jobs/resolution_proofing_job.rb
@@ -143,25 +143,17 @@ def proof_lexisnexis_then_aamva(timer:, applicant_pii:, should_proof_state_id:)
state_id_result = Proofing::StateIdResult.new(
success: true, errors: {}, exception: nil, vendor_name: 'UnsupportedJurisdiction',
)
- if should_proof_state_id && resolution_result.success?
+ if should_proof_state_id
timer.time('state_id') do
state_id_result = state_id_proofer.proof(applicant_pii)
end
end
- result = {
- success: resolution_result.success? && state_id_result.success?,
- errors: resolution_result.errors.merge(state_id_result.errors),
- exception: resolution_result.exception || state_id_result.exception,
- timed_out: resolution_result.timed_out? || state_id_result.timed_out?,
- context: {
- should_proof_state_id: should_proof_state_id,
- stages: {
- resolution: resolution_result.to_h,
- state_id: state_id_result.to_h,
- },
- },
- }
+ result = Proofing::ResolutionResultAdjudicator.new(
+ resolution_result: resolution_result,
+ state_id_result: state_id_result,
+ should_proof_state_id: should_proof_state_id,
+ ).adjudicated_result.to_h
CallbackLogData.new(
result: result,
diff --git a/app/jobs/threat_metrix_js_verification_job.rb b/app/jobs/threat_metrix_js_verification_job.rb
index 62354e04976..dbfbf90c252 100644
--- a/app/jobs/threat_metrix_js_verification_job.rb
+++ b/app/jobs/threat_metrix_js_verification_job.rb
@@ -1,45 +1,50 @@
class ThreatMetrixJsVerificationJob < ApplicationJob
queue_as :default
+ # Ignorable configuration error
+ class ConfigurationError < StandardError; end
+
def perform(session_id: SecureRandom.uuid)
org_id = IdentityConfig.store.lexisnexis_threatmetrix_org_id
-
- return if org_id.blank?
-
- return if !IdentityConfig.store.proofing_device_profiling_collecting_enabled
+ js = nil
+ valid = nil
+ error = nil
+ signature = nil
# Certificate is stored ASCII-armored in config
raw_cert = IdentityConfig.store.lexisnexis_threatmetrix_js_signing_cert
- return if raw_cert.blank?
-
- cert = OpenSSL::X509::Certificate.new raw_cert
-
- raise 'Certificate is expired' if cert.not_after < Time.zone.now
+ cert = OpenSSL::X509::Certificate.new(raw_cert) if raw_cert.present?
+ raise ConfigurationError, 'JS signing certificate is missing' if !cert
+ raise 'JS signing certificate is expired' if cert.not_after < Time.zone.now
url = "https://h.online-metrix.net/fp/tags.js?org_id=#{org_id}&session_id=#{session_id}"
-
- resp = build_faraday.get url
-
- content, signature = parse_js resp.body
-
- log_payload = {
- name: 'ThreatMetrixJsVerification',
- org_id: org_id,
- session_id: session_id,
- http_status: resp.status,
- signature: (signature || '').each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join,
- }
-
- if verify_js content, signature, cert
- log_payload[:valid] = true
- else
- # When signature validation fails, we include the JS payload in the
- # log message for future analysis
- log_payload[:valid] = false
- log_payload[:js] = content
- end
-
- logger.info(log_payload.to_json)
+ resp = build_faraday.get(url)
+ content, signature = parse_js(resp.body)
+
+ valid = js_verified?(content, signature, cert)
+ # When signature validation fails, we include the JS payload in the
+ # log message for future analysis
+ js = content if !valid
+ rescue ConfigurationError => err
+ error = err
+ # configuration errors are OK, those don't need to get re-raised
+ rescue => err
+ error = err
+ raise err
+ ensure
+ logger.info(
+ {
+ name: 'ThreatMetrixJsVerification',
+ org_id: org_id,
+ session_id: session_id,
+ http_status: resp&.status,
+ signature: (signature || '').each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join,
+ js: js,
+ valid: valid,
+ error_class: error&.class,
+ error_message: error&.message,
+ }.compact.to_json,
+ )
end
def build_faraday
@@ -67,12 +72,12 @@ def parse_js(raw)
[content, signature]
end
- def verify_js(js, signature, cert)
+ def js_verified?(js, signature, cert)
return false if signature.nil?
public_key = cert&.public_key
return false if public_key.nil?
- public_key.verify 'SHA256', signature, js
+ public_key.verify('SHA256', signature, js)
end
end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index af48c95fe45..5977ba5aa0d 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -22,6 +22,7 @@ class UserEmailAddressMismatchError < StandardError; end
before_action :validate_user_and_email_address
before_action :attach_images
+ after_action :add_metadata
default(
from: email_with_name(
IdentityConfig.store.email_from,
@@ -43,6 +44,10 @@ def validate_user_and_email_address
end
end
+ def add_metadata
+ message.instance_variable_set(:@_metadata, { user: user, action: action_name })
+ end
+
def email_confirmation_instructions(token, request_id:, instructions:)
with_user_locale(user) do
presenter = ConfirmationEmailPresenter.new(user, view_context)
diff --git a/app/models/registration_log.rb b/app/models/registration_log.rb
index d3ba1b86206..5f3121154a2 100644
--- a/app/models/registration_log.rb
+++ b/app/models/registration_log.rb
@@ -1,12 +1,3 @@
class RegistrationLog < ApplicationRecord
- self.ignored_columns = %w[
- submitted_at
- confirmed_at
- password_at
- first_mfa
- first_mfa_at
- second_mfa
- ]
-
belongs_to :user
end
diff --git a/app/services/analytics.rb b/app/services/analytics.rb
index eb9af535086..fee5bf48573 100644
--- a/app/services/analytics.rb
+++ b/app/services/analytics.rb
@@ -2,6 +2,9 @@
class Analytics
include AnalyticsEvents
+ prepend Idv::AnalyticsEventsEnhancer
+
+ attr_reader :user, :request, :sp, :ahoy
def initialize(user:, request:, sp:, session:, ahoy: nil)
@user = user
@@ -87,8 +90,6 @@ def track_mfa_submit_event(attributes)
attributes[:success] ? 'success' : 'fail'
end
- attr_reader :user, :request, :sp, :ahoy
-
def request_attributes
attributes = {
user_ip: request.remote_ip,
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index 8cac807a960..3e1935e0bae 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -480,12 +480,19 @@ def idv_address_visit
# @param [String] step the step that the user was on when they clicked cancel
# @param [String] request_came_from the controller and action from the
# source such as "users/sessions#new"
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user clicked cancel during IDV (presented with an option to go back or confirm)
- def idv_cancellation_visited(step:, request_came_from:, **extra)
+ def idv_cancellation_visited(
+ step:,
+ request_came_from:,
+ proofing_components: nil,
+ **extra
+ )
track_event(
'IdV: cancellation visited',
step: step,
request_came_from: request_came_from,
+ proofing_components: proofing_components,
**extra,
)
end
@@ -515,20 +522,37 @@ def idv_native_camera_forced(
end
# @param [String] step the step that the user was on when they clicked cancel
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user confirmed their choice to cancel going through IDV
- def idv_cancellation_confirmed(step:, **extra)
- track_event('IdV: cancellation confirmed', step: step, **extra)
+ def idv_cancellation_confirmed(step:, proofing_components: nil, **extra)
+ track_event(
+ 'IdV: cancellation confirmed',
+ step: step,
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [String] step the step that the user was on when they clicked cancel
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user chose to go back instead of cancel IDV
- def idv_cancellation_go_back(step:, **extra)
- track_event('IdV: cancellation go back', step: step, **extra)
+ def idv_cancellation_go_back(step:, proofing_components: nil, **extra)
+ track_event(
+ 'IdV: cancellation go back',
+ step: step,
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# The user visited the "come back later" page shown during the GPO mailing flow
- def idv_come_back_later_visit
- track_event('IdV: come back later visited')
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
+ def idv_come_back_later_visit(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: come back later visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [String] flow_path Document capture path ("hybrid" or "standard")
@@ -583,9 +607,14 @@ def idv_in_person_switch_back_submitted(flow_path:, **extra)
track_event('IdV: in person proofing switch_back submitted', flow_path: flow_path, **extra)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user visited the "ready to verify" page for the in person proofing flow
- def idv_in_person_ready_to_verify_visit
- track_event('IdV: in person ready to verify visited')
+ def idv_in_person_ready_to_verify_visit(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: in person ready to verify visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [String] step_name which step the user was on
@@ -724,34 +753,48 @@ def idv_doc_auth_warning_visited(
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User visited forgot password page
- def idv_forgot_password
- track_event('IdV: forgot password visited')
+ def idv_forgot_password(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: forgot password visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User confirmed forgot password
- def idv_forgot_password_confirmed
- track_event('IdV: forgot password confirmed')
+ def idv_forgot_password_confirmed(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: forgot password confirmed',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [DateTime] enqueued_at
# @param [Boolean] resend
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# GPO letter was enqueued and the time at which it was enqueued
- def idv_gpo_address_letter_enqueued(enqueued_at:, resend:, **extra)
+ def idv_gpo_address_letter_enqueued(enqueued_at:, resend:, proofing_components: nil, **extra)
track_event(
'IdV: USPS address letter enqueued',
enqueued_at: enqueued_at,
resend: resend,
+ proofing_components: proofing_components,
**extra,
)
end
# @param [Boolean] resend
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# GPO letter was requested
- def idv_gpo_address_letter_requested(resend:, **extra)
+ def idv_gpo_address_letter_requested(resend:, proofing_components: nil, **extra)
track_event(
'IdV: USPS address letter requested',
resend: resend,
+ proofing_components: proofing_components,
**extra,
)
end
@@ -801,26 +844,39 @@ def idv_intro_visit
end
# @param [Boolean] success
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# Tracks the last step of IDV, indicates the user successfully prooved
def idv_final(
success:,
+ proofing_components: nil,
**extra
)
track_event(
'IdV: final resolution',
success: success,
+ proofing_components: proofing_components,
**extra,
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User visited IDV personal key page
- def idv_personal_key_visited
- track_event('IdV: personal key visited')
+ def idv_personal_key_visited(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: personal key visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User submitted IDV personal key page
- def idv_personal_key_submitted
- track_event('IdV: personal key submitted')
+ def idv_personal_key_submitted(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: personal key submitted',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# A user has downloaded their backup codes
@@ -830,39 +886,62 @@ def multi_factor_auth_backup_code_download
# A user has downloaded their personal key. This event is no longer emitted.
# @identity.idp.previous_event_name IdV: download personal key
- def idv_personal_key_downloaded
- track_event('IdV: personal key downloaded')
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
+ def idv_personal_key_downloaded(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: personal key downloaded',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [Boolean] success
# @param [Hash] errors
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user submitted their phone on the phone confirmation page
def idv_phone_confirmation_form_submitted(
success:,
errors:,
+ proofing_components: nil,
**extra
)
track_event(
'IdV: phone confirmation form',
success: success,
errors: errors,
+ proofing_components: proofing_components,
**extra,
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user was rate limited for submitting too many OTPs during the IDV phone step
- def idv_phone_confirmation_otp_rate_limit_attempts
- track_event('Idv: Phone OTP attempts rate limited')
+ def idv_phone_confirmation_otp_rate_limit_attempts(proofing_components: nil, **extra)
+ track_event(
+ 'Idv: Phone OTP attempts rate limited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user was locked out for hitting the phone OTP rate limit during IDV
- def idv_phone_confirmation_otp_rate_limit_locked_out
- track_event('Idv: Phone OTP rate limited user')
+ def idv_phone_confirmation_otp_rate_limit_locked_out(proofing_components: nil, **extra)
+ track_event(
+ 'Idv: Phone OTP rate limited user',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user was rate limited for requesting too many OTPs during the IDV phone step
- def idv_phone_confirmation_otp_rate_limit_sends
- track_event('Idv: Phone OTP sends rate limited')
+ def idv_phone_confirmation_otp_rate_limit_sends(proofing_components: nil, **extra)
+ track_event(
+ 'Idv: Phone OTP sends rate limited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [Boolean] success
@@ -872,6 +951,7 @@ def idv_phone_confirmation_otp_rate_limit_sends
# @param [String] area_code area code of phone number
# @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt
# @param [Hash] telephony_response response from Telephony gem
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user resent an OTP during the IDV phone step
def idv_phone_confirmation_otp_resent(
success:,
@@ -881,6 +961,7 @@ def idv_phone_confirmation_otp_resent(
area_code:,
rate_limit_exceeded:,
telephony_response:,
+ proofing_components: nil,
**extra
)
track_event(
@@ -892,6 +973,7 @@ def idv_phone_confirmation_otp_resent(
area_code: area_code,
rate_limit_exceeded: rate_limit_exceeded,
telephony_response: telephony_response,
+ proofing_components: proofing_components,
**extra,
)
end
@@ -904,6 +986,7 @@ def idv_phone_confirmation_otp_resent(
# @param [Boolean] rate_limit_exceeded whether or not the rate limit was exceeded by this attempt
# @param [String] phone_fingerprint the hmac fingerprint of the phone number formatted as e164
# @param [Hash] telephony_response response from Telephony gem
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The user requested an OTP to confirm their phone during the IDV phone step
def idv_phone_confirmation_otp_sent(
success:,
@@ -914,6 +997,7 @@ def idv_phone_confirmation_otp_sent(
rate_limit_exceeded:,
phone_fingerprint:,
telephony_response:,
+ proofing_components: nil,
**extra
)
track_event(
@@ -926,22 +1010,26 @@ def idv_phone_confirmation_otp_sent(
rate_limit_exceeded: rate_limit_exceeded,
phone_fingerprint: phone_fingerprint,
telephony_response: telephony_response,
+ proofing_components: proofing_components,
**extra,
)
end
# @param [Boolean] success
# @param [Hash] errors
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The vendor finished the process of confirming the users phone
def idv_phone_confirmation_vendor_submitted(
success:,
errors:,
+ proofing_components: nil,
**extra
)
track_event(
'IdV: phone confirmation vendor',
success: success,
errors: errors,
+ proofing_components: proofing_components,
**extra,
)
end
@@ -951,8 +1039,8 @@ def idv_phone_confirmation_vendor_submitted(
# @param [Boolean] code_expired if the confirmation code expired
# @param [Boolean] code_matches
# @param [Integer] second_factor_attempts_count number of attempts to confirm this phone
- # @param [Time, nil] second_factor_locked_at timestamp when the phone was
- # locked out at
+ # @param [Time, nil] second_factor_locked_at timestamp when the phone was locked out
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# When a user attempts to confirm posession of a new phone number during the IDV process
def idv_phone_confirmation_otp_submitted(
success:,
@@ -961,6 +1049,7 @@ def idv_phone_confirmation_otp_submitted(
code_matches:,
second_factor_attempts_count:,
second_factor_locked_at:,
+ proofing_components: nil,
**extra
)
track_event(
@@ -971,24 +1060,38 @@ def idv_phone_confirmation_otp_submitted(
code_matches: code_matches,
second_factor_attempts_count: second_factor_attempts_count,
second_factor_locked_at: second_factor_locked_at,
+ proofing_components: proofing_components,
**extra,
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# When a user visits the page to confirm posession of a new phone number during the IDV process
- def idv_phone_confirmation_otp_visit
- track_event('IdV: phone confirmation otp visited')
+ def idv_phone_confirmation_otp_visit(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: phone confirmation otp visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param ['warning','jobfail','failure'] type
# @param [Time] throttle_expires_at when the throttle expires
# @param [Integer] remaining_attempts number of attempts remaining
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# When a user gets an error during the phone finder flow of IDV
- def idv_phone_error_visited(type:, throttle_expires_at: nil, remaining_attempts: nil, **extra)
+ def idv_phone_error_visited(
+ type:,
+ proofing_components: nil,
+ throttle_expires_at: nil,
+ remaining_attempts: nil,
+ **extra
+ )
track_event(
'IdV: phone error visited',
{
type: type,
+ proofing_components: proofing_components,
throttle_expires_at: throttle_expires_at,
remaining_attempts: remaining_attempts,
**extra,
@@ -1000,9 +1103,11 @@ def idv_phone_error_visited(type:, throttle_expires_at: nil, remaining_attempts:
# @param [Boolean] success
# @param [Hash] errors
# @param [Hash] error_details
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
def idv_phone_otp_delivery_selection_submitted(
success:,
otp_delivery_preference:,
+ proofing_components: nil,
errors: nil,
error_details: nil,
**extra
@@ -1014,63 +1119,91 @@ def idv_phone_otp_delivery_selection_submitted(
errors: errors,
error_details: error_details,
otp_delivery_preference: otp_delivery_preference,
+ proofing_components: proofing_components,
**extra,
}.compact,
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User visited idv phone of record
- def idv_phone_of_record_visited
- track_event('IdV: phone of record visited')
+ def idv_phone_of_record_visited(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: phone of record visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User visited idv phone OTP delivery selection
- def idv_phone_otp_delivery_selection_visit
- track_event('IdV: Phone OTP delivery Selection Visited')
+ def idv_phone_otp_delivery_selection_visit(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: Phone OTP delivery Selection Visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# @param [String] step the step the user was on when they clicked use a different phone number
# User decided to use a different phone number in idv
- def idv_phone_use_different(step:, **extra)
+ def idv_phone_use_different(step:, proofing_components: nil, **extra)
track_event(
'IdV: use different phone number',
step: step,
+ proofing_components: proofing_components,
**extra,
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# The system encountered an error and the proofing results are missing
- def idv_proofing_resolution_result_missing
- track_event('Proofing Resolution Result Missing')
+ def idv_proofing_resolution_result_missing(proofing_components: nil, **extra)
+ track_event(
+ 'Proofing Resolution Result Missing',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# User submitted IDV password confirm page
# @param [Boolean] success
- def idv_review_complete(success:, **extra)
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
+ def idv_review_complete(success:, proofing_components: nil, **extra)
track_event(
'IdV: review complete',
success: success,
+ proofing_components: proofing_components,
**extra,
)
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User visited IDV password confirm page
- def idv_review_info_visited
- track_event('IdV: review info visited')
+ def idv_review_info_visited(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: review info visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [String] step
# @param [String] location
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# User started over idv
def idv_start_over(
step:,
location:,
+ proofing_components: nil,
**extra
)
track_event(
'IdV: start over',
step: step,
location: location,
+ proofing_components: proofing_components,
**extra,
)
end
@@ -2777,6 +2910,19 @@ def idv_in_person_usps_proofing_results_job_enrollment_updated(
)
end
+ # Tracks emails that are initiated during GetUspsProofingResultsJob
+ # @param [String] email_type success, failed or failed fraud
+ def idv_in_person_usps_proofing_results_job_email_initiated(
+ email_type:,
+ **extra
+ )
+ track_event(
+ 'GetUspsProofingResultsJob: Success or failure email initiated',
+ email_type: email_type,
+ **extra,
+ )
+ end
+
# Tracks users visiting the recovery options page
def account_reset_recovery_options_visit
track_event('Account Reset: Recovery Options Visited')
@@ -2787,9 +2933,14 @@ def cancel_account_reset_recovery
track_event('Account Reset: Cancel Account Recovery Options')
end
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
# Tracks when the user reaches the verify setup errors page after failing proofing
- def idv_setup_errors_visited
- track_event('IdV: Verify setup errors visited')
+ def idv_setup_errors_visited(proofing_components: nil, **extra)
+ track_event(
+ 'IdV: Verify setup errors visited',
+ proofing_components: proofing_components,
+ **extra,
+ )
end
# @param [String] redirect_url URL user was directed to
@@ -2813,5 +2964,31 @@ def contact_redirect(redirect_url:, step: nil, location: nil, flow: nil, **extra
def show_password_button_clicked(path:, **extra)
track_event('Show Password Button Clicked', path: path, **extra)
end
+
+ # Tracks if a user clicks the 'acknowledge' checkbox during personal
+ # key creation
+ # @param [Idv::ProofingComponentsLogging] proofing_components User's current proofing components
+ # @param [boolean] checked whether the user checked or un-checked
+ # the box with this click
+ def idv_personal_key_acknowledgment_toggled(checked:, proofing_components:, **extra)
+ track_event(
+ 'IdV: personal key acknowledgment toggled',
+ checked: checked,
+ proofing_components: proofing_components,
+ **extra,
+ )
+ end
+
+ # Logs after an email is sent
+ # @param [String] action type of email being sent
+ # @param [String, nil] ses_message_id AWS SES Message ID
+ def email_sent(action:, ses_message_id:, **extra)
+ track_event(
+ 'Email Sent',
+ action: action,
+ ses_message_id: ses_message_id,
+ **extra,
+ )
+ end
end
# rubocop:enable Metrics/ModuleLength
diff --git a/app/services/db/service_provider_quota_limit/notify_if_any_sp_over_quota_limit.rb b/app/services/db/service_provider_quota_limit/notify_if_any_sp_over_quota_limit.rb
index 04f836ac0f5..2d662c3a98e 100644
--- a/app/services/db/service_provider_quota_limit/notify_if_any_sp_over_quota_limit.rb
+++ b/app/services/db/service_provider_quota_limit/notify_if_any_sp_over_quota_limit.rb
@@ -5,7 +5,9 @@ def self.call
return unless Db::ServiceProviderQuotaLimit::AnySpOverQuotaLimit.call
email_list = IdentityConfig.store.sps_over_quota_limit_notify_email_list
email_list.each do |email|
- ReportMailer.sps_over_quota_limit(email).deliver_now_or_later
+ # rubocop:disable IdentityIdp/MailLaterLinter
+ ReportMailer.sps_over_quota_limit(email).deliver_now
+ # rubocop:enable IdentityIdp/MailLaterLinter
end
end
end
diff --git a/app/services/frontend_logger.rb b/app/services/frontend_logger.rb
index 8c41a9f21d8..6155253e9a1 100644
--- a/app/services/frontend_logger.rb
+++ b/app/services/frontend_logger.rb
@@ -8,7 +8,7 @@ def initialize(analytics:, event_map:)
def track_event(name, attributes)
if (analytics_method = event_map[name])
- analytics_method.bind_call(analytics, **hash_from_method_kwargs(attributes, analytics_method))
+ analytics.send(analytics_method.name, **hash_from_method_kwargs(attributes, analytics_method))
else
analytics.track_event("Frontend: #{name}", attributes)
end
diff --git a/app/services/idv/analytics_events_enhancer.rb b/app/services/idv/analytics_events_enhancer.rb
new file mode 100644
index 00000000000..827cb52dfc3
--- /dev/null
+++ b/app/services/idv/analytics_events_enhancer.rb
@@ -0,0 +1,61 @@
+module Idv
+ module AnalyticsEventsEnhancer
+ DECORATED_METHODS = %i[
+ idv_cancellation_confirmed
+ idv_cancellation_go_back
+ idv_cancellation_visited
+ idv_come_back_later_visit
+ idv_forgot_password
+ idv_forgot_password_confirmed
+ idv_final
+ idv_gpo_address_letter_enqueued
+ idv_gpo_address_letter_requested
+ idv_in_person_ready_to_verify_visit
+ idv_personal_key_acknowledgment_toggled
+ idv_personal_key_downloaded
+ idv_personal_key_submitted
+ idv_personal_key_visited
+ idv_phone_confirmation_form_submitted
+ idv_phone_confirmation_otp_rate_limit_attempts
+ idv_phone_confirmation_otp_rate_limit_locked_out
+ idv_phone_confirmation_otp_rate_limit_sends
+ idv_phone_confirmation_otp_resent
+ idv_phone_confirmation_otp_sent
+ idv_phone_confirmation_otp_submitted
+ idv_phone_confirmation_otp_visit
+ idv_phone_confirmation_vendor_submitted
+ idv_phone_error_visited
+ idv_phone_of_record_visited
+ idv_phone_otp_delivery_selection_visit
+ idv_phone_otp_delivery_selection_submitted
+ idv_proofing_resolution_result_missing
+ idv_review_complete
+ idv_review_info_visited
+ idv_setup_errors_visited
+ idv_start_over
+ ].freeze
+
+ DECORATED_METHODS.each do |method_name|
+ define_method(method_name) do |**kwargs|
+ super(**kwargs, **common_analytics_attributes)
+ end
+ end
+
+ def self.included(_mod)
+ raise 'this mixin is intended to be prepended, not included'
+ end
+
+ private
+
+ def common_analytics_attributes
+ {
+ proofing_components: proofing_components,
+ }
+ end
+
+ def proofing_components
+ return if !user&.respond_to?(:proofing_component) || !user.proofing_component
+ ProofingComponentsLogging.new(user.proofing_component)
+ end
+ end
+end
diff --git a/app/services/idv/phone_step.rb b/app/services/idv/phone_step.rb
index b5c3a99e7d8..2fbf6799dd2 100644
--- a/app/services/idv/phone_step.rb
+++ b/app/services/idv/phone_step.rb
@@ -108,7 +108,7 @@ def update_idv_session
idv_session.vendor_phone_confirmation = true
idv_session.user_phone_confirmation = false
- ProofingComponent.create_or_find_by(user: idv_session.current_user).
+ ProofingComponent.find_or_create_by(user: idv_session.current_user).
update(address_check: 'lexis_nexis_address')
end
diff --git a/app/services/idv/proofing_components_logging.rb b/app/services/idv/proofing_components_logging.rb
new file mode 100644
index 00000000000..98a499d18d3
--- /dev/null
+++ b/app/services/idv/proofing_components_logging.rb
@@ -0,0 +1,19 @@
+module Idv
+ ProofingComponentsLogging = Struct.new(:proofing_components) do
+ def as_json(*)
+ proofing_components.slice(
+ :document_check,
+ :document_type,
+ :source_check,
+ :resolution_check,
+ :address_check,
+ :liveness_check,
+ :device_fingerprinting_vendor,
+ :threatmetrix,
+ :threatmetrix_review_status,
+ :threatmetrix_risk_rating,
+ :threatmetrix_policy_score,
+ ).compact
+ end
+ end
+end
diff --git a/app/services/irs_attempts_api/attempt_event.rb b/app/services/irs_attempts_api/attempt_event.rb
index e4a036d9eca..5e0f26aa737 100644
--- a/app/services/irs_attempts_api/attempt_event.rb
+++ b/app/services/irs_attempts_api/attempt_event.rb
@@ -24,6 +24,7 @@ def to_jwe
event_data_encryption_key,
typ: 'secevent+jwe',
zip: 'DEF',
+ alg: 'RSA-OAEP',
enc: 'A256GCM',
)
end
diff --git a/app/services/proofing/resolution_result_adjudicator.rb b/app/services/proofing/resolution_result_adjudicator.rb
new file mode 100644
index 00000000000..1fe419e7434
--- /dev/null
+++ b/app/services/proofing/resolution_result_adjudicator.rb
@@ -0,0 +1,59 @@
+module Proofing
+ class ResolutionResultAdjudicator
+ attr_reader :resolution_result, :state_id_result
+
+ def initialize(resolution_result:, state_id_result:, should_proof_state_id:)
+ @resolution_result = resolution_result
+ @state_id_result = state_id_result
+ @should_proof_state_id = should_proof_state_id
+ end
+
+ def adjudicated_result
+ success, adjudication_reason = result_and_adjudication_reason
+ FormResponse.new(
+ success: success,
+ errors: resolution_result.errors.merge(state_id_result.errors),
+ extra: {
+ exception: resolution_result.exception || state_id_result.exception,
+ timed_out: resolution_result.timed_out? || state_id_result.timed_out?,
+ context: {
+ adjudication_reason: adjudication_reason,
+ should_proof_state_id: should_proof_state_id?,
+ stages: {
+ resolution: resolution_result.to_h,
+ state_id: state_id_result.to_h,
+ },
+ },
+ },
+ )
+ end
+
+ def should_proof_state_id?
+ @should_proof_state_id
+ end
+
+ private
+
+ def result_and_adjudication_reason
+ if resolution_result.success? && state_id_result.success?
+ [true, :pass_resolution_and_state_id]
+ elsif !state_id_result.success?
+ [false, :fail_state_id]
+ elsif !should_proof_state_id?
+ [false, :fail_resolution_skip_state_id]
+ elsif state_id_attributes_cover_resolution_failures?
+ [true, :state_id_covers_failed_resolution]
+ else
+ [false, :fail_resolution_without_state_id_coverage]
+ end
+ end
+
+ def state_id_attributes_cover_resolution_failures?
+ return false unless resolution_result.failed_result_can_pass_with_additional_verification?
+ failed_resolution_attributes = resolution_result.attributes_requiring_additional_verification
+ passed_state_id_attributes = state_id_result.verified_attributes
+
+ (failed_resolution_attributes - passed_state_id_attributes).empty?
+ end
+ end
+end
diff --git a/app/services/usps_in_person_proofing/proofer.rb b/app/services/usps_in_person_proofing/proofer.rb
index 8c2570e4f5c..4210627b77e 100644
--- a/app/services/usps_in_person_proofing/proofer.rb
+++ b/app/services/usps_in_person_proofing/proofer.rb
@@ -1,6 +1,6 @@
module UspsInPersonProofing
class Proofer
- attr_reader :token, :token_expires_at
+ mattr_reader :token, :token_expires_at
# Makes HTTP request to get nearby in-person proofing facilities
# Requires address, city, state and zip code.
@@ -18,13 +18,8 @@ def request_facilities(location)
zipCode: location.zip_code,
}.to_json
- headers = request_headers.merge(
- 'Authorization' => @token,
- 'RequestID' => request_id,
- )
-
parse_facilities(
- faraday.post(url, body, headers) do |req|
+ faraday.post(url, body, dynamic_headers) do |req|
req.options.context = { service_name: 'usps_facilities' }
end.body,
)
@@ -112,12 +107,12 @@ def request_enrollment_code(unique_id)
# @return [String] the token
def retrieve_token!
body = request_token
- @token_expires_at = Time.zone.now + body['expires_in']
- @token = "#{body['token_type']} #{body['access_token']}"
+ @@token_expires_at = Time.zone.now + body['expires_in']
+ @@token = "#{body['token_type']} #{body['access_token']}"
end
def token_valid?
- @token.present? && @token_expires_at.present? && @token_expires_at.future?
+ token.present? && token_expires_at.present? && token_expires_at.future?
end
private
@@ -152,7 +147,7 @@ def dynamic_headers
retrieve_token! unless token_valid?
{
- 'Authorization' => @token,
+ 'Authorization' => token,
'RequestID' => request_id,
}
end
diff --git a/app/views/idv/inherited_proofing/_cancel.html.erb b/app/views/idv/inherited_proofing/_cancel.html.erb
new file mode 100644
index 00000000000..52947a558bc
--- /dev/null
+++ b/app/views/idv/inherited_proofing/_cancel.html.erb
@@ -0,0 +1,7 @@
+<%#
+locals:
+* step: Current step, used in analytics logging.
+%>
+<%= render PageFooterComponent.new do %>
+ <%= link_to cancel_link_text, idv_inherited_proofing_cancel_path(step: local_assigns[:step]) %>
+<% end %>
diff --git a/app/views/idv/inherited_proofing/agreement.html.erb b/app/views/idv/inherited_proofing/agreement.html.erb
index 2bd7a54bc3d..e74f11a9206 100644
--- a/app/views/idv/inherited_proofing/agreement.html.erb
+++ b/app/views/idv/inherited_proofing/agreement.html.erb
@@ -38,3 +38,5 @@
) %>
<%= f.submit t('inherited_proofing.buttons.continue'), class: 'margin-top-4' %>
<% end %>
+
+<%= render 'idv/inherited_proofing/cancel', step: 'agreement' %>
diff --git a/app/views/idv/inherited_proofing/get_started.html.erb b/app/views/idv/inherited_proofing/get_started.html.erb
index 1f690e32bcf..221ea356fff 100644
--- a/app/views/idv/inherited_proofing/get_started.html.erb
+++ b/app/views/idv/inherited_proofing/get_started.html.erb
@@ -59,4 +59,6 @@
),
) %>
+
+ <%= render 'shared/cancel', link: idv_inherited_proofing_cancel_path(step: 'get_started') %>
<% end %>
diff --git a/app/views/idv/inherited_proofing/no_information.html.erb b/app/views/idv/inherited_proofing/no_information.html.erb
new file mode 100644
index 00000000000..49f04ed3876
--- /dev/null
+++ b/app/views/idv/inherited_proofing/no_information.html.erb
@@ -0,0 +1,24 @@
+<%= render(
+ 'idv/shared/error',
+ type: :warning,
+ title: t('inherited_proofing.errors.cannot_retrieve.title'),
+ heading: t('inherited_proofing.errors.cannot_retrieve.heading'),
+ ) do %>
+
+ <%= t('inherited_proofing.errors.cannot_retrieve.info') %>
+
+
+ <%= link_to t('inherited_proofing.buttons.try_again'), root_url, class: 'usa-button usa-button--big usa-button--wide' %>
+
+ <%= render(
+ 'shared/troubleshooting_options',
+ heading: t('components.troubleshooting_options.default_heading'),
+ options: [
+ {
+ url: 'https://www.va.gov/resources/managing-your-vagov-profile/',
+ text: t('inherited_proofing.troubleshooting.options.get_va_help'),
+ new_tab: true,
+ },
+ ].select(&:present?),
+ ) %>
+<% end %>
diff --git a/app/views/idv/inherited_proofing/phone.html.erb b/app/views/idv/inherited_proofing/phone.html.erb
deleted file mode 100644
index 73e389470f6..00000000000
--- a/app/views/idv/inherited_proofing/phone.html.erb
+++ /dev/null
@@ -1,2 +0,0 @@
-Hello
-<% t('step_indicator.flows.idv.verify_phone') %>
diff --git a/app/views/idv/inherited_proofing/verify_info.html.erb b/app/views/idv/inherited_proofing/verify_info.html.erb
index 2c745139b6b..517c81f2153 100644
--- a/app/views/idv/inherited_proofing/verify_info.html.erb
+++ b/app/views/idv/inherited_proofing/verify_info.html.erb
@@ -7,7 +7,6 @@
<%= f.submit t('inherited_proofing.buttons.continue') %>
<% end %>
-
<%= render(
'shared/troubleshooting_options',
heading_tag: :h3,
@@ -20,3 +19,5 @@
},
].select(&:present?),
) %>
+
+<%= render 'idv/inherited_proofing/cancel', step: 'verify_info' %>
diff --git a/app/views/idv/inherited_proofing_cancellations/new.html.erb b/app/views/idv/inherited_proofing_cancellations/new.html.erb
index 957673fa5b1..d8803427e38 100644
--- a/app/views/idv/inherited_proofing_cancellations/new.html.erb
+++ b/app/views/idv/inherited_proofing_cancellations/new.html.erb
@@ -8,7 +8,7 @@
<%= t('inherited_proofing.cancel.description.start_over') %>
- NOTE: Render "Start Over" button here, but first have to create an IP-specific SessionController.
+ <%# NOTE: Render "Start Over" button here, but first have to create an IP-specific SessionController. %>
<%#= render ButtonComponent.new(
action: ->(**tag_options, &block) do
button_to(idv_session_path(step: @flow_step), **tag_options, &block)
diff --git a/app/views/layouts/account_side_nav.html.erb b/app/views/layouts/account_side_nav.html.erb
index df4651abdbb..6ea8fc64d54 100644
--- a/app/views/layouts/account_side_nav.html.erb
+++ b/app/views/layouts/account_side_nav.html.erb
@@ -33,4 +33,5 @@
<% end %>
+<%= javascript_packs_tag_once('navigation') %>
<%= render template: 'layouts/base', locals: { disable_card: true } %>
diff --git a/app/views/partials/personal_key/_key.html.erb b/app/views/partials/personal_key/_key.html.erb
index 3a096babaed..7b7c69fd672 100644
--- a/app/views/partials/personal_key/_key.html.erb
+++ b/app/views/partials/personal_key/_key.html.erb
@@ -1,7 +1,4 @@
-
- <%= t('users.personal_key.header') %>
-
<% code.split('-').each do |word| %>
@@ -9,10 +6,20 @@
<% end %>
-
+
<%= t(
'users.personal_key.generated_on_html',
date: content_tag(:strong, I18n.l(Time.zone.today, format: '%B %d, %Y')),
) %>
+ <%= render ClipboardButtonComponent.new(clipboard_text: code, unstyled: true) %>
+ <%= render ClickObserverComponent.new(event_name: 'IdV: download personal key') do %>
+ <%= render DownloadButtonComponent.new(
+ file_data: code,
+ file_name: 'personal_key.txt',
+ unstyled: true,
+ class: 'margin-x-2 display-inline-block',
+ ).with_content(t('forms.personal_key.download')) %>
+ <% end %>
+ <%= render PrintButtonComponent.new(unstyled: true) %>
diff --git a/app/views/shared/_personal_key.html.erb b/app/views/shared/_personal_key.html.erb
index 24e0c08288b..4aae31d97eb 100644
--- a/app/views/shared/_personal_key.html.erb
+++ b/app/views/shared/_personal_key.html.erb
@@ -1,51 +1,42 @@
-<%= render PageHeadingComponent.new.with_content(t('headings.personal_key')) %>
-
- <%= t('instructions.personal_key.info') %>
-
+<%= render PageHeadingComponent.new.with_content(t('forms.personal_key_partial.header')) %>
<%= render 'partials/personal_key/key', code: code %>
-<%= render ButtonComponent.new(
- action: ->(**tag_options, &block) do
- link_to(
- "data:text/plain;charset=utf-8,#{CGI.escape(code)}",
- download: 'personal_key.txt',
- **tag_options,
- &block
- )
- end,
- icon: :file_download,
- outline: true,
- class: 'margin-right-2 margin-bottom-2 tablet:margin-bottom-0',
- ).with_content(t('forms.backup_code.download')) %>
-<%= render PrintButtonComponent.new(
- icon: :print,
- outline: true,
- type: :button,
- class: 'margin-right-2 margin-bottom-2 tablet:margin-bottom-0',
- ) %>
-<%= render ClipboardButtonComponent.new(
- clipboard_text: code,
- outline: true,
- class: 'margin-bottom-2 tablet:margin-bottom-0',
- ) %>
-
- <%= image_tag(
- asset_url('alert/icon-lock-alert-important.svg'),
- alt: '',
- size: '80',
- class: 'float-left margin-right-2',
- ) %>
+
+<%= render AccordionComponent.new do |c| %>
+ <% c.header { t('forms.personal_key_partial.explanation.header') } %>
+ <% t('forms.personal_key_partial.explanation.text').each do |paragraph| %>
+
<%= paragraph %>
+ <% end %>
+<% end %>
+
+<%= simple_form_for('', url: update_path) do |f| %>
+
+
+ <%= t('forms.personal_key_partial.acknowledgement.header') %>
+
+
+
- <%= t('instructions.personal_key.email_title') %>
+ <%= t('forms.personal_key_partial.acknowledgement.text') %>
-
<%= t('instructions.personal_key.email_body') %>
-
-<%= button_to(
- t('forms.buttons.continue'),
- update_path,
- class: 'display-block usa-button usa-button--big usa-button--wide personal-key-continue margin-top-5',
- 'data-toggle': FeatureManagement.idv_personal_key_confirmation_enabled? ? 'modal' : 'skip',
- ) %>
-<%= render 'shared/personal_key_confirmation_modal', code: code, update_path: update_path %>
-<%== javascript_packs_tag_once 'personal-key-page-controller' %>
+
+
+ <% t('forms.personal_key_partial.acknowledgement.bullets').each do |bullet| %>
+ <%= bullet %>
+ <% end %>
+
+
+ <%= render ClickObserverComponent.new(event_name: 'IdV: personal key acknowledgment toggled') do %>
+ <%= render ValidatedFieldComponent.new(
+ form: f,
+ name: :acknowledgment,
+ as: :boolean,
+ label: t('forms.personal_key.required_checkbox'),
+ label_html: { class: 'margin-bottom-105' },
+ required: true,
+ ) %>
+ <% end %>
+
+ <%= f.submit(t('forms.buttons.continue'), full_width: true, class: 'margin-top-3') %>
+<% end %>
diff --git a/app/views/shared/_personal_key_confirmation_modal.html.erb b/app/views/shared/_personal_key_confirmation_modal.html.erb
deleted file mode 100644
index 133f17cca26..00000000000
--- a/app/views/shared/_personal_key_confirmation_modal.html.erb
+++ /dev/null
@@ -1,34 +0,0 @@
-
- <%= render layout: '/shared/modal_layout', locals: { role: 'dialog' } do |label_id, description_id| %>
-
-
- <%= t('forms.personal_key.title') %>
-
-
- <%= t('forms.personal_key.instructions') %>
-
-
- <%= simple_form_for('', url: update_path, id: 'confirm-key') do |f| %>
- <%= render 'shared/personal_key_input', code: code, form: f %>
- <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
-
-
-
- <%= t('forms.buttons.continue') %>
-
-
-
-
- <%= t('forms.buttons.back') %>
-
-
-
-
- <%= t('forms.buttons.continue') %>
-
-
-
- <% end %>
-
- <% end %>
-
diff --git a/app/views/users/backup_code_setup/create.html.erb b/app/views/users/backup_code_setup/create.html.erb
index 8387b717009..d7075858cc0 100644
--- a/app/views/users/backup_code_setup/create.html.erb
+++ b/app/views/users/backup_code_setup/create.html.erb
@@ -29,18 +29,13 @@
<% if desktop_device? %>
- <%= render ButtonComponent.new(
- action: ->(**tag_options, &block) do
- link_to(
- "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}",
- download: 'backup_codes.txt',
- **tag_options,
- &block
- )
- end,
- outline: true,
- icon: :file_download,
- ).with_content(t('forms.backup_code.download')) %>
+ <%= render ClickObserverComponent.new(event_name: 'Multi-Factor Authentication: download backup code') do %>
+ <%= render DownloadButtonComponent.new(
+ file_data: @codes.join("\n"),
+ file_name: 'backup_codes.txt',
+ outline: true,
+ ) %>
+ <% end %>
<% end %>
<%= render PrintButtonComponent.new(
icon: :print,
@@ -59,4 +54,3 @@
<%= form_tag(backup_code_continue_path, method: :patch) do %>
<%= button_tag t('forms.buttons.continue'), type: 'submit', class: 'usa-button usa-button--big usa-button--wide' %>
<% end %>
-<%= javascript_packs_tag_once 'backup-code-analytics' %>
diff --git a/config/application.rb b/config/application.rb
index 703d4ac6ce7..d37be6d1246 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -12,9 +12,12 @@
require_relative '../lib/identity_config'
require_relative '../lib/fingerprinter'
require_relative '../lib/identity_job_log_subscriber'
+require_relative '../lib/email_delivery_observer'
Bundler.require(*Rails.groups)
+require_relative '../lib/mailer_sensitive_information_checker'
+
APP_NAME = 'Login.gov'.freeze
module Identity
@@ -93,6 +96,7 @@ class Application < Rails::Application
mail.display_name = IdentityConfig.store.email_from_display_name
end.to_s,
}
+ config.action_mailer.observers = %w[EmailDeliveryObserver]
require 'headers_filter'
config.middleware.insert_before 0, HeadersFilter
diff --git a/config/application.yml.default b/config/application.yml.default
index 57d27020f33..32d91fe3713 100644
--- a/config/application.yml.default
+++ b/config/application.yml.default
@@ -107,7 +107,6 @@ idv_max_attempts: 5
idv_min_age_years: 13
idv_native_camera_a_b_testing_enabled: false
idv_native_camera_a_b_testing_percent: 10
-idv_personal_key_confirmation_enabled: true
idv_send_link_attempt_window_in_minutes: 10
idv_send_link_max_attempts: 5
idv_sp_required: false
@@ -118,7 +117,7 @@ in_person_completion_survey_url: 'https://login.gov'
include_slo_in_saml_metadata: false
inherited_proofing_enabled: false
inherited_proofing_va_base_url: 'https://staging-api.va.gov'
-va_inherited_proofing_mock_enabled: true
+va_inherited_proofing_mock_enabled: false
irs_attempt_api_audience: 'https://irs.gov'
irs_attempt_api_auth_tokens: ''
irs_attempt_api_csp_id: 'LOGIN.gov'
diff --git a/config/initializers/ext_action_mailer.rb b/config/initializers/ext_action_mailer.rb
index 11cfbcceac1..3bdc843829b 100644
--- a/config/initializers/ext_action_mailer.rb
+++ b/config/initializers/ext_action_mailer.rb
@@ -1,8 +1,18 @@
# Monkeypatches the MessageDelivery to add deliver_now_or_later that
# can route between #deliver_now and #deliver_later
+module DeliverLaterArgumentChecker
+ def deliver_later(...)
+ MailerSensitiveInformationChecker.check_for_sensitive_pii!(@params, @args, @action)
+ super(...)
+ end
+end
+
module ActionMailer
class MessageDelivery
+ prepend DeliverLaterArgumentChecker
+
def deliver_now_or_later(opts = {})
+ MailerSensitiveInformationChecker.check_for_sensitive_pii!(@params, @args, @action)
# rubocop:disable IdentityIdp/MailLaterLinter
if IdentityConfig.store.deliver_mail_async
deliver_later(opts)
diff --git a/config/locales/components/en.yml b/config/locales/components/en.yml
index c31e884f696..511c1f57755 100644
--- a/config/locales/components/en.yml
+++ b/config/locales/components/en.yml
@@ -5,6 +5,8 @@ en:
image_alt: Barcode
clipboard_button:
label: Copy
+ download_button:
+ label: Download
javascript_required:
browser_instructions: 'Follow the instructions for your browser to enable JavaScript:'
enabled_alert: You have enabled JavaScript in your browser.
diff --git a/config/locales/components/es.yml b/config/locales/components/es.yml
index b8882712470..e90e0ed3230 100644
--- a/config/locales/components/es.yml
+++ b/config/locales/components/es.yml
@@ -5,6 +5,8 @@ es:
image_alt: Código de barras
clipboard_button:
label: Copiar
+ download_button:
+ label: Descargar
javascript_required:
browser_instructions: 'Siga las instrucciones para habilitar JavaScript en su navegador:'
enabled_alert: YHabilitó el JavaScript en su navegador.
@@ -35,7 +37,7 @@ es:
phone_input:
country_code_label: Código del país
print_button:
- label: Imprima esta página
+ label: Imprima
troubleshooting_options:
default_heading: '¿Tiene alguna dificultad? Esto es lo que puede hacer:'
new_feature: Nuevo artículo
diff --git a/config/locales/components/fr.yml b/config/locales/components/fr.yml
index 01a3d53937f..fba668eba5d 100644
--- a/config/locales/components/fr.yml
+++ b/config/locales/components/fr.yml
@@ -5,6 +5,8 @@ fr:
image_alt: Code-barres
clipboard_button:
label: Copier
+ download_button:
+ label: Télécharger
javascript_required:
browser_instructions: 'Suivez les instructions de votre navigateur pour activer JavaScript:'
enabled_alert: Vous avez activé JavaScript dans votre navigateur.
@@ -35,7 +37,7 @@ fr:
phone_input:
country_code_label: Code pays
print_button:
- label: Imprimer cette page
+ label: Imprimer
troubleshooting_options:
default_heading: 'Des difficultés? Voici ce que vous pouvez faire:'
new_feature: Nouvelle fonctionnalité
diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml
index 4478278956a..5aeab9a0cf7 100644
--- a/config/locales/forms/en.yml
+++ b/config/locales/forms/en.yml
@@ -13,7 +13,6 @@ en:
caution_delete: If you delete your backup codes you will no longer be able to
use them to sign in.
confirm_delete: Are you sure you want to delete your backup codes?
- download: Download
generate: Get codes
last_code: You used your last backup code. Please print, copy or download the
codes below. You can use these new codes the next time you sign in.
@@ -66,8 +65,27 @@ en:
personal_key:
alternative: Don’t have your personal key?
confirmation_label: Personal key
+ download: Download (text file)
instructions: Please confirm you have a copy of your personal key by entering it below.
+ required_checkbox: Please check this box to continue.
title: Enter your personal key
+ personal_key_partial:
+ acknowledgement:
+ bullets:
+ - You’ll lose access to your account
+ - You’ll need to verify your identity again
+ header: You need your personal key if you forget your password. Keep it safe and
+ don’t share it with anyone.
+ text: 'If you reset your password without your personal key:'
+ explanation:
+ header: What is a personal key?
+ text:
+ - A personal key “locks” your personal information with your account.
+ It’s the only way to unlock your information if you lose or forget
+ your password.
+ - When you reset your password, Login.gov will ask for your personal
+ key to make sure you are you - not someone pretending to be you.
+ header: Save your personal key
phone:
buttons:
delete: Remove phone
diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml
index 4d449c235a5..93e2d358211 100644
--- a/config/locales/forms/es.yml
+++ b/config/locales/forms/es.yml
@@ -14,7 +14,6 @@ es:
caution_delete: Si elimina sus códigos de respaldo, ya no podrá usarlos para
iniciar sesión.
confirm_delete: '¿Estás seguro de que deseas eliminar tus códigos de respaldo?'
- download: Descargar
generate: Obtener códigos
last_code: Usted utilizó el último código de seguridad. Imprima, copie o
descargue los códigos que aparecen a continuación. Puede introducir
@@ -71,9 +70,29 @@ es:
personal_key:
alternative: '¿No tiene su clave personal?'
confirmation_label: Clave personal
+ download: Descargar (archivo de texto)
instructions: Confirme que tiene una copia de su clave personal ingresándola a
continuación.
+ required_checkbox: Marque esta casilla para continuar.
title: Ingrese su clave personal
+ personal_key_partial:
+ acknowledgement:
+ bullets:
+ - Perderás el acceso a tu cuenta
+ - Tendrás que verificar tu identidad nuevamente
+ header: Necesitarás tu clave personal si olvidas tu contraseña. Mantenla en un
+ lugar seguro y no la compartas con nadie.
+ text: 'Si restableces tu contraseña sin tu clave personal:'
+ explanation:
+ header: ¿Qué es una clave personal?
+ text:
+ - Una clave personal “bloquea” la información personal de tu cuenta.
+ Es la única manera de desbloquear tu información si pierdes u
+ olvidas tu contraseña.
+ - Si restableces tu contraseña, Login.gov te pedirá tu clave personal
+ para asegurarse de que se trata de ti, y no de alguien que quiere
+ hacerse pasar por ti.
+ header: Guarda tu clave personal
phone:
buttons:
delete: Eliminar el teléfono
diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml
index 97cb4bd26ff..f374276e6ac 100644
--- a/config/locales/forms/fr.yml
+++ b/config/locales/forms/fr.yml
@@ -15,7 +15,6 @@ fr:
caution_delete: Si vous supprimez vos codes de sauvegarde, vous ne pourrez plus
les utiliser pour vous connecter.
confirm_delete: Êtes-vous sûr de vouloir supprimer vos codes de sauvegarde?
- download: Télécharger
generate: Obtenir des codes
last_code: Vous avez utilisé votre dernier code de sauvegarde. Veuillez
imprimer, copier ou télécharger les codes ci-dessous. Vous pourrez
@@ -72,9 +71,29 @@ fr:
personal_key:
alternative: Vous n’avez pas votre clé personnelle?
confirmation_label: Clé personnelle
+ download: Télécharger (fichier texte)
instructions: Veuillez confirmer que vous avez une copie de votre clé
personnelle en l’entrant ci-dessous.
+ required_checkbox: Veuillez cocher cette case pour continuer.
title: Entrez votre clé personnelle
+ personal_key_partial:
+ acknowledgement:
+ bullets:
+ - Vous perdrez l’accès à votre compte
+ - Vous allez devoir vérifier à nouveau votre identité
+ header: En cas d’oubli de votre mot de passe, vous aurez besoin de votre clé
+ personnelle. Gardez-la en sécurité et ne la partagez avec personne.
+ text: 'Si vous réinitialisez votre mot de passe sans votre clé personnelle:'
+ explanation:
+ header: Qu’est-ce qu’une clé personnelle?
+ text:
+ - Une clé personnelle « verrouille » vos informations personnelles
+ avec votre compte. C’est le seul moyen de déverrouiller vos
+ informations si vous perdez ou oubliez votre mot de passe.
+ - Lors de la réinitialisation de votre mot de passe, Login.gov vous
+ demandera votre clé personnelle pour s’assurer que vous êtes bien
+ vous, et non quelqu’un qui se fait passer pour vous.
+ header: Sauvegardez votre clé personnelle
phone:
buttons:
delete: Supprimer le numéro de teléfono
diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml
index c19a456178e..aca5a659bc6 100644
--- a/config/locales/headings/en.yml
+++ b/config/locales/headings/en.yml
@@ -34,7 +34,6 @@ en:
confirm: Confirm your current password to continue
confirm_for_personal_key: Enter password and get a new personal key
forgot: Forgot your password?
- personal_key: Save your personal key
piv_cac:
certificate:
bad: The PIV/CAC certificate you selected is invalid
diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml
index 01449e023fc..c8fc13dd168 100644
--- a/config/locales/headings/es.yml
+++ b/config/locales/headings/es.yml
@@ -34,7 +34,6 @@ es:
confirm: Confirme la contraseña actual para continuar
confirm_for_personal_key: Introduzca la contraseña y obtenga una nueva clave personal
forgot: '¿Olvidó su contraseña?'
- personal_key: Guarda tu clave personal
piv_cac:
certificate:
bad: El certificado PIV/CAC que seleccionaste no es válido.
diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml
index de2c4e53066..c70bd3f926c 100644
--- a/config/locales/headings/fr.yml
+++ b/config/locales/headings/fr.yml
@@ -34,7 +34,6 @@ fr:
confirm: Confirmez votre mot de passe actuel pour continuer
confirm_for_personal_key: Entrez le mot de passe et obtenez une nouvelle clé personnelle
forgot: Vous avez oublié votre mot de passe?
- personal_key: Enregistrez votre clé personnelle
piv_cac:
certificate:
bad: Le certificat PIV/CAC que vous avez sélectionné n’est pas valide.
diff --git a/config/locales/inherited_proofing/en.yml b/config/locales/inherited_proofing/en.yml
index c483acac45b..b3debae651b 100644
--- a/config/locales/inherited_proofing/en.yml
+++ b/config/locales/inherited_proofing/en.yml
@@ -3,6 +3,7 @@ en:
inherited_proofing:
buttons:
continue: Continue
+ try_again: Try again
cancel:
actions:
keep_going: No, keep going
@@ -19,6 +20,12 @@ en:
instructions:
switch_back: Switch back to your computer to finish verifying your identity.
switch_back_image: Arrow pointing from phone to computer
+ errors:
+ cannot_retrieve:
+ heading: We could not retrieve your information from My HealtheVet
+ info: We are temporarily having trouble retrieving your information. Please try
+ again.
+ title: Couldn’t retrieve information
headings:
lets_go: How verifying your identity works
retrieval: We are retrieving your information from My HealtheVet
diff --git a/config/locales/inherited_proofing/es.yml b/config/locales/inherited_proofing/es.yml
index f3d54c86544..5db2db25e86 100644
--- a/config/locales/inherited_proofing/es.yml
+++ b/config/locales/inherited_proofing/es.yml
@@ -3,6 +3,7 @@ es:
inherited_proofing:
buttons:
continue: Continuar
+ try_again: Inténtelo de nuevo
cancel:
actions:
keep_going: No, continuar
@@ -20,6 +21,12 @@ es:
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
+ errors:
+ cannot_retrieve:
+ heading: No hemos podido obtener su información de My HealtheVet
+ info: Estamos teniendo problemas temporalmente para obtener su información.
+ Inténtelo de nuevo.
+ title: to be implemented
headings:
lets_go: to be implemented
retrieval: Estamos recuperando su información de My HealtheVet
diff --git a/config/locales/inherited_proofing/fr.yml b/config/locales/inherited_proofing/fr.yml
index 501322985c9..daea03b9f27 100644
--- a/config/locales/inherited_proofing/fr.yml
+++ b/config/locales/inherited_proofing/fr.yml
@@ -3,6 +3,7 @@ fr:
inherited_proofing:
buttons:
continue: Continuer
+ try_again: Réessayez
cancel:
actions:
keep_going: Non, continuer
@@ -21,6 +22,12 @@ fr:
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
+ errors:
+ cannot_retrieve:
+ heading: Nous n’avons pas pu récupérer vos informations dans My HealtheVet
+ info: Nous avons temporairement des difficultés à récupérer vos informations.
+ Veuillez réessayer.
+ title: to be implemented
headings:
lets_go: to be implemented
retrieval: Nous récupérons vos informations depuis My HealtheVet.
diff --git a/config/locales/instructions/en.yml b/config/locales/instructions/en.yml
index 9d3eae94ca6..abdf7e0763b 100644
--- a/config/locales/instructions/en.yml
+++ b/config/locales/instructions/en.yml
@@ -102,13 +102,6 @@ en:
intro: 'Password strength: '
iv: Good
v: Great!
- personal_key:
- email_body: Don’t lose your personal key or share it with others. We’ll ask for
- it if you reset your password.
- email_title: Save it. Keep it safe.
- info: You’ll need this personal key if you forget your password. If you reset
- your password and don’t have this key, you’ll have to verify your
- identity again.
sp_handoff_bounced: Your sign in was successful, but %{sp_name} sent you back to
%{app_name}. Please contact %{sp_link} for help.
sp_handoff_bounced_with_no_sp: your service provider
diff --git a/config/locales/instructions/es.yml b/config/locales/instructions/es.yml
index 9b1cfc800bb..bc2023c54a5 100644
--- a/config/locales/instructions/es.yml
+++ b/config/locales/instructions/es.yml
@@ -109,13 +109,6 @@ es:
intro: 'Seguridad de la contraseña:'
iv: Buena
v: '¡Muy buena!'
- personal_key:
- email_body: No pierdas tu clave personal ni la compartas con otros. La pediremos
- si restableces tu contraseña.
- email_title: Guárdala. Manténla segura.
- info: Necesitarás esta clave personal si olvidas tu contraseña. Si restableces
- tu contraseña y no tienes esta clave, deberás verificar tu identidad
- nuevamente.
sp_handoff_bounced: Su inicio de sesión fue exitoso, pero %{sp_name} lo envió de
regreso a %{app_name}. Póngase en contacto con %{sp_link} para obtener
ayuda.
diff --git a/config/locales/instructions/fr.yml b/config/locales/instructions/fr.yml
index 6cd6ae02317..71174ab25f4 100644
--- a/config/locales/instructions/fr.yml
+++ b/config/locales/instructions/fr.yml
@@ -119,14 +119,6 @@ fr:
intro: 'Force du mot de passe : '
iv: Bonne
v: Excellente!
- personal_key:
- email_body: Ne perdez pas votre clé personnelle et ne la partagez pas avec
- d’autres. Nous vous le demanderons si vous réinitialisez votre mot de
- passe.
- email_title: Sauvegarde le. Garde-le en sécurité.
- info: Vous aurez besoin de cette clé personnelle si vous oubliez votre mot de
- passe. Si vous réinitialisez votre mot de passe et que vous ne possédez
- pas cette clé, vous devrez vérifier votre identité à nouveau.
sp_handoff_bounced: Votre connexion a réussi, mais %{sp_name} vous a renvoyé à
%{app_name}. Veuillez contacter %{sp_link} pour obtenir de l’aide.
sp_handoff_bounced_with_no_sp: votre fournisseur de service
diff --git a/config/locales/step_indicator/en.yml b/config/locales/step_indicator/en.yml
index b7a4fe4b551..4867d45caac 100644
--- a/config/locales/step_indicator/en.yml
+++ b/config/locales/step_indicator/en.yml
@@ -11,7 +11,6 @@ en:
secure_account: Secure your account
verify_id: Verify your ID
verify_info: Verify your personal details
- verify_phone: Verify phone
verify_phone_or_address: Verify phone or address
status:
complete: Completed
diff --git a/config/locales/step_indicator/es.yml b/config/locales/step_indicator/es.yml
index 25217916852..d878afd0bad 100644
--- a/config/locales/step_indicator/es.yml
+++ b/config/locales/step_indicator/es.yml
@@ -11,7 +11,6 @@ es:
secure_account: Proteje tu cuenta
verify_id: Verifica tu identificación
verify_info: Verifica tus datos personales
- verify_phone: to implement
verify_phone_or_address: Verifica el teléfono o dirección
status:
complete: Completo
diff --git a/config/locales/step_indicator/fr.yml b/config/locales/step_indicator/fr.yml
index 619193b1a57..3323146f8dc 100644
--- a/config/locales/step_indicator/fr.yml
+++ b/config/locales/step_indicator/fr.yml
@@ -11,7 +11,6 @@ fr:
secure_account: Sécurisez votre compte
verify_id: Vérifier votre identité
verify_info: Vérifier vos données personnelles
- verify_phone: to implement
verify_phone_or_address: Vérifier le téléphone ou l’adresse
status:
complete: Terminé
diff --git a/config/locales/users/en.yml b/config/locales/users/en.yml
index 378d09f4a02..316a6db802e 100644
--- a/config/locales/users/en.yml
+++ b/config/locales/users/en.yml
@@ -21,8 +21,7 @@ en:
personal_key:
close: Close
confirmation_error: You’ve entered an incorrect personal key.
- generated_on_html: Generated on %{date}
- header: Your personal key
+ generated_on_html: Your personal key was generated on %{date}
phones:
error_message: You’ve added the maximum number of phone numbers.
rules_of_use:
diff --git a/config/locales/users/es.yml b/config/locales/users/es.yml
index f1e8262854a..a883204cc8f 100644
--- a/config/locales/users/es.yml
+++ b/config/locales/users/es.yml
@@ -22,8 +22,7 @@ es:
personal_key:
close: Cerrar
confirmation_error: Ha ingresado una clave personal incorrecta.
- generated_on_html: Generado el %{date}
- header: Su clave personal
+ generated_on_html: Tu clave personal fue generada el %{date}
phones:
error_message: Agregó el número máximo de números de teléfono.
rules_of_use:
diff --git a/config/locales/users/fr.yml b/config/locales/users/fr.yml
index 1c17d049ae1..bbef10bd0f0 100644
--- a/config/locales/users/fr.yml
+++ b/config/locales/users/fr.yml
@@ -24,8 +24,7 @@ fr:
personal_key:
close: Fermer
confirmation_error: Vous avez entré un clé personnelle erronée.
- generated_on_html: Générée le %{date}
- header: Votre clé personnelle
+ generated_on_html: Votre clé personnelle a été générée le %{date}
phones:
error_message: Vous avez ajouté le nombre maximum de numéros de téléphone.
rules_of_use:
diff --git a/config/routes.rb b/config/routes.rb
index e23547ed07a..77b821f0f33 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -364,6 +364,7 @@
get '/:step' => 'inherited_proofing#show', as: :step
put '/:step' => 'inherited_proofing#update'
get '/return_to_sp' => 'inherited_proofing#return_to_sp'
+ get '/errors/no_information' => 'inherited_proofing#no_information'
end
namespace :api do
diff --git a/db/primary_migrate/20221012173528_remove_unused_from_registration_logs.rb b/db/primary_migrate/20221012173528_remove_unused_from_registration_logs.rb
new file mode 100644
index 00000000000..39f5a59e9d5
--- /dev/null
+++ b/db/primary_migrate/20221012173528_remove_unused_from_registration_logs.rb
@@ -0,0 +1,14 @@
+class RemoveUnusedFromRegistrationLogs < ActiveRecord::Migration[7.0]
+ def change
+ remove_index :registration_logs, column: :submitted_at, name: "index_registration_logs_on_submitted_at"
+
+ safety_assured do
+ remove_column :registration_logs, :submitted_at, :datetime
+ remove_column :registration_logs, :confirmed_at, :datetime
+ remove_column :registration_logs, :password_at, :datetime
+ remove_column :registration_logs, :first_mfa, :string
+ remove_column :registration_logs, :first_mfa_at, :datetime
+ remove_column :registration_logs, :second_mfa, :string
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 326cde73108..105f40ea1b4 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2022_10_12_172457) do
+ActiveRecord::Schema[7.0].define(version: 2022_10_12_173528) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pgcrypto"
@@ -484,15 +484,8 @@
create_table "registration_logs", force: :cascade do |t|
t.integer "user_id", null: false
- t.datetime "submitted_at", precision: nil
- t.datetime "confirmed_at", precision: nil
- t.datetime "password_at", precision: nil
- t.string "first_mfa"
- t.datetime "first_mfa_at", precision: nil
- t.string "second_mfa"
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
end
diff --git a/lib/aws/ses.rb b/lib/aws/ses.rb
index b65bea1c349..c80a77fbb22 100644
--- a/lib/aws/ses.rb
+++ b/lib/aws/ses.rb
@@ -9,9 +9,9 @@ class Base
def initialize(*); end
def deliver(mail)
- response = ses_client.send_raw_email(raw_message: { data: mail.to_s })
- mail.message_id = "#{response.message_id}@email.amazonses.com"
- response
+ ses_client.send_raw_email(raw_message: { data: mail.to_s }).tap do |response|
+ mail.header[:ses_message_id] = response.message_id
+ end
end
alias deliver! deliver
diff --git a/lib/email_delivery_observer.rb b/lib/email_delivery_observer.rb
new file mode 100644
index 00000000000..eec394150e0
--- /dev/null
+++ b/lib/email_delivery_observer.rb
@@ -0,0 +1,9 @@
+class EmailDeliveryObserver
+ def self.delivered_email(mail)
+ metadata = mail.instance_variable_get(:@_metadata) || {}
+ user = metadata[:user] || AnonymousUser.new
+ action = metadata[:action]
+ Analytics.new(user: user, request: nil, sp: nil, session: {}).
+ email_sent(action: action, ses_message_id: mail.header[:ses_message_id]&.value)
+ end
+end
diff --git a/lib/feature_management.rb b/lib/feature_management.rb
index 4275487568b..a26e92bbe38 100644
--- a/lib/feature_management.rb
+++ b/lib/feature_management.rb
@@ -112,10 +112,6 @@ def self.log_to_stdout?
!Rails.env.test? && IdentityConfig.store.log_to_stdout
end
- def self.idv_personal_key_confirmation_enabled?
- IdentityConfig.store.idv_personal_key_confirmation_enabled
- end
-
# Manual allowlist for VOIPs, should only include known VOIPs that we use for smoke tests
# @return [Set
] set of phone numbers normalized to e164
def self.voip_allowed_phones
diff --git a/lib/identity_config.rb b/lib/identity_config.rb
index 22d37d1ce81..7ea1c6eff01 100644
--- a/lib/identity_config.rb
+++ b/lib/identity_config.rb
@@ -186,7 +186,6 @@ def self.build_store(config_map)
config.add(:idv_min_age_years, type: :integer)
config.add(:idv_native_camera_a_b_testing_enabled, type: :boolean)
config.add(:idv_native_camera_a_b_testing_percent, type: :integer)
- config.add(:idv_personal_key_confirmation_enabled, type: :boolean)
config.add(:idv_send_link_attempt_window_in_minutes, type: :integer)
config.add(:idv_send_link_max_attempts, type: :integer)
config.add(:idv_sp_required, type: :boolean)
diff --git a/lib/mailer_sensitive_information_checker.rb b/lib/mailer_sensitive_information_checker.rb
new file mode 100644
index 00000000000..061dcf0297e
--- /dev/null
+++ b/lib/mailer_sensitive_information_checker.rb
@@ -0,0 +1,54 @@
+class MailerSensitiveInformationChecker
+ class SensitiveKeyError < StandardError; end
+
+ class SensitiveValueError < StandardError; end
+
+ def self.check_for_sensitive_pii!(params, args_array, action)
+ args = ActiveJob::Arguments.serialize(args_array)
+ serialized_params = ActiveJob::Arguments.serialize(params)
+ serialized_args_string = args.to_s
+ serialized_params_string = serialized_params.to_s
+
+ if params[:email_address].is_a?(EmailAddress)
+ check_hash(params.except(:email_address), action)
+ else
+ check_hash(params, action)
+ end
+ args.each do |arg|
+ next unless arg.is_a?(Hash)
+ check_hash(arg, action)
+ end
+
+ if SessionEncryptor::SENSITIVE_REGEX.match?(serialized_args_string)
+ self.alert(SensitiveValueError.new)
+ end
+
+ if SessionEncryptor::SENSITIVE_REGEX.match?(serialized_params_string)
+ self.alert(SensitiveValueError.new)
+ end
+ end
+
+ def self.check_hash(hash, action)
+ hash.deep_transform_keys do |key|
+ if SessionEncryptor::SENSITIVE_KEYS.include?(key.to_s)
+ exception = SensitiveKeyError.new(
+ "#{key} unexpectedly appeared in #{action} Mailer args",
+ )
+ self.alert(exception)
+ end
+ end
+ end
+
+ def self.alert(exception)
+ if IdentityConfig.store.session_encryptor_alert_enabled
+ NewRelic::Agent.notice_error(exception)
+ else
+ raise exception
+ end
+ end
+
+ class << self
+ include ::NewRelic::Agent::MethodTracer
+ add_method_tracer :check_for_sensitive_pii!, "Custom/#{name}/check_for_sensitive_pii!"
+ end
+end
diff --git a/lib/tasks/attempts.rake b/lib/tasks/attempts.rake
index 351db5f3aa8..2f3ac28cc19 100644
--- a/lib/tasks/attempts.rake
+++ b/lib/tasks/attempts.rake
@@ -1,10 +1,10 @@
namespace :attempts do
- auth_token = IdentityConfig.store.irs_attempt_api_auth_tokens.sample
- puts 'There are no configured irs_attempt_api_auth_tokens' if auth_token.nil?
- private_key_path = 'keys/attempts_api_private_key.key'
-
desc 'Retrieve events via the API'
task fetch_events: :environment do
+ auth_token = IdentityConfig.store.irs_attempt_api_auth_tokens.sample
+ puts 'There are no configured irs_attempt_api_auth_tokens' if auth_token.nil?
+ private_key_path = 'keys/attempts_api_private_key.key'
+
conn = Faraday.new(url: 'http://localhost:3000')
body = "timestamp=#{Time.zone.now.iso8601}"
diff --git a/spec/components/click_observer_component_spec.rb b/spec/components/click_observer_component_spec.rb
new file mode 100644
index 00000000000..7d7b61ee9d5
--- /dev/null
+++ b/spec/components/click_observer_component_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe ClickObserverComponent, type: :component do
+ let(:event_name) { 'Event Name' }
+ let(:content) { 'Content' }
+ let(:tag_options) { {} }
+
+ subject(:rendered) do
+ render_inline ClickObserverComponent.new(
+ event_name: event_name,
+ **tag_options,
+ ).with_content(content)
+ end
+
+ it 'renders wrapped content with event name as attribute' do
+ expect(rendered).to have_css("lg-click-observer[event-name='#{event_name}']", text: content)
+ end
+
+ context 'with tag options' do
+ let(:tag_options) { { data: { foo: 'bar' } } }
+
+ it 'renders with the given the tag options' do
+ expect(rendered).to have_css('lg-click-observer[data-foo="bar"]')
+ end
+ end
+end
diff --git a/spec/components/download_button_component_spec.rb b/spec/components/download_button_component_spec.rb
new file mode 100644
index 00000000000..1d2768680b6
--- /dev/null
+++ b/spec/components/download_button_component_spec.rb
@@ -0,0 +1,40 @@
+require 'rails_helper'
+
+RSpec.describe DownloadButtonComponent, type: :component do
+ let(:file_data) { 'Downloaded Text' }
+ let(:file_name) { 'file.txt' }
+ let(:tag_options) { {} }
+ let(:instance) do
+ DownloadButtonComponent.new(
+ file_data: file_data,
+ file_name: file_name,
+ **tag_options,
+ )
+ end
+
+ subject(:rendered) { render_inline instance }
+
+ it 'renders link with data and file name' do
+ expect(rendered).to have_css(
+ "lg-download-button a[href*='#{CGI.escape(file_data)}'][download='#{file_name}']",
+ text: t('components.download_button.label'),
+ )
+ end
+
+ context 'with tag options' do
+ let(:tag_options) { { outline: true, data: { foo: 'bar' } } }
+
+ it 'renders button given the tag options' do
+ expect(rendered).to have_css('.usa-button.usa-button--outline[data-foo="bar"]')
+ end
+ end
+
+ context 'with content' do
+ let(:content) { 'Download File' }
+ let(:instance) { super().with_content(content) }
+
+ it 'renders with the given content' do
+ expect(rendered).to have_content(content)
+ end
+ end
+end
diff --git a/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb b/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb
index 9ca3ffa218f..0d7c21a67c9 100644
--- a/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb
+++ b/spec/controllers/concerns/idv/phone_otp_rate_limitable_spec.rb
@@ -22,6 +22,7 @@ def handle_max_attempts(_arg = nil)
expect(@analytics).to have_received(:track_event).with(
'Idv: Phone OTP sends rate limited',
+ proofing_components: nil,
)
end
diff --git a/spec/controllers/idv/cancellations_controller_spec.rb b/spec/controllers/idv/cancellations_controller_spec.rb
index cad7e2081bd..57c9f30e7f2 100644
--- a/spec/controllers/idv/cancellations_controller_spec.rb
+++ b/spec/controllers/idv/cancellations_controller_spec.rb
@@ -17,9 +17,13 @@
it 'tracks the event in analytics when referer is nil' do
stub_sign_in
stub_analytics
- properties = { request_came_from: 'no referer', step: nil }
- expect(@analytics).to receive(:track_event).with('IdV: cancellation visited', properties)
+ expect(@analytics).to receive(:track_event).with(
+ 'IdV: cancellation visited',
+ request_came_from: 'no referer',
+ step: nil,
+ proofing_components: nil,
+ )
get :new
end
@@ -28,9 +32,13 @@
stub_sign_in
stub_analytics
request.env['HTTP_REFERER'] = 'http://example.com/'
- properties = { request_came_from: 'users/sessions#new', step: nil }
- expect(@analytics).to receive(:track_event).with('IdV: cancellation visited', properties)
+ expect(@analytics).to receive(:track_event).with(
+ 'IdV: cancellation visited',
+ request_came_from: 'users/sessions#new',
+ step: nil,
+ proofing_components: nil,
+ )
get :new
end
@@ -38,9 +46,13 @@
it 'tracks the event in analytics when step param is present' do
stub_sign_in
stub_analytics
- properties = { request_came_from: 'no referer', step: 'first' }
- expect(@analytics).to receive(:track_event).with('IdV: cancellation visited', properties)
+ expect(@analytics).to receive(:track_event).with(
+ 'IdV: cancellation visited',
+ request_came_from: 'no referer',
+ step: 'first',
+ proofing_components: nil,
+ )
get :new, params: { step: 'first' }
end
@@ -100,6 +112,7 @@
expect(@analytics).to receive(:track_event).with(
'IdV: cancellation go back',
step: 'first',
+ proofing_components: nil,
)
put :update, params: { step: 'first', cancel: 'true' }
@@ -136,6 +149,7 @@
expect(@analytics).to receive(:track_event).with(
'IdV: cancellation confirmed',
step: 'first',
+ proofing_components: nil,
)
delete :destroy, params: { step: 'first' }
diff --git a/spec/controllers/idv/come_back_later_controller_spec.rb b/spec/controllers/idv/come_back_later_controller_spec.rb
index 880d1abe813..f38e60c3a85 100644
--- a/spec/controllers/idv/come_back_later_controller_spec.rb
+++ b/spec/controllers/idv/come_back_later_controller_spec.rb
@@ -16,7 +16,10 @@
it 'renders the show template' do
stub_analytics
- expect(@analytics).to receive(:track_event).with('IdV: come back later visited')
+ expect(@analytics).to receive(:track_event).with(
+ 'IdV: come back later visited',
+ proofing_components: nil,
+ )
get :show
diff --git a/spec/controllers/idv/forgot_password_controller_spec.rb b/spec/controllers/idv/forgot_password_controller_spec.rb
index 41f994ecd51..6c8ac23aed0 100644
--- a/spec/controllers/idv/forgot_password_controller_spec.rb
+++ b/spec/controllers/idv/forgot_password_controller_spec.rb
@@ -18,7 +18,10 @@
it 'tracks the event in analytics when referer is nil' do
get :new
- expect(@analytics).to have_received(:track_event).with('IdV: forgot password visited')
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: forgot password visited',
+ proofing_components: nil,
+ )
end
end
@@ -39,7 +42,10 @@
post :update
- expect(@analytics).to have_received(:track_event).with('IdV: forgot password confirmed')
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: forgot password confirmed',
+ proofing_components: nil,
+ )
end
end
end
diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb
index f2f77664dad..195b48d4705 100644
--- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb
+++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb
@@ -70,7 +70,7 @@
get :new
expect(@analytics).to have_received(:track_event).
- with('IdV: Phone OTP delivery Selection Visited')
+ with('IdV: Phone OTP delivery Selection Visited', proofing_components: nil)
end
end
diff --git a/spec/controllers/idv/otp_verification_controller_spec.rb b/spec/controllers/idv/otp_verification_controller_spec.rb
index 6f10ccd9fb0..4bfceb09bce 100644
--- a/spec/controllers/idv/otp_verification_controller_spec.rb
+++ b/spec/controllers/idv/otp_verification_controller_spec.rb
@@ -58,7 +58,10 @@
it 'tracks an analytics event' do
get :show
- expect(@analytics).to have_received(:track_event).with('IdV: phone confirmation otp visited')
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: phone confirmation otp visited',
+ proofing_components: nil,
+ )
end
end
@@ -91,6 +94,7 @@
code_matches: true,
second_factor_attempts_count: 0,
second_factor_locked_at: nil,
+ proofing_components: nil,
}
expect(@analytics).to have_received(:track_event).with(
diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb
index 2d55898a532..c1ebe1ad1cd 100644
--- a/spec/controllers/idv/phone_controller_spec.rb
+++ b/spec/controllers/idv/phone_controller_spec.rb
@@ -90,8 +90,11 @@
it 'logs an event showing that the user wants to choose a different number' do
get :new, params: params
- expect(@analytics).to have_received(:track_event).
- with('IdV: use different phone number', step: step)
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: use different phone number',
+ step: step,
+ proofing_components: nil,
+ )
end
end
@@ -180,6 +183,7 @@
carrier: 'Test Mobile Carrier',
phone_type: :mobile,
types: [],
+ proofing_components: nil,
}
expect(@analytics).to have_received(:track_event).with(
@@ -217,6 +221,7 @@
carrier: 'Test Mobile Carrier',
phone_type: :mobile,
types: [:fixed_or_mobile],
+ proofing_components: nil,
}
expect(@analytics).to have_received(:track_event).with(
@@ -329,6 +334,7 @@
transaction_id: 'address-mock-transaction-id-123',
reference: '',
},
+ proofing_components: nil,
}
expect(@analytics).to receive(:track_event).ordered.with(
@@ -384,6 +390,7 @@
transaction_id: 'address-mock-transaction-id-123',
reference: '',
},
+ proofing_components: nil,
}
expect(@analytics).to receive(:track_event).ordered.with(
diff --git a/spec/controllers/idv/phone_errors_controller_spec.rb b/spec/controllers/idv/phone_errors_controller_spec.rb
index 0c9dd9b9108..be1fb3af57d 100644
--- a/spec/controllers/idv/phone_errors_controller_spec.rb
+++ b/spec/controllers/idv/phone_errors_controller_spec.rb
@@ -74,12 +74,13 @@
end
it 'logs an event' do
- logged_attributes = { type: action, remaining_attempts: 4 }
-
get action
- expect(@analytics).to have_received(:track_event).
- with('IdV: phone error visited', logged_attributes)
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: phone error visited',
+ type: action,
+ remaining_attempts: 4,
+ )
end
end
end
@@ -125,12 +126,13 @@
end
it 'logs an event' do
- logged_attributes = { type: action, remaining_attempts: 4 }
-
get action
- expect(@analytics).to have_received(:track_event).
- with('IdV: phone error visited', logged_attributes)
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: phone error visited',
+ type: action,
+ remaining_attempts: 4,
+ )
end
end
end
@@ -161,15 +163,14 @@
it 'logs an event' do
freeze_time do
throttle_window = Throttle.attempt_window_in_minutes(:proof_address).minutes
- logged_attributes = {
- type: action,
- throttle_expires_at: attempted_at + throttle_window,
- }
get action
- expect(@analytics).to have_received(:track_event).
- with('IdV: phone error visited', logged_attributes)
+ expect(@analytics).to have_received(:track_event).with(
+ 'IdV: phone error visited',
+ type: action,
+ throttle_expires_at: attempted_at + throttle_window,
+ )
end
end
end
diff --git a/spec/controllers/idv/resend_otp_controller_spec.rb b/spec/controllers/idv/resend_otp_controller_spec.rb
index 4fbb0c085d1..6de425a23c4 100644
--- a/spec/controllers/idv/resend_otp_controller_spec.rb
+++ b/spec/controllers/idv/resend_otp_controller_spec.rb
@@ -61,6 +61,7 @@
area_code: '225',
rate_limit_exceeded: false,
telephony_response: instance_of(Telephony::Response),
+ proofing_components: nil,
}
expect(@analytics).to have_received(:track_event).with(
diff --git a/spec/controllers/idv/setup_errors_controller_spec.rb b/spec/controllers/idv/setup_errors_controller_spec.rb
index d8361383490..ae0f3308aa2 100644
--- a/spec/controllers/idv/setup_errors_controller_spec.rb
+++ b/spec/controllers/idv/setup_errors_controller_spec.rb
@@ -10,7 +10,10 @@
it 'renders the show template' do
stub_analytics
- expect(@analytics).to receive(:track_event).with('IdV: Verify setup errors visited')
+ expect(@analytics).to receive(:track_event).with(
+ 'IdV: Verify setup errors visited',
+ proofing_components: nil,
+ )
get :show
diff --git a/spec/factories/service_providers.rb b/spec/factories/service_providers.rb
index 2d5371f3073..bd27d47121e 100644
--- a/spec/factories/service_providers.rb
+++ b/spec/factories/service_providers.rb
@@ -31,6 +31,14 @@
end
end
+ trait :irs do
+ friendly_name { 'An IRS Service Provider' }
+ ial { 2 }
+ active { true }
+ irs_attempts_api_enabled { true }
+ redirect_uris { ['http://localhost:7654/auth/result'] }
+ end
+
factory :service_provider_without_help_text, traits: [:without_help_text]
end
end
diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb
index 59f6413ab7d..8536c0351fd 100644
--- a/spec/features/idv/analytics_spec.rb
+++ b/spec/features/idv/analytics_spec.rb
@@ -30,16 +30,21 @@
'IdV: doc auth verify visited' => { flow_path: 'standard', step: 'verify', step_count: 1 },
'IdV: doc auth verify submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'verify', step_count: 1 },
'IdV: doc auth verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 },
- 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' },
- 'IdV: phone of record visited' => {},
- 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202' },
- 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false },
- 'IdV: review info visited' => {},
- 'IdV: review complete' => { success: true },
- 'IdV: final resolution' => { success: true },
- 'IdV: personal key visited' => {},
- 'IdV: personal key submitted' => {},
- 'Frontend: IdV: show personal key modal' => {},
+ 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, adjudication_reason: 'pass_resolution_and_state_id', stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' },
+ 'IdV: phone of record visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } },
+ 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } },
+ 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: Phone OTP delivery Selection Visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202', proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: phone confirmation otp visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: review info visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: review complete' => { success: true, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: final resolution' => { success: true, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: personal key visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: personal key acknowledgment toggled' => { checked: true, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: personal key submitted' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
}
end
let(:gpo_path_events) do
@@ -65,17 +70,17 @@
'IdV: doc auth verify visited' => { flow_path: 'standard', step: 'verify', step_count: 1 },
'IdV: doc auth verify submitted' => { success: true, errors: {}, flow_path: 'standard', step: 'verify', step_count: 1 },
'IdV: doc auth verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 },
- 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' },
- 'IdV: phone of record visited' => {},
- 'IdV: USPS address letter requested' => { resend: false },
- 'IdV: review info visited' => {},
- 'IdV: USPS address letter enqueued' => { enqueued_at: Time.zone.now, resend: false },
- 'IdV: review complete' => { success: true },
- 'IdV: final resolution' => { success: true },
- 'IdV: personal key visited' => {},
- 'Frontend: IdV: show personal key modal' => {},
- 'IdV: personal key submitted' => {},
- 'IdV: come back later visited' => {},
+ 'IdV: doc auth optional verify_wait submitted' => { success: true, errors: {}, address_edited: false, proofing_results: { exception: nil, timed_out: false, context: { should_proof_state_id: true, adjudication_reason: 'pass_resolution_and_state_id', stages: { resolution: { vendor_name: 'ResolutionMock', errors: {}, exception: nil, success: true, timed_out: false, transaction_id: 'resolution-mock-transaction-id-123', reference: 'aaa-bbb-ccc', can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [] }, state_id: { vendor_name: 'StateIdMock', errors: {}, success: true, timed_out: false, exception: nil, transaction_id: 'state-id-mock-transaction-id-456', verified_attributes: [], state: 'MT', state_id_jurisdiction: 'ND' } } } }, ssn_is_unique: true, step: 'verify_wait_step_show' },
+ 'IdV: phone of record visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } },
+ 'IdV: USPS address letter requested' => { resend: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } },
+ 'IdV: review info visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: USPS address letter enqueued' => { enqueued_at: Time.zone.now.utc, resend: false, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: review complete' => { success: true, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: final resolution' => { success: true, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: personal key visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: personal key acknowledgment toggled' => { checked: true, proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: personal key submitted' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
+ 'IdV: come back later visited' => { proofing_components: { document_check: 'mock', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'gpo_letter' } },
}
end
let(:in_person_path_events) do
@@ -109,21 +114,21 @@
'IdV: in person proofing verify submitted' => { success: true, step: 'verify', flow_path: 'standard', step_count: 1 },
'IdV: in person proofing verify_wait visited' => { flow_path: 'standard', step: 'verify_wait', step_count: 1 },
'IdV: in person proofing optional verify_wait submitted' => { success: true, step: 'verify_wait_step_show', address_edited: false, ssn_is_unique: true },
- 'IdV: phone of record visited' => {},
- 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202' },
- 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false },
- 'IdV: Phone OTP delivery Selection Visited' => {},
- 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms' },
- 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202' },
- 'IdV: phone confirmation otp visited' => {},
- 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil },
- 'IdV: review info visited' => {},
- 'IdV: review complete' => { success: true },
- 'IdV: final resolution' => { success: true },
- 'IdV: personal key visited' => {},
- 'Frontend: IdV: show personal key modal' => {},
- 'IdV: personal key submitted' => {},
- 'IdV: in person ready to verify visited' => {},
+ 'IdV: phone of record visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } },
+ 'IdV: phone confirmation form' => { success: true, errors: {}, phone_type: :mobile, types: [:fixed_or_mobile], carrier: 'Test Mobile Carrier', country_code: 'US', area_code: '202', proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis' } },
+ 'IdV: phone confirmation vendor' => { success: true, errors: {}, vendor: { exception: nil, vendor_name: 'AddressMock', transaction_id: 'address-mock-transaction-id-123', timed_out: false, reference: '' }, new_phone_added: false, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: Phone OTP delivery Selection Visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: Phone OTP Delivery Selection Submitted' => { success: true, otp_delivery_preference: 'sms', proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: phone confirmation otp sent' => { success: true, otp_delivery_preference: :sms, country_code: 'US', area_code: '202', proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: phone confirmation otp visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: phone confirmation otp submitted' => { success: true, code_expired: false, code_matches: true, second_factor_attempts_count: 0, second_factor_locked_at: nil, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: review info visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: review complete' => { success: true, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: final resolution' => { success: true, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: personal key visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: personal key acknowledgment toggled' => { checked: true, proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: personal key submitted' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
+ 'IdV: in person ready to verify visited' => { proofing_components: { document_check: 'usps', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', address_check: 'lexis_nexis_address' } },
}
end
# rubocop:enable Layout/LineLength
@@ -134,7 +139,10 @@
end
before do
- allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics)
+ allow_any_instance_of(ApplicationController).to receive(:analytics) do |controller|
+ fake_analytics.user = controller.analytics_user
+ fake_analytics
+ end
allow_any_instance_of(DocumentProofingJob).to receive(:build_analytics).
and_return(fake_analytics)
end
diff --git a/spec/features/idv/cancel_spec.rb b/spec/features/idv/cancel_spec.rb
index b35cbf3ee98..844eb301de2 100644
--- a/spec/features/idv/cancel_spec.rb
+++ b/spec/features/idv/cancel_spec.rb
@@ -6,11 +6,12 @@
include InteractionHelper
let(:sp) { nil }
- let(:fake_analytics) { FakeAnalytics.new }
+ let(:user) { user_with_2fa }
+ let(:fake_analytics) { FakeAnalytics.new(user: user) }
before do
start_idv_from_sp(sp)
- sign_in_and_2fa_user
+ sign_in_and_2fa_user(user)
complete_doc_auth_steps_before_agreement_step
allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics)
end
@@ -60,6 +61,52 @@
expect(current_path).to eq(idv_doc_auth_step_path(step: :welcome))
end
+ context 'when user has recorded proofing components' do
+ before do
+ complete_agreement_step
+ complete_upload_step
+ complete_document_capture_step
+ end
+
+ it 'includes proofing components in events' do
+ click_link t('links.cancel')
+
+ expect(fake_analytics).to have_logged_event(
+ 'IdV: cancellation visited',
+ step: 'ssn',
+ proofing_components: { document_check: 'mock', document_type: 'state_id' },
+ )
+
+ click_on t('idv.cancel.actions.keep_going')
+
+ expect(fake_analytics).to have_logged_event(
+ 'IdV: cancellation go back',
+ step: 'ssn',
+ proofing_components: { document_check: 'mock', document_type: 'state_id' },
+ )
+
+ click_link t('links.cancel')
+ click_on t('idv.cancel.actions.start_over')
+
+ expect(fake_analytics).to have_logged_event(
+ 'IdV: start over',
+ step: 'ssn',
+ proofing_components: { document_check: 'mock', document_type: 'state_id' },
+ )
+
+ complete_doc_auth_steps_before_ssn_step
+ click_link t('links.cancel')
+
+ click_spinner_button_and_wait t('idv.cancel.actions.account_page')
+
+ expect(fake_analytics).to have_logged_event(
+ 'IdV: cancellation confirmed',
+ step: 'ssn',
+ proofing_components: { document_check: 'mock', document_type: 'state_id' },
+ )
+ end
+ end
+
context 'with an sp' do
let(:sp) { :oidc }
diff --git a/spec/features/idv/inherited_proofing/agreement_step_spec.rb b/spec/features/idv/inherited_proofing/agreement_step_spec.rb
index abf63f6d5f4..9c8633fd32e 100644
--- a/spec/features/idv/inherited_proofing/agreement_step_spec.rb
+++ b/spec/features/idv/inherited_proofing/agreement_step_spec.rb
@@ -41,6 +41,13 @@ def expect_inherited_proofing_first_step
expect_ip_verify_info_step
end
+
+ context 'when clicking on the Cancel link' do
+ it 'redirects to the Cancellation UI' do
+ click_link t('links.cancel')
+ expect(page).to have_current_path(idv_inherited_proofing_cancel_path(step: :agreement))
+ end
+ end
end
context 'when JS is disabled' do
@@ -63,5 +70,12 @@ def expect_inherited_proofing_first_step
expect_ip_verify_info_step
end
+
+ context 'when clicking on the Cancel link' do
+ it 'redirects to the Cancellation UI' do
+ click_link t('links.cancel')
+ expect(page).to have_current_path(idv_inherited_proofing_cancel_path(step: :agreement))
+ end
+ end
end
end
diff --git a/spec/features/idv/inherited_proofing/get_started_step_spec.rb b/spec/features/idv/inherited_proofing/get_started_step_spec.rb
new file mode 100644
index 00000000000..e8ee86e268a
--- /dev/null
+++ b/spec/features/idv/inherited_proofing/get_started_step_spec.rb
@@ -0,0 +1,52 @@
+require 'rails_helper'
+
+feature 'inherited proofing get started' do
+ include IdvHelper
+ include DocAuthHelper
+
+ before do
+ allow(IdentityConfig.store).to receive(:va_inherited_proofing_mock_enabled).and_return true
+ allow_any_instance_of(Idv::InheritedProofingController).to \
+ receive(:va_inherited_proofing?).and_return true
+ allow_any_instance_of(Idv::InheritedProofingController).to \
+ receive(:va_inherited_proofing_auth_code).and_return auth_code
+ end
+
+ let(:auth_code) { Idv::InheritedProofing::Va::Mocks::Service::VALID_AUTH_CODE }
+
+ def expect_ip_get_started_step
+ expect(page).to have_current_path(idv_ip_get_started_step)
+ end
+
+ def expect_inherited_proofing_get_started_step
+ expect(page).to have_current_path(idv_ip_get_started_step)
+ end
+
+ context 'when JS is enabled', :js do
+ before do
+ sign_in_and_2fa_user
+ complete_inherited_proofing_steps_before_get_started_step
+ end
+
+ context 'when clicking on the Cancel link' do
+ it 'redirects to the Cancellation UI' do
+ click_link t('links.cancel')
+ expect(page).to have_current_path(idv_inherited_proofing_cancel_path(step: :get_started))
+ end
+ end
+ end
+
+ context 'when JS is disabled' do
+ before do
+ sign_in_and_2fa_user
+ complete_inherited_proofing_steps_before_get_started_step
+ end
+
+ context 'when clicking on the Cancel link' do
+ it 'redirects to the Cancellation UI' do
+ click_link t('links.cancel')
+ expect(page).to have_current_path(idv_inherited_proofing_cancel_path(step: :get_started))
+ end
+ end
+ end
+end
diff --git a/spec/features/idv/steps/confirmation_step_spec.rb b/spec/features/idv/steps/confirmation_step_spec.rb
index 9dd13ed6c6a..0052d2d5733 100644
--- a/spec/features/idv/steps/confirmation_step_spec.rb
+++ b/spec/features/idv/steps/confirmation_step_spec.rb
@@ -3,19 +3,14 @@
feature 'idv confirmation step', js: true do
include IdvStepHelper
- let(:idv_personal_key_confirmation_enabled) { true }
let(:sp) { nil }
let(:address_verification_mechanism) { :phone }
before do
- allow(IdentityConfig.store).to receive(:idv_personal_key_confirmation_enabled).
- and_return(idv_personal_key_confirmation_enabled)
start_idv_from_sp(sp)
complete_idv_steps_before_confirmation_step(address_verification_mechanism)
end
- it_behaves_like 'personal key page'
-
it 'shows status content for phone verification progress' do
expect(page).to have_content(t('idv.messages.confirm'))
expect_step_indicator_current_step(t('step_indicator.flows.idv.secure_account'))
@@ -29,7 +24,7 @@
it 'allows the user to refresh and still displays the personal key' do
# Visit the current path is the same as refreshing
visit current_path
- expect(page).to have_content(t('headings.personal_key'))
+ expect(page).to have_content(t('forms.personal_key_partial.acknowledgement.header'))
end
context 'verifying by gpo' do
@@ -41,13 +36,21 @@
expect(page).to have_content(t('step_indicator.flows.idv.get_a_letter'))
expect(page).not_to have_content(t('step_indicator.flows.idv.verify_phone_or_address'))
end
-
- it_behaves_like 'personal key page', :gpo
end
context 'with associated sp' do
let(:sp) { :oidc }
+ it "forces the user to click the 'acknowledge' checkbox before proceeding" do
+ click_continue
+
+ expect(page).to have_content(t('forms.validation.required_checkbox'))
+ expect(current_path).to eq(idv_personal_key_path)
+
+ acknowledge_and_confirm_personal_key
+ expect(page).to have_current_path(sign_up_completed_path)
+ end
+
it 'redirects to the completions page and then to the SP' do
acknowledge_and_confirm_personal_key
@@ -57,19 +60,5 @@
expect(current_url).to start_with('http://localhost:7654/auth/result')
end
-
- context 'with personal key confirmation disabled' do
- let(:idv_personal_key_confirmation_enabled) { false }
-
- it 'redirects to the completions page and then to the SP' do
- click_acknowledge_personal_key
-
- expect(page).to have_current_path(sign_up_completed_path)
-
- click_agree_and_continue
-
- expect(current_url).to start_with('http://localhost:7654/auth/result')
- end
- end
end
end
diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb
index 652aabde783..3b90bd99784 100644
--- a/spec/features/idv/steps/review_step_spec.rb
+++ b/spec/features/idv/steps/review_step_spec.rb
@@ -32,7 +32,7 @@
fill_in 'Password', with: user_password
click_idv_continue
- expect(page).to have_content(t('headings.personal_key'))
+ expect(page).to have_content(t('forms.personal_key_partial.acknowledgement.header'))
expect(current_path).to eq idv_personal_key_path
end
diff --git a/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb b/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb
index afb7ae5fd7d..01afa8f84ce 100644
--- a/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb
+++ b/spec/features/two_factor_authentication/backup_code_sign_up_spec.rb
@@ -10,7 +10,7 @@
have_content t('two_factor_authentication.login_options.backup_code_info')
select_2fa_option('backup_code')
- expect(page).to have_link(t('forms.backup_code.download'))
+ expect(page).to have_link(t('components.download_button.label'))
expect(current_path).to eq backup_code_setup_path
click_on 'Continue'
@@ -28,7 +28,7 @@
select_2fa_option('backup_code')
- expect(page).to_not have_link(t('forms.backup_code.download'))
+ expect(page).to_not have_link(t('components.download_button.label'))
end
it 'works for each code and refreshes the codes on the last one' do
diff --git a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb
index 0dd414794e4..9a696bfda9a 100644
--- a/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb
+++ b/spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb
@@ -32,7 +32,7 @@
click_continue
- expect(page).to have_link(t('forms.backup_code.download'))
+ expect(page).to have_link(t('components.download_button.label'))
click_continue
@@ -93,7 +93,7 @@
click_continue
- expect(page).to have_link(t('forms.backup_code.download'))
+ expect(page).to have_link(t('components.download_button.label'))
click_continue
@@ -121,7 +121,7 @@
click_continue
- expect(page).to have_link(t('forms.backup_code.download'))
+ expect(page).to have_link(t('components.download_button.label'))
click_continue
diff --git a/spec/features/users/regenerate_personal_key_spec.rb b/spec/features/users/regenerate_personal_key_spec.rb
index 4ae3df12d96..bd91b322989 100644
--- a/spec/features/users/regenerate_personal_key_spec.rb
+++ b/spec/features/users/regenerate_personal_key_spec.rb
@@ -54,22 +54,11 @@
expect(page).to have_content(t('account.personal_key.get_new'))
click_continue
- expect(page).to have_content(t('headings.personal_key'))
+ expect(page).to have_content(t('forms.personal_key_partial.acknowledgement.header'))
acknowledge_and_confirm_personal_key
expect(user.reload.encrypted_recovery_code_digest).to_not eq old_digest
end
end
-
- describe 'personal key actions and information' do
- before do
- sign_in_and_2fa_user(user)
- visit account_two_factor_authentication_path
- click_on(t('account.links.regenerate_personal_key'), match: :prefer_exact)
- click_continue
- end
-
- it_behaves_like 'personal key page'
- end
end
end
diff --git a/spec/features/users/sign_in_irs_spec.rb b/spec/features/users/sign_in_irs_spec.rb
new file mode 100644
index 00000000000..3af9cbdedea
--- /dev/null
+++ b/spec/features/users/sign_in_irs_spec.rb
@@ -0,0 +1,107 @@
+require 'rails_helper'
+
+feature 'Sign in to the IRS' do
+ before(:all) do
+ @original_capyabara_wait = Capybara.default_max_wait_time
+ Capybara.default_max_wait_time = 5
+ end
+
+ after(:all) do
+ Capybara.default_max_wait_time = @original_capyabara_wait
+ end
+
+ include IdvHelper
+ include SamlAuthHelper
+
+ let(:irs) { create(:service_provider, :irs) }
+ let(:other_irs) { create(:service_provider, :irs) }
+ let(:not_irs) { create(:service_provider, active: true, ial: 2) }
+
+ let(:initiating_service_provider_issuer) { irs.issuer }
+
+ let(:user) do
+ create(
+ :profile, :active, :verified,
+ pii: { first_name: 'John', ssn: '111223333' },
+ initiating_service_provider_issuer: initiating_service_provider_issuer
+ ).user
+ end
+
+ context 'OIDC' do
+ context 'user verified with IRS returns to IRS' do
+ context 'user visits the same IRS SP they verified with' do
+ it "accepts the user's identity as verified" do
+ visit_idp_from_oidc_sp_with_ial2(client_id: irs.issuer)
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq(sign_up_completed_path)
+ end
+ end
+
+ context 'user visits different IRS SP than the one they verified with' do
+ it "accepts the user's identity as verified" do
+ visit_idp_from_oidc_sp_with_ial2(client_id: other_irs.issuer)
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq(sign_up_completed_path)
+ end
+ end
+ end
+
+ context 'user verified with other agency signs in to IRS' do
+ let(:initiating_service_provider_issuer) { not_irs.issuer }
+
+ it 'forces the user to re-verify their identity' do
+ visit_idp_from_oidc_sp_with_ial2(client_id: irs.issuer)
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq(idv_doc_auth_step_path(step: :welcome))
+ end
+ end
+ end
+
+ context 'SAML', js: true do
+ context 'user verified with IRS returns to IRS' do
+ context 'user visits the same IRS SP they verified with' do
+ it "accepts the user's identity as verified" do
+ visit_idp_from_saml_sp_with_ial2(issuer: irs.issuer)
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq(sign_up_completed_path)
+ end
+ end
+
+ context 'user visits different IRS SP than the one they verified with' do
+ it "accepts the user's identity as verified" do
+ visit_idp_from_saml_sp_with_ial2(issuer: other_irs.issuer)
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq(sign_up_completed_path)
+ end
+ end
+ end
+
+ context 'user verified with other agency signs in to IRS' do
+ let(:initiating_service_provider_issuer) { not_irs.issuer }
+
+ it 'forces the user to re-verify their identity' do
+ visit_idp_from_saml_sp_with_ial2(issuer: irs.issuer)
+ fill_in_credentials_and_submit(user.email, user.password)
+ fill_in_code_with_last_phone_otp
+ click_submit_default
+
+ expect(current_path).to eq(idv_doc_auth_step_path(step: :welcome))
+ end
+ end
+ end
+end
diff --git a/spec/features/visitors/i18n_spec.rb b/spec/features/visitors/i18n_spec.rb
index 460db930b48..315be246168 100644
--- a/spec/features/visitors/i18n_spec.rb
+++ b/spec/features/visitors/i18n_spec.rb
@@ -64,11 +64,9 @@
it 'allows user to manually toggle language from dropdown menu', js: true do
visit root_path
- using_wait_time(5) do
- within(:css, '.i18n-desktop-toggle') do
- click_button t('i18n.language', locale: 'en')
- click_link t('i18n.locale.es')
- end
+ within(:css, '.i18n-desktop-toggle') do
+ click_button t('i18n.language', locale: 'en')
+ click_link t('i18n.locale.es')
end
expect(page).to have_content t('headings.sign_in_without_sp', locale: 'es')
diff --git a/spec/javascripts/app/i18n-dropdown-spec.js b/spec/javascripts/app/i18n-dropdown-spec.js
deleted file mode 100644
index 5edee6a0a2c..00000000000
--- a/spec/javascripts/app/i18n-dropdown-spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { setUp } from '../../../app/javascript/app/i18n-dropdown';
-
-describe('i18n-dropdown', () => {
- let tearDown;
-
- beforeEach(() => {
- document.body.innerHTML = `
-
- `;
-
- tearDown = setUp();
- });
-
- afterEach(() => {
- tearDown();
- });
-
- context('with default language', () => {
- it('updates links on initialization', () => {
- expect(document.querySelector('a[lang="en"]').pathname).to.equal('/');
- expect(document.querySelector('a[lang="es"]').pathname).to.equal('/es/');
- expect(document.querySelector('a[lang="fr"]').pathname).to.equal('/fr/');
- });
-
- it('updates links on url change', () => {
- window.history.replaceState(null, '', '/foo');
- window.dispatchEvent(new window.CustomEvent('lg:url-change'));
-
- expect(document.querySelector('a[lang="en"]').pathname).to.equal('/foo');
- expect(document.querySelector('a[lang="es"]').pathname).to.equal('/es/foo');
- expect(document.querySelector('a[lang="fr"]').pathname).to.equal('/fr/foo');
- });
- });
-
- context('with non default language', () => {
- beforeEach(() => {
- document.documentElement.lang = 'fr';
- });
-
- it('updates links on initialization', () => {
- expect(document.querySelector('a[lang="en"]').pathname).to.equal('/');
- expect(document.querySelector('a[lang="es"]').pathname).to.equal('/es/');
- expect(document.querySelector('a[lang="fr"]').pathname).to.equal('/fr/');
- });
-
- it('updates links on url change', () => {
- window.history.replaceState(null, '', '/fr/bar');
- window.dispatchEvent(new window.CustomEvent('lg:url-change'));
-
- expect(document.querySelector('a[lang="en"]').pathname).to.equal('/bar');
- expect(document.querySelector('a[lang="es"]').pathname).to.equal('/es/bar');
- expect(document.querySelector('a[lang="fr"]').pathname).to.equal('/fr/bar');
- });
- });
-});
diff --git a/spec/javascripts/packs/backup-code-analytics-spec.ts b/spec/javascripts/packs/backup-code-analytics-spec.ts
deleted file mode 100644
index e80c5024994..00000000000
--- a/spec/javascripts/packs/backup-code-analytics-spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { screen } from '@testing-library/dom';
-import userEvent from '@testing-library/user-event';
-import { useSandbox } from '@18f/identity-test-helpers';
-import * as analytics from '@18f/identity-analytics';
-
-describe('backupCodeAnalytics', () => {
- const sandbox = useSandbox();
-
- beforeEach(async () => {
- document.body.innerHTML = `
-
-
- Download
- `;
- await import('../../../app/javascript/packs/backup-code-analytics');
- });
-
- afterEach(() => {
- sandbox.restore();
- delete require.cache[require.resolve('../../../app/javascript/packs/backup-code-analytics')];
- });
-
- it('adds an event listener to the download button', async () => {
- const test = sandbox.spy(analytics, 'trackEvent');
- await userEvent.click(screen.getByText('Download'));
-
- sandbox.assert.calledOnce(test);
- sandbox.assert.calledWith(test, 'Multi-Factor Authentication: download backup code');
- });
-});
diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb
index 49a497732ee..8c8025073b5 100644
--- a/spec/jobs/get_usps_proofing_results_job_spec.rb
+++ b/spec/jobs/get_usps_proofing_results_job_spec.rb
@@ -38,6 +38,24 @@
)
end
+ context 'email_analytics_attributes' do
+ before(:each) do
+ stub_request_passed_proofing_results
+ end
+ it 'logs message with email analytics attributes' do
+ freeze_time do
+ job.perform(Time.zone.now)
+ expect(job_analytics).to have_logged_event(
+ 'GetUspsProofingResultsJob: Success or failure email initiated',
+ timestamp: Time.zone.now,
+ user_id: pending_enrollment.user_id,
+ service_provider: pending_enrollment.issuer,
+ delay_time_seconds: 3600,
+ )
+ end
+ end
+ end
+
it 'updates the status of the enrollment and profile appropriately' do
freeze_time do
pending_enrollment.update(
@@ -395,6 +413,10 @@
'GetUspsProofingResultsJob: Enrollment status updated',
reason: 'Successful status update',
)
+ expect(job_analytics).to have_logged_event(
+ 'GetUspsProofingResultsJob: Success or failure email initiated',
+ email_type: 'Success',
+ )
end
end
@@ -417,6 +439,36 @@
expect(job_analytics).to have_logged_event(
'GetUspsProofingResultsJob: Enrollment status updated',
)
+ expect(job_analytics).to have_logged_event(
+ 'GetUspsProofingResultsJob: Success or failure email initiated',
+ email_type: 'Failed',
+ )
+ end
+ end
+
+ context 'when an enrollment fails and fraud is suspected' do
+ before(:each) do
+ stub_request_failed_suspected_fraud_proofing_results
+ end
+
+ it_behaves_like(
+ 'enrollment with a status update',
+ passed: false,
+ status: 'failed',
+ response_json: UspsInPersonProofing::Mock::Fixtures.
+ request_failed_suspected_fraud_proofing_results_response,
+ )
+
+ it 'logs fraud failure details' do
+ job.perform(Time.zone.now)
+
+ expect(job_analytics).to have_logged_event(
+ 'GetUspsProofingResultsJob: Enrollment status updated',
+ )
+ expect(job_analytics).to have_logged_event(
+ 'GetUspsProofingResultsJob: Success or failure email initiated',
+ email_type: 'Failed fraud suspected',
+ )
end
end
diff --git a/spec/jobs/resolution_proofing_job_spec.rb b/spec/jobs/resolution_proofing_job_spec.rb
index 637d881bf08..cefc4cd661d 100644
--- a/spec/jobs/resolution_proofing_job_spec.rb
+++ b/spec/jobs/resolution_proofing_job_spec.rb
@@ -270,13 +270,15 @@
to eq([])
# result[:context][:stages][:state_id]
- expect(result_context_stages_state_id[:vendor_name]).to eq('UnsupportedJurisdiction')
+ expect(result_context_stages_state_id[:vendor_name]).to eq('aamva:state_id')
expect(result_context_stages_state_id[:errors]).to eq({})
expect(result_context_stages_state_id[:exception]).to eq(nil)
expect(result_context_stages_state_id[:success]).to eq(true)
expect(result_context_stages_state_id[:timed_out]).to eq(false)
- expect(result_context_stages_state_id[:transaction_id]).to eq('')
- expect(result_context_stages_state_id[:verified_attributes]).to eq([])
+ expect(result_context_stages_state_id[:transaction_id]).to eq('1234-abcd-efgh')
+ expect(result_context_stages_state_id[:verified_attributes]).to eq(
+ ['address', 'state_id_number', 'state_id_type', 'dob', 'last_name', 'first_name'],
+ )
# result[:context][:stages][:threatmetrix]
expect(result_context_stages_threatmetrix[:client]).to eq('DdpMock')
@@ -559,11 +561,12 @@
end
end
- context 'does not call state id with an unsuccessful response from the proofer' do
+ context 'does call state id with an unsuccessful response from the proofer' do
it 'posts back to the callback url' do
expect(resolution_proofer).to receive(:proof).
and_return(Proofing::Result.new(exception: 'error'))
- expect(state_id_proofer).not_to receive(:proof)
+ expect(state_id_proofer).to receive(:proof).
+ and_return(Proofing::Result.new)
perform
end
@@ -639,11 +642,12 @@
end
end
- context 'does not call state id with an unsuccessful response from the proofer' do
+ context 'does call state id with an unsuccessful response from the proofer' do
it 'posts back to the callback url' do
expect(resolution_proofer).to receive(:proof).
and_return(Proofing::Result.new(exception: 'error'))
- expect(state_id_proofer).not_to receive(:proof)
+ expect(state_id_proofer).to receive(:proof).
+ and_return(Proofing::Result.new)
perform
end
diff --git a/spec/jobs/threat_metrix_js_verification_job_spec.rb b/spec/jobs/threat_metrix_js_verification_job_spec.rb
index c8ef5a7e90e..473d7aac72f 100644
--- a/spec/jobs/threat_metrix_js_verification_job_spec.rb
+++ b/spec/jobs/threat_metrix_js_verification_job_spec.rb
@@ -78,34 +78,51 @@
)
end
- context 'when collecting is disabled' do
- let(:proofing_device_profiling_collecting_enabled) { false }
- it 'does not run' do
- expect(instance.logger).not_to receive(:info)
- perform
- end
- end
-
context 'when certificate is not configured' do
let(:threatmetrix_signing_certificate) { '' }
- it 'does not run' do
- expect(instance.logger).not_to receive(:info)
- perform
+ it 'logs an error_message but does not raise' do
+ expect(instance.logger).to receive(:info) do |message|
+ expect(JSON.parse(message, symbolize_names: true)).to include(
+ name: 'ThreatMetrixJsVerification',
+ error_class: 'ThreatMetrixJsVerificationJob::ConfigurationError',
+ error_message: 'JS signing certificate is missing',
+ )
+ end
+
+ expect { perform }.to_not raise_error
end
end
context 'when certificate is expired' do
let(:threatmetrix_signing_cert_expiry) { Time.zone.now - 3600 }
- it 'raises an error' do
- expect { perform }.to raise_error
+ it 'logs an error_message, and raises' do
+ expect(instance.logger).to receive(:info) do |message|
+ expect(JSON.parse(message, symbolize_names: true)).to include(
+ name: 'ThreatMetrixJsVerification',
+ error_class: 'RuntimeError',
+ error_message: 'JS signing certificate is expired',
+ )
+ end
+
+ expect { perform }.to raise_error(RuntimeError, 'JS signing certificate is expired')
end
end
- context 'when org id is not configured' do
- let(:threatmetrix_org_id) { nil }
- it 'does not run' do
- expect(instance.logger).not_to receive(:info)
- perform
+ context 'error that is not a configuration error' do
+ before do
+ stub_request(:get, "https://h.online-metrix.net/fp/tags.js?org_id=#{threatmetrix_org_id}&session_id=#{threatmetrix_session_id}").
+ to_timeout
+ end
+
+ it 'logs an error_message, and raises' do
+ expect(instance.logger).to receive(:info) do |message|
+ expect(JSON.parse(message, symbolize_names: true)).to include(
+ name: 'ThreatMetrixJsVerification',
+ error_class: 'Faraday::ConnectionFailed',
+ )
+ end
+
+ expect { perform }.to raise_error(Faraday::ConnectionFailed)
end
end
diff --git a/spec/lib/aws/ses_spec.rb b/spec/lib/aws/ses_spec.rb
index 6d22ee5c12c..094f6405c16 100644
--- a/spec/lib/aws/ses_spec.rb
+++ b/spec/lib/aws/ses_spec.rb
@@ -35,7 +35,7 @@
it 'sets the message id on the mail argument' do
subject.deliver!(mail)
- expect(mail.message_id).to eq('123abc@email.amazonses.com')
+ expect(mail.header['ses-message-id'].value).to eq('123abc')
end
it 'retries timed out requests' do
diff --git a/spec/mailers/previews/user_mailer_preview_spec.rb b/spec/mailers/previews/user_mailer_preview_spec.rb
index 5c6ac3ed4be..1ce068b8a33 100644
--- a/spec/mailers/previews/user_mailer_preview_spec.rb
+++ b/spec/mailers/previews/user_mailer_preview_spec.rb
@@ -13,7 +13,7 @@
it 'has a preview method for each mailer method' do
mailer_methods = UserMailer.instance_methods(false)
preview_methods = UserMailerPreview.instance_methods(false)
- mailer_helper_methods = [:email_address, :user, :validate_user_and_email_address]
+ mailer_helper_methods = [:email_address, :user, :validate_user_and_email_address, :add_metadata]
expect(mailer_methods - mailer_helper_methods - preview_methods).to be_empty
end
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index ec2287933be..1c377097d25 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -670,4 +670,31 @@ def expect_email_body_to_have_help_and_contact_links
)
end
end
+
+ # rubocop:disable IdentityIdp/MailLaterLinter
+ describe '#deliver_later' do
+ it 'does not queue email if it potentially contains sensitive value' do
+ user = create(:user)
+ mailer = UserMailer.with(
+ user: user,
+ email_address: user.email_addresses.first,
+ ).add_email(Idp::Constants::MOCK_IDV_APPLICANT[:last_name])
+ expect { mailer.deliver_later }.to raise_error(
+ MailerSensitiveInformationChecker::SensitiveValueError,
+ )
+ end
+
+ it 'does not queue email if it potentially contains sensitive keys' do
+ user = create(:user)
+ mailer = UserMailer.with(user: user, email_address: user.email_addresses.first).add_email(
+ {
+ first_name: nil,
+ },
+ )
+ expect { mailer.deliver_later }.to raise_error(
+ MailerSensitiveInformationChecker::SensitiveKeyError,
+ )
+ end
+ end
+ # rubocop:enable IdentityIdp/MailLaterLinter
end
diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb
index 5fc50c28631..37f08f9cb18 100644
--- a/spec/services/idv/agent_spec.rb
+++ b/spec/services/idv/agent_spec.rb
@@ -33,7 +33,7 @@
let(:document_capture_session) { DocumentCaptureSession.new(result_id: SecureRandom.hex) }
context 'proofing state_id enabled' do
- it 'does not proof state_id if resolution fails' do
+ it 'still proofs state_id if resolution fails' do
agent = Idv::Agent.new(
Idp::Constants::MOCK_IDV_APPLICANT.merge(uuid: user.uuid, ssn: '444-55-6666'),
)
@@ -49,7 +49,7 @@
result = document_capture_session.load_proofing_result.result
expect(result[:errors][:ssn]).to eq ['Unverified SSN.']
- expect(result[:context][:stages][:state_id][:vendor_name]).to eq 'UnsupportedJurisdiction'
+ expect(result[:context][:stages][:state_id][:vendor_name]).to eq 'StateIdMock'
end
it 'does proof state_id if resolution succeeds' do
@@ -82,7 +82,7 @@
)
agent.proof_resolution(
document_capture_session,
- should_proof_state_id: true,
+ should_proof_state_id: false,
trace_id: trace_id,
user_id: user.id,
threatmetrix_session_id: nil,
diff --git a/spec/services/idv/analytics_events_enhancer_spec.rb b/spec/services/idv/analytics_events_enhancer_spec.rb
new file mode 100644
index 00000000000..61cff7a8831
--- /dev/null
+++ b/spec/services/idv/analytics_events_enhancer_spec.rb
@@ -0,0 +1,66 @@
+require 'rails_helper'
+
+describe Idv::AnalyticsEventsEnhancer do
+ class ExampleAnalytics
+ include AnalyticsEvents
+ prepend Idv::AnalyticsEventsEnhancer
+
+ def idv_final(**kwargs)
+ @called_kwargs = kwargs
+ end
+
+ attr_reader :user, :called_kwargs
+
+ def initialize(user:)
+ @user = user
+ end
+ end
+
+ let(:user) { build(:user) }
+ let(:analytics) { ExampleAnalytics.new(user: user) }
+
+ it 'includes decorated methods' do
+ expect(analytics.methods).to include(*described_class::DECORATED_METHODS)
+ expect(
+ analytics.methods.
+ intersection(described_class::DECORATED_METHODS).
+ map { |method| analytics.method(method).source_location.first }.
+ uniq,
+ ).to eq([Idv::AnalyticsEventsEnhancer.const_source_location(:DECORATED_METHODS).first])
+ end
+
+ it 'calls analytics method with original and decorated attributes' do
+ analytics.idv_final(extra: true)
+
+ expect(analytics.called_kwargs).to eq(extra: true, proofing_components: nil)
+ end
+
+ context 'with anonymous analytics user' do
+ let(:user) { AnonymousUser.new }
+
+ it 'calls analytics method with original and decorated attributes' do
+ analytics.idv_final(extra: true)
+
+ expect(analytics.called_kwargs).to eq(extra: true, proofing_components: nil)
+ end
+ end
+
+ context 'with proofing component' do
+ let(:proofing_components) do
+ ProofingComponent.new(source_check: Idp::Constants::Vendors::AAMVA)
+ end
+
+ before do
+ user.proofing_component = proofing_components
+ end
+
+ it 'calls analytics method with original and decorated attributes' do
+ analytics.idv_final(extra: true)
+
+ expect(analytics.called_kwargs).to match(
+ extra: true,
+ proofing_components: kind_of(Idv::ProofingComponentsLogging),
+ )
+ end
+ end
+end
diff --git a/spec/services/idv/proofing_components_logging_spec.rb b/spec/services/idv/proofing_components_logging_spec.rb
new file mode 100644
index 00000000000..6233d3563ac
--- /dev/null
+++ b/spec/services/idv/proofing_components_logging_spec.rb
@@ -0,0 +1,12 @@
+require 'rails_helper'
+
+describe Idv::ProofingComponentsLogging do
+ describe '#as_json' do
+ it 'returns hash with nil values omitted' do
+ proofing_components = ProofingComponent.new(document_check: Idp::Constants::Vendors::AAMVA)
+ logging = described_class.new(proofing_components)
+
+ expect(logging.as_json).to eq('document_check' => Idp::Constants::Vendors::AAMVA)
+ end
+ end
+end
diff --git a/spec/services/irs_attempts_api/attempt_event_spec.rb b/spec/services/irs_attempts_api/attempt_event_spec.rb
index 547219e62fc..007dea255da 100644
--- a/spec/services/irs_attempts_api/attempt_event_spec.rb
+++ b/spec/services/irs_attempts_api/attempt_event_spec.rb
@@ -32,7 +32,13 @@
it 'returns a JWE for the event' do
jwe = subject.to_jwe
+ header_str, *_rest = JWE::Serialization::Compact.decode(jwe)
+ headers = JSON.parse(header_str)
+
+ expect(headers['alg']).to eq('RSA-OAEP')
+
decrypted_jwe_payload = JWE.decrypt(jwe, irs_attempt_api_private_key)
+
token = JSON.parse(decrypted_jwe_payload)
expect(token['iss']).to eq('http://www.example.com/')
diff --git a/spec/services/proofing/resolution_result_adjudicator_spec.rb b/spec/services/proofing/resolution_result_adjudicator_spec.rb
new file mode 100644
index 00000000000..e2234d8f7e1
--- /dev/null
+++ b/spec/services/proofing/resolution_result_adjudicator_spec.rb
@@ -0,0 +1,97 @@
+require 'rails_helper'
+
+RSpec.describe Proofing::ResolutionResultAdjudicator do
+ let(:resolution_success) { true }
+ let(:can_pass_with_additional_verification) { false }
+ let(:attributes_requiring_additional_verification) { [] }
+ let(:resolution_result) do
+ Proofing::ResolutionResult.new(
+ success: resolution_success,
+ errors: {},
+ exception: nil,
+ vendor_name: 'test-resolution-vendor',
+ failed_result_can_pass_with_additional_verification: can_pass_with_additional_verification,
+ attributes_requiring_additional_verification: attributes_requiring_additional_verification,
+ )
+ end
+
+ let(:state_id_success) { true }
+ let(:state_id_verified_attributes) { [] }
+ let(:state_id_result) do
+ Proofing::StateIdResult.new(
+ success: state_id_success,
+ errors: {},
+ exception: nil,
+ vendor_name: 'test-state-id-vendor',
+ verified_attributes: state_id_verified_attributes,
+ )
+ end
+
+ let(:should_proof_state_id) { true }
+
+ subject do
+ described_class.new(
+ resolution_result: resolution_result,
+ state_id_result: state_id_result,
+ should_proof_state_id: should_proof_state_id,
+ )
+ end
+
+ describe '#adjudicated_result' do
+ context 'AAMVA and LexisNexis both pass' do
+ it 'returns a successful response' do
+ result = subject.adjudicated_result
+
+ expect(result.success?).to eq(true)
+ end
+ end
+
+ context 'LexisNexis fails with attributes covered by AAMVA response' do
+ let(:resolution_success) { false }
+ let(:can_pass_with_additional_verification) { true }
+ let(:attributes_requiring_additional_verification) { [:dob] }
+ let(:state_id_verified_attributes) { [:dob, :address] }
+
+ it 'returns a successful response' do
+ result = subject.adjudicated_result
+
+ expect(result.success?).to eq(true)
+ end
+ end
+
+ context 'LexisNexis fails with attributes not covered by AAMVA response' do
+ let(:resolution_success) { false }
+ let(:can_pass_with_additional_verification) { true }
+ let(:attributes_requiring_additional_verification) { [:address] }
+ let(:state_id_verified_attributes) { [:dob] }
+
+ it 'returns a failed response' do
+ result = subject.adjudicated_result
+
+ expect(result.success?).to eq(false)
+ end
+ end
+
+ context 'LexisNexis fails and AAMVA state is unsupported' do
+ let(:should_proof_state_id) { false }
+ let(:resolution_success) { false }
+
+ it 'returns a failed response' do
+ result = subject.adjudicated_result
+
+ expect(result.success?).to eq(false)
+ end
+ end
+
+ context 'LexisNexis passes and AAMVA fails' do
+ let(:resolution_success) { true }
+ let(:state_id_success) { false }
+
+ it 'returns a failed response' do
+ result = subject.adjudicated_result
+
+ expect(result.success?).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/support/fake_analytics.rb b/spec/support/fake_analytics.rb
index e22358bea44..bbfa2cbc608 100644
--- a/spec/support/fake_analytics.rb
+++ b/spec/support/fake_analytics.rb
@@ -1,7 +1,8 @@
-class FakeAnalytics
+class FakeAnalytics < Analytics
PiiDetected = Class.new(StandardError)
include AnalyticsEvents
+ prepend Idv::AnalyticsEventsEnhancer
module PiiAlerter
def track_event(event, original_attributes = {})
@@ -71,9 +72,11 @@ def track_event(event, original_attributes = {})
prepend PiiAlerter
attr_reader :events
+ attr_accessor :user
- def initialize
+ def initialize(user: AnonymousUser.new)
@events = Hash.new
+ @user = user
end
def track_event(event, attributes = {})
@@ -101,12 +104,10 @@ def browser_attributes
attributes ||= {}
match do |actual|
- expect(actual).to be_kind_of(FakeAnalytics)
-
if RSpec::Support.is_a_matcher?(attributes)
expect(actual.events[event_name]).to include(attributes)
else
- expect(actual.events[event_name]).to(be_any { |event| attributes <= event })
+ expect(actual.events[event_name]).to(be_any { |event| attributes.as_json <= event.as_json })
end
end
diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb
index 238e1890048..672e7add34c 100644
--- a/spec/support/features/idv_helper.rb
+++ b/spec/support/features/idv_helper.rb
@@ -54,25 +54,7 @@ def choose_idv_otp_delivery_method_voice
def visit_idp_from_sp_with_ial2(sp, **extra)
if sp == :saml
- saml_overrides = {
- issuer: sp1_issuer,
- authn_context: [
- Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF,
- "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}first_name:last_name email, ssn",
- "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}phone",
- ],
- security: {
- embed_sign: false,
- },
- }
- if javascript_enabled?
- service_provider = ServiceProvider.find_by(issuer: sp1_issuer)
- acs_url = URI.parse(service_provider.acs_url)
- acs_url.host = page.server.host
- acs_url.port = page.server.port
- service_provider.update(acs_url: acs_url.to_s)
- end
- visit_saml_authn_request_url(overrides: saml_overrides)
+ visit_idp_from_saml_sp_with_ial2
elsif sp == :oidc
@state = SecureRandom.hex
@client_id = sp_oidc_issuer
@@ -97,6 +79,28 @@ def service_provider_issuer(sp)
end
end
+ def visit_idp_from_saml_sp_with_ial2(issuer: sp1_issuer)
+ saml_overrides = {
+ issuer: issuer,
+ authn_context: [
+ Saml::Idp::Constants::IAL2_AUTHN_CONTEXT_CLASSREF,
+ "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}first_name:last_name email, ssn",
+ "#{Saml::Idp::Constants::REQUESTED_ATTRIBUTES_CLASSREF}phone",
+ ],
+ security: {
+ embed_sign: false,
+ },
+ }
+ if javascript_enabled?
+ service_provider = ServiceProvider.find_by(issuer: sp1_issuer)
+ acs_url = URI.parse(service_provider.acs_url)
+ acs_url.host = page.server.host
+ acs_url.port = page.server.port
+ service_provider.update(acs_url: acs_url.to_s)
+ end
+ visit_saml_authn_request_url(overrides: saml_overrides)
+ end
+
def visit_idp_from_oidc_sp_with_ial2(
client_id: sp_oidc_issuer,
state: SecureRandom.hex,
diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb
index 8d4dbc9704d..a7c73c4588f 100644
--- a/spec/support/features/session_helper.rb
+++ b/spec/support/features/session_helper.rb
@@ -323,12 +323,11 @@ def sign_in_with_totp_enabled_user
def acknowledge_and_confirm_personal_key
click_acknowledge_personal_key
-
- page.find(':focus').fill_in with: scrape_personal_key
- within('[role=dialog]') { click_continue }
end
def click_acknowledge_personal_key
+ checkbox_header = t('forms.validation.required_checkbox')
+ find('label', text: /#{checkbox_header}/).click
click_continue
end
diff --git a/spec/support/shared_examples_for_personal_keys.rb b/spec/support/shared_examples_for_personal_keys.rb
deleted file mode 100644
index c271dbdc0ed..00000000000
--- a/spec/support/shared_examples_for_personal_keys.rb
+++ /dev/null
@@ -1,85 +0,0 @@
-require 'rbconfig'
-
-shared_examples_for 'personal key page' do |address_verification_mechanism|
- include PersonalKeyHelper
- include JavascriptDriverHelper
-
- describe 'confirmation modal' do
- before do
- click_continue if javascript_enabled?
- end
-
- it 'displays modal content' do
- expect(page).to have_content t('forms.personal_key.title')
- expect(page).to have_content t('forms.personal_key.instructions')
- end
- end
-
- context 'with javascript enabled', js: true do
- before do
- page.driver.browser.execute_cdp(
- 'Browser.grantPermissions',
- origin: page.server_url,
- permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
- )
- end
-
- after do
- page.driver.browser.execute_cdp('Browser.resetPermissions')
- end
-
- it 'allows a user to copy the code into the confirmation modal' do
- click_on t('components.clipboard_button.label')
- copied_text = page.evaluate_async_script('navigator.clipboard.readText().then(arguments[0])')
-
- expect(copied_text).to eq(scrape_personal_key)
-
- click_continue
- mod = mac? ? :meta : :control
- page.find(':focus').send_keys [mod, 'v']
-
- path_before_submit = current_path
- within('[role=dialog]') { click_on t('forms.buttons.continue') }
- expect(current_path).not_to eq path_before_submit
- end
-
- it 'validates as case-insensitive, crockford-normalized, length-limited, dash-flexible' do
- code_segments = scrape_personal_key.split('-')
-
- click_acknowledge_personal_key
- input = page.find(':focus')
-
- # Validate as incorrect
- input.fill_in with: 'wrong!'
- within('[role=dialog]') { click_on t('forms.buttons.continue') }
- expect(page).to have_content(t('users.personal_key.confirmation_error'))
-
- # Validate as correct, with formatting variations...
-
- # Include dash between some segments and not others
- code = code_segments[0..1].join('-') + code_segments[2..3].join
-
- # Randomize case
- code = code.chars.map { |c| (rand 2) == 0 ? c.downcase : c.upcase }.join
-
- # De-normalize Crockford encoding
- code = code.sub('1', 'l').sub('0', 'O')
-
- # Add extra characters
- code += 'abc123qwerty'
-
- input.fill_in with: code
-
- within('[role=dialog]') { click_on t('forms.buttons.continue') }
- if address_verification_mechanism == :gpo
- expect(current_path).to eq idv_come_back_later_path
- else
- expect(current_path).to eq account_path
- end
- end
- end
-
- def mac?
- RbConfig::CONFIG['host_os'].match? 'darwin'
- end
-end
diff --git a/spec/views/idv/inherited_proofing/agreement.html.erb_spec.rb b/spec/views/idv/inherited_proofing/agreement.html.erb_spec.rb
index d3a8a0fca59..d8f6ac9c379 100644
--- a/spec/views/idv/inherited_proofing/agreement.html.erb_spec.rb
+++ b/spec/views/idv/inherited_proofing/agreement.html.erb_spec.rb
@@ -1,14 +1,16 @@
require 'rails_helper'
describe 'idv/inherited_proofing/agreement.html.erb' do
+ include Devise::Test::ControllerHelpers
+
let(:flow_session) { {} }
- let(:sp_name) { nil }
- let(:locale) { nil }
+ let(:sp_name) { 'test' }
before do
allow(view).to receive(:decorated_session).and_return(@decorated_session)
allow(view).to receive(:flow_session).and_return(flow_session)
allow(view).to receive(:url_for).and_return('https://www.example.com/')
+ allow(view).to receive(:user_signing_up?).and_return(true)
end
it 'renders the Continue button' do
@@ -17,6 +19,12 @@
expect(rendered).to have_button(t('inherited_proofing.buttons.continue'))
end
+ it 'renders the Cancel link' do
+ render template: 'idv/inherited_proofing/agreement'
+
+ expect(rendered).to have_link(t('links.cancel_account_creation'))
+ end
+
context 'with or without service provider' do
it 'renders content' do
render template: 'idv/inherited_proofing/agreement'
diff --git a/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb b/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb
index 5f38a4609fe..d1b9258ba62 100644
--- a/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb
+++ b/spec/views/idv/inherited_proofing/get_started.html.erb_spec.rb
@@ -1,9 +1,10 @@
require 'rails_helper'
describe 'idv/inherited_proofing/get_started.html.erb' do
+ include Devise::Test::ControllerHelpers
+
let(:flow_session) { {} }
- let(:sp_name) { nil }
- let(:locale) { nil }
+ let(:sp_name) { 'test' }
before do
@decorated_session = instance_double(ServiceProviderSessionDecorator)
@@ -11,6 +12,8 @@
allow(view).to receive(:decorated_session).and_return(@decorated_session)
allow(view).to receive(:flow_session).and_return(flow_session)
allow(view).to receive(:url_for).and_return('https://www.example.com/')
+ allow(view).to receive(:user_fully_authenticated?).and_return(true)
+ allow(view).to receive(:user_signing_up?).and_return(true)
end
it 'renders the Continue button' do
@@ -19,30 +22,28 @@
expect(rendered).to have_button(t('inherited_proofing.buttons.continue'))
end
- describe 'I18n' do
- before do
- view.locale = locale
+ it 'renders the Cancel link' do
+ render template: 'idv/inherited_proofing/get_started'
+ expect(rendered).to have_link(t('links.cancel_account_creation'))
+ end
+
+ context 'with or without service provider' do
+ it 'renders troubleshooting options' do
render template: 'idv/inherited_proofing/get_started'
- end
- context 'with or without service provider' do
- it 'renders troubleshooting options' do
- render template: 'idv/inherited_proofing/get_started'
-
- expect(rendered).to have_link(t('inherited_proofing.troubleshooting.options.get_va_help'))
- expect(rendered).to have_link(
- t('inherited_proofing.troubleshooting.options.learn_more_phone_or_mail'),
- )
- expect(rendered).not_to have_link(nil, href: idv_inherited_proofing_return_to_sp_path)
- expect(rendered).to have_link(t('inherited_proofing.troubleshooting.options.get_va_help'))
- expect(rendered).to have_link(
- t('inherited_proofing.troubleshooting.options.learn_more_phone_or_mail'),
- )
- expect(rendered).to have_link(
- t('idv.troubleshooting.options.get_help_at_sp', sp_name: sp_name),
- )
- end
+ expect(rendered).to have_link(t('inherited_proofing.troubleshooting.options.get_va_help'))
+ expect(rendered).to have_link(
+ t('inherited_proofing.troubleshooting.options.learn_more_phone_or_mail'),
+ )
+ expect(rendered).not_to have_link(nil, href: idv_inherited_proofing_return_to_sp_path)
+ expect(rendered).to have_link(t('inherited_proofing.troubleshooting.options.get_va_help'))
+ expect(rendered).to have_link(
+ t('inherited_proofing.troubleshooting.options.learn_more_phone_or_mail'),
+ )
+ expect(rendered).to have_link(
+ t('idv.troubleshooting.options.get_help_at_sp', sp_name: sp_name),
+ )
end
end
end
diff --git a/spec/views/shared/_personal_key.html.erb_spec.rb b/spec/views/shared/_personal_key.html.erb_spec.rb
index 594c9060d64..1d23828ceb4 100644
--- a/spec/views/shared/_personal_key.html.erb_spec.rb
+++ b/spec/views/shared/_personal_key.html.erb_spec.rb
@@ -29,29 +29,4 @@ def self.decode(value)
expect(data_uri.data).to eq(personal_key)
end
end
-
- describe 'continue button' do
- let(:idv_personal_key_confirmation_enabled) { nil }
-
- before do
- allow(FeatureManagement).to receive(:idv_personal_key_confirmation_enabled?).
- and_return(idv_personal_key_confirmation_enabled)
- end
-
- context 'without idv personal key confirmation' do
- let(:idv_personal_key_confirmation_enabled) { false }
-
- it 'renders button with [data-toggle="skip"]' do
- expect(rendered).to have_css('[data-toggle="skip"]')
- end
- end
-
- context 'with idv personal key confirmation' do
- let(:idv_personal_key_confirmation_enabled) { true }
-
- it 'renders button with [data-toggle="modal"]' do
- expect(rendered).to have_css('[data-toggle="modal"]')
- end
- end
- end
end
diff --git a/spec/views/users/backup_code_setup/create.html.erb_spec.rb b/spec/views/users/backup_code_setup/create.html.erb_spec.rb
index 3e1a4a419ba..4600983b259 100644
--- a/spec/views/users/backup_code_setup/create.html.erb_spec.rb
+++ b/spec/views/users/backup_code_setup/create.html.erb_spec.rb
@@ -29,7 +29,7 @@ def self.decode(value)
download_link = doc.at_css('a[download]')
data_uri = URI::Data.new(download_link[:href])
- expect(rendered).to have_content((t('forms.backup_code.download')))
+ expect(rendered).to have_content((t('components.download_button.label')))
expect(data_uri.content_type).to eq('text/plain')
expect(data_uri.data).to eq(@codes.join("\n"))
end
diff --git a/tsconfig.json b/tsconfig.json
index 29b460c420f..b035599fc9e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,7 +28,6 @@
"**/fixtures",
"**/*.spec.js",
"app/javascript/packs/application.js",
- "app/javascript/packs/personal-key-page-controller.js",
"app/javascript/packs/pw-strength.js",
"app/javascript/packs/reactivate-account-modal.js",
"app/javascript/packs/saml-post.js",