diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 125a7cc0dba..bed401f13ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,11 @@ pull request is about. - If the pull request is in response to a Jira ticket, include the ticket ID in the commit title (e.g. "LG-1234 Add the stuff to the thing") +- Include a changelog message which describes the changes in human-readable +terms. These messages are included in release notes, so they should be easy to +understand for our partners and users. In the rare case that a change should +not be included in release notes, add `[skip changelog]` to the commit. + Example: ``` @@ -41,8 +46,15 @@ and making development less efficient. meant to change, and so that only one database call is made. - To prevent the data from being wiped out after each spec, configure Database Cleaner to ignore those static tables. + +changelog: Internal, Automated Testing, Improve performance of test suite ``` +Refer to the [changelog check script] for a complete list of acceptable +changelog categories. + +[changelog check script]: https://github.com/18F/identity-idp/blob/main/scripts/changelog_check.rb + ### Style, Readability, and OO - Rubocop or Reek offenses are not disabled unless they are false positives. If you're not sure, please ask a teammate. diff --git a/app/components/clipboard_button_component.ts b/app/components/clipboard_button_component.ts index b5e54ba0492..c4b163b2086 100644 --- a/app/components/clipboard_button_component.ts +++ b/app/components/clipboard_button_component.ts @@ -1 +1 @@ -import '@18f/identity-clipboard-button'; +import '@18f/identity-clipboard-button/clipboard-button-element'; diff --git a/app/components/password_toggle_component.ts b/app/components/password_toggle_component.ts index f5e3ff3cfc3..066bffe41f7 100644 --- a/app/components/password_toggle_component.ts +++ b/app/components/password_toggle_component.ts @@ -1,3 +1 @@ -import { PasswordToggleElement } from '@18f/identity-password-toggle-element'; - -customElements.define('lg-password-toggle', PasswordToggleElement); +import '@18f/identity-password-toggle/password-toggle-element'; diff --git a/app/components/print_button_component.ts b/app/components/print_button_component.ts index e4215f01c5f..08ff1a6a10c 100644 --- a/app/components/print_button_component.ts +++ b/app/components/print_button_component.ts @@ -1 +1 @@ -import '@18f/identity-print-button'; +import '@18f/identity-print-button/print-button-element'; diff --git a/app/components/spinner_button_component.ts b/app/components/spinner_button_component.ts index ecff046473e..08b958ca78e 100644 --- a/app/components/spinner_button_component.ts +++ b/app/components/spinner_button_component.ts @@ -1 +1 @@ -import '@18f/identity-spinner-button'; +import '@18f/identity-spinner-button/spinner-button-element'; diff --git a/app/components/validated_field_component.js b/app/components/validated_field_component.js index 2a284972c93..26f22265fa6 100644 --- a/app/components/validated_field_component.js +++ b/app/components/validated_field_component.js @@ -1 +1 @@ -import '@18f/identity-validated-field'; +import '@18f/identity-validated-field/validated-field-element'; diff --git a/app/controllers/api/verify/complete_controller.rb b/app/controllers/api/verify/password_confirm_controller.rb similarity index 94% rename from app/controllers/api/verify/complete_controller.rb rename to app/controllers/api/verify/password_confirm_controller.rb index 0cfd610a172..d05f2f40e4c 100644 --- a/app/controllers/api/verify/complete_controller.rb +++ b/app/controllers/api/verify/password_confirm_controller.rb @@ -1,6 +1,6 @@ module Api module Verify - class CompleteController < Api::BaseController + class PasswordConfirmController < Api::BaseController def create result, personal_key = Api::ProfileCreationForm.new( password: verify_params[:password], diff --git a/app/controllers/frontend_log_controller.rb b/app/controllers/frontend_log_controller.rb index d20938a9dad..feb7d61728b 100644 --- a/app/controllers/frontend_log_controller.rb +++ b/app/controllers/frontend_log_controller.rb @@ -10,6 +10,7 @@ class FrontendLogController < ApplicationController 'IdV: personal key submitted' => :idv_personal_key_submitted, 'IdV: personal key confirm visited' => :idv_personal_key_confirm_visited, 'IdV: personal key confirm submitted' => :idv_personal_key_confirm_submitted, + 'IdV: download personal key' => :idv_personal_key_downloaded, }.transform_values { |method| AnalyticsEvents.instance_method(method) }.freeze def create diff --git a/app/controllers/idv/forgot_password_controller.rb b/app/controllers/idv/forgot_password_controller.rb index 2ce4cb84b98..3f6bd773491 100644 --- a/app/controllers/idv/forgot_password_controller.rb +++ b/app/controllers/idv/forgot_password_controller.rb @@ -6,11 +6,11 @@ class ForgotPasswordController < ApplicationController before_action :confirm_idv_needed def new - analytics.track_event(Analytics::IDV_FORGOT_PASSWORD) + analytics.idv_forgot_password end def update - analytics.track_event(Analytics::IDV_FORGOT_PASSWORD_CONFIRMED) + analytics.idv_forgot_password_confirmed request_id = sp_session[:request_id] email = current_user.email reset_password(email, request_id) diff --git a/app/controllers/idv/otp_delivery_method_controller.rb b/app/controllers/idv/otp_delivery_method_controller.rb index 108d87d1416..005b35153a2 100644 --- a/app/controllers/idv/otp_delivery_method_controller.rb +++ b/app/controllers/idv/otp_delivery_method_controller.rb @@ -10,7 +10,7 @@ class OtpDeliveryMethodController < ApplicationController before_action :set_idv_phone def new - analytics.track_event(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT) + analytics.idv_phone_otp_delivery_selection_visit render :new, locals: { gpo_letter_available: gpo_letter_available } end diff --git a/app/controllers/idv/phone_controller.rb b/app/controllers/idv/phone_controller.rb index af0e5131b00..efb1dcede4a 100644 --- a/app/controllers/idv/phone_controller.rb +++ b/app/controllers/idv/phone_controller.rb @@ -8,15 +8,13 @@ class PhoneController < ApplicationController before_action :set_idv_form def new - if params[:step] - analytics.track_event(Analytics::IDV_PHONE_USE_DIFFERENT, step: params[:step]) - end + analytics.idv_phone_use_different(step: params[:step]) if params[:step] redirect_to failure_url(:fail) and return if throttle.throttled? async_state = step.async_state if async_state.none? - analytics.track_event(Analytics::IDV_PHONE_RECORD_VISIT) + analytics.idv_phone_of_record_visited render :new, locals: { gpo_letter_available: gpo_letter_available } elsif async_state.in_progress? render :wait diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index eb304dde471..b451c974572 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -7,6 +7,7 @@ class ReviewController < ApplicationController before_action :confirm_idv_steps_complete before_action :confirm_idv_phone_confirmed + before_action :redirect_to_idv_app_if_enabled before_action :confirm_current_password, only: [:create] def confirm_idv_steps_complete @@ -30,7 +31,7 @@ def confirm_current_password def new @applicant = idv_session.applicant @step_indicator_steps = step_indicator_steps - analytics.track_event(Analytics::IDV_REVIEW_VISIT) + analytics.idv_review_info_visited gpo_mail_service = Idv::GpoMail.new(current_user) flash_now = flash.now @@ -45,7 +46,7 @@ def create init_profile user_session[:need_personal_key_confirmation] = true redirect_to next_step - analytics.track_event(Analytics::IDV_REVIEW_COMPLETE) + analytics.idv_review_complete analytics.idv_final(success: true) return unless FeatureManagement.reveal_gpo_code? @@ -54,6 +55,11 @@ def create private + def redirect_to_idv_app_if_enabled + return if !IdentityConfig.store.idv_api_enabled_steps.include?('password_confirm') + redirect_to idv_app_path + end + def step_indicator_steps steps = Idv::Flows::DocAuthFlow::STEP_INDICATOR_STEPS return steps if idv_session.address_verification_mechanism != 'gpo' diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index a000e55b56f..d36cdfab021 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -36,7 +36,7 @@ def sp_over_quota_limit? end def verify_identity - analytics.track_event(Analytics::IDV_INTRO_VISIT) + analytics.idv_intro_visit redirect_to idv_doc_auth_url end diff --git a/app/controllers/verify_controller.rb b/app/controllers/verify_controller.rb index 1f5e12e6632..42309a31dab 100644 --- a/app/controllers/verify_controller.rb +++ b/app/controllers/verify_controller.rb @@ -4,7 +4,6 @@ class VerifyController < ApplicationController check_or_render_not_found -> { FeatureManagement.idv_api_enabled? }, only: [:show] - before_action :redirect_root_path_to_first_step before_action :validate_step before_action :confirm_two_factor_authenticated before_action :confirm_idv_vendor_session_started @@ -16,12 +15,8 @@ def show private - def redirect_root_path_to_first_step - redirect_to idv_app_path(step: first_step) if params[:step].blank? - end - def validate_step - render_not_found if !enabled_steps.include?(params[:step]) + render_not_found if params[:step].present? && !enabled_steps.include?(params[:step]) end def app_data @@ -29,7 +24,8 @@ def app_data { base_path: idv_app_path, - app_name: APP_NAME, + start_over_url: idv_session_path, + cancel_url: idv_cancel_path, completion_url: completion_url, initial_values: initial_values, enabled_step_names: IdentityConfig.store.idv_api_enabled_steps, diff --git a/app/forms/api/profile_creation_form.rb b/app/forms/api/profile_creation_form.rb index 9218fa049ba..17406dbaace 100644 --- a/app/forms/api/profile_creation_form.rb +++ b/app/forms/api/profile_creation_form.rb @@ -29,7 +29,7 @@ def submit response = FormResponse.new( success: form_valid?, - errors: errors.to_hash, + errors: errors, extra: extra_attributes, ) [response, personal_key] @@ -105,20 +105,20 @@ def session def valid_jwt @user_bundle = Api::UserBundleDecorator.new(user_bundle: jwt, public_key: public_key) - rescue JWT::DecodeError => err - errors.add(:jwt, "decode error: #{err.message}", type: :invalid) - rescue ::Api::UserBundleError => err - errors.add(:jwt, "malformed user bundle: #{err.message}", type: :invalid) + rescue JWT::DecodeError + errors.add(:jwt, I18n.t('idv.failure.exceptions.internal_error'), type: :decode_error) + rescue ::Api::UserBundleError + errors.add(:jwt, I18n.t('idv.failure.exceptions.internal_error'), type: :user_bundle_error) end def valid_user return if user - errors.add(:user, 'user not found', type: :invalid) + errors.add(:user, I18n.t('devise.failure.unauthenticated'), type: :invalid_user) end def valid_password return if user&.valid_password?(password) - errors.add(:password, 'invalid password', type: :invalid) + errors.add(:password, I18n.t('idv.errors.incorrect_password'), type: :invalid_password) end def form_valid? diff --git a/app/javascript/packages/analytics/index.js b/app/javascript/packages/analytics/index.js deleted file mode 100644 index 1383cb14f3d..00000000000 --- a/app/javascript/packages/analytics/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getPageData } from '@18f/identity-page-data'; - -/** - * Logs an event. - * - * @param {string} event Event name. - * @param {Record=} payload Payload object. - * - * @return {Promise} - */ -export async function trackEvent(event, payload = {}) { - const endpoint = getPageData('analyticsEndpoint'); - if (endpoint) { - await window.fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ event, payload }), - }); - } -} diff --git a/app/javascript/packages/analytics/index.ts b/app/javascript/packages/analytics/index.ts new file mode 100644 index 00000000000..99fd2efb7a8 --- /dev/null +++ b/app/javascript/packages/analytics/index.ts @@ -0,0 +1,20 @@ +import { getConfigValue } from '@18f/identity-config'; + +/** + * Logs an event. + * + * @param event Event name. + * @param payload Payload object. + * + * @return Promise resolving once event has been logged. + */ +export async function trackEvent(event: string, payload: object = {}): Promise { + const endpoint = getConfigValue('analyticsEndpoint'); + if (endpoint) { + await window.fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event, payload }), + }); + } +} diff --git a/app/javascript/packages/clipboard-button/README.md b/app/javascript/packages/clipboard-button/README.md index 09773cec044..ea3e8b27285 100644 --- a/app/javascript/packages/clipboard-button/README.md +++ b/app/javascript/packages/clipboard-button/README.md @@ -6,10 +6,10 @@ Custom element and React implementation for a clipboard button component. ### Custom Element -Importing the package will register the `` custom element: +Importing the element will register the `` custom element: ```ts -import '@18f/identity-clipboard-button'; +import '@18f/identity-clipboard-button/clipboard-button-element'; ``` The custom element will implement the copying behavior, but all markup must already exist, rendered server-side or by the included React component. diff --git a/app/javascript/packages/clipboard-button/index.ts b/app/javascript/packages/clipboard-button/index.ts index 602d3242fdb..67087fd2a59 100644 --- a/app/javascript/packages/clipboard-button/index.ts +++ b/app/javascript/packages/clipboard-button/index.ts @@ -1,3 +1 @@ -import './clipboard-button-element'; - export { default as ClipboardButton } from './clipboard-button'; diff --git a/app/javascript/packages/components/button-to.spec.tsx b/app/javascript/packages/components/button-to.spec.tsx new file mode 100644 index 00000000000..2b7b6016af0 --- /dev/null +++ b/app/javascript/packages/components/button-to.spec.tsx @@ -0,0 +1,67 @@ +import sinon from 'sinon'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ButtonTo from './button-to'; + +describe('ButtonTo', () => { + beforeEach(() => { + const csrf = document.createElement('meta'); + csrf.name = 'csrf-token'; + csrf.content = 'token-value'; + document.body.appendChild(csrf); + }); + + it('renders props passed through to Button', () => { + const { getByRole } = render( + + Click me + , + ); + + const button = getByRole('button', { name: 'Click me' }) as HTMLButtonElement; + + expect(button.type).to.equal('button'); + expect(button.classList.contains('usa-button')).to.be.true(); + expect(button.classList.contains('usa-button--unstyled')).to.be.true(); + }); + + it('creates a form in the body outside the root container', () => { + const { container, getByRole } = render( + + Click me + , + ); + + const form = document.querySelector('form')!; + expect(form).to.be.ok(); + expect(container.contains(form)).to.be.false(); + return Promise.all([ + new Promise((resolve) => { + form.addEventListener('submit', (event) => { + event.preventDefault(); + expect(Object.fromEntries(new window.FormData(form))).to.deep.equal({ + _method: 'delete', + authenticity_token: 'token-value', + }); + resolve(); + }); + }), + userEvent.click(getByRole('button')), + ]); + }); + + it('submits to form on click', async () => { + const { getByRole } = render( + + Click me + , + ); + + const form = document.querySelector('form')!; + sinon.stub(form, 'submit'); + + await userEvent.click(getByRole('button')); + + expect(form.submit).to.have.been.calledOnce(); + }); +}); diff --git a/app/javascript/packages/components/button-to.tsx b/app/javascript/packages/components/button-to.tsx new file mode 100644 index 00000000000..8d4c1755fa5 --- /dev/null +++ b/app/javascript/packages/components/button-to.tsx @@ -0,0 +1,49 @@ +import { useRef } from 'react'; +import { createPortal } from 'react-dom'; +import Button from './button'; +import type { ButtonProps } from './button'; + +interface ButtonToProps extends ButtonProps { + /** + * URL to which the user should navigate. + */ + url: string; + + /** + * Form method button should submit as. + */ + method: string; +} + +/** + * Component which renders a button that navigates to the specified URL via form, with method + * parameterized as a hidden input and including authenticity token. The form is rendered to the + * document root, to avoid conflicts with nested forms. + */ +function ButtonTo({ url, method, children, ...buttonProps }: ButtonToProps) { + const formRef = useRef(null); + const csrfRef = useRef(null); + + function submitForm() { + const csrf = document.querySelector('meta[name="csrf-token"]')?.content; + if (csrf && csrfRef.current) { + csrfRef.current.value = csrf; + } + formRef.current?.submit(); + } + + return ( + + ); +} + +export default ButtonTo; diff --git a/app/javascript/packages/components/index.ts b/app/javascript/packages/components/index.ts index 37a884ddf4c..2fd7807b018 100644 --- a/app/javascript/packages/components/index.ts +++ b/app/javascript/packages/components/index.ts @@ -1,11 +1,14 @@ export { default as Alert } from './alert'; export { default as Button } from './button'; +export { default as ButtonTo } from './button-to'; export { default as BlockLink } from './block-link'; export { default as Icon } from './icon'; export { default as FullScreen } from './full-screen'; export { default as PageHeading } from './page-heading'; export { default as SpinnerDots } from './spinner-dots'; +export { default as TextInput } from './text-input'; export { default as TroubleshootingOptions } from './troubleshooting-options'; export type { ButtonProps } from './button'; export type { FullScreenRefHandle } from './full-screen'; +export type { TextInputProps } from './text-input'; diff --git a/app/javascript/packages/components/text-input.spec.tsx b/app/javascript/packages/components/text-input.spec.tsx new file mode 100644 index 00000000000..310a3cab1d6 --- /dev/null +++ b/app/javascript/packages/components/text-input.spec.tsx @@ -0,0 +1,48 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import TextInput from './text-input'; + +describe('TextInput', () => { + it('renders with an associated label', () => { + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect(input).to.be.an.instanceOf(HTMLInputElement); + expect(input.classList.contains('usa-input')).to.be.true(); + }); + + it('uses an explicitly-provided ID', () => { + const customId = 'custom-id'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect(input.id).to.equal(customId); + }); + + it('applies additional given classes', () => { + const customClass = 'custom-class'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input'); + + expect([...input.classList.values()]).to.have.all.members(['usa-input', customClass]); + }); + + it('applies additional input attributes', () => { + const type = 'password'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input') as HTMLInputElement; + + expect(input.type).to.equal(type); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(); + + expect(ref.current).to.be.an.instanceOf(HTMLInputElement); + }); +}); diff --git a/app/javascript/packages/components/text-input.tsx b/app/javascript/packages/components/text-input.tsx new file mode 100644 index 00000000000..31f07aa42a8 --- /dev/null +++ b/app/javascript/packages/components/text-input.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react'; +import type { InputHTMLAttributes, ForwardedRef } from 'react'; +import { useInstanceId } from '@18f/identity-react-hooks'; + +export interface TextInputProps extends InputHTMLAttributes { + /** + * Text of label associated with input. + */ + label: string; + + /** + * Optional explicit ID to use in place of default behavior. + */ + id?: string; + + /** + * Additional class name to be applied to the input element. + */ + className?: string; +} + +function TextInput( + { label, id, className, ...inputProps }: TextInputProps, + ref: ForwardedRef, +) { + const instanceId = useInstanceId(); + const inputId = id ?? `text-input-${instanceId}`; + const classes = ['usa-input', className].filter(Boolean).join(' '); + + return ( + <> + + + + ); +} + +export default forwardRef(TextInput); diff --git a/app/javascript/packages/config/get-config-value.spec.ts b/app/javascript/packages/config/get-config-value.spec.ts new file mode 100644 index 00000000000..a746ce7a391 --- /dev/null +++ b/app/javascript/packages/config/get-config-value.spec.ts @@ -0,0 +1,32 @@ +import getConfigValue from './get-config-value'; +import type { Config } from './get-config-value'; + +describe('getConfigValue', () => { + context('with page config element absent', () => { + it('returns undefined', () => { + expect(getConfigValue('appName')).to.be.undefined(); + expect(getConfigValue('analyticsEndpoint')).to.be.undefined(); + }); + }); + + context('with page config element present', () => { + const APP_NAME = 'app'; + const ANALYTICS_ENDPOINT = 'url'; + + beforeEach(() => { + const config = document.createElement('script'); + config.type = 'application/json'; + config.setAttribute('data-config', ''); + config.textContent = JSON.stringify({ + appName: APP_NAME, + analyticsEndpoint: ANALYTICS_ENDPOINT, + } as Config); + document.body.appendChild(config); + }); + + it('returns the config value corresponding to the given key', () => { + expect(getConfigValue('appName')).to.equal(APP_NAME); + expect(getConfigValue('analyticsEndpoint')).to.equal(ANALYTICS_ENDPOINT); + }); + }); +}); diff --git a/app/javascript/packages/config/get-config-value.ts b/app/javascript/packages/config/get-config-value.ts new file mode 100644 index 00000000000..a93082ed723 --- /dev/null +++ b/app/javascript/packages/config/get-config-value.ts @@ -0,0 +1,45 @@ +/** + * Supported page configuration values. + */ +export interface Config { + /** + * Application name. + */ + appName: string; + + /** + * URL for analytics logging endpoint. + */ + analyticsEndpoint: string; +} + +/** + * Cached configuration. + */ +let cache: Partial; + +/** + * Whether configuration should be cached in this environment. + */ +const isCacheEnvironment = process.env.NODE_ENV !== 'test'; + +/** + * Returns the value associated as initialized through page configuration, if available. + * + * @param key Key for which to return value. + * + * @return Value, if exists. + */ +function getConfigValue(key: K): Config[K] | undefined { + if (cache === undefined || !isCacheEnvironment) { + try { + cache = JSON.parse(document.querySelector('[data-config]')?.textContent || ''); + } catch { + cache = {}; + } + } + + return cache[key]; +} + +export default getConfigValue; diff --git a/app/javascript/packages/config/index.ts b/app/javascript/packages/config/index.ts new file mode 100644 index 00000000000..289bdfcf4fb --- /dev/null +++ b/app/javascript/packages/config/index.ts @@ -0,0 +1 @@ +export { default as getConfigValue } from './get-config-value'; diff --git a/app/javascript/packages/page-data/package.json b/app/javascript/packages/config/package.json similarity index 54% rename from app/javascript/packages/page-data/package.json rename to app/javascript/packages/config/package.json index 76a02212b54..c9af12df0b3 100644 --- a/app/javascript/packages/page-data/package.json +++ b/app/javascript/packages/config/package.json @@ -1,5 +1,5 @@ { - "name": "@18f/identity-page-data", + "name": "@18f/identity-config", "private": true, "version": "1.0.0" } diff --git a/app/javascript/packages/document-capture/components/button-to.jsx b/app/javascript/packages/document-capture/components/button-to.jsx deleted file mode 100644 index ece99c6a296..00000000000 --- a/app/javascript/packages/document-capture/components/button-to.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useContext, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { Button } from '@18f/identity-components'; -import UploadContext from '../context/upload'; - -/** @typedef {import('@18f/identity-components/button').ButtonProps} ButtonProps */ - -/** - * @typedef NativeButtonToProps - * - * @prop {string} url URL to which the user should navigate. - * @prop {string} method Form method button should submit as. - */ - -/** - * @typedef {NativeButtonToProps & ButtonProps} ButtonToProps - */ - -/** - * Component which renders a button that navigates to the specified URL via form, with method - * parameterized as a hidden input and including authenticity token. The form is rendered to the - * document root, to avoid conflicts with nested forms. - * - * @param {ButtonToProps} props - */ -function ButtonTo({ url, method, children, ...buttonProps }) { - const { csrf } = useContext(UploadContext); - const formRef = useRef(/** @type {HTMLFormElement?} */ (null)); - - return ( - - ); -} - -export default ButtonTo; diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx index 7bf56ce35ce..cbcac8045d0 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx @@ -62,7 +62,7 @@ function DocumentSideAcuantCapture({ } onCameraAccessDeclined={() => { onError(new CameraAccessDeclinedError(), { field: side }); - onError(new CameraAccessDeclinedError({ isDetail: true })); + onError(new CameraAccessDeclinedError(undefined, { isDetail: true })); }} errorMessage={error ? error.message : undefined} name={side} diff --git a/app/javascript/packages/document-capture/components/start-over-or-cancel.jsx b/app/javascript/packages/document-capture/components/start-over-or-cancel.jsx deleted file mode 100644 index eee8f3ffa10..00000000000 --- a/app/javascript/packages/document-capture/components/start-over-or-cancel.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useContext } from 'react'; -import { useI18n } from '@18f/identity-react-i18n'; -import UploadContext from '../context/upload'; -import ButtonTo from './button-to'; - -function StartOverOrCancel() { - const { flowPath, startOverURL, cancelURL } = useContext(UploadContext); - const { t } = useI18n(); - - return ( -
- {flowPath !== 'hybrid' && ( - - {t('doc_auth.buttons.start_over')} - - )} - -
- ); -} - -export default StartOverOrCancel; diff --git a/app/javascript/packages/document-capture/components/start-over-or-cancel.spec.tsx b/app/javascript/packages/document-capture/components/start-over-or-cancel.spec.tsx new file mode 100644 index 00000000000..6252cc5c4e4 --- /dev/null +++ b/app/javascript/packages/document-capture/components/start-over-or-cancel.spec.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import { Provider as UploadContextProvider } from '../context/upload'; +import StartOverOrCancel from './start-over-or-cancel'; + +describe('StartOverOrCancel', () => { + it('omits start over link when in hybrid flow', () => { + const { getByText } = render( + + + , + ); + + expect(() => getByText('doc_auth.buttons.start_over')).to.throw(); + expect(getByText('links.cancel')).to.be.ok(); + }); +}); diff --git a/app/javascript/packages/document-capture/components/start-over-or-cancel.tsx b/app/javascript/packages/document-capture/components/start-over-or-cancel.tsx new file mode 100644 index 00000000000..161fb7fbde6 --- /dev/null +++ b/app/javascript/packages/document-capture/components/start-over-or-cancel.tsx @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { StartOverOrCancel as FlowStartOverOrCancel } from '@18f/identity-verify-flow'; +import UploadContext from '../context/upload'; + +function StartOverOrCancel() { + const { flowPath } = useContext(UploadContext); + + return ; +} + +export default StartOverOrCancel; diff --git a/app/javascript/packages/document-capture/context/upload.jsx b/app/javascript/packages/document-capture/context/upload.jsx index f8aca9ea41b..4aedd4fbfb8 100644 --- a/app/javascript/packages/document-capture/context/upload.jsx +++ b/app/javascript/packages/document-capture/context/upload.jsx @@ -9,8 +9,6 @@ const UploadContext = createContext({ backgroundUploadURLs: /** @type {Record} */ ({}), backgroundUploadEncryptKey: /** @type {CryptoKey=} */ (undefined), flowPath: /** @type {FlowPath} */ ('standard'), - startOverURL: /** @type {string} */ (''), - cancelURL: /** @type {string} */ (''), csrf: /** @type {string?} */ (null), }); @@ -76,8 +74,6 @@ UploadContext.displayName = 'UploadContext'; * @prop {string?} csrf CSRF token to send as parameter to upload implementation. * @prop {Record=} formData Extra form data to merge into the payload before uploading * @prop {FlowPath} flowPath The user's session flow path, one of "standard" or "hybrid". - * @prop {string} startOverURL URL to application DELETE path for session restart. - * @prop {string} cancelURL URL to application path for session cancel. * @prop {ReactNode} children Child elements. */ @@ -96,8 +92,6 @@ function UploadContextProvider({ csrf, formData, flowPath, - startOverURL, - cancelURL, children, }) { const uploadWithCSRF = (payload) => @@ -117,8 +111,6 @@ function UploadContextProvider({ backgroundUploadEncryptKey, isMockClient, flowPath, - startOverURL, - cancelURL, csrf, }), [ @@ -129,8 +121,6 @@ function UploadContextProvider({ backgroundUploadEncryptKey, isMockClient, flowPath, - startOverURL, - cancelURL, csrf, ], ); diff --git a/app/javascript/packages/document-capture/services/upload.js b/app/javascript/packages/document-capture/services/upload.js index 2ad7289f32c..bf612eea98f 100644 --- a/app/javascript/packages/document-capture/services/upload.js +++ b/app/javascript/packages/document-capture/services/upload.js @@ -7,15 +7,6 @@ import { FormError } from '@18f/identity-form-steps'; export class UploadFormEntryError extends FormError { /** @type {string} */ field = ''; - - /** - * @param {string} message - */ - constructor(message) { - super(); - - this.message = message; - } } export class UploadFormEntriesError extends FormError { diff --git a/app/javascript/packages/form-steps/form-error.spec.ts b/app/javascript/packages/form-steps/form-error.spec.ts new file mode 100644 index 00000000000..ff2eae562d1 --- /dev/null +++ b/app/javascript/packages/form-steps/form-error.spec.ts @@ -0,0 +1,39 @@ +import FormError from './form-error'; + +describe('FormError', () => { + it('constructs with a message', () => { + const error = new FormError('message'); + + expect(error.message).to.equal('message'); + expect(error.isDetail).to.be.false(); + expect(error.field).to.be.undefined(); + }); + + it('constructs as detailed error', () => { + const error = new FormError('message', { isDetail: true }); + + expect(error.message).to.equal('message'); + expect(error.isDetail).to.be.true(); + expect(error.field).to.be.undefined(); + }); + + it('constructs as associated with a field', () => { + const error = new FormError('message', { field: 'field' }); + + expect(error.message).to.equal('message'); + expect(error.isDetail).to.be.false(); + expect(error.field).to.equal('field'); + }); + + it('supports message on subclass property initializer', () => { + class ExampleFormError extends FormError { + message = 'message'; + } + + const error = new ExampleFormError(); + + expect(error.message).to.equal('message'); + expect(error.isDetail).to.be.false(); + expect(error.field).to.be.undefined(); + }); +}); diff --git a/app/javascript/packages/form-steps/form-error.ts b/app/javascript/packages/form-steps/form-error.ts index 88c43429118..56134def27b 100644 --- a/app/javascript/packages/form-steps/form-error.ts +++ b/app/javascript/packages/form-steps/form-error.ts @@ -4,15 +4,23 @@ export interface FormErrorOptions { * text description. */ isDetail?: boolean; + + /** + * Field associated with the error. + */ + field?: string; } class FormError extends Error { + field?: string; + isDetail: boolean; - constructor(options?: { isDetail: boolean }) { - super(); + constructor(message?: string, options?: FormErrorOptions) { + super(message); this.isDetail = Boolean(options?.isDetail); + this.field = options?.field; } } diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx index ca056344a12..61b4b429488 100644 --- a/app/javascript/packages/form-steps/form-steps.spec.tsx +++ b/app/javascript/packages/form-steps/form-steps.spec.tsx @@ -176,6 +176,19 @@ describe('FormSteps', () => { expect(getByText('Second Title')).to.be.ok(); }); + it('uses submit implementation return value as patch to form values', async () => { + const steps = [ + { ...STEPS[0], submit: () => Promise.resolve({ secondInputOne: 'received' }) }, + STEPS[1], + ]; + const { getByText, findByDisplayValue } = render(); + + const continueButton = getByText('forms.buttons.continue'); + await userEvent.click(continueButton); + + expect(await findByDisplayValue('received')).to.be.ok(); + }); + it('does not proceed if step submit implementation throws an error', async () => { sandbox.useFakeTimers(); const steps = [ @@ -225,6 +238,18 @@ describe('FormSteps', () => { expect(onStepChange.callCount).to.equal(1); }); + it('calls onChange with updated form values', async () => { + const onChange = sinon.spy(); + const { getByText, getByLabelText } = render(); + + await userEvent.click(getByText('forms.buttons.continue')); + await userEvent.type(getByLabelText('Second Input One'), 'one'); + + expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'o' }); + expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'on' }); + expect(onChange).to.have.been.calledWith({ changed: true, secondInputOne: 'one' }); + }); + it('submits with form values', async () => { const onComplete = sinon.spy(); const { getByText, getByLabelText } = render( @@ -402,6 +427,31 @@ describe('FormSteps', () => { expect(document.activeElement).to.equal(inputOne); }); + it('supports ref assignment to arbitrary (non-input) elements', async () => { + const onComplete = sandbox.stub(); + const { getByRole } = render( + + + + ); + }, + }, + ]} + />, + ); + + await userEvent.click(getByRole('button', { name: 'forms.buttons.submit.default' })); + + expect(onComplete).to.have.been.called(); + }); + it('distinguishes empty errors from progressive error removal', async () => { const { getByText, getByLabelText, container } = render(); @@ -561,4 +611,10 @@ describe('FormSteps', () => { expect(getByText('First Title')).to.be.ok(); }); + + it('supports starting at a specific step', () => { + const { getByText } = render(); + + expect(getByText('Second Title')).to.be.ok(); + }); }); diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx index 6ee7a0b034c..25befda4f06 100644 --- a/app/javascript/packages/form-steps/form-steps.tsx +++ b/app/javascript/packages/form-steps/form-steps.tsx @@ -88,7 +88,7 @@ export interface FormStep { /** * Optionally-asynchronous submission behavior, expected to throw any submission error. */ - submit?: (values: V) => void | Promise; + submit?: (values: V) => void | Record | Promise>; /** * Human-readable step label. @@ -100,7 +100,7 @@ interface FieldsRefEntry { /** * Ref callback. */ - refCallback: RefCallback; + refCallback: RefCallback; /** * Whether field is required. @@ -110,7 +110,7 @@ interface FieldsRefEntry { /** * Element assigned by ref callback. */ - element: HTMLInputElement | null; + element: HTMLElement | null; } interface FormStepsProps { @@ -119,6 +119,11 @@ interface FormStepsProps { */ steps?: FormStep[]; + /** + * Step at which to start form. + */ + initialStep?: string; + /** * Form values to populate initial state. */ @@ -134,10 +139,15 @@ interface FormStepsProps { */ autoFocus?: boolean; + /** + * Form values change callback. + */ + onChange?: (values: FormValues) => void; + /** * Form completion callback. */ - onComplete?: (values: Record) => void; + onComplete?: (values: FormValues) => void; /** * Callback triggered on step change. @@ -213,9 +223,11 @@ function getFieldActiveErrorFieldElement( function FormSteps({ steps = [], + onChange = () => {}, onComplete = () => {}, onStepChange = () => {}, onStepSubmit = () => {}, + initialStep, initialValues = {}, initialActiveErrors = [], autoFocus, @@ -226,7 +238,7 @@ function FormSteps({ const [values, setValues] = useState(initialValues); const [activeErrors, setActiveErrors] = useState(initialActiveErrors); const formRef = useRef(null as HTMLFormElement | null); - const [stepName, setStepName] = useHistoryParam({ basePath }); + const [stepName, setStepName] = useHistoryParam(initialStep, { basePath }); const [stepErrors, setStepErrors] = useState([] as Error[]); const [isSubmitting, setIsSubmitting] = useState(false); const fields = useRef({} as Record); @@ -237,7 +249,9 @@ function FormSteps({ if (activeErrors.length && didSubmitWithErrors.current) { const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current); if (activeErrorFieldElement) { - activeErrorFieldElement.reportValidity(); + if (activeErrorFieldElement instanceof HTMLInputElement) { + activeErrorFieldElement.reportValidity(); + } activeErrorFieldElement.focus(); } } @@ -265,6 +279,7 @@ function FormSteps({ useStepTitle(step, titleFormat); useDidUpdateEffect(() => onStepChange(stepName!), [step]); useDidUpdateEffect(onPageTransition, [step]); + useDidUpdateEffect(() => onChange(values), [values]); useEffect(() => { // Treat explicit initial step the same as step transition, placing focus to header. @@ -289,9 +304,11 @@ function FormSteps({ let error: Error | undefined; if (isActive) { - element.checkValidity(); + if (element instanceof HTMLInputElement) { + element.checkValidity(); + } - if (element.validationMessage) { + if (element instanceof HTMLInputElement && element.validationMessage) { error = new Error(element.validationMessage); } else if (isRequired && !values[key]) { error = new RequiredValueMissingError(); @@ -311,6 +328,8 @@ function FormSteps({ return null; } + const setPatchValues = (patch: Partial) => + setValues((prevValues) => ({ ...prevValues, ...patch })); const unknownFieldErrors = activeErrors.filter( ({ field }) => !field || !fields.current[field]?.element, ); @@ -342,7 +361,10 @@ function FormSteps({ if (submit) { try { setIsSubmitting(true); - await submit(values); + const patchValues = await submit(values); + if (patchValues) { + setPatchValues(patchValues); + } setIsSubmitting(false); } catch (error) { setActiveErrors([{ error }]); @@ -389,7 +411,7 @@ function FormSteps({ setActiveErrors((prevActiveErrors) => prevActiveErrors.filter(({ field }) => !field || !(field in nextValuesPatch)), ); - setValues((prevValues) => ({ ...prevValues, ...nextValuesPatch })); + setPatchValues(nextValuesPatch); })} onError={ifStillMounted((error, { field } = {}) => { if (field) { diff --git a/app/javascript/packages/form-steps/index.ts b/app/javascript/packages/form-steps/index.ts index 07eefb28c0c..54ee9c91f5e 100644 --- a/app/javascript/packages/form-steps/index.ts +++ b/app/javascript/packages/form-steps/index.ts @@ -4,6 +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 } 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 8ee2381be45..b5b18851293 100644 --- a/app/javascript/packages/form-steps/use-history-param.spec.tsx +++ b/app/javascript/packages/form-steps/use-history-param.spec.tsx @@ -2,14 +2,41 @@ import sinon from 'sinon'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useDefineProperty } from '@18f/identity-test-helpers'; -import useHistoryParam from './use-history-param'; +import useHistoryParam, { getStepParam } from './use-history-param'; + +describe('getStepParam', () => { + it('returns step', () => { + const path = 'step'; + const result = getStepParam(path); + + 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 = sinon.createSandbox(); const defineProperty = useDefineProperty(); - function TestComponent({ basePath }: { basePath?: string }) { - const [count = 0, setCount] = useHistoryParam({ basePath }); + function TestComponent({ initialValue, basePath }: { initialValue?: string; basePath?: string }) { + const [count = 0, setCount] = useHistoryParam(initialValue, { basePath }); return ( <> @@ -133,13 +160,20 @@ describe('useHistoryParam', () => { }, }); - sandbox.stub(window.history, 'pushState').callsFake((_data, _unused, url) => { - history.push(url as string); - }); - sandbox.stub(window.history, 'back').callsFake(() => { - history.pop(); - window.dispatchEvent(new CustomEvent('popstate')); - }); + 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', () => { @@ -187,9 +221,26 @@ describe('useHistoryParam', () => { expect(await findByDisplayValue('0')).to.be.ok(); expect(window.location.pathname).to.equal(basePath); }); + + context('with initial provided value', () => { + it('returns initial value', () => { + const { getByDisplayValue } = render( + , + ); + + expect(getByDisplayValue('1')).to.be.ok(); + }); + + it('syncs to URL', () => { + render(); + + expect(window.location.pathname).to.equal('/base/1'); + expect(window.history.length).to.equal(1); + }); + }); }); - context('with initial value', () => { + context('with initial URL value', () => { beforeEach(() => { defineProperty(window, 'location', { value: { @@ -207,7 +258,7 @@ describe('useHistoryParam', () => { }); }); - context('with initial value, no trailing slash', () => { + context('with initial URL value, no trailing slash', () => { beforeEach(() => { defineProperty(window, 'location', { value: { diff --git a/app/javascript/packages/form-steps/use-history-param.ts b/app/javascript/packages/form-steps/use-history-param.ts index 2300d7f4d0f..3b1212a0db5 100644 --- a/app/javascript/packages/form-steps/use-history-param.ts +++ b/app/javascript/packages/form-steps/use-history-param.ts @@ -4,6 +4,15 @@ interface HistoryOptions { basePath?: string; } +/** + * Returns the step name from a given path, ignoring any subpaths or leading or trailing slashes. + * + * @param path Path from which to extract step. + * + * @return Step name. + */ +export const getStepParam = (path: string): string => path.split('/').filter(Boolean)[0]; + /** * Returns a hook which syncs a querystring parameter by the given name using History pushState. * Returns a `useState`-like tuple of the current value and a setter to assign the next parameter @@ -16,16 +25,22 @@ interface HistoryOptions { * * @return Tuple of current state, state setter. */ -function useHistoryParam({ basePath }: HistoryOptions = {}): [ - string | undefined, - (nextParamValue?: string) => void, -] { - const getCurrentValue = () => - (typeof basePath === 'string' - ? window.location.pathname.split(basePath)[1]?.replace(/^\/|\/$/g, '') - : window.location.hash.slice(1)) || undefined; +function useHistoryParam( + initialValue?: string, + { basePath }: HistoryOptions = {}, +): [string | undefined, (nextParamValue?: string) => void] { + function getCurrentValue(): string | undefined { + const path = + typeof basePath === 'string' + ? window.location.pathname.split(basePath)[1] + : window.location.hash.slice(1); - const [value, setValue] = useState(getCurrentValue); + if (path) { + return getStepParam(path); + } + } + + const [value, setValue] = useState(initialValue ?? getCurrentValue); function getValueURL(nextValue) { const prefix = typeof basePath === 'string' ? `${basePath.replace(/\/$/, '')}/` : '#'; @@ -46,6 +61,10 @@ function useHistoryParam({ basePath }: HistoryOptions = {}): [ } useEffect(() => { + if (initialValue && initialValue !== getCurrentValue()) { + window.history.replaceState(null, '', getValueURL(initialValue)); + } + const syncValue = () => setValue(getCurrentValue()); window.addEventListener('popstate', syncValue); return () => window.removeEventListener('popstate', syncValue); diff --git a/app/javascript/packages/i18n/README.md b/app/javascript/packages/i18n/README.md new file mode 100644 index 00000000000..a5bd188b53a --- /dev/null +++ b/app/javascript/packages/i18n/README.md @@ -0,0 +1,97 @@ +# `@18f/identity-i18n` + +JavaScript implementation of a Rails-like localization utility. + +When paired with [`@18f/identity-rails-i18n-webpack-plugin`](https://github.com/18F/identity-idp/tree/main/app/javascript/packages/rails-i18n-webpack-plugin), it provides a seamless localization experience to retrieve locale data from [Rails locale data](https://github.com/18F/identity-idp/tree/main/config/locales). + +## Usage + +Usage should provide a behavior similar to [Rails Internationalization](https://guides.rubyonrails.org/i18n.html), where a given key would be expected to match locale data based on the folder structure found in `config/locales`. + +For example, a key of `foo.bar.baz`, would match the file at `config/locales/foo/en.yml` (for English locales), whose content includes... + +```yml +en: + foo: + bar: + baz: Message +``` + +### Basic + +Call the translate function with a key to retrieve the translated message. + +```yml +# config/locales/messages/en.yml +en: + messages: + greeting: Hello world! +``` + +```ts +import { t } from '@18f/identity-i18n'; + +t('messages.greeting'); +// "Hello world!" +``` + +### Interpolation + +Include an object of variables to interpolate those values in the matched entry. + +```yml +# config/locales/messages/en.yml +en: + messages: + greeting: Hello %{recipient}! +``` + +```ts +import { t } from '@18f/identity-i18n'; + +t('messages.greeting', { recipient: 'world' }); +// "Hello world!" +``` + +### Pluralization + +An entry which is an object including `one` or `other` keys will automatically choose the correct message based on the `count` variable. + +```yml +# config/locales/messages/en.yml +en: + messages: + greeting: + one: Hello to you! + other: Hello to all! +``` + +```ts +import { t } from '@18f/identity-i18n'; + +t('messages.greeting', { count: 1 }); +// "Hello to you!" + +t('messages.greeting', { count: 2 }); +// "Hello to all!" +``` + +### Array Values + +An entry may be a single string or an array of strings. Passing an array of key(s) will return an array of messages. + +```yml +# config/locales/messages/en.yml +en: + messages: + greetings: + - Hello! + - Howdy! +``` + +```ts +import { t } from '@18f/identity-i18n'; + +t(['messages.greetings']); +// ["Hello!", "Howdy!"] +``` diff --git a/app/javascript/packages/i18n/index.spec.js b/app/javascript/packages/i18n/index.spec.ts similarity index 67% rename from app/javascript/packages/i18n/index.spec.js rename to app/javascript/packages/i18n/index.spec.ts index 205e030499a..96a07b0e933 100644 --- a/app/javascript/packages/i18n/index.spec.js +++ b/app/javascript/packages/i18n/index.spec.ts @@ -20,6 +20,7 @@ describe('I18n', () => { strings: { known: 'translation', messages: { one: 'one message', other: '%{count} messages' }, + list: ['one', 'two'], }, }); @@ -28,6 +29,10 @@ describe('I18n', () => { expect(t('known')).to.equal('translation'); }); + it('returns multiple localized key values', () => { + expect(t(['known', 'known'])).to.deep.equal(['translation', 'translation']); + }); + it('falls back to key value', () => { expect(t('unknown')).to.equal('unknown'); }); @@ -45,5 +50,21 @@ describe('I18n', () => { expect(t('messages', { count: 2 })).to.equal('2 messages'); }); }); + + describe('array entry', () => { + context('with a singular key', () => { + it('returns array of strings', () => { + expect(t('list')).to.deep.equal(['one', 'two']); + }); + }); + + context('with an array of the key', () => { + it('returns array of strings', () => { + const list = t(['list']).map((value) => value); + + expect(list).to.deep.equal(['one', 'two']); + }); + }); + }); }); }); diff --git a/app/javascript/packages/i18n/index.ts b/app/javascript/packages/i18n/index.ts index ef6e7a4fc56..6d50feeab06 100644 --- a/app/javascript/packages/i18n/index.ts +++ b/app/javascript/packages/i18n/index.ts @@ -3,7 +3,7 @@ interface PluralizedEntry { other: string; } -type Entry = string | PluralizedEntry; +type Entry = string | string[] | PluralizedEntry; type Entries = Record; type Variables = Record; @@ -35,6 +35,25 @@ const getPluralizationKey = (count: number): keyof PluralizedEntry => const getEntry = (strings: Entries, key: string): Entry => hasOwn(strings, key) ? strings[key] : key; +/** + * Returns true if the given entry is a pluralization entry, or false otherwise. + * + * @param entry Entry to test. + * + * @return Whether entry is a pluralization entry. + */ +const isPluralizedEntry = (entry: Entry): entry is PluralizedEntry => + typeof entry === 'object' && 'one' in entry; + +/** + * Returns true if the given entry is a string entry, or false otherwise. + * + * @param entry Entry to test. + * + * @return Whether entry is a string entry. + */ +const isStringEntry = (entry: Entry): entry is string => typeof entry === 'string'; + /** * Returns the resulting string from the given entry, incorporating pluralization if necessary. * @@ -43,8 +62,8 @@ const getEntry = (strings: Entries, key: string): Entry => * * @return Entry string. */ -function getString(entry: Entry, count?: number): string { - if (typeof entry === 'object') { +function getString(entry: Entry, count?: number): string | string[] { + if (isPluralizedEntry(entry)) { if (typeof count !== 'number') { throw new TypeError('Expected count for PluralizedEntry'); } @@ -77,15 +96,22 @@ class I18n { /** * Returns the translated string by the given key. * - * @param key Key to retrieve. + * @param keyOrKeys Key or keys to retrieve. * @param variables Variables to substitute in string. * * @return Translated string. */ - t(key: string, variables: Variables = {}): string { - const entry = getEntry(this.strings, key); - const string = getString(entry, variables.count); - return replaceVariables(string, variables); + t(keyOrKeys: string, variables?: Variables): string; + t(keyOrKeys: string[], variables?: Variables): string[]; + t(keyOrKeys: string | string[], variables: Variables = {}): string | string[] { + const isSingular = !Array.isArray(keyOrKeys); + const keys: string[] = isSingular ? [keyOrKeys] : keyOrKeys; + const entries = keys.map((key) => getEntry(this.strings, key)); + const strings = entries + .map((entry) => (isPluralizedEntry(entry) ? getString(entry, variables?.count) : entry)) + .map((entry) => (isStringEntry(entry) ? replaceVariables(entry, variables) : entry)); + + return isSingular ? strings[0] : strings.flat(); } } diff --git a/app/javascript/packages/page-data/index.js b/app/javascript/packages/page-data/index.js deleted file mode 100644 index 235703adfed..00000000000 --- a/app/javascript/packages/page-data/index.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Naive implementation of converting string to dash-delimited string, targeting support only for - * alphanumerical strings. - * - * @example - * ``` - * kebabCase('HelloWorld'); - * // 'hello-world' - * ``` - * - * @param {string} string - * - * @return {string} - */ -const kebabCase = (string) => string.replace(/(.)([A-Z])/g, '$1-$2').toLowerCase(); - -/** - * Returns data- attribute selector associated with a given dataset key. - * - * @param {string} key Dataset key. - * - * @return {string} Data attribute. - */ -const getDataAttributeSelector = (key) => `[data-${kebabCase(key)}]`; - -/** - * Returns the value associated with a page element with the given dataset property, or undefined if - * the element does not exist. - * - * @param {string} key Key for which to return value. - * - * @return {string=} Value, if exists. - */ -export function getPageData(key) { - const element = document.querySelector(getDataAttributeSelector(key)); - return /** @type {HTMLElement=} */ (element)?.dataset[key]; -} diff --git a/app/javascript/packages/password-toggle-element/package.json b/app/javascript/packages/password-toggle-element/package.json deleted file mode 100644 index 18528af20db..00000000000 --- a/app/javascript/packages/password-toggle-element/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "@18f/identity-password-toggle-element", - "private": true, - "version": "1.0.0" -} diff --git a/app/javascript/packages/password-toggle/index.ts b/app/javascript/packages/password-toggle/index.ts new file mode 100644 index 00000000000..78ae0b948d5 --- /dev/null +++ b/app/javascript/packages/password-toggle/index.ts @@ -0,0 +1 @@ +export { default as PasswordToggle } from './password-toggle'; diff --git a/app/javascript/packages/password-toggle/package.json b/app/javascript/packages/password-toggle/package.json new file mode 100644 index 00000000000..b492c82a94f --- /dev/null +++ b/app/javascript/packages/password-toggle/package.json @@ -0,0 +1,13 @@ +{ + "name": "@18f/identity-password-toggle", + "private": true, + "version": "1.0.0", + "peerDependencies": { + "react": "^17.0.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/app/javascript/packages/password-toggle-element/index.spec.ts b/app/javascript/packages/password-toggle/password-toggle-element.spec.ts similarity index 87% rename from app/javascript/packages/password-toggle-element/index.spec.ts rename to app/javascript/packages/password-toggle/password-toggle-element.spec.ts index f2571d73a66..bb3ec06f2ba 100644 --- a/app/javascript/packages/password-toggle-element/index.spec.ts +++ b/app/javascript/packages/password-toggle/password-toggle-element.spec.ts @@ -1,16 +1,11 @@ import userEvent from '@testing-library/user-event'; import { getByLabelText } from '@testing-library/dom'; -import { PasswordToggleElement } from './index'; +import './password-toggle-element'; +import type PasswordToggleElement from './password-toggle-element'; describe('PasswordToggleElement', () => { let idCounter = 0; - before(() => { - if (!customElements.get('lg-password-toggle')) { - customElements.define('lg-password-toggle', PasswordToggleElement); - } - }); - function createElement() { const element = document.createElement('lg-password-toggle') as PasswordToggleElement; const idSuffix = ++idCounter; diff --git a/app/javascript/packages/password-toggle-element/index.ts b/app/javascript/packages/password-toggle/password-toggle-element.ts similarity index 67% rename from app/javascript/packages/password-toggle-element/index.ts rename to app/javascript/packages/password-toggle/password-toggle-element.ts index c66933fbaed..efd9ec4c58f 100644 --- a/app/javascript/packages/password-toggle-element/index.ts +++ b/app/javascript/packages/password-toggle/password-toggle-element.ts @@ -12,7 +12,7 @@ interface PasswordToggleElements { input: HTMLInputElement; } -export class PasswordToggleElement extends HTMLElement { +class PasswordToggleElement extends HTMLElement { connectedCallback() { this.elements.toggle.addEventListener('change', () => this.setInputType()); this.setInputType(); @@ -30,3 +30,15 @@ export class PasswordToggleElement extends HTMLElement { this.elements.input.type = this.elements.toggle.checked ? 'text' : 'password'; } } + +declare global { + interface HTMLElementTagNameMap { + 'lg-password-toggle': PasswordToggleElement; + } +} + +if (!customElements.get('lg-password-toggle')) { + customElements.define('lg-password-toggle', PasswordToggleElement); +} + +export default PasswordToggleElement; diff --git a/app/javascript/packages/password-toggle/password-toggle.spec.tsx b/app/javascript/packages/password-toggle/password-toggle.spec.tsx new file mode 100644 index 00000000000..9dc744c1684 --- /dev/null +++ b/app/javascript/packages/password-toggle/password-toggle.spec.tsx @@ -0,0 +1,52 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import PasswordToggle from './password-toggle'; + +describe('PasswordToggle', () => { + it('renders with default labels', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('components.password_toggle.label')).to.exist(); + expect(getByLabelText('components.password_toggle.toggle_label')).to.exist(); + }); + + it('renders with custom input label', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('Input').classList.contains('password-toggle__input')).to.be.true(); + }); + + it('renders with custom toggle label', () => { + const { getByLabelText } = render(); + + expect(getByLabelText('Toggle').classList.contains('password-toggle__toggle')).to.be.true(); + }); + + it('renders default toggle position', () => { + const { container } = render(); + + expect(container.querySelector('.password-toggle--toggle-top')).to.exist(); + }); + + it('renders explicit toggle position', () => { + const { container } = render(); + + expect(container.querySelector('.password-toggle--toggle-bottom')).to.exist(); + }); + + it('passes additional props to underlying text input', () => { + const type = 'password'; + const { getByLabelText } = render(); + + const input = getByLabelText('Input') as HTMLInputElement; + + expect(input.type).to.equal(type); + }); + + it('forwards ref to the underlying text input', () => { + const ref = createRef(); + render(); + + expect(ref.current).to.be.an.instanceOf(HTMLInputElement); + }); +}); diff --git a/app/javascript/packages/password-toggle/password-toggle.tsx b/app/javascript/packages/password-toggle/password-toggle.tsx new file mode 100644 index 00000000000..60db2595a56 --- /dev/null +++ b/app/javascript/packages/password-toggle/password-toggle.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from 'react'; +import type { HTMLAttributes, ForwardedRef } from 'react'; +import { t } from '@18f/identity-i18n'; +import { TextInput } from '@18f/identity-components'; +import { useInstanceId } from '@18f/identity-react-hooks'; +import type { TextInputProps } from '@18f/identity-components'; +import './password-toggle-element'; +import type PasswordToggleElement from './password-toggle-element'; + +declare global { + namespace JSX { + interface IntrinsicElements { + 'lg-password-toggle': HTMLAttributes & { class: string }; + } + } +} + +type TogglePosition = 'top' | 'bottom'; + +type PasswordToggleProps = Partial & { + /** + * Input label text. + */ + label?: string; + + /** + * Toggle label text. + */ + toggleLabel?: string; + + /** + * Placement of toggle relative to the input. + */ + togglePosition?: TogglePosition; +}; + +function PasswordToggle( + { + label = t('components.password_toggle.label'), + toggleLabel = t('components.password_toggle.toggle_label'), + togglePosition = 'top', + ...textInputProps + }: PasswordToggleProps, + ref: ForwardedRef, +) { + const instanceId = useInstanceId(); + const inputId = `password-toggle-input-${instanceId}`; + const toggleId = `password-toggle-${instanceId}`; + + const classes = + togglePosition === 'top' ? 'password-toggle--toggle-top' : 'password-toggle--toggle-bottom'; + + return ( + + +
+ + +
+
+ ); +} + +export default forwardRef(PasswordToggle); diff --git a/app/javascript/packages/print-button/README.md b/app/javascript/packages/print-button/README.md index 681cbab1494..9f9b0476c53 100644 --- a/app/javascript/packages/print-button/README.md +++ b/app/javascript/packages/print-button/README.md @@ -6,10 +6,10 @@ Custom element and React implementation for a print button component. ### Custom Element -Importing the package will register the `` custom element: +Importing the element will register the `` custom element: ```ts -import '@18f/identity-print-button'; +import '@18f/identity-print-button/print-button-element'; ``` The custom element will implement the behavior to show a print dialog upon click, but all markup must already exist, rendered server-side or by the included React component. diff --git a/app/javascript/packages/print-button/index.ts b/app/javascript/packages/print-button/index.ts index f28a5ae942a..53ee8c3d1ac 100644 --- a/app/javascript/packages/print-button/index.ts +++ b/app/javascript/packages/print-button/index.ts @@ -1,3 +1 @@ -import './print-button-element'; - export { default as PrintButton } from './print-button'; diff --git a/app/javascript/packages/secret-session-storage/index.spec.ts b/app/javascript/packages/secret-session-storage/index.spec.ts index 43093f0c664..007746288e2 100644 --- a/app/javascript/packages/secret-session-storage/index.spec.ts +++ b/app/javascript/packages/secret-session-storage/index.spec.ts @@ -29,53 +29,113 @@ describe('SecretSessionStorage', () => { sandbox.restore(); }); - it('writes to session storage', async () => { - sandbox.spy(Storage.prototype, 'setItem'); - + it('silently ignores invalid written storage', async () => { + sessionStorage.setItem(STORAGE_KEY, 'nonsense'); const storage = createStorage(); - await storage.setItem('foo', 'bar'); - - expect(Storage.prototype.setItem).to.have.been.calledWith( - STORAGE_KEY, - sinon.match( - (value: string) => - /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), - ), - ); + await storage.load(); }); - it('loads from previous written storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); - - const storage2 = createStorage(); - await storage2.load(); + describe('#setItem', () => { + it('writes to session storage', async () => { + sandbox.spy(Storage.prototype, 'setItem'); + + const storage = createStorage(); + await storage.setItem('foo', 'bar'); + + expect(Storage.prototype.setItem).to.have.been.calledWith( + STORAGE_KEY, + sinon.match( + (value: string) => + /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), + ), + ); + }); + + it('can recall stored data', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('foo')).to.equal('bar'); + }); + }); - expect(storage2.getItem('foo')).to.equal('bar'); + describe('#setItems', () => { + it('writes to session storage', async () => { + sandbox.spy(Storage.prototype, 'setItem'); + + const storage = createStorage(); + await storage.setItems({ foo: 'bar' }); + + expect(Storage.prototype.setItem).to.have.been.calledWith( + STORAGE_KEY, + sinon.match( + (value: string) => + /^\[".+?",".+?"\]$/.test(value) && !value.includes('foo') && !value.includes('bar'), + ), + ); + }); + + it('can recall stored data', async () => { + const storage1 = createStorage(); + await storage1.setItems({ foo: 'bar' }); + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('foo')).to.equal('bar'); + }); }); - it('returns undefined for value not yet loaded from storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); + describe('#getItem', () => { + it('returns the value from web storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); - const storage2 = createStorage(); + const storage2 = createStorage(); + await storage2.load(); - expect(storage2.getItem('foo')).to.be.undefined(); - }); + expect(storage2.getItem('foo')).to.equal('bar'); + }); + + it('returns undefined for value not yet loaded from storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); - it('returns undefined for value not in loaded storage', async () => { - const storage1 = createStorage(); - await storage1.setItem('foo', 'bar'); + expect(storage2.getItem('foo')).to.be.undefined(); + }); - const storage2 = createStorage(); - await storage2.load(); + it('returns undefined for value not in loaded storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); - expect(storage2.getItem('baz')).to.be.undefined(); + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItem('baz')).to.be.undefined(); + }); }); - it('silently ignores invalid written storage', async () => { - sessionStorage.setItem(STORAGE_KEY, 'nonsense'); - const storage = createStorage(); - await storage.load(); + describe('#getItems', () => { + it('returns the values from web storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + await storage2.load(); + + expect(storage2.getItems()).to.deep.equal({ foo: 'bar' }); + }); + + it('returns empty object for value not yet loaded from storage', async () => { + const storage1 = createStorage(); + await storage1.setItem('foo', 'bar'); + + const storage2 = createStorage(); + + expect(storage2.getItems()).to.deep.equal({}); + }); }); }); diff --git a/app/javascript/packages/secret-session-storage/index.ts b/app/javascript/packages/secret-session-storage/index.ts index 453f7a34ff7..9ffce17f7a1 100644 --- a/app/javascript/packages/secret-session-storage/index.ts +++ b/app/javascript/packages/secret-session-storage/index.ts @@ -60,6 +60,16 @@ class SecretSessionStorage> { await this.#writeStorage(); } + /** + * Sets a patch of values into storage. + * + * @param values Storage object values. + */ + async setItems(values: Partial) { + Object.assign(this.storage, values); + await this.#writeStorage(); + } + /** * Gets a value from the in-memory storage. * @@ -69,6 +79,13 @@ class SecretSessionStorage> { return this.storage[key]; } + /** + * Returns values from in-memory storage. + */ + getItems() { + return this.storage; + } + /** * Reads and decrypts storage object, if available. */ diff --git a/app/javascript/packages/spinner-button/index.ts b/app/javascript/packages/spinner-button/index.ts index 76e50279381..413738ac9df 100644 --- a/app/javascript/packages/spinner-button/index.ts +++ b/app/javascript/packages/spinner-button/index.ts @@ -1,4 +1,2 @@ -import './spinner-button-element'; - export { default as SpinnerButton } from './spinner-button'; export type { SpinnerButtonRefHandle } from './spinner-button'; diff --git a/app/javascript/packages/step-indicator/README.md b/app/javascript/packages/step-indicator/README.md index 4b599a4bbfa..687b7a25f46 100644 --- a/app/javascript/packages/step-indicator/README.md +++ b/app/javascript/packages/step-indicator/README.md @@ -6,10 +6,10 @@ Custom element and React implementation for a step indicator UI component. ### Custom Element -Importing the package will register the `` custom element: +Importing the element will register the `` custom element: ```ts -import '@18f/identity-step-indicator'; +import '@18f/identity-step-indicator/step-indicator-element'; ``` The custom element will implement the small viewport scroll behavior, but all markup must already exist, rendered server-side or by the included React component. diff --git a/app/javascript/packages/step-indicator/index.ts b/app/javascript/packages/step-indicator/index.ts index c51e65903c1..9bbc86931f4 100644 --- a/app/javascript/packages/step-indicator/index.ts +++ b/app/javascript/packages/step-indicator/index.ts @@ -1,4 +1,2 @@ -import './step-indicator-element'; - export { default as StepIndicator } from './step-indicator'; export { default as StepIndicatorStep, StepStatus } from './step-indicator-step'; diff --git a/app/javascript/packages/url/index.spec.js b/app/javascript/packages/url/index.spec.ts similarity index 68% rename from app/javascript/packages/url/index.spec.js rename to app/javascript/packages/url/index.spec.ts index ed4b6c981b2..ccc5eec5990 100644 --- a/app/javascript/packages/url/index.spec.js +++ b/app/javascript/packages/url/index.spec.ts @@ -10,18 +10,18 @@ describe('addSearchParams', () => { expect(actual).to.equal(expected); }); - it('adds search params to an existing search fragment', () => { - const params = '?a=1&b=1'; - const expected = '?a=1&b=2&c=3'; + it('accepts URL as a path', () => { + const url = '/example'; + const expected = `${window.location.origin}/example?a=1&b=2&c=3`; - const actual = addSearchParams(params, { b: 2, c: 3 }); + const actual = addSearchParams(url, { a: 1, b: 2, c: 3 }); expect(actual).to.equal(expected); }); it('adds search params to an empty URL', () => { const params = ''; - const expected = '?a=1&b=2&c=3'; + const expected = `${window.location.origin}/?a=1&b=2&c=3`; const actual = addSearchParams(params, { a: 1, b: 2, c: 3 }); diff --git a/app/javascript/packages/url/index.ts b/app/javascript/packages/url/index.ts index 86dbcc29302..5936d0127a5 100644 --- a/app/javascript/packages/url/index.ts +++ b/app/javascript/packages/url/index.ts @@ -2,25 +2,13 @@ * Given a URL or a string fragment of search parameters and an object of parameters, returns a * new URL or search parameters with the parameters added. * - * @param urlOrParams Original URL or search parameters. + * @param url Original URL. * @param params Search parameters to add. * - * @return Modified URL or search parameters. + * @return Modified URL. */ -export function addSearchParams(urlOrParams: string, params: Record): string { - let parsedURLOrParams: URL | URLSearchParams; - let searchParams: URLSearchParams; - - try { - parsedURLOrParams = new URL(urlOrParams); - searchParams = parsedURLOrParams.searchParams; - } catch { - parsedURLOrParams = new URLSearchParams(urlOrParams); - searchParams = parsedURLOrParams; - } - - Object.entries(params).forEach(([key, value]) => searchParams.set(key, value)); - - const result = parsedURLOrParams.toString(); - return parsedURLOrParams instanceof URLSearchParams ? `?${result}` : result; +export function addSearchParams(url: string, params: Record): string { + const parsedURL = new URL(url, window.location.href); + Object.entries(params).forEach(([key, value]) => parsedURL.searchParams.set(key, value)); + return parsedURL.toString(); } diff --git a/app/javascript/packages/validated-field/index.ts b/app/javascript/packages/validated-field/index.ts index 726a6cc890d..aaceb1debfb 100644 --- a/app/javascript/packages/validated-field/index.ts +++ b/app/javascript/packages/validated-field/index.ts @@ -1,5 +1,3 @@ -import './validated-field-element'; - export { default as ValidatedField } from './validated-field'; export type { ValidatedFieldValidator } from './validated-field'; diff --git a/app/javascript/packages/verify-flow/context/flow-context.tsx b/app/javascript/packages/verify-flow/context/flow-context.tsx new file mode 100644 index 00000000000..e60c55a9f97 --- /dev/null +++ b/app/javascript/packages/verify-flow/context/flow-context.tsx @@ -0,0 +1,22 @@ +import { createContext } from 'react'; + +const FlowContext = createContext({ + /** + * URL to path for session restart. + */ + startOverURL: '', + + /** + * URL to path for session cancel. + */ + cancelURL: '', + + /** + * Current step name. + */ + currentStep: '', +}); + +FlowContext.displayName = 'FlowContext'; + +export default FlowContext; diff --git a/app/javascript/packages/verify-flow/context/secrets-context.tsx b/app/javascript/packages/verify-flow/context/secrets-context.tsx index aeae06ca481..b55147d676d 100644 --- a/app/javascript/packages/verify-flow/context/secrets-context.tsx +++ b/app/javascript/packages/verify-flow/context/secrets-context.tsx @@ -1,18 +1,17 @@ -import { createContext, useContext, useEffect, useCallback, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; +import type { ReactNode, Dispatch } from 'react'; import SecretSessionStorage from '@18f/identity-secret-session-storage'; -import { useIfStillMounted } from '@18f/identity-react-hooks'; -import { VerifyFlowValues } from '../verify-flow'; +import type { VerifyFlowValues } from '../verify-flow'; -type SecretValues = Partial; +export type SecretValues = Pick; -type SetItem = typeof SecretSessionStorage.prototype.setItem; +type SetItems = typeof SecretSessionStorage.prototype.setItems; interface SecretsContextProviderProps { /** - * Encryption key. + * Secrets storage. */ - storeKey: Uint8Array; + storage: SecretSessionStorage; /** * Context provider children. @@ -21,49 +20,62 @@ interface SecretsContextProviderProps { } /** - * Web storage key. + * Minimal set of flow values to be synced to secret session storage. */ -const STORAGE_KEY = 'verify'; +const SYNCED_SECRET_VALUES = ['userBundleToken', 'personalKey']; const SecretsContext = createContext({ - storage: new SecretSessionStorage(STORAGE_KEY), - setItem: (async () => {}) as SetItem, + storage: new SecretSessionStorage(''), + setItems: (async () => {}) as SetItems, }); -export function SecretsContextProvider({ storeKey, children }: SecretsContextProviderProps) { - const ifStillMounted = useIfStillMounted(); - const storage = useMemo(() => new SecretSessionStorage(STORAGE_KEY), []); - const [value, setValue] = useState({ storage, setItem: storage.setItem }); - const onChange = useCallback(() => { - setValue({ - storage, - async setItem(...args) { - await storage.setItem(...args); - onChange(); - }, - }); - }, []); +SecretsContext.displayName = 'SecretsContext'; +const pick = (obj: object, keys: string[]) => + Object.fromEntries(keys.map((key) => [key, obj[key]])); + +const isStorageEqual = (values: object, nextValues: object) => + Object.keys(nextValues).every((key) => values[key] === nextValues[key]); + +function useIdleCallbackEffect(callback: () => void, deps: any[]) { useEffect(() => { - crypto.subtle - .importKey('raw', storeKey, 'AES-GCM', true, ['encrypt', 'decrypt']) - .then((cryptoKey) => { - storage.key = cryptoKey; - storage.load().then(ifStillMounted(onChange)); - }); - }, []); + // Idle callback is implemented as a progressive enhancement in supported environments... + if (typeof requestIdleCallback === 'function') { + const callbackId = requestIdleCallback(callback); + return () => cancelIdleCallback(callbackId); + } + + // ...where the fallback behavior is to invoke the callback synchronously. + callback(); + }, deps); +} + +export function SecretsContextProvider({ storage, children }: SecretsContextProviderProps) { + const [value, setValue] = useState({ + storage, + async setItems(nextValues: SecretValues) { + await storage.setItems(nextValues); + setValue({ ...value }); + }, + }); return {children}; } -export function useSecretValue( - key: K, -): [SecretValues[K], (nextValue: SecretValues[K]) => void] { - const { storage, setItem } = useContext(SecretsContext); +export function useSyncedSecretValues( + initialValues?: SecretValues, +): [SecretValues, Dispatch] { + const { storage, setItems } = useContext(SecretsContext); + const [values, setValues] = useState({ ...storage.getItems(), ...initialValues }); - const setValue = (nextValue: SecretValues[K]) => setItem(key, nextValue); + useIdleCallbackEffect(() => { + const nextSecretValues: SecretValues = pick(values, SYNCED_SECRET_VALUES); + if (!isStorageEqual(storage.getItems(), nextSecretValues)) { + setItems(nextSecretValues); + } + }, [values]); - return [storage.getItem(key), setValue]; + return [values, setValues]; } export default SecretsContext; diff --git a/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.spec.ts b/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.spec.ts new file mode 100644 index 00000000000..29bf0f2f0d1 --- /dev/null +++ b/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.spec.ts @@ -0,0 +1,69 @@ +import sinon from 'sinon'; +import { renderHook } from '@testing-library/react-hooks'; +import { useDefineProperty } from '@18f/identity-test-helpers'; +import { FormStep } from '@18f/identity-form-steps'; +import useInitialStepValidation from './use-initial-step-validation'; + +const TEST_BASE_PATH = '/step/'; +const STEPS = [{ name: 'one' }, { name: 'two' }, { name: 'three' }] as FormStep[]; + +describe('useInitialStepValidation', () => { + const defineProperty = useDefineProperty(); + + context('with no path param', () => { + beforeEach(() => { + defineProperty(window, 'location', { + value: { + pathname: TEST_BASE_PATH, + }, + }); + }); + + it('returns the first step', () => { + const { result } = renderHook(() => useInitialStepValidation(TEST_BASE_PATH, STEPS)); + const [initialStep] = result.current; + + expect(initialStep).to.equal(STEPS[0].name); + }); + }); + + context('with path param exceeding progress', () => { + beforeEach(() => { + defineProperty(window, 'location', { + value: { + pathname: TEST_BASE_PATH + STEPS[1].name, + }, + }); + }); + + it('returns furthest step progress', () => { + const { result } = renderHook(() => useInitialStepValidation(TEST_BASE_PATH, STEPS)); + const [initialStep] = result.current; + + expect(initialStep).to.equal(STEPS[0].name); + }); + }); + + context('with path param not exceeding progress', () => { + beforeEach(() => { + defineProperty(window, 'location', { + value: { + pathname: TEST_BASE_PATH + STEPS[1].name, + }, + }); + + defineProperty(global, 'sessionStorage', { + value: { + getItem: sinon.stub().withArgs('completedStep').returns(STEPS[0].name), + }, + }); + }); + + it('returns path param', () => { + const { result } = renderHook(() => useInitialStepValidation(TEST_BASE_PATH, STEPS)); + const [initialStep] = result.current; + + expect(initialStep).to.equal(STEPS[1].name); + }); + }); +}); diff --git a/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.ts b/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.ts new file mode 100644 index 00000000000..345ef1c49c3 --- /dev/null +++ b/app/javascript/packages/verify-flow/hooks/use-initial-step-validation.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import type { Dispatch } from 'react'; +import { FormStep, getStepParam } from '@18f/identity-form-steps'; +import useSessionStorage from './use-session-storage'; + +/** + * Returns the index of the given step name in the form steps order. + * + * @param stepName Step name. + * @param steps Steps order. + * + * @return Step index. + */ +const getStepIndex = (stepName: string, steps: FormStep[]) => + steps.findIndex((step) => step.name === stepName); + +/** + * React hook which validates the expected initial step to present the user, based on past + * completion and presence of a URL path fragment. Behaves similar to a useState hook, where the + * return value is a tuple of the validated initial step, and a setter for assigning a completed + * step. + * + * @param basePath Path to which the current step is appended to create the current step URL. + * @param steps Steps order. + * + * @return Tuple of the validated initial step and a setter for assigning a completed step. + */ +function useInitialStepValidation(basePath: string, steps: FormStep[]): [string, Dispatch] { + const [completedStep, setCompletedStep] = useSessionStorage('completedStep'); + const initialStep = useMemo(() => { + const pathStep = getStepParam(window.location.pathname.split(basePath)[1]); + const completedStepIndex = completedStep ? getStepIndex(completedStep, steps) : -1; + const pathStepIndex = getStepIndex(pathStep, steps); + const firstStepIndex = 0; + const stepIndex = Math.max(Math.min(completedStepIndex + 1, pathStepIndex), firstStepIndex); + return steps[stepIndex].name; + }, []); + + return [initialStep, setCompletedStep]; +} + +export default useInitialStepValidation; diff --git a/app/javascript/packages/verify-flow/hooks/use-session-storage.spec.ts b/app/javascript/packages/verify-flow/hooks/use-session-storage.spec.ts new file mode 100644 index 00000000000..cf944ff1b41 --- /dev/null +++ b/app/javascript/packages/verify-flow/hooks/use-session-storage.spec.ts @@ -0,0 +1,54 @@ +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDefineProperty } from '@18f/identity-test-helpers'; +import useSessionStorage from './use-session-storage'; + +const TEST_KEY = 'key'; + +describe('useSessionStorage', () => { + const defineProperty = useDefineProperty(); + + beforeEach(() => { + defineProperty(global, 'sessionStorage', { + value: { + getItem: sinon.stub(), + setItem: sinon.stub(), + }, + }); + }); + + afterEach(() => { + sessionStorage.removeItem(TEST_KEY); + }); + + it('returns null for a value not in storage', () => { + (sessionStorage.getItem as SinonStub).withArgs(TEST_KEY).returns(null); + + const { result } = renderHook(() => useSessionStorage(TEST_KEY)); + const [value, setValue] = result.current; + + expect(value).to.be.null(); + expect(setValue).to.be.a('function'); + expect(sessionStorage.setItem).not.to.have.been.called(); + }); + + it('returns a value from storage', () => { + (sessionStorage.getItem as SinonStub).withArgs(TEST_KEY).returns('value'); + + const { result } = renderHook(() => useSessionStorage(TEST_KEY)); + const [value, setValue] = result.current; + + expect(value).to.equal('value'); + expect(setValue).to.be.a('function'); + expect(sessionStorage.setItem).not.to.have.been.called(); + }); + + it('sets a value into storage', () => { + const { result } = renderHook(() => useSessionStorage(TEST_KEY)); + const [, setValue] = result.current; + act(() => setValue('value')); + + expect(sessionStorage.setItem).to.have.been.calledWith(TEST_KEY, 'value'); + }); +}); diff --git a/app/javascript/packages/verify-flow/hooks/use-session-storage.ts b/app/javascript/packages/verify-flow/hooks/use-session-storage.ts new file mode 100644 index 00000000000..89b7c7e8014 --- /dev/null +++ b/app/javascript/packages/verify-flow/hooks/use-session-storage.ts @@ -0,0 +1,23 @@ +import { useState } from 'react'; +import { useDidUpdateEffect } from '@18f/identity-react-hooks'; +import type { Dispatch } from 'react'; + +/** + * React hook for maintaining a value in sessionStorage. + * + * @param key Session storage key. + * + * @return Tuple of the current value and a setter to assign a value into storage. + */ +function useSessionStorage(key: string): [string | null, Dispatch] { + const [value, setValue] = useState(() => sessionStorage.getItem(key)); + useDidUpdateEffect(() => { + if (value !== null) { + sessionStorage.setItem(key, value); + } + }, [value]); + + return [value, setValue]; +} + +export default useSessionStorage; diff --git a/app/javascript/packages/verify-flow/index.ts b/app/javascript/packages/verify-flow/index.ts index a412251b0cd..26ca86f8c73 100644 --- a/app/javascript/packages/verify-flow/index.ts +++ b/app/javascript/packages/verify-flow/index.ts @@ -1,4 +1,7 @@ +export { default as FlowContext } from './context/flow-context'; export { SecretsContextProvider } from './context/secrets-context'; +export { default as StartOverOrCancel } from './start-over-or-cancel'; export { default as VerifyFlow } from './verify-flow'; +export type { SecretValues } from './context/secrets-context'; export type { VerifyFlowValues } from './verify-flow'; diff --git a/app/javascript/packages/verify-flow/services/api.spec.ts b/app/javascript/packages/verify-flow/services/api.spec.ts new file mode 100644 index 00000000000..fc52cb78a4f --- /dev/null +++ b/app/javascript/packages/verify-flow/services/api.spec.ts @@ -0,0 +1,91 @@ +import type { SinonStub } from 'sinon'; +import { useSandbox } from '@18f/identity-test-helpers'; +import { isErrorResponse, post } from './api'; + +describe('post', () => { + const sandbox = useSandbox(); + + beforeEach(() => { + sandbox.stub(window, 'fetch'); + }); + + it('sends to API route associated with current path', () => { + post('/foo/bar', 'body'); + + expect(window.fetch).to.have.been.calledWith( + '/foo/bar', + sandbox.match({ method: 'POST', body: 'body' }), + ); + }); + + it('resolves to plaintext', async () => { + (window.fetch as SinonStub).resolves({ + text: () => Promise.resolve('response'), + } as Response); + + const response = await post('/foo/bar', 'body'); + + expect(response).to.equal('response'); + }); + + context('with json option', () => { + it('sends as JSON', () => { + post('/foo/bar', { foo: 'bar' }, { json: true }); + + expect(window.fetch).to.have.been.calledWith( + '/foo/bar', + sandbox.match({ + method: 'POST', + body: '{"foo":"bar"}', + headers: { 'Content-Type': 'application/json' }, + }), + ); + }); + + it('resolves to parsed response JSON', async () => { + (window.fetch as SinonStub).resolves({ + json: () => Promise.resolve({ received: true }), + } as Response); + + const { received } = await post('/foo/bar', { foo: 'bar' }, { json: true }); + + expect(received).to.equal(true); + }); + }); + + context('with csrf option', () => { + it('sends CSRF', () => { + const csrf = document.createElement('meta'); + csrf.name = 'csrf-token'; + csrf.content = 'csrf-value'; + document.body.appendChild(csrf); + + post('/foo/bar', 'body', { csrf: true }); + + expect(window.fetch).to.have.been.calledWith( + '/foo/bar', + sandbox.match({ + method: 'POST', + body: 'body', + headers: { 'X-CSRF-Token': 'csrf-value' }, + }), + ); + }); + }); +}); + +describe('isErrorResponse', () => { + it('returns false if object is not an error response', () => { + const response = {}; + const result = isErrorResponse(response); + + expect(result).to.be.false(); + }); + + it('returns true if object is an error response', () => { + const response = { error: { field: ['message'] } }; + const result = isErrorResponse(response); + + expect(result).to.be.true(); + }); +}); diff --git a/app/javascript/packages/verify-flow/services/api.ts b/app/javascript/packages/verify-flow/services/api.ts new file mode 100644 index 00000000000..442c41cb45e --- /dev/null +++ b/app/javascript/packages/verify-flow/services/api.ts @@ -0,0 +1,51 @@ +export interface ErrorResponse { + error: Record; +} + +interface PostOptions { + /** + * Whether to send the request as a JSON request. + */ + json: boolean; + + /** + * Whether to include CSRF token in the request. + */ + csrf: boolean; +} + +/** + * Submits the given payload to the API route controller associated with the current path, resolving + * to a promise containing the parsed response JSON object. + * + * @param body Request body. + * + * @return Parsed response JSON object. + */ +export async function post( + url: string, + body: BodyInit | object, + options: Partial = {}, +): Promise { + const headers: HeadersInit = {}; + + if (options.csrf) { + const csrf = document.querySelector('meta[name="csrf-token"]')?.content; + if (csrf) { + headers['X-CSRF-Token'] = csrf; + } + } + + if (options.json) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(body); + } + + const response = await window.fetch(url, { method: 'POST', headers, body: body as BodyInit }); + + return options.json ? response.json() : response.text(); +} + +export const isErrorResponse = ( + response: object | ErrorResponse, +): response is ErrorResponse => 'error' in response; diff --git a/app/javascript/packages/verify-flow/start-over-or-cancel.spec.tsx b/app/javascript/packages/verify-flow/start-over-or-cancel.spec.tsx new file mode 100644 index 00000000000..f9883092798 --- /dev/null +++ b/app/javascript/packages/verify-flow/start-over-or-cancel.spec.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react'; +import FlowContext from './context/flow-context'; +import StartOverOrCancel from './start-over-or-cancel'; + +describe('StartOverOrCancel', () => { + it('renders start over and cancel links', () => { + const { queryByText } = render(); + + expect(queryByText('doc_auth.buttons.start_over')).to.exist(); + expect(queryByText('links.cancel')).to.exist(); + }); + + context('with excluded start over option', () => { + it('renders only cancel link', () => { + const { queryByText } = render(); + + expect(queryByText('doc_auth.buttons.start_over')).to.not.exist(); + expect(queryByText('links.cancel')).to.exist(); + }); + }); + + context('with flow context', () => { + it('renders links with current step', () => { + const { getByText, baseElement } = render( + + + , + ); + + const startOverForm = baseElement.querySelector('form')!; + const cancelLink = getByText('links.cancel') as HTMLAnchorElement; + + expect(startOverForm.getAttribute('action')).to.equal( + 'http://example.test/start-over?step=one', + ); + expect(cancelLink.getAttribute('href')).to.equal('http://example.test/cancel?step=one'); + }); + }); +}); diff --git a/app/javascript/packages/verify-flow/start-over-or-cancel.tsx b/app/javascript/packages/verify-flow/start-over-or-cancel.tsx new file mode 100644 index 00000000000..43531a1e2f0 --- /dev/null +++ b/app/javascript/packages/verify-flow/start-over-or-cancel.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react'; +import { ButtonTo } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; +import { addSearchParams } from '@18f/identity-url'; +import FlowContext from './context/flow-context'; + +interface StartOverOrCancelProps { + /** + * Whether to show the option to start over. + */ + canStartOver?: boolean; +} + +function StartOverOrCancel({ canStartOver = true }: StartOverOrCancelProps) { + const { currentStep: step, startOverURL, cancelURL } = useContext(FlowContext); + const { t } = useI18n(); + + return ( +
+ {canStartOver && ( + + {t('doc_auth.buttons.start_over')} + + )} + +
+ ); +} + +export default StartOverOrCancel; diff --git a/app/javascript/packages/verify-flow/steps/index.ts b/app/javascript/packages/verify-flow/steps/index.ts index c81f41d657e..fde4b7813e6 100644 --- a/app/javascript/packages/verify-flow/steps/index.ts +++ b/app/javascript/packages/verify-flow/steps/index.ts @@ -1,4 +1,5 @@ import personalKeyStep from './personal-key'; import personalKeyConfirmStep from './personal-key-confirm'; +import passwordConfirmStep from './password-confirm'; -export const STEPS = [personalKeyStep, personalKeyConfirmStep]; +export const STEPS = [passwordConfirmStep, personalKeyStep, personalKeyConfirmStep]; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/index.ts b/app/javascript/packages/verify-flow/steps/password-confirm/index.ts new file mode 100644 index 00000000000..b06eba50311 --- /dev/null +++ b/app/javascript/packages/verify-flow/steps/password-confirm/index.ts @@ -0,0 +1,13 @@ +import { t } from '@18f/identity-i18n'; +import { getConfigValue } from '@18f/identity-config'; +import type { FormStep } from '@18f/identity-form-steps'; +import type { VerifyFlowValues } from '../../verify-flow'; +import form from './password-confirm-step'; +import submit from './submit'; + +export default { + name: 'password_confirm', + title: t('idv.titles.session.review', { app_name: getConfigValue('appName') }), + form, + submit, +} as FormStep; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx new file mode 100644 index 00000000000..d67c525abaa --- /dev/null +++ b/app/javascript/packages/verify-flow/steps/password-confirm/password-confirm-step.tsx @@ -0,0 +1,32 @@ +import type { ChangeEvent } from 'react'; +import { PasswordToggle } from '@18f/identity-password-toggle'; +import { FormStepsButton } from '@18f/identity-form-steps'; +import { Alert } from '@18f/identity-components'; +import type { FormStepComponentProps } from '@18f/identity-form-steps'; +import StartOverOrCancel from '../../start-over-or-cancel'; +import type { VerifyFlowValues } from '../../verify-flow'; + +interface PasswordConfirmStepStepProps extends FormStepComponentProps {} + +function PasswordConfirmStep({ errors, registerField, onChange }: PasswordConfirmStepStepProps) { + return ( + <> + {errors.map(({ error }) => ( + + {error.message} + + ))} + ) => { + onChange({ password: event.target.value }); + }} + /> + + + + ); +} + +export default PasswordConfirmStep; diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts b/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts new file mode 100644 index 00000000000..8238940081c --- /dev/null +++ b/app/javascript/packages/verify-flow/steps/password-confirm/submit.spec.ts @@ -0,0 +1,53 @@ +import { FormError } from '@18f/identity-form-steps'; +import { useSandbox } from '@18f/identity-test-helpers'; +import submit, { API_ENDPOINT } from './submit'; + +describe('submit', () => { + const sandbox = useSandbox(); + + context('with successful submission', () => { + beforeEach(() => { + sandbox + .stub(window, 'fetch') + .withArgs( + API_ENDPOINT, + sandbox.match({ body: JSON.stringify({ user_bundle_token: '..', password: 'hunter2' }) }), + ) + .resolves({ + json: () => Promise.resolve({ personal_key: '0000-0000-0000-0000' }), + } as Response); + }); + + it('sends with password confirmation values', async () => { + const patch = await submit({ userBundleToken: '..', password: 'hunter2' }); + + expect(patch).to.deep.equal({ personalKey: '0000-0000-0000-0000' }); + }); + }); + + context('error submission', () => { + beforeEach(() => { + sandbox + .stub(window, 'fetch') + .withArgs( + API_ENDPOINT, + sandbox.match({ body: JSON.stringify({ user_bundle_token: '..', password: 'hunter2' }) }), + ) + .resolves({ + json: () => Promise.resolve({ error: { password: ['incorrect password'] } }), + } as Response); + }); + + it('throws error for the offending field', async () => { + const didError = await submit({ userBundleToken: '..', password: 'hunter2' }).catch( + (error: FormError) => { + expect(error.field).to.equal('password'); + expect(error.message).to.equal('incorrect password'); + return true; + }, + ); + + expect(didError).to.be.true(); + }); + }); +}); diff --git a/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts b/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts new file mode 100644 index 00000000000..b9f74fbc77e --- /dev/null +++ b/app/javascript/packages/verify-flow/steps/password-confirm/submit.ts @@ -0,0 +1,42 @@ +import { FormError } from '@18f/identity-form-steps'; +import { post, ErrorResponse, isErrorResponse } from '../../services/api'; +import type { VerifyFlowValues } from '../../verify-flow'; + +/** + * API endpoint for password confirmation submission. + */ +export const API_ENDPOINT = '/api/verify/v2/password_confirm'; + +/** + * Successful API response shape. + */ +interface PasswordConfirmSuccessResponse { + personal_key: string; +} + +/** + * Failed API response shape. + */ +type PasswordConfirmErrorResponse = ErrorResponse<'password'>; + +/** + * API response shape. + */ +type PasswordConfirmResponse = PasswordConfirmSuccessResponse | PasswordConfirmErrorResponse; + +async function submit({ userBundleToken, password }: VerifyFlowValues) { + const payload = { user_bundle_token: userBundleToken, password }; + const json = await post(API_ENDPOINT, payload, { + json: true, + csrf: true, + }); + + if (isErrorResponse(json)) { + const [field, [error]] = Object.entries(json.error)[0]; + throw new FormError(error, { field }); + } + + return { personalKey: json.personal_key }; +} + +export default submit; diff --git a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx index 8b134f51db5..16bf41696ff 100644 --- a/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx +++ b/app/javascript/packages/verify-flow/verify-flow-step-indicator.tsx @@ -18,6 +18,7 @@ type VerifyFlowStepIndicatorStep = * Mapping of flow form steps to corresponding step indicator step. */ const FLOW_STEP_STEP_MAPPING: Record = { + password_confirm: 'secure_account', personal_key: 'secure_account', personal_key_confirm: 'secure_account', }; diff --git a/app/javascript/packages/verify-flow/verify-flow.spec.tsx b/app/javascript/packages/verify-flow/verify-flow.spec.tsx index 423112dadb1..f64d4707f6d 100644 --- a/app/javascript/packages/verify-flow/verify-flow.spec.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.spec.tsx @@ -1,33 +1,49 @@ -import sinon from 'sinon'; import { render } from '@testing-library/react'; -import * as analytics from '@18f/identity-analytics'; import userEvent from '@testing-library/user-event'; +import * as analytics from '@18f/identity-analytics'; +import { useSandbox } from '@18f/identity-test-helpers'; +import { STEPS } from './steps'; import VerifyFlow from './verify-flow'; describe('VerifyFlow', () => { - const sandbox = sinon.createSandbox(); + const sandbox = useSandbox(); const personalKey = '0000-0000-0000-0000'; beforeEach(() => { sandbox.spy(analytics, 'trackEvent'); - }); - - afterEach(() => { - sandbox.restore(); + sandbox.stub(window, 'fetch').resolves({ + json: () => Promise.resolve({ personal_key: personalKey }), + } as Response); + document.body.innerHTML = ``; }); it('advances through flow to completion', async () => { - const onComplete = sinon.spy(); + const onComplete = sandbox.spy(); - const { getByText, getByLabelText } = render( - , + const { getByText, findByText, getByLabelText } = render( + , ); + // Password confirm + expect(document.title).to.equal('idv.titles.session.review - Example App'); + expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm visited'); + expect(window.location.pathname).to.equal('/password_confirm'); + await userEvent.type(getByLabelText('components.password_toggle.label'), 'password'); + await userEvent.click(getByText('forms.buttons.continue')); + expect(analytics.trackEvent).to.have.been.calledWith('IdV: password confirm submitted'); + // Personal key - expect(getByText('idv.messages.confirm')).to.be.ok(); + expect(document.title).to.equal('titles.idv.personal_key - Example App'); + await findByText('idv.messages.confirm'); + expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key visited'); + expect(window.location.pathname).to.equal('/personal_key'); await userEvent.click(getByText('forms.buttons.continue')); + expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key submitted'); // Personal key confirm + expect(document.title).to.equal('titles.idv.personal_key - Example App'); + expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key confirm visited'); + expect(window.location.pathname).to.equal('/personal_key_confirm'); expect(getByText('idv.messages.confirm')).to.be.ok(); await userEvent.type(getByLabelText('forms.personal_key.confirmation_label'), personalKey); await userEvent.keyboard('{Enter}'); @@ -35,17 +51,19 @@ describe('VerifyFlow', () => { expect(onComplete).to.have.been.called(); }); - it('calls trackEvents for personal key steps', async () => { - const { getByLabelText, getByText, getAllByText } = render( - {}} />, - ); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key visited'); - - await userEvent.click(getByText('forms.buttons.continue')); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key confirm visited'); - await userEvent.type(getByLabelText('forms.personal_key.confirmation_label'), personalKey); - await userEvent.click(getAllByText('forms.buttons.continue')[1]); + context('with specific enabled steps', () => { + it('sets details according to the first enabled steps', () => { + render( + {}} + enabledStepNames={[STEPS[1].name]} + basePath="/" + />, + ); - expect(analytics.trackEvent).to.have.been.calledWith('IdV: personal key submitted'); + expect(document.title).to.equal(`${STEPS[1].title} - Example App`); + expect(window.location.pathname).to.equal(`/${STEPS[1].name}`); + }); }); }); diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx index f4097973926..81444cd49ba 100644 --- a/app/javascript/packages/verify-flow/verify-flow.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -1,9 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { FormSteps } from '@18f/identity-form-steps'; import { trackEvent } from '@18f/identity-analytics'; +import { getConfigValue } from '@18f/identity-config'; import { STEPS } from './steps'; import VerifyFlowStepIndicator from './verify-flow-step-indicator'; import VerifyFlowAlert from './verify-flow-alert'; +import { useSyncedSecretValues } from './context/secrets-context'; +import FlowContext from './context/flow-context'; +import useInitialStepValidation from './hooks/use-initial-step-validation'; export interface VerifyFlowValues { userBundleToken?: string; @@ -29,6 +33,8 @@ export interface VerifyFlowValues { phone?: string; ssn?: string; + + password?: string; } interface VerifyFlowProps { @@ -45,12 +51,17 @@ interface VerifyFlowProps { /** * The path to which the current step is appended to create the current step URL. */ - basePath?: string; + basePath: string; + + /** + * URL to path for session restart. + */ + startOverURL?: string; /** - * Application name, used in generating page titles for current step. + * URL to path for session cancel. */ - appName: string; + cancelURL?: string; /** * Callback invoked after completing the form. @@ -83,34 +94,48 @@ function VerifyFlow({ initialValues = {}, enabledStepNames, basePath, - appName, + startOverURL = '', + cancelURL = '', onComplete, }: VerifyFlowProps) { - const [currentStep, setCurrentStep] = useState(STEPS[0].name); + let steps = STEPS; + if (enabledStepNames) { + steps = steps.filter(({ name }) => enabledStepNames.includes(name)); + } + + const [syncedValues, setSyncedValues] = useSyncedSecretValues(initialValues); + const [currentStep, setCurrentStep] = useState(steps[0].name); + const [initialStep, setCompletedStep] = useInitialStepValidation(basePath, steps); + const context = useMemo( + () => ({ startOverURL, cancelURL, currentStep }), + [startOverURL, cancelURL, currentStep], + ); useEffect(() => { logStepVisited(currentStep); }, [currentStep]); - let steps = STEPS; - if (enabledStepNames) { - steps = steps.filter(({ name }) => enabledStepNames.includes(name)); + function onStepSubmit(stepName: string) { + logStepSubmitted(stepName); + setCompletedStep(stepName); } return ( - <> + - + ); } diff --git a/app/javascript/packs/doc-capture-polling.js b/app/javascript/packs/doc-capture-polling.js deleted file mode 100644 index f70da30e557..00000000000 --- a/app/javascript/packs/doc-capture-polling.js +++ /dev/null @@ -1,12 +0,0 @@ -import { DocumentCapturePolling } from '@18f/identity-document-capture-polling'; -import { getPageData } from '@18f/identity-page-data'; - -new DocumentCapturePolling({ - statusEndpoint: /** @type {string} */ (getPageData('docCaptureStatusEndpoint')), - elements: { - backLink: /** @type {HTMLAnchorElement} */ (document.querySelector('.link-sent-back-link')), - form: /** @type {HTMLFormElement} */ ( - document.querySelector('.link-sent-continue-button-form') - ), - }, -}).bind(); diff --git a/app/javascript/packs/doc-capture-polling.ts b/app/javascript/packs/doc-capture-polling.ts new file mode 100644 index 00000000000..b53647e05bd --- /dev/null +++ b/app/javascript/packs/doc-capture-polling.ts @@ -0,0 +1,11 @@ +import { DocumentCapturePolling } from '@18f/identity-document-capture-polling'; + +new DocumentCapturePolling({ + statusEndpoint: document + .querySelector('[data-status-endpoint]') + ?.getAttribute('data-status-endpoint') as string, + elements: { + backLink: document.querySelector('.link-sent-back-link') as HTMLAnchorElement, + form: document.querySelector('.link-sent-continue-button-form') as HTMLFormElement, + }, +}).bind(); diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index 317377fb4ba..b6f2e79a132 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -12,6 +12,7 @@ import { HelpCenterContextProvider, } from '@18f/identity-document-capture'; import { isCameraCapableMobile } from '@18f/identity-device'; +import { FlowContext } from '@18f/identity-verify-flow'; import { trackEvent } from '@18f/identity-analytics'; /** @typedef {import('@18f/identity-document-capture').FlowPath} FlowPath */ @@ -182,8 +183,16 @@ const noticeError = (error) => backgroundUploadEncryptKey, formData, flowPath, - startOverURL, - cancelURL, + }, + ], + [ + FlowContext.Provider, + { + value: { + startOverURL, + cancelURL, + currentStep: 'document_capture', + }, }, ], [ServiceProviderContextProvider, { value: getServiceProvider() }], diff --git a/app/javascript/packs/personal-key-page-controller.js b/app/javascript/packs/personal-key-page-controller.js index e65aa35c564..8491a825551 100644 --- a/app/javascript/packs/personal-key-page-controller.js +++ b/app/javascript/packs/personal-key-page-controller.js @@ -125,9 +125,14 @@ function downloadForIE(event) { window.navigator.msSaveBlob(blob, filename); } +function trackDownload() { + trackEvent('IdV: download personal key'); +} + modalTrigger.addEventListener('click', show); modalDismiss.addEventListener('click', hide); formEl.addEventListener('submit', handleSubmit); +downloadLink.addEventListener('click', trackDownload); if (window.navigator.msSaveBlob) { downloadLink.addEventListener('click', downloadForIE); diff --git a/app/javascript/packs/step-indicator.js b/app/javascript/packs/step-indicator.js index 38b359bf6dc..9de94d18c2e 100644 --- a/app/javascript/packs/step-indicator.js +++ b/app/javascript/packs/step-indicator.js @@ -1 +1 @@ -import '@18f/identity-step-indicator'; +import '@18f/identity-step-indicator/step-indicator-element'; diff --git a/app/javascript/packs/verify-flow.tsx b/app/javascript/packs/verify-flow.tsx index 99c8e9c4576..ab4112eb960 100644 --- a/app/javascript/packs/verify-flow.tsx +++ b/app/javascript/packs/verify-flow.tsx @@ -1,7 +1,7 @@ import { render } from 'react-dom'; import { VerifyFlow, SecretsContextProvider } from '@18f/identity-verify-flow'; -import { s2ab } from '@18f/identity-secret-session-storage'; -import type { VerifyFlowValues } from '@18f/identity-verify-flow'; +import SecretSessionStorage, { s2ab } from '@18f/identity-secret-session-storage'; +import type { SecretValues, VerifyFlowValues } from '@18f/identity-verify-flow'; interface AppRootValues { /** @@ -20,9 +20,14 @@ interface AppRootValues { basePath: string; /** - * Application name. + * URL to path for session restart. */ - appName: string; + startOverUrl: string; + + /** + * URL to path for session cancel. + */ + cancelUrl: string; /** * URL to which user should be redirected after completing the form. @@ -49,7 +54,8 @@ const { initialValues: initialValuesJSON, enabledStepNames: enabledStepNamesJSON, basePath, - appName, + startOverUrl: startOverURL, + cancelUrl: cancelURL, completionUrl: completionURL, storeKey: storeKeyBase64, } = appRoot.dataset; @@ -60,27 +66,44 @@ const enabledStepNames = JSON.parse(enabledStepNamesJSON) as string[]; const camelCase = (string: string) => string.replace(/[^a-z]([a-z])/gi, (_match, nextLetter) => nextLetter.toUpperCase()); -if (initialValues.userBundleToken) { - const jwtData = JSON.parse(atob(initialValues.userBundleToken.split('.')[1])); - const pii = Object.fromEntries( - Object.entries(jwtData.pii).map(([key, value]) => [camelCase(key), value]), - ); - Object.assign(initialValues, pii); -} +const mapKeys = (object: object, mapKey: (key: string) => string) => + Object.entries(object).map(([key, value]) => [mapKey(key), value]); function onComplete() { window.location.href = completionURL; } -render( - - - , - appRoot, -); +const storage = new SecretSessionStorage('verify'); + +(async () => { + const cryptoKey = await crypto.subtle.importKey('raw', storeKey, 'AES-GCM', true, [ + 'encrypt', + 'decrypt', + ]); + storage.key = cryptoKey; + await storage.load(); + if (initialValues.userBundleToken) { + await storage.setItem('userBundleToken', initialValues.userBundleToken); + } + + const userBundleToken = storage.getItem('userBundleToken'); + if (userBundleToken) { + const jwtData = JSON.parse(atob(userBundleToken.split('.')[1])); + const pii = Object.fromEntries(mapKeys(jwtData.pii, camelCase)); + Object.assign(initialValues, pii); + } + + render( + + + , + appRoot, + ); +})(); diff --git a/app/models/user.rb b/app/models/user.rb index a96101a7a4b..5106710d1f7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -99,14 +99,26 @@ def default_phone_configuration phone_configurations.order('made_default_at DESC NULLS LAST, created_at').first end + MINIMUM_LIKELY_ENCRYPTED_DATA_LENGTH = 1000 + def broken_personal_key? window_start = IdentityConfig.store.broken_personal_key_window_start window_finish = IdentityConfig.store.broken_personal_key_window_finish last_personal_key_at = self.encrypted_recovery_code_digest_generated_at - (!last_personal_key_at || last_personal_key_at < window_finish) && - active_profile.present? && - (window_start..window_finish).cover?(active_profile.verified_at) + if active_profile.present? + encrypted_pii_too_short = + active_profile.encrypted_pii_recovery.present? && + active_profile.encrypted_pii_recovery.length < MINIMUM_LIKELY_ENCRYPTED_DATA_LENGTH + + inside_broken_key_window = + (!last_personal_key_at || last_personal_key_at < window_finish) && + (window_start..window_finish).cover?(active_profile.verified_at) + + encrypted_pii_too_short || inside_broken_key_window + else + false + end end # To send emails asynchronously via ActiveJob. diff --git a/app/services/analytics.rb b/app/services/analytics.rb index fc65bba6b40..a83699202c2 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -138,16 +138,6 @@ def session_started_at # rubocop:disable Layout/LineLength DOC_AUTH = 'Doc Auth' # visited or submitted is appended - IDV_FORGOT_PASSWORD = 'IdV: forgot password visited' - IDV_FORGOT_PASSWORD_CONFIRMED = 'IdV: forgot password confirmed' - IDV_INTRO_VISIT = 'IdV: intro visited' - IDV_JURISDICTION_VISIT = 'IdV: jurisdiction visited' - IDV_JURISDICTION_FORM = 'IdV: jurisdiction form submitted' - IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT = 'IdV: Phone OTP delivery Selection Visited' - IDV_PHONE_USE_DIFFERENT = 'IdV: use different phone number' - IDV_PHONE_RECORD_VISIT = 'IdV: phone of record visited' - IDV_REVIEW_COMPLETE = 'IdV: review complete' - IDV_REVIEW_VISIT = 'IdV: review info visited' IDV_START_OVER = 'IdV: start over' IDV_GPO_ADDRESS_LETTER_REQUESTED = 'IdV: USPS address letter requested' IDV_GPO_ADDRESS_SUBMITTED = 'IdV: USPS address submitted' diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index e739bae42b6..e938ccaa487 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -584,6 +584,21 @@ def idv_doc_auth_warning_visited( ) end + # User visited forgot password page + def idv_forgot_password + track_event('IdV: forgot password visited') + end + + # User confirmed forgot password + def idv_forgot_password_confirmed + track_event('IdV: forgot password confirmed') + end + + # User visits IdV + def idv_intro_visit + track_event('IdV: intro visited') + end + # @param [Boolean] success # Tracks the last step of IDV, indicates the user successfully prooved def idv_final( @@ -607,7 +622,6 @@ def idv_personal_key_submitted track_event('IdV: personal key submitted') end - # @deprecated # 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 @@ -807,6 +821,36 @@ def idv_phone_otp_delivery_selection_submitted( ) end + # User visited idv phone of record + def idv_phone_of_record_visited + track_event('IdV: phone of record visited') + end + + # User visited idv phone OTP delivery selection + def idv_phone_otp_delivery_selection_visit + track_event('IdV: Phone OTP delivery Selection Visited') + end + + # @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) + track_event( + 'IdV: use different phone number', + step: step, + **extra, + ) + end + + # User completed idv + def idv_review_complete + track_event('IdV: review complete') + end + + # User visited idv phone of record + def idv_review_info_visited + track_event('IdV: review info visited') + end + # User has visited the page that lets them confirm if they want a new personal key def profile_personal_key_visit track_event('Profile: Visited new personal key') diff --git a/app/services/doc_auth/mock/result_response_builder.rb b/app/services/doc_auth/mock/result_response_builder.rb index 7c575b2c7ad..04d128b0964 100644 --- a/app/services/doc_auth/mock/result_response_builder.rb +++ b/app/services/doc_auth/mock/result_response_builder.rb @@ -1,23 +1,6 @@ module DocAuth module Mock class ResultResponseBuilder - DEFAULT_PII_FROM_DOC = { - first_name: 'FAKEY', - middle_name: nil, - last_name: 'MCFAKERSON', - address1: '1 FAKE RD', - address2: nil, - city: 'GREAT FALLS', - state: 'MT', - zipcode: '59010', - dob: '1938-10-06', - state_id_number: '1111111111111', - state_id_jurisdiction: 'ND', - state_id_type: 'drivers_license', - state_id_expiration: '2099-12-31', - phone: nil, - }.freeze - attr_reader :uploaded_file, :config, :liveness_enabled def initialize(uploaded_file, config, liveness_enabled) @@ -117,7 +100,7 @@ def pii_from_doc raw_pii = parsed_data_from_uploaded_file['document'] raw_pii&.symbolize_keys || {} else - DEFAULT_PII_FROM_DOC + Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC end end diff --git a/app/services/encryption/encryptors/user_access_key_encryptor.rb b/app/services/encryption/encryptors/user_access_key_encryptor.rb deleted file mode 100644 index 652bda96a41..00000000000 --- a/app/services/encryption/encryptors/user_access_key_encryptor.rb +++ /dev/null @@ -1,57 +0,0 @@ -module Encryption - module Encryptors - class UserAccessKeyEncryptor - include Encodable - - DELIMITER = '.'.freeze - - def initialize(user_access_key) - @user_access_key = user_access_key - @encryptor = AesEncryptor.new - end - - def encrypt(plaintext) - user_access_key.build unless user_access_key.built? - encrypted_contents = encryptor.encrypt(plaintext, user_access_key.cek) - build_ciphertext(user_access_key.encryption_key, encrypted_contents) - end - - def decrypt(ciphertext) - encryption_key = encryption_key_from_ciphertext(ciphertext) - encrypted_contents = encrypted_contents_from_ciphertext(ciphertext) - unlock_user_access_key(encryption_key) - encryptor.decrypt(encrypted_contents, user_access_key.cek) - end - - private - - attr_reader :encryptor, :user_access_key - - def build_ciphertext(encryption_key, encrypted_contents) - [ - encode(encryption_key), - encrypted_contents, - ].join(DELIMITER) - end - - def encryption_key_from_ciphertext(ciphertext) - encoded_encryption_key = ciphertext.split(DELIMITER).first - raise EncryptionError, 'ciphertext is invalid' unless valid_base64_encoding?( - encoded_encryption_key, - ) - decode(encoded_encryption_key) - end - - def encrypted_contents_from_ciphertext(ciphertext) - contents = ciphertext.split(DELIMITER).second - raise EncryptionError, 'ciphertext is missing encrypted contents' if contents.nil? - contents - end - - def unlock_user_access_key(encryption_key) - return if user_access_key.unlocked? && user_access_key.encryption_key == encryption_key - user_access_key.unlock(encryption_key) - end - end - end -end diff --git a/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb b/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb index f873533b01e..4ba65687317 100644 --- a/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb +++ b/app/services/funnel/doc_auth/register_step_from_analytics_view_event.rb @@ -2,8 +2,8 @@ module Funnel module DocAuth class RegisterStepFromAnalyticsViewEvent ANALYTICS_EVENT_TO_DOC_AUTH_LOG_TOKEN = { - Analytics::IDV_PHONE_RECORD_VISIT => :verify_phone, - Analytics::IDV_REVIEW_VISIT => :encrypt, + 'IdV: phone of record visited' => :verify_phone, + 'IdV: review info visited' => :encrypt, 'IdV: final resolution' => :verified, Analytics::IDV_GPO_ADDRESS_VISITED => :usps_address, }.freeze diff --git a/app/services/pii/cacher.rb b/app/services/pii/cacher.rb index 55b5a9705b5..89f72f90b29 100644 --- a/app/services/pii/cacher.rb +++ b/app/services/pii/cacher.rb @@ -31,16 +31,30 @@ def fetch Pii::Attributes.new_from_json(pii_string) end + # Between requests, the decrypted PII bundle is encrypted with KMS and moved to the + # 'encrypted_pii' key by the SessionEncryptor. + # + # The PII is decrypted on-demand by this method and moved into the 'decrypted_pii' key. + # See SessionEncryptor#kms_encrypt_pii! for more detail. def fetch_string - user_session[:decrypted_pii] + return unless user_session[:decrypted_pii] || user_session[:encrypted_pii] + return user_session[:decrypted_pii] if user_session[:decrypted_pii].present? + + decrypted = SessionEncryptor.new.kms_decrypt( + user_session[:encrypted_pii], + ) + user_session[:decrypted_pii] = decrypted + + decrypted end def exists_in_session? - fetch_string.present? + return user_session[:decrypted_pii] || user_session[:encrypted_pii] end def delete user_session.delete(:decrypted_pii) + user_session.delete(:encrypted_pii) end private diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 76cee21444f..8dad65addca 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -19,9 +19,13 @@ input_html: { autocorrect: 'off', aria: { invalid: false, describedby: 'email-description' } } %> <%= f.input :request_id, as: :hidden, input_html: { value: request_id } %> - <%= f.button :submit, t('forms.buttons.continue'), class: 'usa-button--big usa-button--wide margin-top-2' %> + <%= f.button( + :submit, + t('forms.buttons.continue'), + class: 'display-block usa-button--big usa-button--wide margin-y-5', + ) %> <% end %> -
+<%= render(PageFooterComponent.new) do %> <%= link_to t('links.cancel'), decorated_session.cancel_link_url %> -
+<% end %> diff --git a/app/views/forgot_password/show.html.erb b/app/views/forgot_password/show.html.erb index 7491b476026..2897f91a9a6 100644 --- a/app/views/forgot_password/show.html.erb +++ b/app/views/forgot_password/show.html.erb @@ -20,8 +20,8 @@ html: { autocomplete: 'off', method: :post, class: 'margin-bottom-2' }, url: user_password_path do |f| %> - <%= f.input :email, as: :hidden, wrapper: false %> - <%= f.input :resend, as: :hidden, wrapper: false %> + <%= f.input :email, as: :hidden %> + <%= f.input :resend, as: :hidden %> <%= f.input :request_id, as: :hidden, input_html: { value: request_id } %>

<%= t('notices.forgot_password.no_email_sent_explanation_start') %> <%= f.button :button, t('links.resend'), class: 'usa-button--unstyled margin-left-05' %>

diff --git a/app/views/idv/doc_auth/link_sent.html.erb b/app/views/idv/doc_auth/link_sent.html.erb index 1ed880bfc93..4fce17076e7 100644 --- a/app/views/idv/doc_auth/link_sent.html.erb +++ b/app/views/idv/doc_auth/link_sent.html.erb @@ -37,7 +37,7 @@ <% if FeatureManagement.doc_capture_polling_enabled? %> - <%= content_tag 'script', '', data: { doc_capture_status_endpoint: idv_capture_doc_status_url } %> + <%= content_tag 'script', '', data: { status_endpoint: idv_capture_doc_status_url } %> <%= javascript_packs_tag_once 'doc-capture-polling' %> <% end %> diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index a1b8f5a4505..390163d1279 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -31,8 +31,8 @@ max_capture_attempts_before_tips: IdentityConfig.store.doc_auth_max_capture_attempts_before_tips, sp_name: sp_name, flow_path: flow_path, - start_over_url: idv_session_path(step: 'document_capture'), - cancel_url: idv_cancel_path(step: 'document_capture'), + start_over_url: idv_session_path, + cancel_url: idv_cancel_path, failure_to_proof_url: failure_to_proof_url, front_image_upload_url: front_image_upload_url, back_image_upload_url: back_image_upload_url, diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 18b3f91f379..d814df410bd 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -84,7 +84,15 @@ } %> <% end %> - <%= content_tag 'script', '', data: { analytics_endpoint: api_logger_path } %> + <%= content_tag( + :script, + { + 'appName' => APP_NAME, + 'analyticsEndpoint' => api_logger_path, + }.to_json, + { type: 'application/json', data: { config: '' } }, + false, + ) %> <%= javascript_packs_tag_once('application', prepend: true) %> <%= render_javascript_pack_once_tags %> diff --git a/app/views/sign_up/emails/show.html.erb b/app/views/sign_up/emails/show.html.erb index 23551abf632..bea5f9e71c8 100644 --- a/app/views/sign_up/emails/show.html.erb +++ b/app/views/sign_up/emails/show.html.erb @@ -22,9 +22,9 @@ <%= validated_form_for @resend_email_confirmation_form, html: { class: 'margin-bottom-2' }, url: sign_up_register_path do |f| %> - <%= f.input :email, as: :hidden, wrapper: false %> - <%= f.input :resend, as: :hidden, wrapper: false %> - <%= f.input :request_id, as: :hidden, wrapper: false %> + <%= f.input :email, as: :hidden %> + <%= f.input :resend, as: :hidden %> + <%= f.input :request_id, as: :hidden %>

<%= t('notices.signed_up_but_unconfirmed.no_email_sent_explanation_start') %> <%= f.button :button, t('links.resend'), class: 'usa-button--unstyled margin-left-05' %>

diff --git a/config/application.yml.default b/config/application.yml.default index a16558208ad..bfb8384068a 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -216,6 +216,8 @@ select_multiple_mfa_options: false service_provider_request_ttl_hours: 24 session_check_delay: 30 session_check_frequency: 30 +session_encryptor_alert_enabled: false +session_encryptor_v2_enabled: true session_timeout_in_minutes: 15 session_timeout_warning_seconds: 150 session_total_duration_timeout_in_minutes: 720 @@ -362,6 +364,8 @@ production: password_pepper: piv_cac_verify_token_secret: platform_authentication_enabled: false + session_encryptor_alert_enabled: true + session_encryptor_v2_enabled: false recurring_jobs_disabled_names: "[]" redis_throttle_url: redis://redis.login.gov.internal:6379/1 redis_url: redis://redis.login.gov.internal:6379 diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 32b689fe2ea..954161851ea 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,4 +1,5 @@ require 'session_encryptor' +require 'legacy_session_encryptor' require 'session_encryptor_error_handler' Rails.application.config.session_store( diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb index 2d278c802c3..7cc76ce5b2b 100644 --- a/config/initializers/simple_form.rb +++ b/config/initializers/simple_form.rb @@ -9,6 +9,7 @@ config.wrapper_mappings = { boolean: :uswds_checkbox, radio_buttons: :uswds_radio_buttons, + hidden: :unwrapped, } config.wrappers :base do |b| @@ -31,6 +32,10 @@ b.use :error, wrap_with: { tag: 'div', class: 'usa-error-message' } end + config.wrappers :unwrapped, wrapper: false do |b| + b.use :input + end + config.wrappers :uswds_checkbox do |b| b.use :html5 b.use :hint, wrap_with: { tag: 'div', class: 'usa-hint' } diff --git a/config/routes.rb b/config/routes.rb index cc5a5de2fc0..6bffb1a09ee 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -329,7 +329,7 @@ get '/verify/v2(/:step)' => 'verify#show', as: :idv_app namespace :api do - post '/verify/complete' => 'verify/complete#create' + post '/verify/v2/password_confirm' => 'verify/password_confirm#create' end get '/account/verify' => 'idv/gpo_verify#index', as: :idv_gpo_verify diff --git a/knapsack_rspec_report.json b/knapsack_rspec_report.json index aaa797f93eb..acdb4276dd7 100644 --- a/knapsack_rspec_report.json +++ b/knapsack_rspec_report.json @@ -1,822 +1,820 @@ { - "spec/blueprints/agreements/agency_blueprint_spec.rb": 0.006590999953914434, - "spec/blueprints/agreements/iaa_blueprint_spec.rb": 0.020056000037584454, - "spec/blueprints/agreements/partner_account_blueprint_spec.rb": 0.011812999960966408, - "spec/components/accordion_component_spec.rb": 0.010516999987885356, - "spec/components/alert_component_spec.rb": 0.055206999997608364, - "spec/components/base_component_spec.rb": 0.010506999969948083, - "spec/components/button_component_spec.rb": 0.04488499998115003, - "spec/components/clipboard_button_component_spec.rb": 0.0269059999845922, - "spec/components/flash_component_spec.rb": 0.018195000000559958, - "spec/components/icon_component_spec.rb": 0.05076100002042949, - "spec/components/page_footer_component_spec.rb": 0.01317300001392141, - "spec/components/page_heading_component_spec.rb": 0.015363999991677701, - "spec/components/phone_input_component_spec.rb": 1.6587389999767765, - "spec/components/process_list_component_spec.rb": 0.07846799999242648, - "spec/components/time_component_spec.rb": 0.028799999970942736, - "spec/components/validated_field_component_spec.rb": 0.08930799999507144, - "spec/components/vendor_outage_alert_component_spec.rb": 0.02613000000201282, - "spec/config/initializers/ahoy_spec.rb": 0.02662299998337403, - "spec/config/initializers/phonelib_spec.rb": 0.026941999967675656, - "spec/controllers/account_reset/cancel_controller_spec.rb": 0.5463889999664389, - "spec/controllers/account_reset/confirm_delete_account_controller_spec.rb": 0.013554000004660338, - "spec/controllers/account_reset/confirm_request_controller_spec.rb": 0.05548300000373274, - "spec/controllers/account_reset/delete_account_controller_spec.rb": 0.6870759999728762, - "spec/controllers/account_reset/pending_controller_spec.rb": 0.2268000000040047, - "spec/controllers/account_reset/request_controller_spec.rb": 0.8787019999581389, - "spec/controllers/accounts/personal_keys_controller_spec.rb": 0.28714999998919666, - "spec/controllers/accounts_controller_spec.rb": 0.2689529999624938, - "spec/controllers/analytics_events_controller_spec.rb": 0.014964999980293214, - "spec/controllers/application_controller_spec.rb": 2.1102909999899566, - "spec/controllers/concerns/effective_user_spec.rb": 0.05513500003144145, - "spec/controllers/concerns/idv/document_capture_concern_spec.rb": 0.09357199998339638, - "spec/controllers/concerns/idv_step_concern_spec.rb": 0.14577200001804158, - "spec/controllers/concerns/verify_sp_attributes_concern_spec.rb": 0.43789800000377, - "spec/controllers/country_support_controller_spec.rb": 0.02383299998473376, - "spec/controllers/event_disavowal_controller_spec.rb": 0.28618200001074, - "spec/controllers/fake_s3_controller_spec.rb": 0.026658000017050654, - "spec/controllers/forgot_password_controller_spec.rb": 0.02369400003226474, - "spec/controllers/frontend_log_controller_spec.rb": 0.16412999999010935, - "spec/controllers/health/database_controller_spec.rb": 0.022410999983549118, - "spec/controllers/health/health_controller_spec.rb": 0.026191999961156398, - "spec/controllers/health/job_controller_spec.rb": 0.01804299996001646, - "spec/controllers/health/outbound_controller_spec.rb": 0.0866710000555031, - "spec/controllers/idv/cancellations_controller_spec.rb": 0.2216500000213273, - "spec/controllers/idv/capture_doc_controller_spec.rb": 0.2618640000000596, - "spec/controllers/idv/capture_doc_status_controller_spec.rb": 0.13562400004593655, - "spec/controllers/idv/come_back_later_controller_spec.rb": 0.09455199999501929, - "spec/controllers/idv/confirmations_controller_spec.rb": 0.9070530000026338, - "spec/controllers/idv/doc_auth_controller_spec.rb": 0.7076499999966472, - "spec/controllers/idv/forgot_password_controller_spec.rb": 0.2122899999958463, - "spec/controllers/idv/gpo_controller_spec.rb": 0.5831569999572821, - "spec/controllers/idv/gpo_verify_controller_spec.rb": 0.5078499999945052, - "spec/controllers/idv/image_uploads_controller_spec.rb": 1.2266349999699742, - "spec/controllers/idv/otp_delivery_method_controller_spec.rb": 0.3489669999689795, - "spec/controllers/idv/otp_verification_controller_spec.rb": 0.11187799996696413, - "spec/controllers/idv/personal_key_controller_spec.rb": 0.9693050000350922, - "spec/controllers/idv/phone_controller_spec.rb": 0.5693759999703616, - "spec/controllers/idv/phone_errors_controller_spec.rb": 0.17612600000575185, - "spec/controllers/idv/resend_otp_controller_spec.rb": 0.11073700000997633, - "spec/controllers/idv/review_controller_spec.rb": 1.1269719999982044, - "spec/controllers/idv/session_errors_controller_spec.rb": 0.13314600003650412, - "spec/controllers/idv/sessions_controller_spec.rb": 0.1530229999916628, - "spec/controllers/idv_controller_spec.rb": 0.48395099997287616, - "spec/controllers/mfa_confirmation_controller_spec.rb": 0.21629000001121312, - "spec/controllers/openid_connect/authorization_controller_spec.rb": 0.6166690000100061, - "spec/controllers/openid_connect/certs_controller_spec.rb": 0.008838999958243221, - "spec/controllers/openid_connect/configuration_controller_spec.rb": 0.0147510000388138, - "spec/controllers/openid_connect/logout_controller_spec.rb": 0.24080899998079985, - "spec/controllers/openid_connect/token_controller_spec.rb": 0.1723780000465922, - "spec/controllers/openid_connect/user_info_controller_spec.rb": 0.13860900001600385, - "spec/controllers/pages_controller_spec.rb": 0.048395999998319894, - "spec/controllers/password_capture_controller_spec.rb": 0.1540610000374727, - "spec/controllers/reactivate_account_controller_spec.rb": 0.1323339999653399, - "spec/controllers/reauthn_required_controller_spec.rb": 0.0768650000100024, - "spec/controllers/redirect/help_center_controller_spec.rb": 0.0465579999727197, - "spec/controllers/redirect/return_to_sp_controller_spec.rb": 0.11653699999442324, - "spec/controllers/return_to_sp_controller_spec.rb": 0.04875499999616295, - "spec/controllers/risc/configuration_controller_spec.rb": 0.015060000005178154, - "spec/controllers/risc/security_events_controller_spec.rb": 0.5057789999991655, - "spec/controllers/saml_idp_controller_spec.rb": 10.655609000008553, - "spec/controllers/saml_post_controller_spec.rb": 0.07014200000048731, - "spec/controllers/saml_signed_message_spec.rb": 0.46719100000336766, - "spec/controllers/service_provider_controller_spec.rb": 0.053694000001996756, - "spec/controllers/sign_out_controller_spec.rb": 0.03090200002770871, - "spec/controllers/sign_up/cancellations_controller_spec.rb": 0.0674569999682717, - "spec/controllers/sign_up/completions_controller_spec.rb": 0.5222310000099242, - "spec/controllers/sign_up/email_confirmations_controller_spec.rb": 0.11907799995969981, - "spec/controllers/sign_up/emails_controller_spec.rb": 0.013709000020753592, - "spec/controllers/sign_up/passwords_controller_spec.rb": 0.16101099998923019, - "spec/controllers/sign_up/personal_keys_controller_spec.rb": 0.13565599999856204, - "spec/controllers/sign_up/registrations_controller_spec.rb": 0.8895320000010543, - "spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb": 0.035242000012658536, - "spec/controllers/test/push_notification_controller_spec.rb": 0.020437999977730215, - "spec/controllers/test/telephony_controller_spec.rb": 0.01789800002006814, - "spec/controllers/two_factor_authentication/backup_code_verification_controller_spec.rb": 0.4267099999706261, - "spec/controllers/two_factor_authentication/options_controller_spec.rb": 0.272491000010632, - "spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb": 1.7147690000128932, - "spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb": 0.7888769999844953, - "spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb": 0.8228419999941252, - "spec/controllers/two_factor_authentication/sms_opt_in_controller_spec.rb": 0.616148000000976, - "spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb": 0.5480570000363514, - "spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb": 0.1575759999686852, - "spec/controllers/users/aal3_controller_spec.rb": 0.00858600001083687, - "spec/controllers/users/authorization_confirmation_controller_spec.rb": 0.2119630000088364, - "spec/controllers/users/backup_code_setup_controller_spec.rb": 0.5164419999928214, - "spec/controllers/users/delete_controller_spec.rb": 0.3274470000178553, - "spec/controllers/users/edit_phone_controller_spec.rb": 0.15906699997140095, - "spec/controllers/users/email_confirmations_controller_spec.rb": 0.5017220000154339, - "spec/controllers/users/email_language_controller_spec.rb": 0.18850899999961257, - "spec/controllers/users/emails_controller_spec.rb": 0.019782999996095896, - "spec/controllers/users/forget_all_browsers_controller_spec.rb": 0.1451309999683872, - "spec/controllers/users/passwords_controller_spec.rb": 0.8540110000176355, - "spec/controllers/users/personal_keys_controller_spec.rb": 0.4724910000222735, - "spec/controllers/users/phone_setup_controller_spec.rb": 0.2114519999595359, - "spec/controllers/users/phones_controller_spec.rb": 0.024490000039804727, - "spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb": 0.6617160000023432, - "spec/controllers/users/reset_passwords_controller_spec.rb": 2.0434920000261627, - "spec/controllers/users/rules_of_use_controller_spec.rb": 0.3354500000132248, - "spec/controllers/users/service_provider_revoke_controller_spec.rb": 0.41406599996844307, - "spec/controllers/users/sessions_controller_spec.rb": 1.9071180000319146, - "spec/controllers/users/totp_setup_controller_spec.rb": 1.3435910000116564, - "spec/controllers/users/two_factor_authentication_controller_spec.rb": 0.9592260000063106, - "spec/controllers/users/two_factor_authentication_setup_controller_spec.rb": 0.2013460000162013, - "spec/controllers/users/verify_password_controller_spec.rb": 0.40021300001535565, - "spec/controllers/users/verify_personal_key_controller_spec.rb": 0.47911900002509356, - "spec/controllers/users/webauthn_setup_controller_spec.rb": 0.3193550000432879, - "spec/controllers/users_controller_spec.rb": 0.1636389999766834, - "spec/controllers/vendor_outage_controller_spec.rb": 0.011704000004101545, - "spec/decorators/device_decorator_spec.rb": 0.08465400000568479, - "spec/decorators/email_context_spec.rb": 0.05298799998126924, - "spec/decorators/event_decorator_spec.rb": 0.08396199997514486, - "spec/decorators/mfa_context_spec.rb": 0.8965149999712594, - "spec/decorators/service_provider_session_decorator_spec.rb": 0.18400199996540323, - "spec/decorators/session_decorator_spec.rb": 0.017126999970059842, - "spec/decorators/user_decorator_spec.rb": 0.5591940000304021, - "spec/features/accessibility/idv_pages_spec.rb": 39.450964999967255, - "spec/features/accessibility/static_pages_spec.rb": 4.9093670001020655, - "spec/features/accessibility/user_pages_spec.rb": 21.339085999992676, - "spec/features/accessibility/visitor_pages_spec.rb": 3.123157999943942, - "spec/features/account/backup_codes_spec.rb": 3.0658690000418574, - "spec/features/account/device_spec.rb": 0.1939410000341013, - "spec/features/account/unphishable_badge_spec.rb": 0.2186489999294281, - "spec/features/account_connected_apps_spec.rb": 1.6723450000863522, - "spec/features/account_creation/multiple_browsers_spec.rb": 14.140583000029437, - "spec/features/account_creation/sp_return_log_spec.rb": 1.6781770000234246, - "spec/features/account_email_language_spec.rb": 1.2794979999307543, - "spec/features/account_history_spec.rb": 0.2591740000061691, - "spec/features/account_reset/cancel_request_spec.rb": 1.6959090000018477, - "spec/features/account_reset/delete_account_spec.rb": 5.374966999981552, - "spec/features/account_reset/pending_request_spec.rb": 2.2841389999957755, - "spec/features/device_tracking_spec.rb": 0.5141469999216497, - "spec/features/event_disavowal_spec.rb": 16.493442000006326, - "spec/features/idv/account_creation_spec.rb": 17.908079999964684, - "spec/features/idv/actions/cancel_link_sent_action_spec.rb": 1.4456380000337958, - "spec/features/idv/actions/cancel_send_link_action_spec.rb": 1.0875320000341162, - "spec/features/idv/actions/reset_action_spec.rb": 0.9435989999910817, - "spec/features/idv/clearing_and_restarting_spec.rb": 27.42069800000172, - "spec/features/idv/doc_auth/address_step_spec.rb": 12.424475000007078, - "spec/features/idv/doc_auth/agreement_step_spec.rb": 9.952607000013813, - "spec/features/idv/doc_auth/cancel_spec.rb": 1.7999110000673681, - "spec/features/idv/doc_auth/document_capture_step_spec.rb": 61.651320999953896, - "spec/features/idv/doc_auth/email_sent_step_spec.rb": 2.0642989999614656, - "spec/features/idv/doc_auth/finished_spec.rb": 3.450222000014037, - "spec/features/idv/doc_auth/link_sent_step_spec.rb": 27.812629000050947, - "spec/features/idv/doc_auth/send_link_step_spec.rb": 12.77137600001879, - "spec/features/idv/doc_auth/ssn_step_spec.rb": 20.509144999901764, - "spec/features/idv/doc_auth/test_credentials_spec.rb": 8.029206000035629, - "spec/features/idv/doc_auth/upload_step_spec.rb": 6.010871999897063, - "spec/features/idv/doc_auth/verify_step_spec.rb": 54.162255999981426, - "spec/features/idv/doc_auth/welcome_step_spec.rb": 2.8040620000101626, - "spec/features/idv/doc_capture/capture_complete_step_spec.rb": 3.0934889999916777, - "spec/features/idv/doc_capture/document_capture_step_spec.rb": 56.814081999938935, - "spec/features/idv/gpo_disabled_spec.rb": 6.910195000004023, - "spec/features/idv/hybrid_flow_spec.rb": 13.074644000036642, - "spec/features/idv/liveness/upgrade_to_strong_ial2_spec.rb": 7.987497000023723, - "spec/features/idv/phone_input_spec.rb": 11.17537200008519, - "spec/features/idv/phone_otp_rate_limiting_spec.rb": 25.98328499996569, - "spec/features/idv/proofing_components_spec.rb": 43.197928999958094, - "spec/features/idv/sp_handoff_spec.rb": 30.07814200001303, - "spec/features/idv/sp_requested_attributes_spec.rb": 16.733703000005335, - "spec/features/idv/steps/confirmation_step_spec.rb": 57.95603200001642, - "spec/features/idv/steps/forgot_password_step_spec.rb": 6.943409000057727, - "spec/features/idv/steps/gpo_otp_verification_step_spec.rb": 19.36003800004255, - "spec/features/idv/steps/gpo_step_spec.rb": 11.315794000052847, - "spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb": 27.78859100001864, - "spec/features/idv/steps/phone_otp_verification_step_spec.rb": 25.285217000055127, - "spec/features/idv/steps/phone_step_spec.rb": 104.97321700002067, - "spec/features/idv/steps/review_step_spec.rb": 37.92662599997129, - "spec/features/idv/uak_password_spec.rb": 6.835907000000589, - "spec/features/idv/vendor_outage_spec.rb": 20.898211999970954, - "spec/features/legacy_passwords_spec.rb": 2.232516000047326, - "spec/features/load_testing/email_sign_up_spec.rb": 0.555809999932535, - "spec/features/multiple_emails/add_email_spec.rb": 12.246642000041902, - "spec/features/multiple_emails/email_management_spec.rb": 3.354556000092998, - "spec/features/multiple_emails/reset_password_spec.rb": 0.8763980000512674, - "spec/features/multiple_emails/sign_in_spec.rb": 3.8980389999924228, - "spec/features/multiple_emails/sp_sign_in_spec.rb": 3.4887609999859706, - "spec/features/new_device_tracking_spec.rb": 1.3356360000325367, - "spec/features/openid_connect/aal3_required_spec.rb": 3.0507140000117943, - "spec/features/openid_connect/authorization_confirmation_spec.rb": 6.935756999999285, - "spec/features/openid_connect/openid_connect_spec.rb": 31.604887999943458, - "spec/features/openid_connect/redirect_uri_validation_spec.rb": 3.556150000076741, - "spec/features/phone/add_phone_spec.rb": 4.639212999958545, - "spec/features/phone/confirmation_spec.rb": 27.08038800000213, - "spec/features/phone/default_phone_selection_spec.rb": 3.3339739999501035, - "spec/features/phone/edit_phone_spec.rb": 0.3707530000247061, - "spec/features/phone/rate_limitting_spec.rb": 26.21592700004112, - "spec/features/phone/remove_phone_spec.rb": 0.6765279999235645, - "spec/features/remember_device/cookie_expiration_spec.rb": 1.3009539999766275, - "spec/features/remember_device/phone_spec.rb": 14.568899999954738, - "spec/features/remember_device/revocation_spec.rb": 22.228613000013866, - "spec/features/remember_device/session_expiration_spec.rb": 1.281091000069864, - "spec/features/remember_device/sp_expiration_spec.rb": 54.62715500011109, - "spec/features/remember_device/totp_spec.rb": 15.391289000050165, - "spec/features/remember_device/user_opted_preference_spec.rb": 6.414986999996472, - "spec/features/remember_device/webauthn_spec.rb": 14.509207999915816, - "spec/features/reports/authorization_count_spec.rb": 126.24552900000708, - "spec/features/reports/doc_auth_drop_off_rates_per_sprint_report_spec.rb": 0.09556400001747534, - "spec/features/reports/doc_auth_drop_off_rates_report_spec.rb": 0.18812000000616536, - "spec/features/reports/doc_auth_funnel_report_spec.rb": 7.103085000009742, - "spec/features/reports/monthly_gpo_letter_requests_report_spec.rb": 0.02937599999131635, - "spec/features/reports/omb_fitara_report_spec.rb": 4.186494999972638, - "spec/features/reports/proofing_costs_report_spec.rb": 10.523690000001807, - "spec/features/reports/sp_active_users_report_spec.rb": 1.842938999994658, - "spec/features/reports/sp_success_rate_report_spec.rb": 2.045356000016909, - "spec/features/saml/aal3_required_spec.rb": 0.8917849999852479, - "spec/features/saml/authorization_confirmation_spec.rb": 9.90238300000783, - "spec/features/saml/ial1/account_creation_spec.rb": 2.3203420001082122, - "spec/features/saml/ial1_sso_spec.rb": 11.902218999923207, - "spec/features/saml/ial2_sso_spec.rb": 19.354483999894, - "spec/features/saml/multiple_endpoints_spec.rb": 0.9963309998856857, - "spec/features/saml/redirect_uri_validation_spec.rb": 0.8586019999347627, - "spec/features/saml/saml_logout_spec.rb": 2.6910289999796078, - "spec/features/saml/saml_relay_state_spec.rb": 3.0204300000332296, - "spec/features/saml/saml_spec.rb": 8.99776300007943, - "spec/features/session/decryption_spec.rb": 0.27054800000041723, - "spec/features/session/timeout_spec.rb": 0.667081999941729, - "spec/features/sign_in/banned_users_spec.rb": 4.234591999964323, - "spec/features/sign_in/remember_device_default_spec.rb": 1.209076999919489, - "spec/features/sign_in/sp_return_log_spec.rb": 0.46381500002462417, - "spec/features/sign_in/two_factor_options_spec.rb": 6.510118999984115, - "spec/features/sp_cost_tracking_spec.rb": 22.13169900001958, - "spec/features/two_factor_authentication/backup_code_sign_up_spec.rb": 10.905828999937512, - "spec/features/two_factor_authentication/change_factor_spec.rb": 3.8136279999744147, - "spec/features/two_factor_authentication/multiple_tabs_spec.rb": 6.794110999908298, - "spec/features/two_factor_authentication/sign_in_spec.rb": 13.99378500005696, - "spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb": 1.3779199999989942, - "spec/features/users/password_recovery_via_recovery_code_spec.rb": 30.77490800002124, - "spec/features/users/piv_cac_management_spec.rb": 4.091757000074722, - "spec/features/users/regenerate_personal_key_spec.rb": 5.657094999914989, - "spec/features/users/sign_in_spec.rb": 96.90526500006672, - "spec/features/users/sign_out_spec.rb": 0.22002100001554936, - "spec/features/users/sign_up_spec.rb": 26.630111000034958, - "spec/features/users/totp_management_spec.rb": 3.25529599993024, - "spec/features/users/user_edit_spec.rb": 0.35816199996042997, - "spec/features/users/user_profile_spec.rb": 10.792559999972582, - "spec/features/users/verify_profile_spec.rb": 2.836282999953255, - "spec/features/visitors/bad_password_spec.rb": 2.9419529999722727, - "spec/features/visitors/email_confirmation_spec.rb": 5.082456000032835, - "spec/features/visitors/i18n_spec.rb": 1.8353409999981523, - "spec/features/visitors/js_disabled_spec.rb": 0.4646909999428317, - "spec/features/visitors/navigation_spec.rb": 0.1518960000248626, - "spec/features/visitors/password_recovery_spec.rb": 16.159362999955192, - "spec/features/visitors/resend_email_confirmation_spec.rb": 3.411179000046104, - "spec/features/visitors/set_password_spec.rb": 4.063090000068769, - "spec/features/visitors/sign_up_with_email_spec.rb": 6.317034999956377, - "spec/features/webauthn/hidden_spec.rb": 5.740791000018362, - "spec/features/webauthn/management_spec.rb": 3.974606000003405, - "spec/features/webauthn/sign_in_spec.rb": 2.2993790000327863, - "spec/features/webauthn/sign_up_spec.rb": 4.746820999949705, - "spec/forms/add_user_email_form_spec.rb": 0.35702400002628565, - "spec/forms/delete_user_email_form_spec.rb": 0.24060300004202873, - "spec/forms/edit_phone_form_spec.rb": 0.1537920000264421, - "spec/forms/event_disavowal/password_reset_from_disavowal_form_spec.rb": 0.6568909999914467, - "spec/forms/gpo_verify_form_spec.rb": 0.2928119999705814, - "spec/forms/idv/api_document_verification_form_spec.rb": 0.06759300001431257, - "spec/forms/idv/api_document_verification_status_form_spec.rb": 0.028384000004734844, - "spec/forms/idv/api_image_upload_form_spec.rb": 0.6194489999907091, - "spec/forms/idv/doc_pii_form_spec.rb": 0.014590999984648079, - "spec/forms/idv/document_capture_form_spec.rb": 0.027249999984633178, - "spec/forms/idv/otp_delivery_method_form_spec.rb": 0.008923000015784055, - "spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb": 0.1631819999893196, - "spec/forms/idv/phone_form_spec.rb": 0.2901599999750033, - "spec/forms/idv/ssn_form_spec.rb": 0.18856999999843538, - "spec/forms/idv/ssn_format_form_spec.rb": 0.0729120000032708, - "spec/forms/new_phone_form_spec.rb": 0.7912099999957718, - "spec/forms/openid_connect_authorize_form_spec.rb": 0.27145900001050904, - "spec/forms/openid_connect_logout_form_spec.rb": 0.33074599999235943, - "spec/forms/openid_connect_token_form_spec.rb": 1.680530000012368, - "spec/forms/otp_delivery_selection_form_spec.rb": 0.18008000002009794, - "spec/forms/otp_verification_form_spec.rb": 0.0346760000102222, - "spec/forms/password_form_spec.rb": 0.6710120000061579, - "spec/forms/password_reset_email_form_spec.rb": 0.05428600002778694, - "spec/forms/personal_key_form_spec.rb": 0.055749000050127506, - "spec/forms/register_user_email_form_spec.rb": 1.935371000028681, - "spec/forms/reset_password_form_spec.rb": 0.7729689999832772, - "spec/forms/security_event_form_spec.rb": 2.417842000024393, - "spec/forms/totp_setup_form_spec.rb": 0.08574700000463054, - "spec/forms/totp_verification_form_spec.rb": 0.04410600004484877, - "spec/forms/two_factor_authentication/phone_deletion_form_spec.rb": 0.43350400001509115, - "spec/forms/two_factor_login_options_form_spec.rb": 0.022511999995913357, - "spec/forms/two_factor_options_form_spec.rb": 0.066272999974899, - "spec/forms/update_email_language_form_spec.rb": 0.054921000031754375, - "spec/forms/update_user_password_form_spec.rb": 0.7175289999577217, - "spec/forms/user_piv_cac_login_form_spec.rb": 0.03060400002868846, - "spec/forms/user_piv_cac_setup_form_spec.rb": 0.16587099997559562, - "spec/forms/user_piv_cac_verification_form_spec.rb": 0.13643799995770678, - "spec/forms/verify_password_form_spec.rb": 0.1348810000345111, - "spec/forms/verify_personal_key_form_spec.rb": 0.4419900000211783, - "spec/forms/webauthn_setup_form_spec.rb": 0.09530600003199652, - "spec/forms/webauthn_verification_form_spec.rb": 0.038155000016558915, - "spec/forms/webauthn_visit_form_spec.rb": 0.025507999991532415, - "spec/helpers/application_helper_spec.rb": 0.11493099998915568, - "spec/helpers/asset_helper_spec.rb": 0.006096999975852668, - "spec/helpers/aws_s3_helper_spec.rb": 0.06452000001445413, - "spec/helpers/go_back_helper_spec.rb": 0.03488400002242997, - "spec/helpers/link_helper_spec.rb": 0.011707999976351857, - "spec/helpers/locale_helper_spec.rb": 0.07052000000840053, - "spec/helpers/script_helper_spec.rb": 0.4073249999783002, - "spec/helpers/session_timeout_warning_helper_spec.rb": 0.030900000012479722, - "spec/i18n_spec.rb": 10.77534499997273, - "spec/jobs/address_proofing_job_spec.rb": 0.13023800001246855, - "spec/jobs/application_job_spec.rb": 0.003708000003825873, - "spec/jobs/backup_code_backfiller_job_spec.rb": 1.9289870000211522, - "spec/jobs/document_proofing_job_spec.rb": 0.6653219999861903, - "spec/jobs/gpo_daily_job_spec.rb": 0.07469399995170534, - "spec/jobs/heartbeat_job_spec.rb": 0.0022120000212453306, - "spec/jobs/job_helpers/encryption_helper_spec.rb": 0.002096999960485846, - "spec/jobs/job_helpers/s3_helper_spec.rb": 0.02984699996886775, - "spec/jobs/job_helpers/stale_job_helper_spec.rb": 0.011769999982789159, - "spec/jobs/job_helpers/timer_spec.rb": 0.006338000006508082, - "spec/jobs/phone_number_opt_out_sync_job_spec.rb": 0.06255099998088554, - "spec/jobs/remove_old_throttles_job_spec.rb": 0.07101199997123331, - "spec/jobs/reports/agency_invoice_iaa_supplement_report_spec.rb": 0.08734999998705462, - "spec/jobs/reports/agency_invoice_issuer_supplement_report_spec.rb": 0.04119199997512624, - "spec/jobs/reports/agency_user_counts_report_spec.rb": 0.019288000010419637, - "spec/jobs/reports/agreement_summary_report_spec.rb": 0.08888100000331178, - "spec/jobs/reports/base_report_spec.rb": 0.04786900000181049, - "spec/jobs/reports/combined_invoice_supplement_report_spec.rb": 1.1094269999885, - "spec/jobs/reports/daily_auths_report_spec.rb": 0.03252000000793487, - "spec/jobs/reports/daily_dropoffs_report_spec.rb": 0.4107910000020638, - "spec/jobs/reports/deleted_user_accounts_report_spec.rb": 0.19772500003455207, - "spec/jobs/reports/gpo_report_spec.rb": 0.1449129999964498, - "spec/jobs/reports/iaa_billing_report_spec.rb": 0.09024099999805912, - "spec/jobs/reports/month_helper_spec.rb": 0.008534999971743673, - "spec/jobs/reports/query_helpers_spec.rb": 0.02299900003708899, - "spec/jobs/reports/sp_active_users_over_period_of_performance_report_spec.rb": 0.021115000010468066, - "spec/jobs/reports/sp_active_users_report_spec.rb": 0.02675899997120723, - "spec/jobs/reports/sp_cost_report_spec.rb": 0.02759800001513213, - "spec/jobs/reports/sp_user_counts_report_spec.rb": 0.018903000047430396, - "spec/jobs/reports/sp_user_quotas_report_spec.rb": 0.061277000000700355, - "spec/jobs/reports/total_monthly_auths_report_spec.rb": 0.02166700002271682, - "spec/jobs/reports/total_sp_cost_report_spec.rb": 0.02227599994512275, - "spec/jobs/reports/unique_monthly_auths_report_spec.rb": 0.013701999967452139, - "spec/jobs/reports/unique_yearly_auths_report_spec.rb": 0.019680999976117164, - "spec/jobs/resolution_proofing_job_spec.rb": 0.13581899995915592, - "spec/jobs/risc_delivery_job_spec.rb": 0.18415099999401718, - "spec/lib/analytics_events_documenter_spec.rb": 0.16123399999924004, - "spec/lib/app_artifacts_spec.rb": 0.025138999975752085, - "spec/lib/asset_checker_spec.rb": 0.010629999975208193, - "spec/lib/asset_sources_spec.rb": 0.04068500001449138, - "spec/lib/aws/ses_spec.rb": 0.014733999967575073, - "spec/lib/deploy/activate_spec.rb": 0.0448270000051707, - "spec/lib/feature_management_spec.rb": 0.11491399997612461, - "spec/lib/fingerprinter_spec.rb": 0.005327999999281019, - "spec/lib/headers_filter_spec.rb": 0.0024090000079013407, - "spec/lib/identity_job_log_subscriber_spec.rb": 0.04343299998436123, - "spec/lib/linters/errors_add_linter_spec.rb": 0.02783799997996539, - "spec/lib/linters/localized_validation_mesasge_linter_spec.rb": 0.019807000004220754, - "spec/lib/linters/mail_later_linter_spec.rb": 0.16611799999373034, - "spec/lib/linters/redirect_back_linter_spec.rb": 0.20296700001927093, - "spec/lib/linters/url_options_linter_spec.rb": 0.030101999989710748, - "spec/lib/makefile_help_parser_spec.rb": 0.13258599996333942, - "spec/lib/otp_code_generator_spec.rb": 0.006647999980486929, - "spec/lib/pinpoint_supported_countries_spec.rb": 0.050749999994877726, - "spec/lib/production_database_configuration_spec.rb": 0.07819000002928078, - "spec/lib/tasks/dev_rake_spec.rb": 0.5554680000059307, - "spec/lib/tasks/partners_rake_spec.rb": 0.5946460000122897, - "spec/lib/tasks/rotate_rake_spec.rb": 0.11027800000738353, - "spec/lib/telephony/alert_sender_spec.rb": 0.028553000000101747, - "spec/lib/telephony/otp_sender_spec.rb": 0.1370439999991504, - "spec/lib/telephony/pinpoint/aws_credential_builder_spec.rb": 0.011575000000448199, - "spec/lib/telephony/pinpoint/opt_out_manager_spec.rb": 0.050707000016700476, - "spec/lib/telephony/pinpoint/sms_sender_spec.rb": 0.13313299999936135, - "spec/lib/telephony/pinpoint/voice_sender_spec.rb": 0.09706400000141002, - "spec/lib/telephony/response_spec.rb": 0.040213000000221655, - "spec/lib/telephony/telephony_spec.rb": 0.010371000000304775, - "spec/lib/telephony/test/call_spec.rb": 0.01886000000013155, - "spec/lib/telephony/test/message_spec.rb": 0.015091999997821404, - "spec/lib/telephony/test/sms_sender_spec.rb": 0.01946900000257301, - "spec/lib/telephony/test/voice_sender_spec.rb": 0.0065630000026430935, - "spec/lib/telephony/util_spec.rb": 0.002357000001211418, - "spec/lib/utf8_sanitizer_spec.rb": 0.026462000038009137, - "spec/mailers/previews/user_mailer_preview_spec.rb": 0.14119400002527982, - "spec/mailers/user_mailer_spec.rb": 1.7547030000132509, - "spec/models/account_reset_request_spec.rb": 0.020245000021532178, - "spec/models/agency_identity_spec.rb": 0.014334000006783754, - "spec/models/agency_spec.rb": 0.055364000028930604, - "spec/models/agreements/iaa_gtc_spec.rb": 0.21610199997667223, - "spec/models/agreements/iaa_order_spec.rb": 0.38216899998951703, - "spec/models/agreements/iaa_spec.rb": 0.19401300000026822, - "spec/models/agreements/integration_spec.rb": 0.3082259999937378, - "spec/models/agreements/integration_status_spec.rb": 0.03881299996282905, - "spec/models/agreements/integration_usage_spec.rb": 0.2943640000303276, - "spec/models/agreements/partner_account_spec.rb": 0.1421390000032261, - "spec/models/agreements/partner_account_status_spec.rb": 0.050740999984554946, - "spec/models/anonymous_user_spec.rb": 0.021007000003010035, - "spec/models/backup_code_configuration_spec.rb": 1.3611500000115484, - "spec/models/concerns/user_otp_methods_spec.rb": 0.009658000024501234, - "spec/models/device_spec.rb": 0.03197700000600889, - "spec/models/doc_auth_record_spec.rb": 0.015571000054478645, - "spec/models/document_capture_session_spec.rb": 0.08878699998604134, - "spec/models/email_address_spec.rb": 0.137845000019297, - "spec/models/event_spec.rb": 0.022779999999329448, - "spec/models/gpo_confirmation_code_spec.rb": 0.12638900004094467, - "spec/models/monthly_auth_count_spec.rb": 0.024175000027753413, - "spec/models/null_identity_spec.rb": 0.0020650000078603625, - "spec/models/otp_requests_tracker_spec.rb": 0.03390199999557808, - "spec/models/phone_configuration_spec.rb": 0.06425199995283037, - "spec/models/phone_number_opt_out_spec.rb": 0.10309799999231473, - "spec/models/profile_spec.rb": 0.6763170000049286, - "spec/models/service_provider_identity_spec.rb": 0.3968299999833107, - "spec/models/service_provider_spec.rb": 0.049534999998286366, - "spec/models/sp_return_log_spec.rb": 0.0029830000130459666, - "spec/models/throttle_spec.rb": 0.4605540000484325, - "spec/models/user_spec.rb": 0.6623580000014044, - "spec/models/webauthn_configuration_spec.rb": 0.11598300002515316, - "spec/policies/backup_code_policy_spec.rb": 0.023431000008713454, - "spec/policies/service_provider_mfa_policy_spec.rb": 1.0563720000209287, - "spec/policies/two_factor_authentication/piv_cac_policy_spec.rb": 0.10315999999875203, - "spec/policies/user_mfa_policy_spec.rb": 0.1611140000168234, - "spec/policies/webauthn_login_option_policy_spec.rb": 0.039877999981399626, - "spec/presenters/account_reset/pending_presenter_spec.rb": 0.12521399999968708, - "spec/presenters/account_show_presenter_spec.rb": 0.20578299998305738, - "spec/presenters/backup_code_create_presenter_spec.rb": 0.010768999985884875, - "spec/presenters/backup_code_depleted_presenter_spec.rb": 0.010240000032354146, - "spec/presenters/cancellation_presenter_spec.rb": 0.017059999983757734, - "spec/presenters/completions_presenter_spec.rb": 0.5115830000140704, - "spec/presenters/confirm_delete_email_presenter_spec.rb": 0.029151000024285167, - "spec/presenters/eastern_time_presenter_spec.rb": 0.003099000023212284, - "spec/presenters/failure_presenter_spec.rb": 0.03756299999076873, - "spec/presenters/fully_signed_in_modal_presenter_spec.rb": 0.007406999997328967, - "spec/presenters/idv/gpo_presenter_spec.rb": 0.10714999999618158, - "spec/presenters/image_upload_response_presenter_spec.rb": 0.027460999961476773, - "spec/presenters/max_attempts_reached_presenter_spec.rb": 0.024711999983992428, - "spec/presenters/navigation_presenter_spec.rb": 0.050853999971877784, - "spec/presenters/openid_connect_certs_presenter_spec.rb": 0.002244999981485307, - "spec/presenters/openid_connect_configuration_presenter_spec.rb": 0.004853999998886138, - "spec/presenters/openid_connect_user_info_presenter_spec.rb": 0.4844780000275932, - "spec/presenters/partially_signed_in_modal_presenter_spec.rb": 0.00793099997099489, - "spec/presenters/piv_cac_authentication_setup_presenter_spec.rb": 0.04376400000182912, - "spec/presenters/piv_cac_error_presenter_spec.rb": 0.009284000028856099, - "spec/presenters/risc_configuration_presenter_spec.rb": 0.002402000012807548, - "spec/presenters/saml_request_presenter_spec.rb": 0.048836000030860305, - "spec/presenters/setup_presenter_spec.rb": 0.05812000000150874, - "spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb": 0.006913000019267201, - "spec/presenters/two_factor_auth_code/backup_code_presenter_spec.rb": 0.011057000025175512, - "spec/presenters/two_factor_auth_code/generic_delivery_presenter_spec.rb": 0.00205800001276657, - "spec/presenters/two_factor_auth_code/personal_key_presenter_spec.rb": 0.00518999999621883, - "spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb": 0.011360000004060566, - "spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb": 0.051663000020198524, - "spec/presenters/two_factor_auth_code/webauthn_authentication_presenter_spec.rb": 0.049269999959506094, - "spec/presenters/two_factor_authentication/auth_app_selection_presenter_spec.rb": 0.005473999946843833, - "spec/presenters/two_factor_authentication/personal_key_selection_presenter_spec.rb": 0.004014000005554408, - "spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb": 0.017038999998476356, - "spec/presenters/two_factor_authentication/piv_cac_selection_presenter_spec.rb": 0.007754999969620258, - "spec/presenters/two_factor_authentication/selection_presenter_spec.rb": 0.0026249999973515514, - "spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb": 0.045686000026762486, - "spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb": 0.03975599998375401, - "spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb": 0.007589999993797392, - "spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb": 0.006324999965727329, - "spec/presenters/two_factor_login_options_presenter_spec.rb": 0.0456899999990128, - "spec/presenters/two_factor_options_presenter_spec.rb": 0.028107999998610467, - "spec/presenters/utc_time_presenter_spec.rb": 0.0032820000196807086, - "spec/requests/acuant_sdk_spec.rb": 0.9878460000036284, - "spec/requests/api_cors_spec.rb": 0.8226409999770112, - "spec/requests/country_support_cors_spec.rb": 0.43752899998798966, - "spec/requests/csp_spec.rb": 1.3553689999971539, - "spec/requests/headers_spec.rb": 0.4034349999856204, - "spec/requests/i18n_spec.rb": 0.26939100003801286, - "spec/requests/invalid_encoding_spec.rb": 0.29150900000240654, - "spec/requests/invalid_sign_in_params_spec.rb": 0.22254600003361702, - "spec/requests/not_acceptable_spec.rb": 0.28763099998468533, - "spec/requests/openid_connect_authorize_spec.rb": 0.7700460000196472, - "spec/requests/openid_connect_cors_spec.rb": 0.5003290000022389, - "spec/requests/page_not_found_spec.rb": 0.13776100001996383, - "spec/requests/params_is_string_instead_of_hash_spec.rb": 0.054673999955412, - "spec/requests/preload_spec.rb": 1.1474629999720491, - "spec/requests/rack_attack_spec.rb": 10.496881000057328, - "spec/requests/redirects_spec.rb": 0.09873299999162555, - "spec/requests/saml_post_spec.rb": 0.20326900000145542, - "spec/requests/secure_cookies_spec.rb": 0.17710299999453127, - "spec/routing/gpo_verification_routing_spec.rb": 1.01398099999642, - "spec/services/access_token_verifier_spec.rb": 0.03994399996008724, - "spec/services/account_reset/cancel_request_for_user_spec.rb": 0.3602330000139773, - "spec/services/account_reset/cancel_spec.rb": 0.79777499998454, - "spec/services/account_reset/create_request_spec.rb": 0.3825809999834746, - "spec/services/account_reset/delete_account_spec.rb": 0.3149499999708496, - "spec/services/account_reset/find_prending_request_for_user_spec.rb": 0.09169900004053488, - "spec/services/account_reset/grant_request_spec.rb": 0.023437000054400414, - "spec/services/account_reset/grant_requests_and_send_emails_spec.rb": 0.896496000001207, - "spec/services/account_reset/notify_user_of_request_cancellation_spec.rb": 0.19556199997896329, - "spec/services/account_reset_health_checker_spec.rb": 0.02705999999307096, - "spec/services/active_profile_encryptor_spec.rb": 0.04347299999790266, - "spec/services/agency_identity_linker_spec.rb": 0.25241600000299513, - "spec/services/agency_seeder_spec.rb": 0.05349899997236207, - "spec/services/agreements/db/accounts_by_agency_spec.rb": 0.07254199997987598, - "spec/services/agreements/db/iaas_by_agency_spec.rb": 0.06483499996829778, - "spec/services/agreements/db/sp_return_log_scan_spec.rb": 0.009206000017002225, - "spec/services/agreements/iaa_gtc_seeder_spec.rb": 0.032706999976653606, - "spec/services/agreements/iaa_order_seeder_spec.rb": 0.0618990000220947, - "spec/services/agreements/iaa_usage_spec.rb": 0.3455160000012256, - "spec/services/agreements/integration_seeder_spec.rb": 0.07380299997748807, - "spec/services/agreements/integration_status_seeder_spec.rb": 0.025250000006053597, - "spec/services/agreements/partner_account_seeder_spec.rb": 0.029351000033784658, - "spec/services/agreements/partner_account_status_seeder_spec.rb": 0.03432099998462945, - "spec/services/agreements/reports/agencies_report_spec.rb": 0.012109000002965331, - "spec/services/agreements/reports/agency_iaas_report_spec.rb": 0.07537100004265085, - "spec/services/agreements/reports/agency_partner_accounts_report_spec.rb": 0.017558999999891967, - "spec/services/agreements/reports/partner_api_report_spec.rb": 0.022486000030767173, - "spec/services/agreements/usage_summarizer_spec.rb": 0.1331909999717027, - "spec/services/analytics_spec.rb": 0.10388100001728162, - "spec/services/attribute_asserter_spec.rb": 1.0788180000381544, - "spec/services/backup_code_benchmarker_spec.rb": 0.35868599999230355, - "spec/services/backup_code_generator_spec.rb": 1.9309440000215545, - "spec/services/banned_user_resolver_spec.rb": 0.1622380000189878, - "spec/services/browser_cache_spec.rb": 0.00961700000334531, - "spec/services/calendar_service_spec.rb": 0.046334999962709844, - "spec/services/completions_decider_spec.rb": 0.03317200002493337, - "spec/services/data_requests/create_email_addresses_report_spec.rb": 0.01655099994968623, - "spec/services/data_requests/create_mfa_configurations_report_spec.rb": 0.11097999999765307, - "spec/services/data_requests/create_user_events_report_spec.rb": 0.037721000029705465, - "spec/services/data_requests/create_user_report_spec.rb": 0.07378600002266467, - "spec/services/data_requests/fetch_cloudwatch_logs_spec.rb": 0.012273999978788197, - "spec/services/data_requests/lookup_shared_device_users_spec.rb": 0.06341100001009181, - "spec/services/data_requests/lookup_user_by_uuid_spec.rb": 0.027947000053245574, - "spec/services/data_requests/write_cloudwatch_logs_spec.rb": 0.011669999978039414, - "spec/services/data_requests/write_user_events_spec.rb": 0.00684200000250712, - "spec/services/data_requests/write_user_info_spec.rb": 0.0062209999887272716, - "spec/services/database_health_checker_spec.rb": 0.0076209999970160425, - "spec/services/date_parser_spec.rb": 0.010490000015124679, - "spec/services/db/add_document_verification_and_selfie_costs_spec.rb": 0.1342880000011064, - "spec/services/db/agency_identity/agency_user_counts_spec.rb": 0.04103999998187646, - "spec/services/db/deleted_user/create_spec.rb": 0.046846000012010336, - "spec/services/db/identity/sp_active_user_counts_spec.rb": 0.04726900003151968, - "spec/services/db/identity/sp_active_user_counts_within_iaa_window_spec.rb": 0.05287100002169609, - "spec/services/db/identity/sp_user_counts_spec.rb": 0.0221529999980703, - "spec/services/db/identity/sp_user_quotas_spec.rb": 0.019893000018782914, - "spec/services/db/monthly_auth_count/total_monthly_auth_counts_spec.rb": 0.03051499999128282, - "spec/services/db/monthly_auth_count/unique_monthly_auth_counts_spec.rb": 0.01322200003778562, - "spec/services/db/monthly_auth_count/unique_yearly_auth_counts_spec.rb": 0.010855000000447035, - "spec/services/db/monthly_sp_auth_count/total_monthly_auth_counts_within_iaa_window_spec.rb": 0.08020600001327693, - "spec/services/db/monthly_sp_auth_count/unique_monthly_auth_counts_by_iaa_spec.rb": 0.28082899999571964, - "spec/services/deleted_accounts_report_spec.rb": 0.11646900000050664, - "spec/services/device_tracking/create_device_spec.rb": 0.03863600001204759, - "spec/services/device_tracking/device_name_spec.rb": 0.04602599999634549, - "spec/services/device_tracking/forget_all_browsers_spec.rb": 0.014430000039283186, - "spec/services/device_tracking/list_device_events_spec.rb": 0.1932379999780096, - "spec/services/device_tracking/list_devices_spec.rb": 0.1573740000021644, - "spec/services/device_tracking/lookup_device_for_user_spec.rb": 0.09385800000745803, - "spec/services/device_tracking/update_device_spec.rb": 0.02249100001063198, - "spec/services/displayable_pii_formatter_spec.rb": 0.9157770000165328, - "spec/services/doc_auth/acuant/acuant_client_spec.rb": 0.8085410000057891, - "spec/services/doc_auth/acuant/pii_from_doc_spec.rb": 0.07630200003040954, - "spec/services/doc_auth/acuant/request_spec.rb": 0.5548650000127964, - "spec/services/doc_auth/acuant/requests/create_document_request_spec.rb": 0.031230999971739948, - "spec/services/doc_auth/acuant/requests/facial_match_request_spec.rb": 0.04043699998874217, - "spec/services/doc_auth/acuant/requests/get_face_image_request_spec.rb": 0.02226100000552833, - "spec/services/doc_auth/acuant/requests/get_results_request_spec.rb": 0.018375999992713332, - "spec/services/doc_auth/acuant/requests/liveness_request_spec.rb": 0.04847799998242408, - "spec/services/doc_auth/acuant/requests/upload_image_request_spec.rb": 0.0389629999990575, - "spec/services/doc_auth/acuant/responses/create_document_response_spec.rb": 0.0064359999960288405, - "spec/services/doc_auth/acuant/responses/facial_match_response_spec.rb": 0.006926000001840293, - "spec/services/doc_auth/acuant/responses/get_face_image_response_spec.rb": 0.0023830000427551568, - "spec/services/doc_auth/acuant/responses/get_results_response_spec.rb": 0.05634999996982515, - "spec/services/doc_auth/acuant/responses/liveness_response_spec.rb": 0.004704000020865351, - "spec/services/doc_auth/acuant/result_codes_spec.rb": 0.004738999996334314, - "spec/services/doc_auth/error_generator_spec.rb": 0.05801700003212318, - "spec/services/doc_auth/lexis_nexis/lexis_nexis_client_spec.rb": 0.2955669999937527, - "spec/services/doc_auth/lexis_nexis/request_spec.rb": 0.526863000006415, - "spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb": 0.045276999997440726, - "spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb": 0.12932100001489744, - "spec/services/doc_auth/mock/dock_auth_mock_client_spec.rb": 0.07453899999381974, - "spec/services/doc_auth/mock/result_response_builder_spec.rb": 0.03583000000799075, - "spec/services/doc_auth/response_spec.rb": 0.04707500000949949, - "spec/services/doc_auth_router_spec.rb": 0.04617300000973046, - "spec/services/document_capture_session_async_result_spec.rb": 0.01683999999659136, - "spec/services/document_capture_session_result_spec.rb": 0.006973999959882349, - "spec/services/duration_parser_spec.rb": 0.02540799998678267, - "spec/services/email_confirmation_token_validator_spec.rb": 0.15554900001734495, - "spec/services/email_notifier_spec.rb": 0.041800000006332994, - "spec/services/encrypted_attribute_spec.rb": 0.01791700004832819, - "spec/services/encrypted_redis_struct_storage_spec.rb": 0.060624999983701855, - "spec/services/encryption/aes_cipher_spec.rb": 0.00946600001771003, - "spec/services/encryption/contextless_kms_client_spec.rb": 0.08285300002899021, - "spec/services/encryption/encryptors/aes_encryptor_spec.rb": 0.018507000000681728, - "spec/services/encryption/encryptors/attribute_encryptor_spec.rb": 0.02061499998671934, - "spec/services/encryption/encryptors/pii_encryptor_spec.rb": 0.07425200002035126, - "spec/services/encryption/encryptors/session_encryptor_spec.rb": 0.00818300002720207, - "spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb": 0.07551100000273436, - "spec/services/encryption/kms_client_spec.rb": 0.07024799997452646, - "spec/services/encryption/kms_logger_spec.rb": 0.013321999984327704, - "spec/services/encryption/multi_region_kms_client_spec.rb": 0.05330400000093505, - "spec/services/encryption/password_verifier_spec.rb": 0.10082200000761077, - "spec/services/encryption/uak_password_verifier_spec.rb": 0.19590099999913946, - "spec/services/encryption/user_access_key_spec.rb": 0.11599299998488277, - "spec/services/event_disavowal/disavow_event_spec.rb": 0.028476999956183136, - "spec/services/event_disavowal/find_disavowed_event_spec.rb": 0.03965099999913946, - "spec/services/event_disavowal/generate_disavowal_token_spec.rb": 0.03131499997107312, - "spec/services/event_disavowal/validate_disavowed_event_spec.rb": 0.09553499997127801, - "spec/services/expired_license_allower_spec.rb": 0.03267099999357015, - "spec/services/forget_all_browsers_spec.rb": 0.0192370000295341, - "spec/services/form_response_spec.rb": 0.14322899997932836, - "spec/services/funnel/registration/add_mfa_spec.rb": 0.07139100000495091, - "spec/services/funnel/registration/add_password_spec.rb": 0.02401100000133738, - "spec/services/funnel/registration/confirm_email_spec.rb": 0.019971000030636787, - "spec/services/funnel/registration/range_registered_count_spec.rb": 0.15500599995721132, - "spec/services/funnel/registration/range_submitted_count_spec.rb": 0.1348979999893345, - "spec/services/funnel/registration/total_registered_count_spec.rb": 0.08264500001678243, - "spec/services/funnel/registration/total_submitted_count_spec.rb": 0.06153000000631437, - "spec/services/gpo_confirmation_exporter_spec.rb": 0.008352999982889742, - "spec/services/gpo_confirmation_maker_spec.rb": 0.10435700003290549, - "spec/services/gpo_confirmation_spec.rb": 0.013263000000733882, - "spec/services/gpo_confirmation_uploader_spec.rb": 0.05137499998090789, - "spec/services/gpo_daily_test_sender_spec.rb": 0.060077000001911074, - "spec/services/health_check_summary_spec.rb": 0.0045899999677203596, - "spec/services/iaa_reporting_helper_spec.rb": 0.19123799999943003, - "spec/services/ial_context_spec.rb": 0.4878770000068471, - "spec/services/id_token_builder_spec.rb": 0.3177259999793023, - "spec/services/identity_linker_spec.rb": 0.2668379999813624, - "spec/services/idv/actions/verify_document_status_action_spec.rb": 0.04311600001528859, - "spec/services/idv/agent_spec.rb": 0.2684179999632761, - "spec/services/idv/cancel_verification_attempt_spec.rb": 0.22110499994596466, - "spec/services/idv/data_url_image_spec.rb": 0.014082000008784235, - "spec/services/idv/duplicate_ssn_finder_spec.rb": 0.2339909999864176, - "spec/services/idv/gpo_mail_spec.rb": 0.11400200001662597, - "spec/services/idv/phone_step_spec.rb": 0.7966010000091046, - "spec/services/idv/profile_maker_spec.rb": 0.09223700000438839, - "spec/services/idv/send_phone_confirmation_otp_spec.rb": 0.14647799998056144, - "spec/services/idv/session_spec.rb": 0.19095199997536838, - "spec/services/idv/steps/verify_step_spec.rb": 0.09351799997966737, - "spec/services/image_upload_presigned_url_generator_spec.rb": 0.008743999991565943, - "spec/services/key_rotator/attribute_encryption_spec.rb": 0.035191000031773, - "spec/services/key_rotator/hmac_fingerprinter_spec.rb": 0.16919799998868257, - "spec/services/maintenance_window_spec.rb": 0.029925000038929284, - "spec/services/marketing_site_spec.rb": 0.02937599999131635, - "spec/services/multi_health_checker_spec.rb": 0.0295820000465028, - "spec/services/openid_connect_attribute_scoper_spec.rb": 0.06011500000022352, - "spec/services/otp_preference_updater_spec.rb": 0.043643000011797994, - "spec/services/otp_rate_limiter_spec.rb": 0.13465999998152256, - "spec/services/out_of_band_session_accessor_spec.rb": 0.07557200000155717, - "spec/services/outbound_health_checker_spec.rb": 0.20836399996187538, - "spec/services/parse_controller_from_referer_spec.rb": 0.004154999973252416, - "spec/services/personal_key_generator_spec.rb": 0.3608150000218302, - "spec/services/phone_confirmation/confirmaton_session_spec.rb": 0.02344499999890104, - "spec/services/phone_formatter_spec.rb": 0.013543999986723065, - "spec/services/phone_number_capabilities_spec.rb": 0.15429699997184798, - "spec/services/pii/attributes_spec.rb": 0.024803999986033887, - "spec/services/pii/cacher_spec.rb": 0.4100019999896176, - "spec/services/pii/fingerprinter_spec.rb": 0.031137999962083995, - "spec/services/pii/re_encryptor_spec.rb": 0.1126189999631606, - "spec/services/pii/session_store_spec.rb": 0.024398000037763268, - "spec/services/piv_cac/check_config_spec.rb": 0.007646000012755394, - "spec/services/piv_cac_service_spec.rb": 0.11888199998065829, - "spec/services/profanity_detector_spec.rb": 0.02533700002823025, - "spec/services/proofing/aamva/applicant_spec.rb": 0.008595000021159649, - "spec/services/proofing/aamva/authentication_client_spec.rb": 0.16821800003526732, - "spec/services/proofing/aamva/hmac_secret_spec.rb": 0.0020169999916106462, - "spec/services/proofing/aamva/proofing_spec.rb": 0.17251800000667572, - "spec/services/proofing/aamva/request/authentication_token_request_spec.rb": 1.7323449999676086, - "spec/services/proofing/aamva/request/security_token_request_spec.rb": 1.6865019999677315, - "spec/services/proofing/aamva/request/verification_request_spec.rb": 1.6711120000109076, - "spec/services/proofing/aamva/response/authentication_token_response_spec.rb": 0.03861899999901652, - "spec/services/proofing/aamva/response/security_token_response_spec.rb": 0.06841100001474842, - "spec/services/proofing/aamva/response/verification_response_spec.rb": 0.18430700001772493, - "spec/services/proofing/aamva/soap_error_handler_spec.rb": 0.044141999969724566, - "spec/services/proofing/aamva/verification_client_spec.rb": 0.0919010000070557, - "spec/services/proofing/base_spec.rb": 0.05845499999122694, - "spec/services/proofing/lexis_nexis/date_formatter_spec.rb": 0.007766000053379685, - "spec/services/proofing/lexis_nexis/instant_verify/proofing_spec.rb": 0.16831600002478808, - "spec/services/proofing/lexis_nexis/instant_verify/verification_request_spec.rb": 0.06679599999915808, - "spec/services/proofing/lexis_nexis/phone_finder/proofing_spec.rb": 0.03723099996568635, - "spec/services/proofing/lexis_nexis/phone_finder/verification_request_spec.rb": 0.04841300001135096, - "spec/services/proofing/lexis_nexis/response_spec.rb": 0.02282999997260049, - "spec/services/proofing/lexis_nexis/verification_error_parser_spec.rb": 0.032844999979715794, - "spec/services/proofing/result_spec.rb": 0.061962999985553324, - "spec/services/proofing_session_async_result_spec.rb": 0.006385000015143305, - "spec/services/push_notification/account_purged_event_spec.rb": 0.020653999992646277, - "spec/services/push_notification/email_changed_event_spec.rb": 0.025683999992907047, - "spec/services/push_notification/http_push_spec.rb": 0.7781299999915063, - "spec/services/push_notification/identifier_recycled_event_spec.rb": 0.025385000044479966, - "spec/services/push_notification/mfa_limit_account_locked_event_spec.rb": 0.028294999967329204, - "spec/services/push_notification/recovery_activated_event_spec.rb": 0.021372999995946884, - "spec/services/push_notification/reproof_completed_event_spec.rb": 0.017989999963901937, - "spec/services/pwned_passwords/lookup_password_spec.rb": 0.008809999970253557, - "spec/services/random_phrase_spec.rb": 0.015782999980729073, - "spec/services/reactivate_account_session_spec.rb": 0.1225049999775365, - "spec/services/redis_rate_limiter_spec.rb": 0.04739199997857213, - "spec/services/remember_device_cookie_spec.rb": 0.12538999994285405, - "spec/services/request_password_reset_spec.rb": 2.7663989999564365, - "spec/services/reset_user_password_spec.rb": 1.5159979999880306, - "spec/services/revoke_service_provider_consent_spec.rb": 0.009962000011000782, - "spec/services/saml_endpoint_spec.rb": 0.017974000016693026, - "spec/services/saml_request_validator_spec.rb": 0.06722399999853224, - "spec/services/secure_headers_allow_list_spec.rb": 0.006746999977622181, - "spec/services/send_sign_up_email_confirmation_spec.rb": 0.30197500000940636, - "spec/services/service_provider_quota_limit/any_sp_over_quota_limit_spec.rb": 0.0578720000339672, - "spec/services/service_provider_quota_limit/is_sp_over_quota_spec.rb": 0.021837999986018986, - "spec/services/service_provider_quota_limit/notify_if_any_sp_over_quota_limit_spec.rb": 0.21369699999922886, - "spec/services/service_provider_quota_limit/update_from_report_spec.rb": 0.014922999951522797, - "spec/services/service_provider_request_proxy_spec.rb": 0.04011200001696125, - "spec/services/service_provider_seeder_spec.rb": 1.0436509999562986, - "spec/services/service_provider_updater_spec.rb": 0.2186259999871254, - "spec/services/session_encryptor_spec.rb": 0.009159999957773834, - "spec/services/sp_return_url_resolver_spec.rb": 0.03471600002376363, - "spec/services/ssn_formatter_spec.rb": 0.026830000046174973, - "spec/services/store_sp_metadata_in_session_spec.rb": 0.012287999968975782, - "spec/services/throttler/increment_spec.rb": 0.014947000017855316, - "spec/services/throttler/is_throttled_spec.rb": 0.015467000019270927, - "spec/services/throttler/reset_spec.rb": 0.0079799999948591, - "spec/services/time_service_spec.rb": 0.0035389999975450337, - "spec/services/update_user_spec.rb": 0.2406870000413619, - "spec/services/uri_service_spec.rb": 0.015483000024687499, - "spec/services/user_alerts/alert_user_about_account_verified_spec.rb": 0.4555030000046827, - "spec/services/user_alerts/alert_user_about_new_device_spec.rb": 0.31001900002593175, - "spec/services/user_alerts/alert_user_about_password_change_spec.rb": 0.3263990000123158, - "spec/services/user_alerts/alert_user_about_personal_key_sign_in_spec.rb": 0.34637499996460974, - "spec/services/user_event_creator_spec.rb": 0.2584529999876395, - "spec/services/user_seeder_spec.rb": 2.6293759999680333, - "spec/services/user_session_context_spec.rb": 0.010798999981489033, - "spec/services/usps_in_person_proofer_spec.rb": 0.1974589999881573, - "spec/services/uuid_reporter_spec.rb": 0.21517399995354936, - "spec/services/vendor_status_spec.rb": 0.056539000011980534, - "spec/services/x509/attribute_spec.rb": 0.0031679999665357172, - "spec/services/x509/attributes_spec.rb": 0.013852000003680587, - "spec/services/x509/session_store_spec.rb": 0.008479999960400164, - "spec/svg_spec.rb": 0.3546240000287071, - "spec/view_models/account_show_spec.rb": 0.10118900000816211, - "spec/view_models/forgot_password_show_spec.rb": 0.001866999955382198, - "spec/view_models/sign_up_completions_show_spec.rb": 0.09519199997885153, - "spec/views/account_reset/cancel/show.html.erb_spec.rb": 0.009075000009033829, - "spec/views/account_reset/confirm_delete_account/show.html.erb_spec.rb": 0.017106999992392957, - "spec/views/account_reset/confirm_request/show.html.erb_spec.rb": 0.00971800001570955, - "spec/views/account_reset/delete_account/show.html.erb_spec.rb": 0.05029199999989942, - "spec/views/account_reset/request/show.html.erb_spec.rb": 0.11513600003672764, - "spec/views/account_reset/user_mailer/email_confirmation_instructions.html.erb_spec.rb": 0.13876299996627495, - "spec/views/account_reset/user_mailer/unconfirmed_email_instructions.html.erb_spec.rb": 0.12314800004241988, - "spec/views/accounts/_nav_auth.html.erb_spec.rb": 0.06294299999717623, - "spec/views/accounts/connected_accounts/show.html.erb_spec.rb": 0.06396699999459088, - "spec/views/accounts/history/show.html.erb_spec.rb": 0.054348999983631074, - "spec/views/accounts/show.html.erb_spec.rb": 0.5544130000052974, - "spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb": 0.19353099999716505, - "spec/views/devise/passwords/edit.html.erb_spec.rb": 0.08431700000073761, - "spec/views/devise/passwords/new.html.erb_spec.rb": 0.0812459999579005, - "spec/views/devise/sessions/new.html.erb_spec.rb": 0.2591440000105649, - "spec/views/devise/shared/_password_strength.html.erb_spec.rb": 0.05785500002093613, - "spec/views/forgot_password/show.html.erb_spec.rb": 0.04616700002225116, - "spec/views/idv/activated.html.erb_spec.rb": 0.011742000002413988, - "spec/views/idv/cancellations/destroy.html.erb_spec.rb": 0.040963999985251576, - "spec/views/idv/cancellations/new.html.erb_spec.rb": 0.04271900001913309, - "spec/views/idv/come_back_later/show.html.erb_spec.rb": 0.04626500001177192, - "spec/views/idv/doc_auth/_back.html.erb_spec.rb": 0.046288999961689115, - "spec/views/idv/doc_auth/_start_over_or_cancel.html.erb_spec.rb": 0.02915800001937896, - "spec/views/idv/doc_auth/upload.html.erb_spec.rb": 0.018768000009004027, - "spec/views/idv/doc_auth/welcome.html.erb_spec.rb": 0.058102000039070845, - "spec/views/idv/gpo/index.html.erb_spec.rb": 0.20512100000632927, - "spec/views/idv/otp_delivery_method/new.html.erb_spec.rb": 0.6548910000128672, - "spec/views/idv/phone/new.html.erb_spec.rb": 0.07012300001224503, - "spec/views/idv/phone_errors/_warning.html.erb_spec.rb": 0.07010399998398498, - "spec/views/idv/phone_errors/failure.html.erb_spec.rb": 0.02501400001347065, - "spec/views/idv/phone_errors/jobfail.html.erb_spec.rb": 0.03437200002372265, - "spec/views/idv/phone_errors/timeout.html.erb_spec.rb": 0.03040099999634549, - "spec/views/idv/phone_errors/warning.html.erb_spec.rb": 0.04071599995950237, - "spec/views/idv/review/new.html.erb_spec.rb": 0.1852480000234209, - "spec/views/idv/session_errors/exception.html.erb_spec.rb": 0.017896000004839152, - "spec/views/idv/session_errors/failure.html.erb_spec.rb": 0.020753999997396022, - "spec/views/idv/session_errors/throttled.html.erb_spec.rb": 0.0571129999589175, - "spec/views/idv/session_errors/warning.html.erb_spec.rb": 0.018773999996483326, - "spec/views/idv/shared/_document_capture.html.erb_spec.rb": 0.10213600000133738, - "spec/views/idv/shared/_error.html.erb_spec.rb": 0.2981499999877997, - "spec/views/layouts/application.html.erb_spec.rb": 0.9167400000151247, - "spec/views/layouts/user_mailer.html.erb_spec.rb": 0.23699800000758842, - "spec/views/phone_setup/index.html.erb_spec.rb": 0.14467300003161654, - "spec/views/reactivate_account/index.html.erb_spec.rb": 0.01616599998669699, - "spec/views/shared/_address.html.erb_spec.rb": 0.04051399999298155, - "spec/views/shared/_alert.html.erb_spec.rb": 0.180837000021711, - "spec/views/shared/_banner.html.erb_spec.rb": 0.014243000012356788, - "spec/views/shared/_block_link.html.erb_spec.rb": 0.028404999990016222, - "spec/views/shared/_email_languages.html.erb_spec.rb": 0.08914100000401959, - "spec/views/shared/_failure.html.erb_spec.rb": 0.08319900004426017, - "spec/views/shared/_footer_lite.html.erb_spec.rb": 0.11756700003752485, - "spec/views/shared/_maintenance_window_alert.html.erb_spec.rb": 0.06905200000619516, - "spec/views/shared/_masked_text.html.erb_spec.rb": 0.06562800001120195, - "spec/views/shared/_nav_branded.html.erb_spec.rb": 0.08721500000683591, - "spec/views/shared/_nav_lite.html.erb_spec.rb": 0.023530999955255538, - "spec/views/shared/_one_time_code_input.html.erb_spec.rb": 0.19697300001280382, - "spec/views/shared/_spinner_button.html.erb_spec.rb": 0.08500199997797608, - "spec/views/shared/_step_indicator.html.erb_spec.rb": 0.19154099997831509, - "spec/views/shared/_step_indicator_step.html.erb_spec.rb": 0.16559699998470023, - "spec/views/shared/_troubleshooting_options.html.erb_spec.rb": 0.11607899999944493, - "spec/views/shared/personal_key/_key.html.erb_spec.rb": 0.009442000009585172, - "spec/views/sign_up/completions/show.html.erb_spec.rb": 0.13186500000301749, - "spec/views/sign_up/email_resend/new.html.erb_spec.rb": 0.02607199997873977, - "spec/views/sign_up/emails/show.html.erb_spec.rb": 0.022542999999132007, - "spec/views/sign_up/passwords/new.html.erb_spec.rb": 0.06667399994330481, - "spec/views/sign_up/personal_keys/show.html.erb_spec.rb": 0.061695999989751726, - "spec/views/sign_up/registrations/new.html.erb_spec.rb": 0.11790399998426437, - "spec/views/two_factor_authentication/options/index.html.erb_spec.rb": 0.015162000025156885, - "spec/views/two_factor_authentication/otp_verification/show.html.erb_spec.rb": 0.2677720000501722, - "spec/views/two_factor_authentication/personal_key_verification/show.html.erb_spec.rb": 0.10650799999712035, - "spec/views/two_factor_authentication/sms_opt_in/error.html.erb_spec.rb": 0.10890699998708442, - "spec/views/two_factor_authentication/sms_opt_in/new.html.erb_spec.rb": 0.07184799999231473, - "spec/views/two_factor_authentication/totp_verification/show.html.erb_spec.rb": 0.21120599994901568, - "spec/views/users/delete/show.html.erb_spec.rb": 0.17219399998430163, - "spec/views/users/passwords/edit.html.erb_spec.rb": 0.03355499997269362, - "spec/views/users/phones/add.html.erb_spec.rb": 0.6870679999992717, - "spec/views/users/piv_cac_authentication_setup/new.html.erb_spec.rb": 0.046470000001136214, - "spec/views/users/shared/_otp_delivery_preference_selection.html.erb_spec.rb": 0.09801700000025448, - "spec/views/users/totp_setup/new.html.erb_spec.rb": 0.15096100000664592, - "spec/views/users/two_factor_authentication_setup/index.html.erb_spec.rb": 0.083360000000539, - "spec/views/vendor_outage/show.html.erb_spec.rb": 0.02351299999645562 + "spec/blueprints/agreements/agency_blueprint_spec.rb": 0.0060320000047795475, + "spec/blueprints/agreements/iaa_blueprint_spec.rb": 0.018472999974619597, + "spec/blueprints/agreements/partner_account_blueprint_spec.rb": 0.012541999982204288, + "spec/components/accordion_component_spec.rb": 0.026347999984864146, + "spec/components/alert_component_spec.rb": 0.04572699998971075, + "spec/components/base_component_spec.rb": 0.019376000011106953, + "spec/components/block_link_component_spec.rb": 0.012413999997079372, + "spec/components/button_component_spec.rb": 0.031522000004770234, + "spec/components/clipboard_button_component_spec.rb": 0.018435000005410984, + "spec/components/countdown_component_spec.rb": 0.016773000010289252, + "spec/components/flash_component_spec.rb": 0.015008999995188788, + "spec/components/icon_component_spec.rb": 0.02815199998440221, + "spec/components/page_footer_component_spec.rb": 0.022548000008100644, + "spec/components/page_heading_component_spec.rb": 0.014786000014282763, + "spec/components/password_toggle_component_spec.rb": 0.051183999981731176, + "spec/components/phone_input_component_spec.rb": 0.4179989999975078, + "spec/components/print_button_component_spec.rb": 0.01521899999352172, + "spec/components/process_list_component_spec.rb": 0.027048999996623024, + "spec/components/spinner_button_component_spec.rb": 0.012859000009484589, + "spec/components/status_page_component_spec.rb": 0.036521000001812354, + "spec/components/time_component_spec.rb": 0.044931999989785254, + "spec/components/troubleshooting_options_component_spec.rb": 0.024838000012096018, + "spec/components/validated_field_component_spec.rb": 0.03337000001920387, + "spec/components/vendor_outage_alert_component_spec.rb": 0.029314000013982877, + "spec/config/initializers/ahoy_spec.rb": 0.016961999994236976, + "spec/config/initializers/ext_digest_spec.rb": 0.005311999993864447, + "spec/config/initializers/phonelib_spec.rb": 0.002945000014733523, + "spec/config/initializers/secure_headers_spec.rb": 0.00846000001183711, + "spec/controllers/account_reset/cancel_controller_spec.rb": 0.762817000009818, + "spec/controllers/account_reset/confirm_delete_account_controller_spec.rb": 0.01486199998180382, + "spec/controllers/account_reset/confirm_request_controller_spec.rb": 0.012705000001005828, + "spec/controllers/account_reset/delete_account_controller_spec.rb": 0.6109099999885075, + "spec/controllers/account_reset/pending_controller_spec.rb": 0.26991800000541843, + "spec/controllers/account_reset/request_controller_spec.rb": 0.9930609999864828, + "spec/controllers/accounts/personal_keys_controller_spec.rb": 0.5756930000206921, + "spec/controllers/accounts_controller_spec.rb": 0.257119000016246, + "spec/controllers/analytics_events_controller_spec.rb": 0.01279799998155795, + "spec/controllers/api/verify/complete_controller_spec.rb": 0.14957599999615923, + "spec/controllers/application_controller_spec.rb": 2.094707999989623, + "spec/controllers/concerns/effective_user_spec.rb": 0.05327699999907054, + "spec/controllers/concerns/idv/document_capture_concern_spec.rb": 0.08692199998768046, + "spec/controllers/concerns/idv_step_concern_spec.rb": 0.09949999998207204, + "spec/controllers/concerns/render_condition_concern_spec.rb": 0.28048700001090765, + "spec/controllers/concerns/verify_sp_attributes_concern_spec.rb": 0.28604900001664646, + "spec/controllers/country_support_controller_spec.rb": 0.03560999999172054, + "spec/controllers/event_disavowal_controller_spec.rb": 0.27734200001577847, + "spec/controllers/fake_s3_controller_spec.rb": 0.016155999997863546, + "spec/controllers/forgot_password_controller_spec.rb": 0.011568999994779006, + "spec/controllers/frontend_log_controller_spec.rb": 0.2728110000025481, + "spec/controllers/health/database_controller_spec.rb": 0.022645999997621402, + "spec/controllers/health/health_controller_spec.rb": 0.017896999983349815, + "spec/controllers/health/outbound_controller_spec.rb": 0.0795829999842681, + "spec/controllers/idv/cancellations_controller_spec.rb": 0.28440400000545196, + "spec/controllers/idv/capture_doc_controller_spec.rb": 0.18775699997786433, + "spec/controllers/idv/capture_doc_status_controller_spec.rb": 0.17522699999972247, + "spec/controllers/idv/come_back_later_controller_spec.rb": 0.057773000007728115, + "spec/controllers/idv/doc_auth_controller_spec.rb": 0.7337110000080429, + "spec/controllers/idv/forgot_password_controller_spec.rb": 0.2585150000231806, + "spec/controllers/idv/gpo_controller_spec.rb": 0.7582880000118166, + "spec/controllers/idv/gpo_verify_controller_spec.rb": 0.5405469999823254, + "spec/controllers/idv/image_uploads_controller_spec.rb": 1.1993820000207052, + "spec/controllers/idv/in_person_controller_spec.rb": 0.023729000007733703, + "spec/controllers/idv/otp_delivery_method_controller_spec.rb": 0.27087599999504164, + "spec/controllers/idv/otp_verification_controller_spec.rb": 0.1469269999943208, + "spec/controllers/idv/personal_key_controller_spec.rb": 0.6248999999952503, + "spec/controllers/idv/phone_controller_spec.rb": 1.1220710000197869, + "spec/controllers/idv/phone_errors_controller_spec.rb": 0.388329999987036, + "spec/controllers/idv/resend_otp_controller_spec.rb": 0.08931399998255074, + "spec/controllers/idv/review_controller_spec.rb": 1.524102999974275, + "spec/controllers/idv/session_errors_controller_spec.rb": 0.3754530000151135, + "spec/controllers/idv/sessions_controller_spec.rb": 0.08470899998792447, + "spec/controllers/idv_controller_spec.rb": 0.26583399999071844, + "spec/controllers/mfa_confirmation_controller_spec.rb": 0.19860000000335276, + "spec/controllers/openid_connect/authorization_controller_spec.rb": 0.7011070000007749, + "spec/controllers/openid_connect/certs_controller_spec.rb": 0.012482999998610467, + "spec/controllers/openid_connect/configuration_controller_spec.rb": 0.012012999999569729, + "spec/controllers/openid_connect/logout_controller_spec.rb": 0.2555419999989681, + "spec/controllers/openid_connect/token_controller_spec.rb": 0.21044299998902716, + "spec/controllers/openid_connect/user_info_controller_spec.rb": 0.11347999999998137, + "spec/controllers/pages_controller_spec.rb": 0.03143100001034327, + "spec/controllers/password_capture_controller_spec.rb": 0.09105899999849498, + "spec/controllers/reactivate_account_controller_spec.rb": 0.14042299997527152, + "spec/controllers/reauthn_required_controller_spec.rb": 0.06614899999112822, + "spec/controllers/redirect/help_center_controller_spec.rb": 0.04786600000807084, + "spec/controllers/redirect/return_to_sp_controller_spec.rb": 0.04920700000366196, + "spec/controllers/risc/configuration_controller_spec.rb": 0.01095799999893643, + "spec/controllers/risc/security_events_controller_spec.rb": 0.6164829999906942, + "spec/controllers/saml_idp_controller_spec.rb": 12.90779600001406, + "spec/controllers/saml_post_controller_spec.rb": 0.06909099998301826, + "spec/controllers/saml_signed_message_spec.rb": 0.47533399998792447, + "spec/controllers/service_provider_controller_spec.rb": 0.051709999999729916, + "spec/controllers/sign_out_controller_spec.rb": 0.034546000009868294, + "spec/controllers/sign_up/cancellations_controller_spec.rb": 0.036481000017374754, + "spec/controllers/sign_up/completions_controller_spec.rb": 0.3954420000081882, + "spec/controllers/sign_up/email_confirmations_controller_spec.rb": 0.12100000001373701, + "spec/controllers/sign_up/emails_controller_spec.rb": 0.011729999998351559, + "spec/controllers/sign_up/passwords_controller_spec.rb": 0.1689640000113286, + "spec/controllers/sign_up/registrations_controller_spec.rb": 0.9901059999829158, + "spec/controllers/test/piv_cac_authentication_test_subject_controller_spec.rb": 0.0375110000022687, + "spec/controllers/test/push_notification_controller_spec.rb": 0.019308999995701015, + "spec/controllers/test/telephony_controller_spec.rb": 0.018496000004233792, + "spec/controllers/two_factor_authentication/backup_code_verification_controller_spec.rb": 0.7011639999982435, + "spec/controllers/two_factor_authentication/options_controller_spec.rb": 0.2974859999958426, + "spec/controllers/two_factor_authentication/otp_verification_controller_spec.rb": 1.4771360000013374, + "spec/controllers/two_factor_authentication/personal_key_verification_controller_spec.rb": 1.2585550000076182, + "spec/controllers/two_factor_authentication/piv_cac_verification_controller_spec.rb": 0.792795000015758, + "spec/controllers/two_factor_authentication/sms_opt_in_controller_spec.rb": 0.4739609999815002, + "spec/controllers/two_factor_authentication/totp_verification_controller_spec.rb": 0.5370059999986552, + "spec/controllers/two_factor_authentication/webauthn_verification_controller_spec.rb": 0.27944399998523295, + "spec/controllers/users/aal3_controller_spec.rb": 0.00973699998576194, + "spec/controllers/users/authorization_confirmation_controller_spec.rb": 0.12046100001316518, + "spec/controllers/users/backup_code_setup_controller_spec.rb": 1.0833909999928437, + "spec/controllers/users/delete_controller_spec.rb": 0.33398600001237355, + "spec/controllers/users/edit_phone_controller_spec.rb": 0.12859300000127405, + "spec/controllers/users/email_confirmations_controller_spec.rb": 0.6150069999857806, + "spec/controllers/users/email_language_controller_spec.rb": 0.2098779999942053, + "spec/controllers/users/emails_controller_spec.rb": 0.5037169999850448, + "spec/controllers/users/forget_all_browsers_controller_spec.rb": 0.14614399999845773, + "spec/controllers/users/mfa_selection_controller_spec.rb": 0.15490600001066923, + "spec/controllers/users/passwords_controller_spec.rb": 1.2283950000128243, + "spec/controllers/users/personal_keys_controller_spec.rb": 0.22861799999373034, + "spec/controllers/users/phone_setup_controller_spec.rb": 0.18669199998839758, + "spec/controllers/users/phones_controller_spec.rb": 0.16074200000730343, + "spec/controllers/users/piv_cac_authentication_setup_controller_spec.rb": 0.6561219999857713, + "spec/controllers/users/reset_passwords_controller_spec.rb": 1.5257050000072923, + "spec/controllers/users/rules_of_use_controller_spec.rb": 0.3507180000015069, + "spec/controllers/users/service_provider_revoke_controller_spec.rb": 0.3429770000220742, + "spec/controllers/users/sessions_controller_spec.rb": 1.5296070000040345, + "spec/controllers/users/totp_setup_controller_spec.rb": 1.5072799999907147, + "spec/controllers/users/two_factor_authentication_controller_spec.rb": 1.3976990000228398, + "spec/controllers/users/two_factor_authentication_setup_controller_spec.rb": 0.25329900000360794, + "spec/controllers/users/verify_password_controller_spec.rb": 0.3445110000029672, + "spec/controllers/users/verify_personal_key_controller_spec.rb": 0.5419330000004265, + "spec/controllers/users/webauthn_setup_controller_spec.rb": 0.3609209999849554, + "spec/controllers/users_controller_spec.rb": 0.17019400000572205, + "spec/controllers/vendor_outage_controller_spec.rb": 0.016879000002518296, + "spec/controllers/verify_controller_spec.rb": 0.304785999993328, + "spec/decorators/device_decorator_spec.rb": 0.07260099999257363, + "spec/decorators/email_context_spec.rb": 0.053961999976309016, + "spec/decorators/event_decorator_spec.rb": 0.07769000000553206, + "spec/decorators/mfa_context_spec.rb": 0.8195010000199545, + "spec/decorators/service_provider_session_decorator_spec.rb": 0.18534599998383783, + "spec/decorators/session_decorator_spec.rb": 0.020514000003458932, + "spec/decorators/user_decorator_spec.rb": 0.4616399999940768, + "spec/features/accessibility/idv_pages_spec.rb": 68.19815199999721, + "spec/features/accessibility/static_pages_spec.rb": 29.393261000019265, + "spec/features/accessibility/user_pages_spec.rb": 100.80429100000765, + "spec/features/accessibility/visitor_pages_spec.rb": 24.062226000009105, + "spec/features/account/backup_codes_spec.rb": 24.036498000001302, + "spec/features/account/device_spec.rb": 4.3584439999831375, + "spec/features/account/unphishable_badge_spec.rb": 8.434508000005735, + "spec/features/account_connected_apps_spec.rb": 9.419745999999577, + "spec/features/account_creation/multiple_browsers_spec.rb": 22.48721199997817, + "spec/features/account_creation/sp_return_log_spec.rb": 5.8236159999796655, + "spec/features/account_email_language_spec.rb": 13.71107600000687, + "spec/features/account_history_spec.rb": 4.3911150000058115, + "spec/features/account_reset/cancel_request_spec.rb": 5.98263399998541, + "spec/features/account_reset/delete_account_spec.rb": 21.576264999981504, + "spec/features/account_reset/pending_request_spec.rb": 6.6317200000048615, + "spec/features/device_tracking_spec.rb": 8.670856999990065, + "spec/features/event_disavowal_spec.rb": 55.953578999993624, + "spec/features/ialmax/saml_sign_in_spec.rb": 35.98976400002721, + "spec/features/idv/account_creation_spec.rb": 58.26531699998304, + "spec/features/idv/actions/cancel_link_sent_action_spec.rb": 5.197271000011824, + "spec/features/idv/actions/cancel_send_link_action_spec.rb": 5.034592000010889, + "spec/features/idv/clearing_and_restarting_spec.rb": 98.0412490000017, + "spec/features/idv/doc_auth/address_step_spec.rb": 32.749706999980845, + "spec/features/idv/doc_auth/agreement_step_spec.rb": 37.05160900001647, + "spec/features/idv/doc_auth/cancel_spec.rb": 5.737958000012441, + "spec/features/idv/doc_auth/document_capture_step_spec.rb": 154.88749100000132, + "spec/features/idv/doc_auth/email_sent_step_spec.rb": 9.975016999989748, + "spec/features/idv/doc_auth/finished_spec.rb": 11.302563000004739, + "spec/features/idv/doc_auth/link_sent_step_spec.rb": 59.96444700000575, + "spec/features/idv/doc_auth/send_link_step_spec.rb": 53.86055499999202, + "spec/features/idv/doc_auth/ssn_step_spec.rb": 54.416611000022385, + "spec/features/idv/doc_auth/test_credentials_spec.rb": 15.770573000016157, + "spec/features/idv/doc_auth/upload_step_spec.rb": 35.17936399998143, + "spec/features/idv/doc_auth/verify_step_spec.rb": 122.42119400002412, + "spec/features/idv/doc_auth/welcome_step_spec.rb": 28.551015000004554, + "spec/features/idv/doc_capture/capture_complete_step_spec.rb": 11.151015999988886, + "spec/features/idv/doc_capture/document_capture_step_spec.rb": 196.75808899998083, + "spec/features/idv/gpo_disabled_spec.rb": 12.227680999989389, + "spec/features/idv/hybrid_flow_spec.rb": 29.486306999984663, + "spec/features/idv/phone_input_spec.rb": 18.805239999986952, + "spec/features/idv/phone_otp_rate_limiting_spec.rb": 41.68011399998795, + "spec/features/idv/proofing_components_spec.rb": 48.248277999984566, + "spec/features/idv/sp_handoff_spec.rb": 107.16861100000096, + "spec/features/idv/sp_requested_attributes_spec.rb": 62.8075710000121, + "spec/features/idv/steps/confirmation_step_spec.rb": 434.0708889999951, + "spec/features/idv/steps/forgot_password_step_spec.rb": 18.755891000007978, + "spec/features/idv/steps/gpo_otp_verification_step_spec.rb": 92.32745499999146, + "spec/features/idv/steps/gpo_step_spec.rb": 37.90869899999234, + "spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb": 80.23257299998659, + "spec/features/idv/steps/phone_otp_verification_step_spec.rb": 64.66406599999755, + "spec/features/idv/steps/phone_step_spec.rb": 267.3205059999891, + "spec/features/idv/steps/review_step_spec.rb": 78.54854699998396, + "spec/features/idv/strict_ial2/feature_flag_spec.rb": 4.14451199999894, + "spec/features/idv/strict_ial2/upgrade_spec.rb": 32.03733800002374, + "spec/features/idv/strict_ial2/usps_upload_disallowed_spec.rb": 30.238822999992408, + "spec/features/idv/uak_password_spec.rb": 11.389461000013398, + "spec/features/idv/vendor_outage_spec.rb": 49.89529099999345, + "spec/features/legacy_passwords_spec.rb": 18.440311999991536, + "spec/features/load_testing/email_sign_up_spec.rb": 4.702813000010792, + "spec/features/multi_factor_authentication/mfa_cta_spec.rb": 21.700714999984484, + "spec/features/multiple_emails/add_email_spec.rb": 77.13934000002337, + "spec/features/multiple_emails/email_management_spec.rb": 33.57247400001506, + "spec/features/multiple_emails/reset_password_spec.rb": 9.197643999999855, + "spec/features/multiple_emails/sign_in_spec.rb": 15.13523700000951, + "spec/features/multiple_emails/sp_sign_in_spec.rb": 22.81210499998997, + "spec/features/new_device_tracking_spec.rb": 13.675709000002826, + "spec/features/openid_connect/aal3_required_spec.rb": 27.64996300000348, + "spec/features/openid_connect/authorization_confirmation_spec.rb": 27.439626000006683, + "spec/features/openid_connect/openid_connect_spec.rb": 172.11068099999102, + "spec/features/openid_connect/redirect_uri_validation_spec.rb": 44.845298000000184, + "spec/features/phone/add_phone_spec.rb": 38.97116200000164, + "spec/features/phone/confirmation_spec.rb": 151.07794899999863, + "spec/features/phone/default_phone_selection_spec.rb": 20.550835999980336, + "spec/features/phone/edit_phone_spec.rb": 8.616691999981413, + "spec/features/phone/rate_limitting_spec.rb": 75.68155999999726, + "spec/features/phone/remove_phone_spec.rb": 9.447890999988886, + "spec/features/remember_device/cookie_expiration_spec.rb": 9.92755799999577, + "spec/features/remember_device/phone_spec.rb": 51.53255299999728, + "spec/features/remember_device/revocation_spec.rb": 61.28120100000524, + "spec/features/remember_device/session_expiration_spec.rb": 5.415104000014253, + "spec/features/remember_device/sp_expiration_spec.rb": 278.9590850000095, + "spec/features/remember_device/totp_spec.rb": 67.1241549999977, + "spec/features/remember_device/user_opted_preference_spec.rb": 36.98311299999477, + "spec/features/remember_device/webauthn_spec.rb": 131.44424800001434, + "spec/features/reports/authorization_count_spec.rb": 121.78929499999504, + "spec/features/reports/doc_auth_drop_off_rates_per_sprint_report_spec.rb": 4.163344000000507, + "spec/features/reports/doc_auth_drop_off_rates_report_spec.rb": 8.335011000017403, + "spec/features/reports/doc_auth_funnel_report_spec.rb": 24.67265100000077, + "spec/features/reports/monthly_gpo_letter_requests_report_spec.rb": 16.245779000018956, + "spec/features/reports/omb_fitara_report_spec.rb": 19.448978000000352, + "spec/features/reports/proofing_costs_report_spec.rb": 16.99347599997418, + "spec/features/reports/sp_active_users_report_spec.rb": 9.837255000020377, + "spec/features/saml/aal3_required_spec.rb": 13.486076000001049, + "spec/features/saml/authorization_confirmation_spec.rb": 34.27725199999986, + "spec/features/saml/ial1/account_creation_spec.rb": 14.568479000008665, + "spec/features/saml/ial1_sso_spec.rb": 71.77020799997263, + "spec/features/saml/ial2_sso_spec.rb": 75.92810499999905, + "spec/features/saml/multiple_endpoints_spec.rb": 29.955882999987807, + "spec/features/saml/redirect_uri_validation_spec.rb": 5.05011499999091, + "spec/features/saml/saml_logout_spec.rb": 32.71727899997495, + "spec/features/saml/saml_relay_state_spec.rb": 19.936320999986492, + "spec/features/saml/saml_spec.rb": 121.1874630000093, + "spec/features/session/decryption_spec.rb": 4.358211999991909, + "spec/features/session/timeout_spec.rb": 12.872933000006014, + "spec/features/sign_in/banned_users_spec.rb": 17.16396999999415, + "spec/features/sign_in/remember_device_default_spec.rb": 13.446655000007013, + "spec/features/sign_in/sp_return_log_spec.rb": 5.02165400001104, + "spec/features/sign_in/two_factor_options_spec.rb": 64.28750899998704, + "spec/features/sp_cost_tracking_spec.rb": 36.819776000018464, + "spec/features/two_factor_authentication/backup_code_sign_up_spec.rb": 28.20057999997516, + "spec/features/two_factor_authentication/change_factor_spec.rb": 21.40169800000149, + "spec/features/two_factor_authentication/multiple_mfa_sign_up_spec.rb": 24.920263000007253, + "spec/features/two_factor_authentication/multiple_tabs_spec.rb": 14.709330999990925, + "spec/features/two_factor_authentication/sign_in_spec.rb": 117.27599900000496, + "spec/features/two_factor_authentication/sign_in_via_personal_key_spec.rb": 9.515468999976292, + "spec/features/users/password_recovery_via_recovery_code_spec.rb": 58.986590000014985, + "spec/features/users/piv_cac_management_spec.rb": 42.23901299998397, + "spec/features/users/regenerate_personal_key_spec.rb": 46.4969239999773, + "spec/features/users/sign_in_spec.rb": 602.8075680000184, + "spec/features/users/sign_out_spec.rb": 4.334268999984488, + "spec/features/users/sign_up_spec.rb": 164.13631200001691, + "spec/features/users/totp_management_spec.rb": 24.030060000019148, + "spec/features/users/user_edit_spec.rb": 4.433244999992894, + "spec/features/users/user_profile_spec.rb": 48.537106999981916, + "spec/features/users/verify_profile_spec.rb": 19.352175000007264, + "spec/features/visitors/bad_password_spec.rb": 5.140772999991896, + "spec/features/visitors/email_confirmation_spec.rb": 30.028401999996277, + "spec/features/visitors/i18n_spec.rb": 52.77211499999976, + "spec/features/visitors/js_disabled_spec.rb": 8.62439899999299, + "spec/features/visitors/navigation_spec.rb": 4.170687000005273, + "spec/features/visitors/password_recovery_spec.rb": 90.45579899998847, + "spec/features/visitors/resend_email_confirmation_spec.rb": 24.140336000011303, + "spec/features/visitors/set_password_spec.rb": 44.467757000005804, + "spec/features/visitors/sign_up_with_email_spec.rb": 39.49391900000046, + "spec/features/webauthn/hidden_spec.rb": 18.856514999992214, + "spec/features/webauthn/management_spec.rb": 68.3042350000178, + "spec/features/webauthn/sign_in_spec.rb": 14.537219999998342, + "spec/features/webauthn/sign_up_spec.rb": 19.478673000005074, + "spec/forms/add_user_email_form_spec.rb": 0.4346499999810476, + "spec/forms/api/profile_creation_form_spec.rb": 0.6951560000015888, + "spec/forms/delete_user_email_form_spec.rb": 0.24235200000111945, + "spec/forms/edit_phone_form_spec.rb": 0.15491900002234615, + "spec/forms/event_disavowal/password_reset_from_disavowal_form_spec.rb": 1.1458440000133123, + "spec/forms/gpo_verify_form_spec.rb": 0.2507270000060089, + "spec/forms/idv/api_document_verification_form_spec.rb": 0.21902499999850988, + "spec/forms/idv/api_document_verification_status_form_spec.rb": 0.08593199998722412, + "spec/forms/idv/api_image_upload_form_spec.rb": 1.0652490000065882, + "spec/forms/idv/doc_pii_form_spec.rb": 0.02111999999033287, + "spec/forms/idv/document_capture_form_spec.rb": 0.026008999993791804, + "spec/forms/idv/otp_delivery_method_form_spec.rb": 0.010594000021228567, + "spec/forms/idv/phone_confirmation_otp_verification_form_spec.rb": 0.1451859999797307, + "spec/forms/idv/phone_form_spec.rb": 0.4927200000092853, + "spec/forms/idv/ssn_form_spec.rb": 0.11313199999858625, + "spec/forms/idv/ssn_format_form_spec.rb": 0.06832200000644661, + "spec/forms/new_phone_form_spec.rb": 0.7364709999819752, + "spec/forms/openid_connect_authorize_form_spec.rb": 0.2413339999911841, + "spec/forms/openid_connect_logout_form_spec.rb": 0.29372699998202734, + "spec/forms/openid_connect_token_form_spec.rb": 1.550558999995701, + "spec/forms/otp_delivery_selection_form_spec.rb": 0.10355699999490753, + "spec/forms/otp_verification_form_spec.rb": 0.04149400000460446, + "spec/forms/password_form_spec.rb": 0.6602480000001378, + "spec/forms/password_reset_email_form_spec.rb": 0.05228300002636388, + "spec/forms/personal_key_form_spec.rb": 0.061830999999074265, + "spec/forms/register_user_email_form_spec.rb": 2.6497570000065025, + "spec/forms/reset_password_form_spec.rb": 0.7977709999831859, + "spec/forms/security_event_form_spec.rb": 2.5660270000225864, + "spec/forms/totp_setup_form_spec.rb": 0.12379700000747107, + "spec/forms/totp_verification_form_spec.rb": 0.04977000001235865, + "spec/forms/two_factor_authentication/phone_deletion_form_spec.rb": 0.4119910000008531, + "spec/forms/two_factor_login_options_form_spec.rb": 0.026247000001603737, + "spec/forms/two_factor_options_form_spec.rb": 0.07154500001342967, + "spec/forms/update_email_language_form_spec.rb": 0.06430999998701736, + "spec/forms/update_user_password_form_spec.rb": 0.738746999995783, + "spec/forms/user_piv_cac_login_form_spec.rb": 0.036567999981343746, + "spec/forms/user_piv_cac_setup_form_spec.rb": 0.1597030000120867, + "spec/forms/user_piv_cac_verification_form_spec.rb": 0.13202000001911074, + "spec/forms/verify_password_form_spec.rb": 0.1148539999849163, + "spec/forms/verify_personal_key_form_spec.rb": 0.21787999998196028, + "spec/forms/webauthn_setup_form_spec.rb": 0.11177999997744337, + "spec/forms/webauthn_verification_form_spec.rb": 0.08333600001060404, + "spec/forms/webauthn_visit_form_spec.rb": 0.029111000010743737, + "spec/helpers/application_helper_spec.rb": 0.12000200001057237, + "spec/helpers/asset_helper_spec.rb": 0.008199999982025474, + "spec/helpers/aws_s3_helper_spec.rb": 0.04203899999265559, + "spec/helpers/go_back_helper_spec.rb": 0.029638999985763803, + "spec/helpers/link_helper_spec.rb": 0.013384999998379499, + "spec/helpers/locale_helper_spec.rb": 0.06084499999997206, + "spec/helpers/script_helper_spec.rb": 0.04228500000317581, + "spec/helpers/session_timeout_warning_helper_spec.rb": 0.03068999998504296, + "spec/i18n_spec.rb": 13.78119899999001, + "spec/jobs/address_proofing_job_spec.rb": 0.15498700001626275, + "spec/jobs/application_job_spec.rb": 0.003257000003941357, + "spec/jobs/document_proofing_job_spec.rb": 0.5981190000020433, + "spec/jobs/gpo_daily_job_spec.rb": 0.0764340000168886, + "spec/jobs/heartbeat_job_spec.rb": 0.09934200000134297, + "spec/jobs/job_helpers/encryption_helper_spec.rb": 0.002765000012004748, + "spec/jobs/job_helpers/s3_helper_spec.rb": 0.02945900001213886, + "spec/jobs/job_helpers/stale_job_helper_spec.rb": 0.014480000012554228, + "spec/jobs/job_helpers/timer_spec.rb": 0.009076999995158985, + "spec/jobs/phone_number_opt_out_sync_job_spec.rb": 0.043799999984912574, + "spec/jobs/psql_stats_job_spec.rb": 0.04678600002080202, + "spec/jobs/remove_old_throttles_job_spec.rb": 0.059997000003932044, + "spec/jobs/reports/agency_invoice_iaa_supplement_report_spec.rb": 0.34430900000734255, + "spec/jobs/reports/agency_invoice_issuer_supplement_report_spec.rb": 0.0381409999972675, + "spec/jobs/reports/agency_user_counts_report_spec.rb": 0.01761199999600649, + "spec/jobs/reports/agreement_summary_report_spec.rb": 0.08204399998066947, + "spec/jobs/reports/base_report_spec.rb": 0.006724000006215647, + "spec/jobs/reports/combined_invoice_supplement_report_spec.rb": 0.263611000002129, + "spec/jobs/reports/daily_auths_report_spec.rb": 0.037945999996736646, + "spec/jobs/reports/daily_dropoffs_report_spec.rb": 0.08494299999438226, + "spec/jobs/reports/deleted_user_accounts_report_spec.rb": 0.22140500001842156, + "spec/jobs/reports/gpo_report_spec.rb": 0.06598999997368082, + "spec/jobs/reports/iaa_billing_report_spec.rb": 0.0912440000101924, + "spec/jobs/reports/month_helper_spec.rb": 0.009831000003032386, + "spec/jobs/reports/query_helpers_spec.rb": 0.06200500001432374, + "spec/jobs/reports/sp_active_users_over_period_of_performance_report_spec.rb": 0.02431599999545142, + "spec/jobs/reports/sp_active_users_report_spec.rb": 0.044861000002129, + "spec/jobs/reports/sp_cost_report_spec.rb": 0.030070000007981434, + "spec/jobs/reports/sp_user_counts_report_spec.rb": 0.04800100001739338, + "spec/jobs/reports/sp_user_quotas_report_spec.rb": 0.041896000009728596, + "spec/jobs/reports/total_ial2_costs_report_spec.rb": 0.036071999988052994, + "spec/jobs/reports/total_monthly_auths_report_spec.rb": 0.015943000005790964, + "spec/jobs/reports/total_sp_cost_report_spec.rb": 0.030346999992616475, + "spec/jobs/reports/unique_monthly_auths_report_spec.rb": 0.018515000003390014, + "spec/jobs/reports/unique_yearly_auths_report_spec.rb": 0.014682999986689538, + "spec/jobs/reports/verification_errors_report_spec.rb": 0.18541599999298342, + "spec/jobs/resolution_proofing_job_spec.rb": 0.1243479999830015, + "spec/jobs/risc_delivery_job_spec.rb": 0.21294399999896996, + "spec/lib/analytics_events_documenter_spec.rb": 0.3297829999937676, + "spec/lib/app_artifacts_spec.rb": 0.019483999989461154, + "spec/lib/asset_sources_spec.rb": 0.045131999999284744, + "spec/lib/aws/ses_spec.rb": 0.011945000005653128, + "spec/lib/deploy/activate_spec.rb": 0.061871000012615696, + "spec/lib/feature_management_spec.rb": 158.3213270000124, + "spec/lib/fingerprinter_spec.rb": 0.012623999995412305, + "spec/lib/headers_filter_spec.rb": 0.0030839999963063747, + "spec/lib/identity_job_log_subscriber_spec.rb": 0.12238600000273436, + "spec/lib/linters/errors_add_linter_spec.rb": 0.023109000001568347, + "spec/lib/linters/localized_validation_mesasge_linter_spec.rb": 0.021066000015707687, + "spec/lib/linters/mail_later_linter_spec.rb": 0.08733799998299219, + "spec/lib/linters/redirect_back_linter_spec.rb": 0.02056300002732314, + "spec/lib/linters/url_options_linter_spec.rb": 0.02309799997601658, + "spec/lib/makefile_help_parser_spec.rb": 0.09910600000876002, + "spec/lib/otp_code_generator_spec.rb": 0.010727999993832782, + "spec/lib/pinpoint_supported_countries_spec.rb": 0.03996900000493042, + "spec/lib/query_tracker_spec.rb": 0.017789000004995614, + "spec/lib/tasks/dev_rake_spec.rb": 0.5388229999807663, + "spec/lib/tasks/partners_rake_spec.rb": 0.6740839999984019, + "spec/lib/tasks/rotate_rake_spec.rb": 0.09821299999020994, + "spec/lib/telephony/alert_sender_spec.rb": 0.02887199999531731, + "spec/lib/telephony/otp_sender_spec.rb": 0.06045700001413934, + "spec/lib/telephony/pinpoint/aws_credential_builder_spec.rb": 0.016826999984914437, + "spec/lib/telephony/pinpoint/opt_out_manager_spec.rb": 0.0615189999807626, + "spec/lib/telephony/pinpoint/sms_sender_spec.rb": 0.1233899999933783, + "spec/lib/telephony/pinpoint/voice_sender_spec.rb": 0.05048299999907613, + "spec/lib/telephony/response_spec.rb": 0.02415300000575371, + "spec/lib/telephony/telephony_spec.rb": 0.029577999986940995, + "spec/lib/telephony/test/call_spec.rb": 0.0170650000218302, + "spec/lib/telephony/test/message_spec.rb": 0.01571999999578111, + "spec/lib/telephony/test/sms_sender_spec.rb": 0.017842000001110137, + "spec/lib/telephony/test/voice_sender_spec.rb": 0.008673000003909692, + "spec/lib/telephony/util_spec.rb": 0.005415999999968335, + "spec/lib/utf8_sanitizer_spec.rb": 0.026367000013124198, + "spec/mailers/previews/user_mailer_preview_spec.rb": 0.11896200000774115, + "spec/mailers/user_mailer_spec.rb": 1.9060440000030212, + "spec/models/account_reset_request_spec.rb": 0.01919699998688884, + "spec/models/agency_identity_spec.rb": 0.016658000007737428, + "spec/models/agency_spec.rb": 0.030987000005552545, + "spec/models/agreements/iaa_gtc_spec.rb": 0.21828900001128204, + "spec/models/agreements/iaa_order_spec.rb": 0.38044500001706183, + "spec/models/agreements/iaa_spec.rb": 0.19932799998787232, + "spec/models/agreements/integration_spec.rb": 0.2981079999881331, + "spec/models/agreements/integration_status_spec.rb": 0.06796399998711422, + "spec/models/agreements/integration_usage_spec.rb": 0.25192800001241267, + "spec/models/agreements/partner_account_spec.rb": 0.1951849999895785, + "spec/models/agreements/partner_account_status_spec.rb": 0.05706399999326095, + "spec/models/anonymous_user_spec.rb": 0.015190000005532056, + "spec/models/backup_code_configuration_spec.rb": 1.1667780000134371, + "spec/models/concerns/user_otp_methods_spec.rb": 0.01100399999995716, + "spec/models/device_spec.rb": 0.06412100000306964, + "spec/models/document_capture_session_spec.rb": 0.024319999996805564, + "spec/models/email_address_spec.rb": 0.10691800000495277, + "spec/models/event_spec.rb": 0.02770899998722598, + "spec/models/gpo_confirmation_code_spec.rb": 0.11262200001510791, + "spec/models/monthly_auth_count_spec.rb": 0.022816999990027398, + "spec/models/null_identity_spec.rb": 0.006357000005664304, + "spec/models/otp_requests_tracker_spec.rb": 0.03377499998896383, + "spec/models/phone_configuration_spec.rb": 0.09706000000005588, + "spec/models/phone_number_opt_out_spec.rb": 0.08468500000890344, + "spec/models/profile_spec.rb": 0.9762380000029225, + "spec/models/service_provider_identity_spec.rb": 0.37015400000382215, + "spec/models/service_provider_spec.rb": 0.040053000004263595, + "spec/models/sp_return_log_spec.rb": 0.0046910000091884285, + "spec/models/throttle_spec.rb": 0.27664999998523854, + "spec/models/user_spec.rb": 0.8125029999937396, + "spec/models/webauthn_configuration_spec.rb": 0.1364999999932479, + "spec/policies/backup_code_policy_spec.rb": 0.022473999997600913, + "spec/policies/service_provider_mfa_policy_spec.rb": 0.9951369999907911, + "spec/policies/two_factor_authentication/piv_cac_policy_spec.rb": 0.06198599998606369, + "spec/policies/user_mfa_policy_spec.rb": 0.1649199999810662, + "spec/policies/webauthn_login_option_policy_spec.rb": 0.03218799998285249, + "spec/presenters/account_reset/pending_presenter_spec.rb": 0.1420219999854453, + "spec/presenters/account_show_presenter_spec.rb": 0.16458199999760836, + "spec/presenters/cancellation_presenter_spec.rb": 0.01634800000465475, + "spec/presenters/completions_presenter_spec.rb": 0.4536480000242591, + "spec/presenters/confirm_delete_email_presenter_spec.rb": 0.01772000000346452, + "spec/presenters/eastern_time_presenter_spec.rb": 0.003144999995129183, + "spec/presenters/fully_signed_in_modal_presenter_spec.rb": 0.015043999999761581, + "spec/presenters/idv/gpo_presenter_spec.rb": 0.11757599998963997, + "spec/presenters/image_upload_response_presenter_spec.rb": 0.04717700000037439, + "spec/presenters/max_attempts_reached_presenter_spec.rb": 0.01156099999207072, + "spec/presenters/navigation_presenter_spec.rb": 0.02885699999751523, + "spec/presenters/openid_connect_certs_presenter_spec.rb": 0.003316000016639009, + "spec/presenters/openid_connect_configuration_presenter_spec.rb": 0.003484999993816018, + "spec/presenters/openid_connect_user_info_presenter_spec.rb": 0.4443350000074133, + "spec/presenters/partially_signed_in_modal_presenter_spec.rb": 0.013630000001285225, + "spec/presenters/piv_cac_authentication_setup_presenter_spec.rb": 0.048089000018080696, + "spec/presenters/piv_cac_error_presenter_spec.rb": 0.012085999973351136, + "spec/presenters/risc_configuration_presenter_spec.rb": 0.003265000006649643, + "spec/presenters/saml_request_presenter_spec.rb": 0.023450000007869676, + "spec/presenters/setup_presenter_spec.rb": 0.07023199999821372, + "spec/presenters/two_factor_auth_code/authenticator_delivery_presenter_spec.rb": 0.008882000023731962, + "spec/presenters/two_factor_auth_code/backup_code_presenter_spec.rb": 0.01317000002018176, + "spec/presenters/two_factor_auth_code/generic_delivery_presenter_spec.rb": 0.002306999987922609, + "spec/presenters/two_factor_auth_code/personal_key_presenter_spec.rb": 0.005846000014571473, + "spec/presenters/two_factor_auth_code/phone_delivery_presenter_spec.rb": 0.013750999991316348, + "spec/presenters/two_factor_auth_code/piv_cac_authentication_presenter_spec.rb": 0.0497449999966193, + "spec/presenters/two_factor_auth_code/webauthn_authentication_presenter_spec.rb": 0.06423199997516349, + "spec/presenters/two_factor_authentication/auth_app_selection_presenter_spec.rb": 0.003435000020544976, + "spec/presenters/two_factor_authentication/personal_key_selection_presenter_spec.rb": 0.0053610000177286565, + "spec/presenters/two_factor_authentication/phone_selection_presenter_spec.rb": 0.03263099998002872, + "spec/presenters/two_factor_authentication/piv_cac_selection_presenter_spec.rb": 0.005287000007228926, + "spec/presenters/two_factor_authentication/selection_presenter_spec.rb": 0.01921800000127405, + "spec/presenters/two_factor_authentication/sms_selection_presenter_spec.rb": 0.07518200000049546, + "spec/presenters/two_factor_authentication/voice_selection_presenter_spec.rb": 0.07490700000198558, + "spec/presenters/two_factor_authentication/webauthn_platform_selection_presenter_spec.rb": 0.005410000012489036, + "spec/presenters/two_factor_authentication/webauthn_selection_presenter_spec.rb": 0.006459000025643036, + "spec/presenters/two_factor_login_options_presenter_spec.rb": 0.07195499999215826, + "spec/presenters/two_factor_options_presenter_spec.rb": 0.026286999986041337, + "spec/presenters/utc_time_presenter_spec.rb": 0.005551000009290874, + "spec/requests/acuant_sdk_spec.rb": 0.10849700000835583, + "spec/requests/api_cors_spec.rb": 0.5812740000255872, + "spec/requests/csp_spec.rb": 0.18140000000130385, + "spec/requests/headers_spec.rb": 0.43698900000890717, + "spec/requests/i18n_spec.rb": 0.20232599999872036, + "spec/requests/invalid_encoding_spec.rb": 0.3430579999985639, + "spec/requests/invalid_sign_in_params_spec.rb": 0.12360199997783639, + "spec/requests/not_acceptable_spec.rb": 0.15620299999136478, + "spec/requests/openid_connect_authorize_spec.rb": 0.7540469999948982, + "spec/requests/openid_connect_cors_spec.rb": 0.5400059999956284, + "spec/requests/page_not_found_spec.rb": 0.15221599998767488, + "spec/requests/params_is_string_instead_of_hash_spec.rb": 0.04072000001906417, + "spec/requests/preload_spec.rb": 0.09482699999352917, + "spec/requests/rack_attack_spec.rb": 3.9001250000146683, + "spec/requests/redirects_spec.rb": 0.1121149999962654, + "spec/requests/saml_requests_spec.rb": 0.18340599999646656, + "spec/requests/secure_cookies_spec.rb": 0.1724349999858532, + "spec/routing/gpo_verification_routing_spec.rb": 0.6076640000101179, + "spec/scripts/changelog_check_spec.rb": 0.017425999976694584, + "spec/services/access_token_verifier_spec.rb": 0.033709999988786876, + "spec/services/account_reset/cancel_request_for_user_spec.rb": 0.44792999999481253, + "spec/services/account_reset/cancel_spec.rb": 0.958293000003323, + "spec/services/account_reset/create_request_spec.rb": 0.4759360000025481, + "spec/services/account_reset/delete_account_spec.rb": 0.25786599999992177, + "spec/services/account_reset/find_prending_request_for_user_spec.rb": 0.08873799999128096, + "spec/services/account_reset/grant_request_spec.rb": 0.02730600000359118, + "spec/services/account_reset/grant_requests_and_send_emails_spec.rb": 0.9972520000010263, + "spec/services/account_reset/notify_user_of_request_cancellation_spec.rb": 0.31368099999963306, + "spec/services/account_reset_health_checker_spec.rb": 0.020088000019313768, + "spec/services/active_profile_encryptor_spec.rb": 0.04167599999345839, + "spec/services/agency_identity_linker_spec.rb": 0.2378749999916181, + "spec/services/agency_seeder_spec.rb": 0.05245300001115538, + "spec/services/agreements/db/accounts_by_agency_spec.rb": 0.06535299998358823, + "spec/services/agreements/db/iaas_by_agency_spec.rb": 0.0648640000144951, + "spec/services/agreements/db/sp_return_log_scan_spec.rb": 0.009102999989409, + "spec/services/agreements/iaa_gtc_seeder_spec.rb": 0.033617000008234754, + "spec/services/agreements/iaa_order_seeder_spec.rb": 0.06874700001208112, + "spec/services/agreements/iaa_usage_spec.rb": 0.35344700000132434, + "spec/services/agreements/integration_seeder_spec.rb": 0.06196399999316782, + "spec/services/agreements/integration_status_seeder_spec.rb": 0.02741400001104921, + "spec/services/agreements/partner_account_seeder_spec.rb": 0.03463099998771213, + "spec/services/agreements/partner_account_status_seeder_spec.rb": 0.04729700001189485, + "spec/services/agreements/reports/agencies_report_spec.rb": 0.007145999989006668, + "spec/services/agreements/reports/agency_iaas_report_spec.rb": 0.09208800000487827, + "spec/services/agreements/reports/agency_partner_accounts_report_spec.rb": 0.01584000000730157, + "spec/services/agreements/reports/partner_api_report_spec.rb": 0.018391999998129904, + "spec/services/agreements/usage_summarizer_spec.rb": 0.15722999998251908, + "spec/services/analytics_spec.rb": 0.1425839999865275, + "spec/services/attribute_asserter_spec.rb": 1.1762849999940954, + "spec/services/backup_code_generator_spec.rb": 1.4923849999904633, + "spec/services/banned_user_resolver_spec.rb": 0.12047299998812377, + "spec/services/browser_cache_spec.rb": 0.0108440000039991, + "spec/services/calendar_service_spec.rb": 0.054573999979766086, + "spec/services/completions_decider_spec.rb": 0.022852999973110855, + "spec/services/data_requests/create_email_addresses_report_spec.rb": 0.020428999996511266, + "spec/services/data_requests/create_mfa_configurations_report_spec.rb": 0.10642399999778718, + "spec/services/data_requests/create_user_events_report_spec.rb": 0.03978400002233684, + "spec/services/data_requests/create_user_report_spec.rb": 0.08060499999555759, + "spec/services/data_requests/fetch_cloudwatch_logs_spec.rb": 0.012174999981652945, + "spec/services/data_requests/lookup_shared_device_users_spec.rb": 0.05714799999259412, + "spec/services/data_requests/lookup_user_by_uuid_spec.rb": 0.02953299999353476, + "spec/services/data_requests/write_cloudwatch_logs_spec.rb": 0.013891000009607524, + "spec/services/data_requests/write_user_events_spec.rb": 0.00443000000086613, + "spec/services/data_requests/write_user_info_spec.rb": 0.006840000016381964, + "spec/services/database_health_checker_spec.rb": 0.008916000020690262, + "spec/services/date_parser_spec.rb": 0.013919000019086525, + "spec/services/db/add_document_verification_and_selfie_costs_spec.rb": 0.13234499999089167, + "spec/services/db/agency_identity/agency_user_counts_spec.rb": 0.013906000007409602, + "spec/services/db/deleted_user/create_spec.rb": 0.07155600000987761, + "spec/services/db/identity/sp_active_user_counts_spec.rb": 0.058841999998549, + "spec/services/db/identity/sp_active_user_counts_within_iaa_window_spec.rb": 0.05453000002307817, + "spec/services/db/identity/sp_user_counts_spec.rb": 0.02047600000514649, + "spec/services/db/identity/sp_user_quotas_spec.rb": 0.026270000002114102, + "spec/services/db/monthly_auth_count/total_monthly_auth_counts_spec.rb": 0.012740000005578622, + "spec/services/db/monthly_auth_count/unique_monthly_auth_counts_spec.rb": 0.019991000008303672, + "spec/services/db/monthly_auth_count/unique_yearly_auth_counts_spec.rb": 0.01385600000503473, + "spec/services/db/monthly_sp_auth_count/total_monthly_auth_counts_within_iaa_window_spec.rb": 0.06335699997725897, + "spec/services/db/monthly_sp_auth_count/unique_monthly_auth_counts_by_iaa_spec.rb": 0.08629599999403581, + "spec/services/db/sp_return_log_spec.rb": 0.00724499998614192, + "spec/services/deleted_accounts_report_spec.rb": 0.11511000001337379, + "spec/services/displayable_pii_formatter_spec.rb": 0.6833190000033937, + "spec/services/doc_auth/acuant/acuant_client_spec.rb": 0.7170629999891389, + "spec/services/doc_auth/acuant/pii_from_doc_spec.rb": 0.014524999976856634, + "spec/services/doc_auth/acuant/request_spec.rb": 0.6080220000003465, + "spec/services/doc_auth/acuant/requests/create_document_request_spec.rb": 0.03370800000266172, + "spec/services/doc_auth/acuant/requests/facial_match_request_spec.rb": 0.054911999992327765, + "spec/services/doc_auth/acuant/requests/get_face_image_request_spec.rb": 0.025720999983604997, + "spec/services/doc_auth/acuant/requests/get_results_request_spec.rb": 0.01847700000507757, + "spec/services/doc_auth/acuant/requests/liveness_request_spec.rb": 0.042988000001059845, + "spec/services/doc_auth/acuant/requests/upload_image_request_spec.rb": 0.0358330000017304, + "spec/services/doc_auth/acuant/responses/create_document_response_spec.rb": 0.0031759999983478338, + "spec/services/doc_auth/acuant/responses/facial_match_response_spec.rb": 0.008126999979140237, + "spec/services/doc_auth/acuant/responses/get_face_image_response_spec.rb": 0.0029360000044107437, + "spec/services/doc_auth/acuant/responses/get_results_response_spec.rb": 0.04064199997810647, + "spec/services/doc_auth/acuant/responses/liveness_response_spec.rb": 0.009978999994928017, + "spec/services/doc_auth/acuant/result_codes_spec.rb": 0.005658000009134412, + "spec/services/doc_auth/error_generator_spec.rb": 0.0711320000118576, + "spec/services/doc_auth/lexis_nexis/lexis_nexis_client_spec.rb": 0.30789299999014474, + "spec/services/doc_auth/lexis_nexis/request_spec.rb": 0.537255000002915, + "spec/services/doc_auth/lexis_nexis/requests/true_id_request_spec.rb": 0.1987700000172481, + "spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb": 0.2274219999962952, + "spec/services/doc_auth/mock/dock_auth_mock_client_spec.rb": 0.059650000010151416, + "spec/services/doc_auth/mock/result_response_builder_spec.rb": 0.05015500000445172, + "spec/services/doc_auth/response_spec.rb": 0.028029999986756593, + "spec/services/doc_auth_router_spec.rb": 0.3131759999960195, + "spec/services/document_capture_session_async_result_spec.rb": 0.018176999990828335, + "spec/services/document_capture_session_result_spec.rb": 0.004188999999314547, + "spec/services/duration_parser_spec.rb": 0.030968000006396323, + "spec/services/email_confirmation_token_validator_spec.rb": 0.13900500000454485, + "spec/services/encrypted_attribute_spec.rb": 0.01958100000047125, + "spec/services/encrypted_redis_struct_storage_spec.rb": 0.04514199998811819, + "spec/services/encryption/aes_cipher_spec.rb": 0.012596999993547797, + "spec/services/encryption/contextless_kms_client_spec.rb": 0.07409799998276867, + "spec/services/encryption/encryptors/aes_encryptor_spec.rb": 0.008956000005127862, + "spec/services/encryption/encryptors/attribute_encryptor_spec.rb": 0.02138399999239482, + "spec/services/encryption/encryptors/background_proofing_arg_encryptor_spec.rb": 0.009836999990511686, + "spec/services/encryption/encryptors/pii_encryptor_spec.rb": 0.0594660000060685, + "spec/services/encryption/encryptors/session_encryptor_spec.rb": 0.010376999998698011, + "spec/services/encryption/kms_client_spec.rb": 0.06719599998905323, + "spec/services/encryption/kms_logger_spec.rb": 0.009034000016981736, + "spec/services/encryption/multi_region_kms_client_spec.rb": 0.06112100000609644, + "spec/services/encryption/password_verifier_spec.rb": 0.08078200000454672, + "spec/services/encryption/uak_password_verifier_spec.rb": 0.21708100000978447, + "spec/services/encryption/user_access_key_spec.rb": 0.04805700000724755, + "spec/services/event_disavowal/disavow_event_spec.rb": 0.030223000008845702, + "spec/services/event_disavowal/find_disavowed_event_spec.rb": 0.0548490000073798, + "spec/services/event_disavowal/generate_disavowal_token_spec.rb": 0.029272999992826954, + "spec/services/event_disavowal/validate_disavowed_event_spec.rb": 0.08685600000899285, + "spec/services/forget_all_browsers_spec.rb": 0.01597900001797825, + "spec/services/form_response_spec.rb": 0.10660999998799525, + "spec/services/funnel/doc_auth/log_document_error_spec.rb": 0.014851999992970377, + "spec/services/funnel/registration/add_mfa_spec.rb": 0.0692949999938719, + "spec/services/funnel/registration/add_password_spec.rb": 0.023985000007087365, + "spec/services/funnel/registration/confirm_email_spec.rb": 0.026906999992206693, + "spec/services/funnel/registration/range_registered_count_spec.rb": 0.1455100000021048, + "spec/services/funnel/registration/range_submitted_count_spec.rb": 0.15492999998969026, + "spec/services/funnel/registration/total_registered_count_spec.rb": 0.08255500000086613, + "spec/services/funnel/registration/total_submitted_count_spec.rb": 0.05251700000371784, + "spec/services/gpo_confirmation_exporter_spec.rb": 0.008112000010441989, + "spec/services/gpo_confirmation_maker_spec.rb": 0.1097379999991972, + "spec/services/gpo_confirmation_spec.rb": 0.011173000006237999, + "spec/services/gpo_confirmation_uploader_spec.rb": 0.044791000022087246, + "spec/services/gpo_daily_test_sender_spec.rb": 0.024619000003440306, + "spec/services/health_check_summary_spec.rb": 0.0059139999793842435, + "spec/services/iaa_reporting_helper_spec.rb": 0.1931650000042282, + "spec/services/ial_context_spec.rb": 0.416179000021657, + "spec/services/id_token_builder_spec.rb": 0.34888299999875017, + "spec/services/identity_linker_spec.rb": 0.2594530000060331, + "spec/services/idv/actions/verify_document_status_action_spec.rb": 0.22304700000677258, + "spec/services/idv/agent_spec.rb": 0.3420619999815244, + "spec/services/idv/cancel_verification_attempt_spec.rb": 0.20416799999657087, + "spec/services/idv/data_url_image_spec.rb": 0.014391000004252419, + "spec/services/idv/duplicate_ssn_finder_spec.rb": 0.20023899999796413, + "spec/services/idv/gpo_mail_spec.rb": 0.11378399998648092, + "spec/services/idv/phone_step_spec.rb": 1.058986999996705, + "spec/services/idv/profile_maker_spec.rb": 0.04445899999700487, + "spec/services/idv/send_phone_confirmation_otp_spec.rb": 0.13096400001086295, + "spec/services/idv/session_spec.rb": 0.16786899999715388, + "spec/services/idv/steps/ssn_step_spec.rb": 0.01991699999780394, + "spec/services/idv/steps/verify_step_spec.rb": 0.2305599999963306, + "spec/services/idv/user_bundle_tokenizer_spec.rb": 0.034340000012889504, + "spec/services/image_upload_presigned_url_generator_spec.rb": 0.010746999992989004, + "spec/services/key_rotator/attribute_encryption_spec.rb": 0.04097499998169951, + "spec/services/key_rotator/hmac_fingerprinter_spec.rb": 0.1277669999981299, + "spec/services/maintenance_window_spec.rb": 0.01969200000166893, + "spec/services/marketing_site_spec.rb": 0.05471399999805726, + "spec/services/multi_health_checker_spec.rb": 0.008874999999534339, + "spec/services/openid_connect_attribute_scoper_spec.rb": 0.0382310000131838, + "spec/services/otp_preference_updater_spec.rb": 0.04677099999389611, + "spec/services/otp_rate_limiter_spec.rb": 0.10379600001033396, + "spec/services/out_of_band_session_accessor_spec.rb": 0.016868999984581023, + "spec/services/outbound_health_checker_spec.rb": 0.19922099998802878, + "spec/services/parse_controller_from_referer_spec.rb": 0.006597999978112057, + "spec/services/personal_key_generator_spec.rb": 0.32523099999525584, + "spec/services/phone_confirmation/confirmaton_session_spec.rb": 0.02094099999521859, + "spec/services/phone_formatter_spec.rb": 0.01760900000226684, + "spec/services/phone_number_capabilities_spec.rb": 0.13503299999865703, + "spec/services/pii/attributes_spec.rb": 0.022384999989299104, + "spec/services/pii/cacher_spec.rb": 0.41784999999799766, + "spec/services/pii/fingerprinter_spec.rb": 0.029638999985763803, + "spec/services/pii/re_encryptor_spec.rb": 0.11880700002075173, + "spec/services/pii/session_store_spec.rb": 0.009015000017825514, + "spec/services/piv_cac/check_config_spec.rb": 0.009111999999731779, + "spec/services/piv_cac_service_spec.rb": 0.13052299999981187, + "spec/services/profanity_detector_spec.rb": 0.02012000000104308, + "spec/services/proofing/aamva/applicant_spec.rb": 0.008403000014368445, + "spec/services/proofing/aamva/authentication_client_spec.rb": 0.15916999999899417, + "spec/services/proofing/aamva/hmac_secret_spec.rb": 0.004822999995667487, + "spec/services/proofing/aamva/proofing_spec.rb": 0.2141280000214465, + "spec/services/proofing/aamva/request/authentication_token_request_spec.rb": 0.08372600001166575, + "spec/services/proofing/aamva/request/security_token_request_spec.rb": 1.7146840000059456, + "spec/services/proofing/aamva/request/verification_request_spec.rb": 0.10451799997827038, + "spec/services/proofing/aamva/response/authentication_token_response_spec.rb": 0.03353899999638088, + "spec/services/proofing/aamva/response/security_token_response_spec.rb": 0.06254199999966659, + "spec/services/proofing/aamva/response/verification_response_spec.rb": 0.16606300001149066, + "spec/services/proofing/aamva/soap_error_handler_spec.rb": 0.0438809999905061, + "spec/services/proofing/aamva/verification_client_spec.rb": 0.14172800001688302, + "spec/services/proofing/base_spec.rb": 0.05930400002398528, + "spec/services/proofing/lexis_nexis/date_formatter_spec.rb": 0.008326000010129064, + "spec/services/proofing/lexis_nexis/instant_verify/proofing_spec.rb": 0.15141799999400973, + "spec/services/proofing/lexis_nexis/instant_verify/verification_request_spec.rb": 0.0548269999853801, + "spec/services/proofing/lexis_nexis/phone_finder/proofing_spec.rb": 0.029678999999305233, + "spec/services/proofing/lexis_nexis/phone_finder/verification_request_spec.rb": 0.05003200002829544, + "spec/services/proofing/lexis_nexis/response_spec.rb": 0.029660999978659675, + "spec/services/proofing/lexis_nexis/verification_error_parser_spec.rb": 0.03718699997989461, + "spec/services/proofing/result_spec.rb": 0.0819750000082422, + "spec/services/proofing_session_async_result_spec.rb": 0.00483300001360476, + "spec/services/push_notification/account_purged_event_spec.rb": 0.022280000004684553, + "spec/services/push_notification/email_changed_event_spec.rb": 0.021622999978717417, + "spec/services/push_notification/http_push_spec.rb": 0.5474749999993946, + "spec/services/push_notification/identifier_recycled_event_spec.rb": 0.02279600000474602, + "spec/services/push_notification/mfa_limit_account_locked_event_spec.rb": 0.025870000012218952, + "spec/services/push_notification/recovery_activated_event_spec.rb": 0.022089999984018505, + "spec/services/push_notification/reproof_completed_event_spec.rb": 0.02013500002794899, + "spec/services/pwned_passwords/lookup_password_spec.rb": 0.006265000003622845, + "spec/services/random_phrase_spec.rb": 0.01990399998612702, + "spec/services/reactivate_account_session_spec.rb": 0.1049349999811966, + "spec/services/redis_rate_limiter_spec.rb": 0.029692000010982156, + "spec/services/remember_device_cookie_spec.rb": 0.12805899998056702, + "spec/services/request_password_reset_spec.rb": 3.3842270000022836, + "spec/services/reset_user_password_spec.rb": 1.6465949999983422, + "spec/services/revoke_service_provider_consent_spec.rb": 0.01139500000863336, + "spec/services/saml_endpoint_spec.rb": 0.015762000024551526, + "spec/services/saml_request_validator_spec.rb": 0.06565599999157712, + "spec/services/secure_headers_allow_list_spec.rb": 0.012995000026421621, + "spec/services/send_sign_up_email_confirmation_spec.rb": 0.29652400000486523, + "spec/services/service_provider_quota_limit/any_sp_over_quota_limit_spec.rb": 0.014391000004252419, + "spec/services/service_provider_quota_limit/is_sp_over_quota_spec.rb": 0.023686999978963286, + "spec/services/service_provider_quota_limit/notify_if_any_sp_over_quota_limit_spec.rb": 0.21100700000533834, + "spec/services/service_provider_quota_limit/update_from_report_spec.rb": 0.022219000005861744, + "spec/services/service_provider_request_proxy_spec.rb": 0.038869000010890886, + "spec/services/service_provider_seeder_spec.rb": 1.1251120000088122, + "spec/services/service_provider_updater_spec.rb": 0.23167500001727603, + "spec/services/session_encryptor_spec.rb": 0.016746000008424744, + "spec/services/sp_return_url_resolver_spec.rb": 0.032995999994454905, + "spec/services/ssn_formatter_spec.rb": 0.025028000003658235, + "spec/services/store_sp_metadata_in_session_spec.rb": 0.019033000018680468, + "spec/services/time_service_spec.rb": 0.0033260000054724514, + "spec/services/update_user_spec.rb": 0.20273699998506345, + "spec/services/uri_service_spec.rb": 0.015584000007947907, + "spec/services/user_alerts/alert_user_about_account_verified_spec.rb": 0.5850400000053924, + "spec/services/user_alerts/alert_user_about_new_device_spec.rb": 0.41659000000800006, + "spec/services/user_alerts/alert_user_about_password_change_spec.rb": 0.3989139999903273, + "spec/services/user_alerts/alert_user_about_personal_key_sign_in_spec.rb": 0.42019199999049306, + "spec/services/user_event_creator_spec.rb": 0.22246099999756552, + "spec/services/user_seeder_spec.rb": 2.981567000009818, + "spec/services/user_session_context_spec.rb": 0.021227999997790903, + "spec/services/usps_in_person_proofer_spec.rb": 0.19730800000252202, + "spec/services/uuid_reporter_spec.rb": 0.21011399998678826, + "spec/services/vendor_status_spec.rb": 0.11189200001535937, + "spec/services/x509/attribute_spec.rb": 0.002498999994713813, + "spec/services/x509/attributes_spec.rb": 0.016490000009071082, + "spec/services/x509/session_store_spec.rb": 0.011144999996758997, + "spec/svg_spec.rb": 0.369077000010293, + "spec/views/account_reset/cancel/show.html.erb_spec.rb": 0.013137000001734123, + "spec/views/account_reset/confirm_delete_account/show.html.erb_spec.rb": 0.021467999991727993, + "spec/views/account_reset/confirm_request/show.html.erb_spec.rb": 0.012686000001849607, + "spec/views/account_reset/delete_account/show.html.erb_spec.rb": 0.015253999998094514, + "spec/views/account_reset/request/show.html.erb_spec.rb": 0.053596000012476, + "spec/views/account_reset/user_mailer/email_confirmation_instructions.html.erb_spec.rb": 0.12937300000339746, + "spec/views/account_reset/user_mailer/unconfirmed_email_instructions.html.erb_spec.rb": 0.10199399999692105, + "spec/views/accounts/_nav_auth.html.erb_spec.rb": 0.06984499999089167, + "spec/views/accounts/connected_accounts/show.html.erb_spec.rb": 0.07782799997949041, + "spec/views/accounts/history/show.html.erb_spec.rb": 0.03618799999821931, + "spec/views/accounts/show.html.erb_spec.rb": 0.4838150000141468, + "spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb": 0.21697300000232644, + "spec/views/devise/passwords/edit.html.erb_spec.rb": 0.10025399998994544, + "spec/views/devise/passwords/new.html.erb_spec.rb": 0.1111910000036005, + "spec/views/devise/sessions/new.html.erb_spec.rb": 0.2580010000092443, + "spec/views/devise/shared/_password_strength.html.erb_spec.rb": 0.06528999999864027, + "spec/views/forgot_password/show.html.erb_spec.rb": 0.04379299998981878, + "spec/views/idv/activated.html.erb_spec.rb": 0.011663999990560114, + "spec/views/idv/cancellations/destroy.html.erb_spec.rb": 0.048192999995080754, + "spec/views/idv/cancellations/new.html.erb_spec.rb": 0.05363999999826774, + "spec/views/idv/come_back_later/show.html.erb_spec.rb": 0.04338300001109019, + "spec/views/idv/doc_auth/_back.html.erb_spec.rb": 0.05193600000347942, + "spec/views/idv/doc_auth/_start_over_or_cancel.html.erb_spec.rb": 0.031955999991623685, + "spec/views/idv/doc_auth/upload.html.erb_spec.rb": 0.023603000008733943, + "spec/views/idv/doc_auth/welcome.html.erb_spec.rb": 0.07798999999067746, + "spec/views/idv/gpo/index.html.erb_spec.rb": 0.21538400001008995, + "spec/views/idv/otp_delivery_method/new.html.erb_spec.rb": 0.046666999987792224, + "spec/views/idv/phone/new.html.erb_spec.rb": 0.051069999986793846, + "spec/views/idv/phone_errors/_warning.html.erb_spec.rb": 0.08083700001589023, + "spec/views/idv/phone_errors/failure.html.erb_spec.rb": 0.022175999998580664, + "spec/views/idv/phone_errors/jobfail.html.erb_spec.rb": 0.048678999999538064, + "spec/views/idv/phone_errors/timeout.html.erb_spec.rb": 0.053002000000560656, + "spec/views/idv/phone_errors/warning.html.erb_spec.rb": 0.044022000016411766, + "spec/views/idv/review/new.html.erb_spec.rb": 0.21260599998640828, + "spec/views/idv/session_errors/exception.html.erb_spec.rb": 0.036437999980989844, + "spec/views/idv/session_errors/failure.html.erb_spec.rb": 0.017369999986840412, + "spec/views/idv/session_errors/throttled.html.erb_spec.rb": 0.07068299999809824, + "spec/views/idv/session_errors/warning.html.erb_spec.rb": 0.023828000004868954, + "spec/views/idv/shared/_document_capture.html.erb_spec.rb": 0.04808500001672655, + "spec/views/idv/shared/_error.html.erb_spec.rb": 0.3126639999973122, + "spec/views/layouts/application.html.erb_spec.rb": 0.8924530000076629, + "spec/views/layouts/user_mailer.html.erb_spec.rb": 0.24813799999537878, + "spec/views/mfa_confirmation/show.html.erb_spec.rb": 0.1376099999761209, + "spec/views/partials/multi_factor_authentication/_mfa_selection.html.erb_spec.rb": 0.03583700000308454, + "spec/views/phone_setup/index.html.erb_spec.rb": 0.2124490000132937, + "spec/views/reactivate_account/index.html.erb_spec.rb": 0.007416000007651746, + "spec/views/shared/_address.html.erb_spec.rb": 0.0324290000135079, + "spec/views/shared/_banner.html.erb_spec.rb": 0.02989299999899231, + "spec/views/shared/_email_languages.html.erb_spec.rb": 0.07968999998411164, + "spec/views/shared/_footer_lite.html.erb_spec.rb": 0.08590999999432825, + "spec/views/shared/_maintenance_window_alert.html.erb_spec.rb": 0.03247799997916445, + "spec/views/shared/_masked_text.html.erb_spec.rb": 0.05908199999248609, + "spec/views/shared/_nav_branded.html.erb_spec.rb": 0.07709499998600222, + "spec/views/shared/_nav_lite.html.erb_spec.rb": 0.01811099998303689, + "spec/views/shared/_one_time_code_input.html.erb_spec.rb": 0.19070099998498335, + "spec/views/shared/_personal_key.html.erb_spec.rb": 0.02702300000237301, + "spec/views/shared/_step_indicator.html.erb_spec.rb": 0.1450759999861475, + "spec/views/shared/_step_indicator_step.html.erb_spec.rb": 0.1460660000157077, + "spec/views/shared/_troubleshooting_options.html.erb_spec.rb": 0.12174600001890212, + "spec/views/shared/personal_key/_key.html.erb_spec.rb": 0.015745999990031123, + "spec/views/sign_up/completions/show.html.erb_spec.rb": 0.28441900000325404, + "spec/views/sign_up/email_resend/new.html.erb_spec.rb": 0.013029000023379922, + "spec/views/sign_up/emails/show.html.erb_spec.rb": 0.04854300001170486, + "spec/views/sign_up/passwords/new.html.erb_spec.rb": 0.07690499999444, + "spec/views/sign_up/registrations/new.html.erb_spec.rb": 0.10302800001227297, + "spec/views/two_factor_authentication/options/index.html.erb_spec.rb": 0.07210700001451187, + "spec/views/two_factor_authentication/otp_verification/show.html.erb_spec.rb": 0.28846100001828745, + "spec/views/two_factor_authentication/personal_key_verification/show.html.erb_spec.rb": 0.11934499998460524, + "spec/views/two_factor_authentication/sms_opt_in/error.html.erb_spec.rb": 0.06627799998386763, + "spec/views/two_factor_authentication/sms_opt_in/new.html.erb_spec.rb": 0.0938529999984894, + "spec/views/two_factor_authentication/totp_verification/show.html.erb_spec.rb": 0.21301900001708418, + "spec/views/users/delete/show.html.erb_spec.rb": 0.21328200001153164, + "spec/views/users/passwords/edit.html.erb_spec.rb": 0.03606099999160506, + "spec/views/users/phones/add.html.erb_spec.rb": 0.054386000003432855, + "spec/views/users/piv_cac_authentication_setup/new.html.erb_spec.rb": 0.04755199997453019, + "spec/views/users/shared/_otp_delivery_preference_selection.html.erb_spec.rb": 0.10467599998810329, + "spec/views/users/totp_setup/new.html.erb_spec.rb": 0.15556500002276152, + "spec/views/users/two_factor_authentication_setup/index.html.erb_spec.rb": 0.13108399999327958, + "spec/views/vendor_outage/show.html.erb_spec.rb": 0.020102999988012016, + "spec/views/verify/show.html.erb_spec.rb": 0.011774999991757795 } diff --git a/lib/deploy/activate.rb b/lib/deploy/activate.rb index 7a14dcd59e7..d1e2df8ccc0 100644 --- a/lib/deploy/activate.rb +++ b/lib/deploy/activate.rb @@ -77,12 +77,15 @@ def setup_idp_config_symlinks # Inject the logo files into the app's asset folder. deploy/activate is # run before deploy/build-post-config, so these will be picked up by the # rails asset pipeline. + FileUtils.mkdir_p(File.join(root, 'public/assets/sp-logos')) logos_dir = File.join(root, idp_config_checkout_name, 'public/assets/images/sp-logos') Dir.entries(logos_dir).each do |name| next if name.start_with?('.') target = File.join(logos_dir, name) link = File.join(root, 'app/assets/images/sp-logos', name) symlink_verbose(target, link, force: true) + link = File.join(root, 'public/assets/sp-logos', name) + symlink_verbose(target, link, force: true) end end diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 33001e01f9d..0b12cee9364 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -306,6 +306,8 @@ def self.build_store(config_map) config.add(:session_check_delay, type: :integer) config.add(:session_check_frequency, type: :integer) config.add(:session_encryption_key, type: :string) + config.add(:session_encryptor_alert_enabled, type: :boolean) + config.add(:session_encryptor_v2_enabled, type: :boolean) config.add(:session_timeout_in_minutes, type: :integer) config.add(:session_timeout_warning_seconds, type: :integer) config.add(:session_total_duration_timeout_in_minutes, type: :integer) diff --git a/lib/idp/constants.rb b/lib/idp/constants.rb index 4c942770549..6860cdcde23 100644 --- a/lib/idp/constants.rb +++ b/lib/idp/constants.rb @@ -10,5 +10,22 @@ module Constants AAL1 = 1 AAL2 = 2 AAL3 = 3 + + DEFAULT_MOCK_PII_FROM_DOC = { + first_name: 'FAKEY', + middle_name: nil, + last_name: 'MCFAKERSON', + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010', + dob: '1938-10-06', + state_id_number: '1111111111111', + state_id_jurisdiction: 'ND', + state_id_type: 'drivers_license', + state_id_expiration: '2099-12-31', + phone: nil, + }.freeze end end diff --git a/lib/legacy_session_encryptor.rb b/lib/legacy_session_encryptor.rb new file mode 100644 index 00000000000..660c787577d --- /dev/null +++ b/lib/legacy_session_encryptor.rb @@ -0,0 +1,18 @@ +class LegacySessionEncryptor + def load(value) + decrypted = encryptor.decrypt(value) + + JSON.parse(decrypted, quirks_mode: true).with_indifferent_access + end + + def dump(value) + plain = JSON.generate(value, quirks_mode: true) + encryptor.encrypt(plain) + end + + private + + def encryptor + Encryption::Encryptors::SessionEncryptor.new + end +end diff --git a/lib/session_encryptor.rb b/lib/session_encryptor.rb index 4ddf1b37aa6..535f0c3232a 100644 --- a/lib/session_encryptor.rb +++ b/lib/session_encryptor.rb @@ -1,18 +1,187 @@ class SessionEncryptor + class SensitiveKeyError < StandardError; end + + class SensitiveValueError < StandardError; end + NEW_CIPHERTEXT_HEADER = 'v2' + SENSITIVE_KEYS = [ + 'first_name', 'middle_name', 'last_name', 'address1', 'address2', 'city', 'state', 'zipcode', + 'zip_code', 'dob', 'phone_number', 'phone', 'ssn', 'prev_address1', 'prev_address2', + 'prev_city', 'prev_state', 'prev_zipcode', 'pii', 'pii_from_doc', 'password', 'personal_key', + 'email', 'email_address', 'unconfirmed_phone' + ].to_set.freeze + + # 'idv/doc_auth' and 'idv' are used during the proofing process and can contain PII + # personal keys are generated and stored in the session between requests, but are used + # to decrypt PII bundles, so we treat them similarly to the PII itself. + SENSITIVE_PATHS = [ + ['warden.user.user.session', 'idv/doc_auth'], + ['warden.user.user.session', 'idv'], + ['warden.user.user.session', 'personal_key'], + ['warden.user.user.session', 'unconfirmed_phone'], + ['flash', 'flashes', 'personal_key'], + ['flash', 'flashes', 'email'], + ['email'], + ] + + SENSITIVE_DEFAULT_FIELDS = Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC.slice( + :first_name, + :last_name, + :address1, + :city, + :dob, + :state_id_number, + :state_id_expiration, + ).values + SENSITIVE_REGEX = %r{#{SENSITIVE_DEFAULT_FIELDS.join('|')}}i + def load(value) - decrypted = encryptor.decrypt(value) + return LegacySessionEncryptor.new.load(value) if should_use_legacy_encryptor_for_read?(value) + + _v2, ciphertext = value.split(':') + decrypted = outer_encryptor.decrypt(ciphertext) - JSON.parse(decrypted, quirks_mode: true).with_indifferent_access + session = JSON.parse(decrypted, quirks_mode: true).with_indifferent_access + kms_decrypt_sensitive_paths!(session) + + session end def dump(value) + return LegacySessionEncryptor.new.dump(value) if should_use_legacy_encryptor_for_write? + value.deep_stringify_keys! + + kms_encrypt_pii!(value) + kms_encrypt_sensitive_paths!(value, SENSITIVE_PATHS) + alert_or_raise_if_contains_sensitive_keys!(value) plain = JSON.generate(value, quirks_mode: true) - encryptor.encrypt(plain) + alert_or_raise_if_contains_sensitive_value!(plain, value) + NEW_CIPHERTEXT_HEADER + ':' + outer_encryptor.encrypt(plain) + end + + def kms_encrypt(text) + Base64.encode64(Encryption::KmsClient.new.encrypt(text, 'context' => 'session-encryption')) + end + + def kms_decrypt(text) + Encryption::KmsClient.new.decrypt( + Base64.decode64(text), 'context' => 'session-encryption' + ) + end + + def outer_encryptor + Encryption::Encryptors::AttributeEncryptor.new end private - def encryptor - Encryption::Encryptors::SessionEncryptor.new + # The PII bundle is stored in the user session in the 'decrypted_pii' key. + # The PII is decrypted with the user's password when they successfully submit it and then + # stored in the session. Before saving the session, this method encrypts the PII with KMS and + # stores it in the 'encrypted_pii' key. + # + # The PII is not frequently needed in its KMS-decrypted state. To reduce the + # risks around holding plaintext PII in memory during requests, this PII is KMS-decrypted + # on-demand by the Pii::Cacher. + def kms_encrypt_pii!(session) + return unless session.dig('warden.user.user.session', 'decrypted_pii') + decrypted_pii = session['warden.user.user.session'].delete('decrypted_pii') + session['warden.user.user.session']['encrypted_pii'] = + kms_encrypt(decrypted_pii) + nil + end + + # This method extracts all of the sensitive paths that exist into a + # separate hash. This separate hash is then encrypted and placed in the session. + # We use #reduce to build the nested empty hash if needed. If Hash#bury + # (https://bugs.ruby-lang.org/issues/11747) existed, we could use that instead. + def kms_encrypt_sensitive_paths!(session, sensitive_paths) + sensitive_data = { + } + + sensitive_paths.each do |path| + all_but_last_key = path[0..-2] + last_key = path.last + + if all_but_last_key.blank? + value = session.delete(last_key) + else + value = session.dig(*all_but_last_key)&.delete(last_key) + end + + if value + all_but_last_key.reduce(sensitive_data) do |hash, key| + hash[key] ||= {} + + hash[key] + end + + if all_but_last_key.blank? + sensitive_data[last_key] = value + else + sensitive_data.dig(*all_but_last_key).store(last_key, value) + end + end + end + + raise "invalid session, 'sensitive_data' is reserved key" if session['sensitive_data'].present? + return if sensitive_data.blank? + session['sensitive_data'] = kms_encrypt(JSON.generate(sensitive_data)) + end + + # This method reverses the steps taken in #kms_encrypt_sensitive_paths! + # The encrypted hash is decrypted and then deep merged into the session hash. + # The merge must be a deep merge to avoid collisions with existing hashes in the + # session. + def kms_decrypt_sensitive_paths!(session) + sensitive_data = session.delete('sensitive_data') + return if sensitive_data.blank? + + sensitive_data = JSON.parse( + kms_decrypt(sensitive_data), quirks_mode: true + ) + + session.deep_merge!(sensitive_data) + end + + def alert_or_raise_if_contains_sensitive_value!(string, hash) + if SENSITIVE_REGEX.match?(string) + exception = SensitiveValueError.new + if IdentityConfig.store.session_encryptor_alert_enabled + NewRelic::Agent.notice_error( + exception, custom_params: { + session_structure: hash.deep_transform_values { |v| nil }, + } + ) + else + raise exception + end + end + end + + def alert_or_raise_if_contains_sensitive_keys!(hash) + hash.deep_transform_keys do |key| + if SENSITIVE_KEYS.include?(key.to_s) + exception = SensitiveKeyError.new("#{key} unexpectedly appeared in session") + if IdentityConfig.store.session_encryptor_alert_enabled + NewRelic::Agent.notice_error( + exception, custom_params: { + session_structure: hash.deep_transform_values { |_v| '' }, + } + ) + else + raise exception + end + end + end + end + + def should_use_legacy_encryptor_for_read?(value) + ## Legacy ciphertexts will not include a colon and thus will have no header + header = value.split(':').first + header != NEW_CIPHERTEXT_HEADER + end + + def should_use_legacy_encryptor_for_write? + !IdentityConfig.store.session_encryptor_v2_enabled end end diff --git a/spec/controllers/api/verify/complete_controller_spec.rb b/spec/controllers/api/verify/password_confirm_controller_spec.rb similarity index 91% rename from spec/controllers/api/verify/complete_controller_spec.rb rename to spec/controllers/api/verify/password_confirm_controller_spec.rb index 4d5cecbc6e8..ab00ade7b6d 100644 --- a/spec/controllers/api/verify/complete_controller_spec.rb +++ b/spec/controllers/api/verify/password_confirm_controller_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe Api::Verify::CompleteController do +describe Api::Verify::PasswordConfirmController do include PersonalKeyValidator include SamlAuthHelper @@ -66,7 +66,9 @@ def stub_idv_session it 'does not create a profile and return a key when it has the wrong password' do post :create, params: { password: 'iamnotbatman', user_bundle_token: jwt } - expect(JSON.parse(response.body)['personal_key']).to be_nil + response_json = JSON.parse(response.body) + expect(response_json['personal_key']).to be_nil + expect(response_json['error']['password']).to eq([I18n.t('idv.errors.incorrect_password')]) expect(response.status).to eq 400 end end diff --git a/spec/controllers/idv/forgot_password_controller_spec.rb b/spec/controllers/idv/forgot_password_controller_spec.rb index c6df48e5e31..32abc87ccbb 100644 --- a/spec/controllers/idv/forgot_password_controller_spec.rb +++ b/spec/controllers/idv/forgot_password_controller_spec.rb @@ -12,7 +12,7 @@ stub_sign_in stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::IDV_FORGOT_PASSWORD) + expect(@analytics).to receive(:track_event).with('IdV: forgot password visited') get :new end @@ -23,7 +23,7 @@ stub_sign_in stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::IDV_FORGOT_PASSWORD_CONFIRMED) + expect(@analytics).to receive(:track_event).with('IdV: forgot password confirmed') post :update end diff --git a/spec/controllers/idv/otp_delivery_method_controller_spec.rb b/spec/controllers/idv/otp_delivery_method_controller_spec.rb index 51253c2ed35..b3f444e6abc 100644 --- a/spec/controllers/idv/otp_delivery_method_controller_spec.rb +++ b/spec/controllers/idv/otp_delivery_method_controller_spec.rb @@ -69,7 +69,7 @@ get :new expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_PHONE_OTP_DELIVERY_SELECTION_VISIT) + with('IdV: Phone OTP delivery Selection Visited') end end diff --git a/spec/controllers/idv/phone_controller_spec.rb b/spec/controllers/idv/phone_controller_spec.rb index c27e6121573..8eaa95152fb 100644 --- a/spec/controllers/idv/phone_controller_spec.rb +++ b/spec/controllers/idv/phone_controller_spec.rb @@ -91,7 +91,7 @@ get :new, params: params expect(@analytics).to have_received(:track_event). - with(Analytics::IDV_PHONE_USE_DIFFERENT, step: step) + with('IdV: use different phone number', step: step) end end diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index cd866e877b7..bf9eb9d893e 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -219,6 +219,19 @@ def show hash_including(name: :verify_phone_or_address, status: :pending), ) end + + context 'idv app password confirm step is enabled' do + before do + allow(IdentityConfig.store).to receive(:idv_api_enabled_steps). + and_return(['password_confirm']) + end + + it 'redirects to idv app' do + get :new + + expect(response).to redirect_to idv_app_path + end + end end context 'user chooses address verification' do @@ -296,7 +309,7 @@ def show it 'redirects to personal key path' do put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } - expect(@analytics).to have_received(:track_event).with(Analytics::IDV_REVIEW_COMPLETE) + expect(@analytics).to have_received(:track_event).with('IdV: review complete') expect(@analytics).to have_received(:track_event).with( 'IdV: final resolution', success: true, diff --git a/spec/controllers/idv_controller_spec.rb b/spec/controllers/idv_controller_spec.rb index c00fcb29841..28626e2ddbf 100644 --- a/spec/controllers/idv_controller_spec.rb +++ b/spec/controllers/idv_controller_spec.rb @@ -6,7 +6,7 @@ stub_sign_in stub_analytics - expect(@analytics).to receive(:track_event).with(Analytics::IDV_INTRO_VISIT) + expect(@analytics).to receive(:track_event).with('IdV: intro visited') get :index end @@ -17,7 +17,7 @@ stub_sign_in(profile.user) stub_analytics - expect(@analytics).to_not receive(:track_event).with(Analytics::IDV_INTRO_VISIT) + expect(@analytics).to_not receive(:track_event).with('IdV: intro visited') get :index end diff --git a/spec/controllers/verify_controller_spec.rb b/spec/controllers/verify_controller_spec.rb index c785a50ce87..38461cf851e 100644 --- a/spec/controllers/verify_controller_spec.rb +++ b/spec/controllers/verify_controller_spec.rb @@ -67,8 +67,9 @@ response expect(assigns[:app_data]).to include( - app_name: APP_NAME, base_path: idv_app_path, + start_over_url: idv_session_path, + cancel_url: idv_cancel_path, completion_url: idv_gpo_verify_url, enabled_step_names: idv_api_enabled_steps, initial_values: { 'personalKey' => kind_of(String) }, @@ -79,8 +80,8 @@ context 'empty step' do let(:step) { nil } - it 'redirects to first step' do - expect(response).to redirect_to idv_app_path(step: 'personal_key') + it 'renders view' do + expect(response).to render_template(:show) end end end @@ -97,8 +98,9 @@ response expect(assigns[:app_data]).to include( - app_name: APP_NAME, base_path: idv_app_path, + start_over_url: idv_session_path, + cancel_url: idv_cancel_path, completion_url: account_url, enabled_step_names: idv_api_enabled_steps, initial_values: { 'userBundleToken' => kind_of(String) }, @@ -109,8 +111,8 @@ context 'empty step' do let(:step) { nil } - it 'redirects to first step' do - expect(response).to redirect_to idv_app_path(step: 'password_confirm') + it 'renders view' do + expect(response).to render_template(:show) end end end diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb index 4f0cdb1414d..f00067b82be 100644 --- a/spec/factories/profiles.rb +++ b/spec/factories/profiles.rb @@ -25,7 +25,7 @@ trait :with_pii do pii do - DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC.merge( ssn: DocAuthHelper::GOOD_SSN, phone: '+1 (555) 555-1234', ) @@ -34,7 +34,7 @@ trait :with_pii do pii do - DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC.merge( ssn: DocAuthHelper::GOOD_SSN, phone: '+1 (555) 555-1234', ) diff --git a/spec/features/idv/doc_auth/verify_step_spec.rb b/spec/features/idv/doc_auth/verify_step_spec.rb index edcc4e72563..7bb8893d0f4 100644 --- a/spec/features/idv/doc_auth/verify_step_spec.rb +++ b/spec/features/idv/doc_auth/verify_step_spec.rb @@ -165,7 +165,7 @@ stub_const( 'Idv::Steps::VerifyBaseStep::AAMVA_SUPPORTED_JURISDICTIONS', Idv::Steps::VerifyBaseStep::AAMVA_SUPPORTED_JURISDICTIONS + - [DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC[:state_id_jurisdiction]], + [Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC[:state_id_jurisdiction]], ) sign_in_and_2fa_user complete_doc_auth_steps_before_verify_step @@ -188,7 +188,7 @@ stub_const( 'Idv::Steps::VerifyBaseStep::AAMVA_SUPPORTED_JURISDICTIONS', Idv::Steps::VerifyBaseStep::AAMVA_SUPPORTED_JURISDICTIONS - - [DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC[:state_id_jurisdiction]], + [Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC[:state_id_jurisdiction]], ) sign_in_and_2fa_user complete_doc_auth_steps_before_verify_step diff --git a/spec/features/visitors/i18n_spec.rb b/spec/features/visitors/i18n_spec.rb index acce94392da..460db930b48 100644 --- a/spec/features/visitors/i18n_spec.rb +++ b/spec/features/visitors/i18n_spec.rb @@ -22,10 +22,9 @@ end it 'initializes front-end logger with default locale' do - expect(page).to have_selector( - "[data-analytics-endpoint='#{api_logger_path(locale: nil)}']", - visible: :all, - ) + config = JSON.parse(page.find('[data-config]', visible: :all).text(:all)) + + expect(config['analyticsEndpoint']).to eq api_logger_path(locale: nil) end end @@ -37,10 +36,9 @@ end it 'initializes front-end logger with locale parameter' do - expect(page).to have_selector( - "[data-analytics-endpoint='#{api_logger_path(locale: 'es')}']", - visible: :all, - ) + config = JSON.parse(page.find('[data-config]', visible: :all).text(:all)) + + expect(config['analyticsEndpoint']).to eq api_logger_path(locale: 'es') end end diff --git a/spec/forms/api/profile_creation_form_spec.rb b/spec/forms/api/profile_creation_form_spec.rb index 3f1611d0483..d627b5a059b 100644 --- a/spec/forms/api/profile_creation_form_spec.rb +++ b/spec/forms/api/profile_creation_form_spec.rb @@ -119,7 +119,7 @@ expect(response.success?).to be false expect(personal_key).to be_nil - expect(response.errors[:password]).to eq ['invalid password'] + expect(response.errors[:password]).to eq [I18n.t('idv.errors.incorrect_password')] end end @@ -131,7 +131,7 @@ expect(response.success?).to be false expect(personal_key).to be_nil - expect(response.errors[:user]).to eq ['user not found'] + expect(response.errors[:user]).to eq [I18n.t('devise.failure.unauthenticated')] end end @@ -143,7 +143,7 @@ expect(response.success?).to be false expect(personal_key).to be_nil - expect(response.errors[:jwt]).to eq ['decode error: Signature has expired'] + expect(response.errors[:jwt]).to eq [I18n.t('idv.failure.exceptions.internal_error')] end end end @@ -183,7 +183,8 @@ it 'is an invalid form' do expect(subject.valid?).to be false - expect(subject.errors.to_a.join(' ')).to match(%r{decode error}) + expect(subject.errors[:jwt]).to eq [I18n.t('idv.failure.exceptions.internal_error')] + expect(subject.errors).to include { |error| error.options[:type] == :decode_error } end end @@ -199,7 +200,8 @@ it 'is an invalid form' do expect(subject.valid?).to be false - expect(subject.errors.to_a.join(' ')).to match(%r{pii is missing}) + expect(subject.errors[:jwt]).to eq [I18n.t('idv.failure.exceptions.internal_error')] + expect(subject.errors).to include { |error| error.options[:type] == :user_bundle_error } end end @@ -215,7 +217,8 @@ it 'is an invalid form' do expect(subject.valid?).to be false - expect(subject.errors.to_a.join(' ')).to match(%r{metadata is missing}) + expect(subject.errors[:jwt]).to eq [I18n.t('idv.failure.exceptions.internal_error')] + expect(subject.errors).to include { |error| error.options[:type] == :user_bundle_error } end end end diff --git a/spec/javascripts/packages/analytics/index-spec.js b/spec/javascripts/packages/analytics/index-spec.js index ff7e9ead970..1778ec39178 100644 --- a/spec/javascripts/packages/analytics/index-spec.js +++ b/spec/javascripts/packages/analytics/index-spec.js @@ -21,7 +21,7 @@ describe('trackEvent', () => { const endpoint = '/log'; beforeEach(() => { - document.body.innerHTML = ``; + document.body.innerHTML = ``; }); context('no payload', () => { diff --git a/spec/javascripts/packages/document-capture/components/button-to-spec.jsx b/spec/javascripts/packages/document-capture/components/button-to-spec.jsx deleted file mode 100644 index 28ba0702c14..00000000000 --- a/spec/javascripts/packages/document-capture/components/button-to-spec.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import sinon from 'sinon'; -import userEvent from '@testing-library/user-event'; -import ButtonTo from '@18f/identity-document-capture/components/button-to'; -import { UploadContextProvider } from '@18f/identity-document-capture'; -import { render } from '../../../support/document-capture'; - -describe('document-capture/components/button-to', () => { - it('renders props passed through to Button', () => { - const { getByRole } = render( - - Click me - , - ); - - const button = getByRole('button', { name: 'Click me' }); - - expect(button.type).to.equal('button'); - expect(button.classList.contains('usa-button')).to.be.true(); - expect(button.classList.contains('usa-button--unstyled')).to.be.true(); - }); - - it('creates a form in the body outside the root container', () => { - const { container } = render( - - Click me - , - ); - - const form = document.querySelector('form'); - expect(form).to.be.ok(); - expect(Object.fromEntries(new window.FormData(form))).to.deep.equal({ - _method: 'delete', - }); - expect(container.contains(form)).to.be.false(); - }); - - context('with csrf token', () => { - it('creates a form in the body outside the root container', () => { - const { container } = render( - - - Click me - - , - ); - - const form = document.querySelector('form'); - expect(form).to.be.ok(); - expect(Object.fromEntries(new window.FormData(form))).to.deep.equal({ - _method: 'delete', - authenticity_token: 'token-value', - }); - expect(container.contains(form)).to.be.false(); - }); - }); - - it('submits to form on click', async () => { - const { getByRole } = render( - - Click me - , - ); - - const form = document.querySelector('form'); - sinon.stub(form, 'submit'); - - await userEvent.click(getByRole('button')); - - expect(form.submit).to.have.been.calledOnce(); - }); -}); diff --git a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx index 30d56a94248..32bc2ff47f3 100644 --- a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx @@ -1,7 +1,7 @@ import sinon from 'sinon'; import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/dom'; -import { render as baseRender, fireEvent } from '@testing-library/react'; +import { render as baseRender, fireEvent, cleanup } from '@testing-library/react'; import httpUpload, { UploadFormEntriesError, toFormEntryError, @@ -16,12 +16,13 @@ import DocumentCapture, { except, } from '@18f/identity-document-capture/components/document-capture'; import { expect } from 'chai'; -import { useSandbox } from '@18f/identity-test-helpers'; +import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers'; import { render, useAcuant, useDocumentCaptureForm } from '../../../support/document-capture'; import { getFixture, getFixtureFile } from '../../../support/file'; describe('document-capture/components/document-capture', () => { const onSubmit = useDocumentCaptureForm(); + const defineProperty = useDefineProperty(); const sandbox = useSandbox(); const { initialize } = useAcuant(); @@ -560,4 +561,67 @@ describe('document-capture/components/document-capture', () => { }); }); }); + + context('desktop selfie capture', () => { + beforeEach(() => { + function MediaStream() {} + MediaStream.prototype = { play() {}, getTracks() {} }; + sandbox.stub(MediaStream.prototype, 'play'); + sandbox.stub(MediaStream.prototype, 'getTracks').returns([{ stop() {} }]); + sandbox.stub(window.HTMLMediaElement.prototype, 'play'); + + defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: () => Promise.resolve(new MediaStream()), + }, + }); + defineProperty(window, 'MediaStream', { + configurable: true, + value: MediaStream, + }); + defineProperty(navigator, 'permissions', { + configurable: true, + value: { + query: sinon + .stub() + .withArgs({ name: 'camera' }) + .returns(Promise.resolve({ state: 'granted' })), + }, + }); + sandbox.stub(window.HTMLCanvasElement.prototype, 'getContext').returns({ drawImage() {} }); + sandbox.stub(window.HTMLCanvasElement.prototype, 'toDataURL').returns('data:,'); + }); + + // DOM globals are stubbed with sandbox, so run cleanup before sandbox is restored, as otherwise + // it will attempt to reference globals which are already restored to undefined. + afterEach(cleanup); + + it('progresses through steps to completion', async () => { + const { getByLabelText, getByText } = render( + + + + + , + ); + + await userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + validUpload, + ); + await userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + validUpload, + ); + + await userEvent.click(getByText('forms.buttons.continue')); + await userEvent.click(getByLabelText('doc_auth.buttons.take_picture')); + + await new Promise((resolve) => { + onSubmit.callsFake(resolve); + userEvent.click(getByText('forms.buttons.submit.default')); + }); + }); + }); }); diff --git a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx index 032c0b2810d..e0fe3c1f963 100644 --- a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx @@ -4,7 +4,7 @@ import { cleanup } from '@testing-library/react'; import { I18nContext } from '@18f/identity-react-i18n'; import { I18n } from '@18f/identity-i18n'; import SelfieCapture from '@18f/identity-document-capture/components/selfie-capture'; -import { useSandbox } from '@18f/identity-test-helpers'; +import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; @@ -14,6 +14,7 @@ describe('document-capture/components/selfie-capture', () => { afterEach(cleanup); const sandbox = useSandbox(); + const defineProperty = useDefineProperty(); const wrapper = ({ children }) => ( { value = await getFixtureFile('doc_auth_images/selfie.jpg'); }); - let originalMediaDevices; - let originalMediaStream; - let originalPermissions; beforeEach(() => { - originalMediaDevices = navigator.mediaDevices; - originalPermissions = navigator.permissions; - function MediaStream() {} MediaStream.prototype = { play() {}, getTracks() {} }; sandbox.stub(MediaStream.prototype, 'play'); @@ -50,36 +45,20 @@ describe('document-capture/components/selfie-capture', () => { sandbox.stub(window.HTMLMediaElement.prototype, 'play'); - navigator.mediaDevices = { - getUserMedia: () => Promise.resolve(new MediaStream()), - }; - - originalMediaStream = window.MediaStream; - window.MediaStream = MediaStream; + defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia: () => Promise.resolve(new MediaStream()), + }, + }); + defineProperty(window, 'MediaStream', { + configurable: true, + value: MediaStream, + }); track.stop.resetHistory(); }); - afterEach(() => { - if (originalMediaDevices === undefined) { - delete navigator.mediaDevices; - } else { - navigator.mediaDevices = originalMediaDevices; - } - - if (originalMediaStream === undefined) { - delete window.MediaStream; - } else { - window.MediaStream = originalMediaStream; - } - - if (originalPermissions === undefined) { - delete navigator.permissions; - } else { - navigator.permissions = originalPermissions; - } - }); - it('renders a consent prompt', () => { const { getByText } = render(); @@ -87,12 +66,15 @@ describe('document-capture/components/selfie-capture', () => { }); it('renders video element that auto-plays if previous consent granted', async () => { - navigator.permissions = { - query: sinon - .stub() - .withArgs({ name: 'camera' }) - .returns(Promise.resolve({ state: 'granted' })), - }; + defineProperty(navigator, 'permissions', { + configurable: true, + value: { + query: sinon + .stub() + .withArgs({ name: 'camera' }) + .returns(Promise.resolve({ state: 'granted' })), + }, + }); const { getByLabelText, findByLabelText } = render(); await findByLabelText('doc_auth.buttons.take_picture'); @@ -119,12 +101,15 @@ describe('document-capture/components/selfie-capture', () => { }); it('renders error state if previous consent denied', async () => { - navigator.permissions = { - query: sinon - .stub() - .withArgs({ name: 'camera' }) - .returns(Promise.resolve({ state: 'denied' })), - }; + defineProperty(navigator, 'permissions', { + configurable: true, + value: { + query: sinon + .stub() + .withArgs({ name: 'camera' }) + .returns(Promise.resolve({ state: 'denied' })), + }, + }); const { findByText } = render(); await findByText('doc_auth.instructions.document_capture_selfie_consent_blocked'); diff --git a/spec/javascripts/packages/document-capture/components/start-over-or-cancel-spec.jsx b/spec/javascripts/packages/document-capture/components/start-over-or-cancel-spec.jsx deleted file mode 100644 index 237fa9e3e9b..00000000000 --- a/spec/javascripts/packages/document-capture/components/start-over-or-cancel-spec.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import StartOverOrCancel from '@18f/identity-document-capture/components/start-over-or-cancel'; -import { UploadContextProvider } from '@18f/identity-document-capture'; -import { render } from '../../../support/document-capture'; - -describe('document-capture/components/start-over-or-cancel', () => { - it('renders start over and cancel links', () => { - const { getByText } = render(); - - expect(getByText('doc_auth.buttons.start_over')).to.be.ok(); - expect(getByText('links.cancel')).to.be.ok(); - }); - - it('omits start over link when in hybrid flow', () => { - const { getByText } = render( - - - , - ); - - expect(() => getByText('doc_auth.buttons.start_over')).to.throw(); - expect(getByText('links.cancel')).to.be.ok(); - }); -}); diff --git a/spec/javascripts/packages/document-capture/context/help-center-spec.jsx b/spec/javascripts/packages/document-capture/context/help-center-spec.jsx index b8030bc1382..3ca4647971c 100644 --- a/spec/javascripts/packages/document-capture/context/help-center-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/help-center-spec.jsx @@ -21,7 +21,9 @@ describe('document-capture/context/help-center', () => { location: 'location', }); - expect(failureToProofURL).to.equal('?category=category&article=article&location=location'); + expect(failureToProofURL).to.equal( + `${window.location.origin}/?category=category&article=article&location=location`, + ); }); }); diff --git a/spec/javascripts/packages/document-capture/context/upload-spec.jsx b/spec/javascripts/packages/document-capture/context/upload-spec.jsx index 5d2ba0bb948..f4c08d97253 100644 --- a/spec/javascripts/packages/document-capture/context/upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/upload-spec.jsx @@ -20,8 +20,6 @@ describe('document-capture/context/upload', () => { 'backgroundUploadURLs', 'backgroundUploadEncryptKey', 'flowPath', - 'startOverURL', - 'cancelURL', 'csrf', ]); expect(result.current.upload).to.equal(defaultUpload); diff --git a/spec/javascripts/packages/page-data/index-spec.js b/spec/javascripts/packages/page-data/index-spec.js deleted file mode 100644 index 7313b6816c6..00000000000 --- a/spec/javascripts/packages/page-data/index-spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import { getPageData } from '@18f/identity-page-data'; - -describe('getPageData', () => { - context('page data exists', () => { - beforeEach(() => { - document.body.innerHTML = ''; - }); - - it('returns value', () => { - const result = getPageData('fooBarBaz'); - - expect(result).to.equal('value'); - }); - }); - - context('page data does not exist', () => { - it('returns undefined', () => { - const result = getPageData('fooBarBaz'); - - expect(result).to.be.undefined(); - }); - }); -}); diff --git a/spec/javascripts/support/dom.js b/spec/javascripts/support/dom.js index fe93882609f..0abbbcbf7f4 100644 --- a/spec/javascripts/support/dom.js +++ b/spec/javascripts/support/dom.js @@ -2,6 +2,8 @@ import sinon from 'sinon'; import { JSDOM, ResourceLoader } from 'jsdom'; import matchMediaPolyfill from 'mq-polyfill'; +const TEST_URL = 'http://example.test'; + /** * Returns an instance of a JSDOM DOM instance configured for the test environment. * @@ -9,7 +11,7 @@ import matchMediaPolyfill from 'mq-polyfill'; */ export function createDOM() { const dom = new JSDOM('JSDOM', { - url: 'http://example.test', + url: TEST_URL, resources: new (class extends ResourceLoader { /** * @param {string} url @@ -91,6 +93,8 @@ export function useCleanDOM(dom) { while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } + window.history.replaceState(null, '', TEST_URL); + window.location.pathname = ''; window.location.hash = ''; dom.cookieJar.removeAllCookiesSync(); }); diff --git a/spec/lib/session_encryptor_spec.rb b/spec/lib/session_encryptor_spec.rb new file mode 100644 index 00000000000..79a105ec1f6 --- /dev/null +++ b/spec/lib/session_encryptor_spec.rb @@ -0,0 +1,229 @@ +require 'rails_helper' + +RSpec.describe SessionEncryptor do + subject { SessionEncryptor.new } + describe '#load' do + context 'with a legacy session ciphertext' do + it 'decrypts the legacy session' do + session = { 'foo' => 'bar' } + + ciphertext = LegacySessionEncryptor.new.dump(session) + + result = subject.load(ciphertext) + + expect(result).to eq(session) + end + end + + context 'with version 2 encryption enabled' do + before do + allow(IdentityConfig.store).to receive(:session_encryptor_v2_enabled).and_return(true) + end + + it 'decrypts the new version of the session' do + session = { 'foo' => 'bar' } + + ciphertext = SessionEncryptor.new.dump(session) + + result = subject.load(ciphertext) + + expect(result).to eq session + end + end + end + + describe '#dump' do + context 'with version 2 encryption enabled' do + before do + allow(IdentityConfig.store).to receive(:session_encryptor_v2_enabled).and_return(true) + end + + it 'transparently encrypts/decrypts sensitive elements of the session' do + session = { 'warden.user.user.session' => { + 'idv' => { 'ssn' => '666-66-6666' }, + 'idv/doc_auth' => { 'ssn' => '666-66-6666' }, + 'other_value' => 42, + } } + + ciphertext = subject.dump(session) + + result = subject.load(ciphertext) + expect(result).to eq( + { 'warden.user.user.session' => { + 'idv' => { 'ssn' => '666-66-6666' }, + 'idv/doc_auth' => { 'ssn' => '666-66-6666' }, + 'other_value' => 42, + } }, + ) + end + + it 'encrypts decrypted_pii bundle without automatically decrypting' do + session = { 'warden.user.user.session' => { + 'decrypted_pii' => { 'ssn' => '666-66-6666' }.to_json, + } } + + ciphertext = subject.dump(session) + + result = subject.load(ciphertext) + + expect(result.fetch('warden.user.user.session')['decrypted_pii']).to eq nil + expect(result.fetch('warden.user.user.session')['encrypted_pii']).to_not eq nil + end + + it 'can decrypt PII bundle with Pii::Cacher' do + session = { 'warden.user.user.session' => { + 'decrypted_pii' => { 'ssn' => '666-66-6666' }.to_json, + } } + + ciphertext = subject.dump(session) + + result = subject.load(ciphertext) + pii_cacher = Pii::Cacher.new(nil, result.fetch('warden.user.user.session')) + + expect(result.fetch('warden.user.user.session')['decrypted_pii']).to eq nil + expect(result.fetch('warden.user.user.session')['encrypted_pii']).to_not eq nil + + pii_cacher.fetch + expect(JSON.parse(result.fetch('warden.user.user.session')['decrypted_pii'])).to eq( + { + 'ssn' => '666-66-6666', + }, + ) + end + + it 'KMS encrypts/decrypts doc auth elements of the session' do + session = { 'warden.user.user.session' => { + 'idv' => { 'ssn' => '666-66-6666' }, + 'idv/doc_auth' => { 'ssn' => '666-66-6666' }, + 'other_value' => 42, + } } + + ciphertext = subject.dump(session) + + partially_decrypted = subject.outer_encryptor.decrypt(ciphertext.split(':').last) + partially_decrypted_json = JSON.parse(partially_decrypted) + + expect(partially_decrypted_json.fetch('warden.user.user.session')['idv']).to eq nil + expect(partially_decrypted_json.fetch('warden.user.user.session')['idv/doc_auth']).to eq nil + expect( + partially_decrypted_json.fetch('sensitive_data'), + ).to_not eq nil + + expect( + partially_decrypted_json.fetch('warden.user.user.session')['other_value'], + ).to eq 42 + end + + it 'raises if reserved key is used' do + session = { + 'sensitive_data' => 'test', + 'warden.user.user.session' => { + 'other_value' => 42, + }, + } + + expect { + subject.dump(session) + }.to raise_error( + RuntimeError, "invalid session, 'sensitive_data' is reserved key" + ) + end + + it 'raises if PII key appears outside of expected areas when alerting is disabled' do + nested_session = { 'warden.user.user.session' => { + 'idv_new' => { 'nested' => { 'ssn' => '666-66-6666' } }, + } } + + nested_array_session = { 'warden.user.user.session' => { + 'idv_new' => [{ 'nested' => { 'ssn' => '666-66-6666' } }], + } } + + expect { + subject.dump(nested_session) + }.to raise_error( + SessionEncryptor::SensitiveKeyError, 'ssn unexpectedly appeared in session' + ) + + expect { + subject.dump(nested_array_session) + }.to raise_error( + SessionEncryptor::SensitiveKeyError, 'ssn unexpectedly appeared in session' + ) + end + + it 'sends alert if PII key appears outside of expected areas if alerting is enabled' do + allow(IdentityConfig.store).to receive(:session_encryptor_alert_enabled).and_return(true) + session = { 'warden.user.user.session' => { + 'idv_new' => { 'nested' => { 'ssn' => '666-66-6666' } }, + } } + + expect(NewRelic::Agent).to receive(:notice_error).with( + SessionEncryptor::SensitiveKeyError.new('ssn unexpectedly appeared in session'), + custom_params: { + session_structure: { 'warden.user.user.session' => { + 'idv_new' => { 'nested' => { 'ssn' => '' } }, + } }, + }, + ) + + subject.dump(session) + end + + it 'raises if sensitive value is not KMS encrypted' do + session = { + 'new_key' => Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC[:first_name], + } + + expect { + subject.dump(session) + }.to raise_error( + SessionEncryptor::SensitiveValueError, + ) + end + end + + context 'without version 2 encryption enabled' do + before do + allow(IdentityConfig.store).to receive(:session_encryptor_v2_enabled).and_return(false) + end + + it 'encrypts the session with the legacy encryptor' do + session = { 'foo' => 'bar' } + ciphertext = subject.dump(session) + decrypted_session = LegacySessionEncryptor.new.load(ciphertext) + + expect(decrypted_session).to eq(session) + end + end + end + + describe '#kms_encrypt_sensitive_paths!' do + it 'encrypts/decrypts transparently' do + sensitive_paths = [ + ['a'], + ['1', '2', '3'], + ] + + original_session = { + 'unencrypted' => 0, + 'a' => 414, + '1' => { + '2' => { + '3' => 34, + }, + }, + } + + session = original_session.deep_dup + SessionEncryptor.new.send(:kms_encrypt_sensitive_paths!, session, sensitive_paths) + + expect(session['unencrypted']).to eq 0 + expect(session.key?('a')).to eq false + expect(session.dig('1', '2').key?('3')).to eq false + + SessionEncryptor.new.send(:kms_decrypt_sensitive_paths!, session) + + expect(session).to eq original_session + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5bb17b55a3f..f2cc84fa4d2 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -491,5 +491,23 @@ it { expect(user.broken_personal_key?).to eq(false) } end end + + context 'for a user that has encrypted profile data that is suspiciously too short' do + let(:user) { create(:user) } + let(:personal_key) { RandomPhrase.new(num_words: 4).to_s } + + before do + create( + :profile, + user: user, + active: true, + verified_at: Time.zone.now, + encrypted_pii_recovery: Encryption::Encryptors::PiiEncryptor.new(personal_key). + encrypt('null', user_uuid: user.uuid), + ) + end + + it { expect(user.broken_personal_key?).to eq(true) } + end end end diff --git a/spec/services/analytics_spec.rb b/spec/services/analytics_spec.rb index e7c04aae384..4b5ac42f887 100644 --- a/spec/services/analytics_spec.rb +++ b/spec/services/analytics_spec.rb @@ -186,7 +186,7 @@ it 'does not alert when pii values are inside words' do expect(ahoy).to receive(:track) - stub_const('DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC', zipcode: '12345') + stub_const('Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC', zipcode: '12345') expect do analytics.track_event( diff --git a/spec/services/doc_auth/mock/result_response_builder_spec.rb b/spec/services/doc_auth/mock/result_response_builder_spec.rb index 03f2e875410..617aaba227f 100644 --- a/spec/services/doc_auth/mock/result_response_builder_spec.rb +++ b/spec/services/doc_auth/mock/result_response_builder_spec.rb @@ -24,7 +24,7 @@ expect(response.errors).to eq({}) expect(response.exception).to eq(nil) expect(response.pii_from_doc). - to eq(DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC) + to eq(Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC) end end @@ -178,7 +178,7 @@ expect(response.errors).to eq({}) expect(response.exception).to eq(nil) expect(response.pii_from_doc). - to eq(DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC) + to eq(Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC) end end diff --git a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb b/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb deleted file mode 100644 index c78776164d0..00000000000 --- a/spec/services/encryption/encryptors/user_access_key_encryptor_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -require 'rails_helper' - -describe Encryption::Encryptors::UserAccessKeyEncryptor do - let(:password) { 'password' } - let(:salt) { 'n-pepa' } - let(:plaintext) { 'Oooh baby baby' } - let(:user_access_key) { Encryption::UserAccessKey.new(password: password, salt: salt) } - - subject { described_class.new(user_access_key) } - - describe '#encrypt' do - it 'returns encrypted text' do - ciphertext = subject.encrypt(plaintext) - - expect(ciphertext).to_not match plaintext - end - - it 'only builds the user access key once' do - expect(user_access_key).to receive(:build).once.and_call_original - expect(user_access_key).to_not receive(:unlock) - - subject.encrypt(plaintext) - subject.encrypt(plaintext) - end - end - - describe '#decrypt' do - it 'returns the original text' do - ciphertext = subject.encrypt(plaintext) - decrypted_ciphertext = subject.decrypt(ciphertext) - - expect(decrypted_ciphertext).to eq(plaintext) - end - - it 'requires the same user access key used for encrypt' do - ciphertext = subject.encrypt(plaintext) - wrong_key = Encryption::UserAccessKey.new(password: 'This is not the password', salt: salt) - new_encryptor = described_class.new(wrong_key) - - expect { new_encryptor.decrypt(ciphertext) }.to raise_error Encryption::EncryptionError - end - - it 'raises an error if the ciphertext is not base64 encoded' do - expect { subject.decrypt('@@@@@@@') }.to raise_error Encryption::EncryptionError - end - - it 'only unlocks the user access key once' do - # Encrypt the plaintext so that user access key is not built by encrypt - # but encryption instead of being unlocked - ciphertext = described_class.new(user_access_key.dup).encrypt(plaintext) - - expect(user_access_key).to receive(:unlock).once.and_call_original - expect(user_access_key).to_not receive(:build) - - subject.decrypt(ciphertext) - subject.decrypt(ciphertext) - end - - it 'can decrypt contents created by different user access keys if the password is the same' do - uak1 = Encryption::UserAccessKey.new(password: password, salt: salt) - uak2 = Encryption::UserAccessKey.new(password: password, salt: salt) - payload1 = described_class.new(uak1).encrypt(plaintext) - payload2 = described_class.new(uak2).encrypt(plaintext) - - expect(payload1).to_not eq(payload2) - - expect(user_access_key).to receive(:unlock).twice.and_call_original - - result1 = subject.decrypt(payload1) - result2 = subject.decrypt(payload2) - - expect(result1).to eq(plaintext) - expect(result2).to eq(plaintext) - end - end -end diff --git a/spec/support/fake_analytics.rb b/spec/support/fake_analytics.rb index 5db1c4a62a3..8a327c5c5ce 100644 --- a/spec/support/fake_analytics.rb +++ b/spec/support/fake_analytics.rb @@ -20,7 +20,7 @@ def track_event(event, original_attributes = {}) ERROR end - DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.slice( + Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC.slice( :first_name, :last_name, :address1, diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index ea8bd6987df..65a65123238 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -160,7 +160,7 @@ def complete_proofing_steps end def mock_doc_auth_no_name_pii(method) - pii_with_no_name = DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.dup + pii_with_no_name = Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC.dup pii_with_no_name[:last_name] = nil DocAuth::Mock::DocAuthMockClient.mock_response!( method: method, diff --git a/spec/support/features/idv_step_helper.rb b/spec/support/features/idv_step_helper.rb index 49f0063e344..c0a77c3e2f3 100644 --- a/spec/support/features/idv_step_helper.rb +++ b/spec/support/features/idv_step_helper.rb @@ -47,7 +47,7 @@ def complete_idv_steps_with_phone_before_confirmation_step(user = user_with_2fa) complete_idv_steps_with_phone_before_review_step(user) password = user.password || user_password fill_in 'Password', with: password - click_continue + click_idv_continue end alias complete_idv_steps_before_review_step complete_idv_steps_with_phone_before_review_step diff --git a/spec/support/idv_examples/sp_requested_attributes.rb b/spec/support/idv_examples/sp_requested_attributes.rb index cec18edee03..629a93b7d0d 100644 --- a/spec/support/idv_examples/sp_requested_attributes.rb +++ b/spec/support/idv_examples/sp_requested_attributes.rb @@ -6,7 +6,7 @@ let(:good_ssn) { DocAuthHelper::GOOD_SSN } let(:profile) { create(:profile, :active, :verified, user: user, pii: saved_pii) } let(:saved_pii) do - DocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.merge( + Idp::Constants::DEFAULT_MOCK_PII_FROM_DOC.merge( ssn: good_ssn, phone: '+1 (555) 555-1234', ) diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index a940785c583..c476d8a380b 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -195,59 +195,83 @@ and_return(window_end) end - def user_with_broken_personal_key(protocol) + def user_with_broken_personal_key(protocol, scenario) user = create_ial2_account_go_back_to_sp_and_sign_out(protocol) - user.active_profile.update(verified_at: window_start + 1.hour) - user.update(encrypted_recovery_code_digest_generated_at: nil) + case scenario + when :broken_personal_key_window + user.active_profile.update(verified_at: window_start + 1.hour) + user.update(encrypted_recovery_code_digest_generated_at: nil) + when :encrypted_data_too_short + personal_key = RandomPhrase.new(num_words: 4).to_s + user.active_profile.update( + encrypted_pii_recovery: Encryption::Encryptors::PiiEncryptor.new(personal_key). + encrypt('null', user_uuid: user.uuid), + ) + else + raise "unknown scenario #{scenario}" + end user end - context "protocol: #{protocol}, ial: #{sp_ial}" do - it 'prompts the user to get a new personal key when signing in with email/password', js: true do - user = user_with_broken_personal_key(protocol) - - case sp_ial - when 1 - visit_idp_from_sp_with_ial2(protocol) - when 2 - visit_idp_from_sp_with_ial1(protocol) - else - raise "unknown sp_ial=#{sp_ial}" + [ + [ + 'with a personal key during generated during the broken window', + :broken_personal_key_window, + ], + [ + 'with encrypted recovery PII that is too short to be actual data', + :encrypted_data_too_short, + ], + ].each do |description, scenario| + context description do + context "protocol: #{protocol}, ial: #{sp_ial}" do + it 'prompts the user to get a new personal key when using email/password', js: true do + user = user_with_broken_personal_key(protocol, scenario) + + case sp_ial + when 1 + visit_idp_from_sp_with_ial2(protocol) + when 2 + visit_idp_from_sp_with_ial1(protocol) + else + raise "unknown sp_ial=#{sp_ial}" + end + + fill_in_credentials_and_submit(user.email, user.password) + + expect(page).to have_content(t('account.personal_key.needs_new')) + code = page.all('.separator-text__code').map(&:text).join(' ') + acknowledge_and_confirm_personal_key + + expect(user.reload.valid_personal_key?(code)).to eq(true) + expect(user.active_profile.reload.recover_pii(code)).to be_present + end + + it 'prompts for password when signing in via PIV/CAC', js: true do + user = user_with_broken_personal_key(protocol, scenario) + + create(:piv_cac_configuration, user: user) + + visit_idp_from_sp_with_ial1(protocol) + click_on t('account.login.piv_cac') + fill_in_piv_cac_credentials_and_submit(user) + + expect(page).to have_content(t('account.personal_key.needs_new')) + expect(page).to have_content(t('headings.passwords.confirm_for_personal_key')) + + fill_in t('forms.password'), with: user.password + click_button t('forms.buttons.submit.default') + + expect(page).to have_content(t('account.personal_key.needs_new')) + code = page.all('.separator-text__code').map(&:text).join(' ') + acknowledge_and_confirm_personal_key + + expect(user.reload.valid_personal_key?(code)).to eq(true) + expect(user.active_profile.reload.recover_pii(code)).to be_present + end end - - fill_in_credentials_and_submit(user.email, user.password) - - expect(page).to have_content(t('account.personal_key.needs_new')) - code = page.all('.separator-text__code').map(&:text).join(' ') - acknowledge_and_confirm_personal_key - - expect(user.reload.valid_personal_key?(code)).to eq(true) - expect(user.active_profile.reload.recover_pii(code)).to be_present - end - - it 'prompts for password when signing in via PIV/CAC', js: true do - user = user_with_broken_personal_key(protocol) - - create(:piv_cac_configuration, user: user) - - visit_idp_from_sp_with_ial1(protocol) - click_on t('account.login.piv_cac') - fill_in_piv_cac_credentials_and_submit(user) - - expect(page).to have_content(t('account.personal_key.needs_new')) - expect(page).to have_content(t('headings.passwords.confirm_for_personal_key')) - - fill_in t('forms.password'), with: user.password - click_button t('forms.buttons.submit.default') - - expect(page).to have_content(t('account.personal_key.needs_new')) - code = page.all('.separator-text__code').map(&:text).join(' ') - acknowledge_and_confirm_personal_key - - expect(user.reload.valid_personal_key?(code)).to eq(true) - expect(user.active_profile.reload.recover_pii(code)).to be_present end end end