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 + + + +``` 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 = ` + + + `; + 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 = ` + + + + `; + 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 = ` + + + `; + 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. - - - - ); -} -``` 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 ( - - -
-
-
-
-
-
- {children} -
-
-
-
-
-
-
-
- ); -} - -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 @@ - 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",