diff --git a/.gitignore b/.gitignore index 114f1a040d5..705451c2553 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ .databag_secret .env .idea +.irb_history .powrc .sass-cache .zeus.sock diff --git a/.irbrc b/.irbrc new file mode 100644 index 00000000000..2507b8ca1e9 --- /dev/null +++ b/.irbrc @@ -0,0 +1 @@ +IRB.conf[:USE_AUTOCOMPLETE] = false diff --git a/app/assets/images/come-back.svg b/app/assets/images/come-back.svg index cc689224a4b..38875a9104d 100644 --- a/app/assets/images/come-back.svg +++ b/app/assets/images/come-back.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/components/base_component.rb b/app/components/base_component.rb index 9b445580b21..abbacf2f842 100644 --- a/app/components/base_component.rb +++ b/app/components/base_component.rb @@ -8,7 +8,11 @@ def before_render end def self.scripts - @scripts ||= _sidecar_files(['js', 'ts']).map { |file| File.basename(file, '.*') } + @scripts ||= begin + scripts = _sidecar_files(['js', 'ts']).map { |file| File.basename(file, '.*') } + scripts.concat superclass.scripts if superclass.respond_to?(:scripts) + scripts + end end def unique_id diff --git a/app/controllers/idv/gpo_verify_controller.rb b/app/controllers/idv/gpo_verify_controller.rb index 26e26a4104f..dbc3871baef 100644 --- a/app/controllers/idv/gpo_verify_controller.rb +++ b/app/controllers/idv/gpo_verify_controller.rb @@ -55,7 +55,7 @@ def create disavowal_token: disavowal_token, ) flash[:success] = t('account.index.verification.success') - redirect_to sign_up_completed_url + redirect_to next_step end end else @@ -67,6 +67,15 @@ def create private + def next_step + if IdentityConfig.store.gpo_personal_key_after_otp + enable_personal_key_generation + idv_personal_key_url + else + sign_up_completed_url + end + end + def throttle @throttle ||= Throttle.new( user: current_user, @@ -104,5 +113,10 @@ def confirm_verification_needed def threatmetrix_enabled? FeatureManagement.proofing_device_profiling_decisioning_enabled? end + + def enable_personal_key_generation + idv_session.resolution_successful = 'gpo' + idv_session.applicant = pii + end end end diff --git a/app/controllers/idv/personal_key_controller.rb b/app/controllers/idv/personal_key_controller.rb index 0e251cbc5a8..57c1d3f2c13 100644 --- a/app/controllers/idv/personal_key_controller.rb +++ b/app/controllers/idv/personal_key_controller.rb @@ -47,7 +47,7 @@ def next_step end def confirm_profile_has_been_created - redirect_to account_url if idv_session.profile.blank? + redirect_to account_url if profile.blank? end def add_proofing_component @@ -62,7 +62,9 @@ def finish_idv_session irs_attempts_api_tracker.idv_personal_key_generated if idv_session.address_verification_mechanism == 'gpo' - flash.now[:success] = t('idv.messages.mail_sent') + if !IdentityConfig.store.gpo_personal_key_after_otp + flash.now[:success] = t('idv.messages.mail_sent') + end else flash.now[:success] = t('idv.messages.confirm') end @@ -73,9 +75,14 @@ def personal_key idv_session.personal_key || generate_personal_key end + def profile + return idv_session.profile if idv_session.profile + current_user.active_profile + end + def generate_personal_key cacher = Pii::Cacher.new(current_user, user_session) - idv_session.profile.encrypt_recovery_pii(cacher.fetch) + profile.encrypt_recovery_pii(cacher.fetch) end def in_person_enrollment? @@ -88,8 +95,8 @@ def pending_profile? end def blocked_by_device_profiling? - !idv_session.profile.active && - idv_session.profile.deactivation_reason == 'threatmetrix_review_pending' + !profile.active && + profile.deactivation_reason == 'threatmetrix_review_pending' end end end diff --git a/app/controllers/idv/review_controller.rb b/app/controllers/idv/review_controller.rb index 200e6758708..453c3372304 100644 --- a/app/controllers/idv/review_controller.rb +++ b/app/controllers/idv/review_controller.rb @@ -126,10 +126,11 @@ def password end def confirm_verify_info_complete - if IdentityConfig.store.doc_auth_verify_info_controller_enabled && - !idv_session.resolution_successful - redirect_to idv_verify_info_url - end + return unless IdentityConfig.store.doc_auth_verify_info_controller_enabled + return unless user_fully_authenticated? + return if idv_session.resolution_successful + + redirect_to idv_verify_info_url end def personal_key_confirmed @@ -143,7 +144,16 @@ def need_personal_key_confirmation? end def next_step - idv_personal_key_url + if gpo_user_flow? + idv_come_back_later_url + else + idv_personal_key_url + end + end + + def gpo_user_flow? + idv_session.address_verification_mechanism == 'gpo' && + IdentityConfig.store.gpo_personal_key_after_otp end def handle_request_enroll_exception(err) diff --git a/app/controllers/idv/verify_info_controller.rb b/app/controllers/idv/verify_info_controller.rb index 1729289c2ad..10e5e6063f4 100644 --- a/app/controllers/idv/verify_info_controller.rb +++ b/app/controllers/idv/verify_info_controller.rb @@ -9,6 +9,8 @@ class VerifyInfoController < ApplicationController def show increment_step_counts analytics.idv_doc_auth_verify_visited(**analytics_arguments) + Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). + call('verify', :view, true) if ssn_throttle.throttled? redirect_to idv_session_errors_ssn_failure_url @@ -27,6 +29,8 @@ def show def update return if idv_session.verify_info_step_document_capture_session_uuid analytics.idv_doc_auth_verify_submitted(**analytics_arguments) + Funnel::DocAuth::RegisterStep.new(current_user.id, sp_session[:issuer]). + call('verify', :update, true) pii[:uuid_prefix] = ServiceProvider.find_by(issuer: sp_session[:issuer])&.app_id @@ -114,6 +118,7 @@ def pii end def delete_pii + flow_session.delete(:pii_from_doc) flow_session.delete(:pii_from_user) end diff --git a/app/controllers/users/phone_setup_controller.rb b/app/controllers/users/phone_setup_controller.rb index 449b836048c..d6d5dd5ae73 100644 --- a/app/controllers/users/phone_setup_controller.rb +++ b/app/controllers/users/phone_setup_controller.rb @@ -12,14 +12,14 @@ class PhoneSetupController < ApplicationController def index @new_phone_form = NewPhoneForm.new( - current_user, + user: current_user, setup_voice_preference: setup_voice_preference?, ) track_phone_setup_visit end def create - @new_phone_form = NewPhoneForm.new(current_user) + @new_phone_form = NewPhoneForm.new(user: current_user) result = @new_phone_form.submit(new_phone_form_params) analytics.multi_factor_auth_phone_setup(**result.to_h) diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb index c166c0fd3cd..d4c63743712 100644 --- a/app/controllers/users/phones_controller.rb +++ b/app/controllers/users/phones_controller.rb @@ -8,11 +8,11 @@ class PhonesController < ReauthnRequiredController def add user_session[:phone_id] = nil - @new_phone_form = NewPhoneForm.new(current_user) + @new_phone_form = NewPhoneForm.new(user: current_user) end def create - @new_phone_form = NewPhoneForm.new(current_user) + @new_phone_form = NewPhoneForm.new(user: current_user) if @new_phone_form.submit(user_params).success? confirm_phone bypass_sign_in current_user diff --git a/app/controllers/users/verify_password_controller.rb b/app/controllers/users/verify_password_controller.rb index 538dd626b64..2e656413473 100644 --- a/app/controllers/users/verify_password_controller.rb +++ b/app/controllers/users/verify_password_controller.rb @@ -14,6 +14,9 @@ def update @decrypted_pii = decrypted_pii result = verify_password_form.submit + irs_attempts_api_tracker.logged_in_profile_change_reauthentication_submitted( + success: result.success?, + ) if result.success? handle_success(result) else @@ -38,6 +41,7 @@ def decrypted_pii def handle_success(result) flash[:personal_key] = result.extra[:personal_key] + irs_attempts_api_tracker.idv_personal_key_generated reactivate_account_session.clear redirect_to account_url end diff --git a/app/forms/new_phone_form.rb b/app/forms/new_phone_form.rb index e259e031022..2924a9137ae 100644 --- a/app/forms/new_phone_form.rb +++ b/app/forms/new_phone_form.rb @@ -15,23 +15,26 @@ class NewPhoneForm validate :validate_not_premium_rate validate :validate_allowed_carrier - attr_accessor :phone, :international_code, :otp_delivery_preference, - :otp_make_default_number, :setup_voice_preference + attr_reader :phone, + :international_code, + :otp_delivery_preference, + :otp_make_default_number, + :setup_voice_preference alias_method :setup_voice_preference?, :setup_voice_preference - def initialize(user, setup_voice_preference: false) - self.user = user - self.otp_delivery_preference = user.otp_delivery_preference - self.otp_make_default_number = false - self.setup_voice_preference = setup_voice_preference + def initialize(user:, setup_voice_preference: false) + @user = user + @otp_delivery_preference = user.otp_delivery_preference + @otp_make_default_number = false + @setup_voice_preference = setup_voice_preference end def submit(params) ingest_submitted_params(params) success = valid? - self.phone = submitted_phone unless success + @phone = submitted_phone unless success FormResponse.new(success: success, errors: errors, extra: extra_analytics_attributes) end @@ -64,15 +67,12 @@ def phone_info private - attr_accessor :user, :submitted_phone + attr_reader :user, :submitted_phone def ingest_phone_number(params) - self.international_code = params[:international_code] - self.submitted_phone = params[:phone] - self.phone = PhoneFormatter.format( - submitted_phone, - country_code: international_code, - ) + @international_code = params[:international_code] + @submitted_phone = params[:phone] + @phone = PhoneFormatter.format(submitted_phone, country_code: international_code) end def extra_analytics_attributes @@ -135,8 +135,8 @@ def ingest_submitted_params(params) delivery_prefs = params[:otp_delivery_preference] default_prefs = params[:otp_make_default_number] - self.otp_delivery_preference = delivery_prefs if delivery_prefs - self.otp_make_default_number = true if default_prefs + @otp_delivery_preference = delivery_prefs if delivery_prefs + @otp_make_default_number = true if default_prefs end def confirmed_phone? diff --git a/app/javascript/packages/document-capture/components/address-search.tsx b/app/javascript/packages/document-capture/components/address-search.tsx index 31cffd14ca2..e8a5374c797 100644 --- a/app/javascript/packages/document-capture/components/address-search.tsx +++ b/app/javascript/packages/document-capture/components/address-search.tsx @@ -161,6 +161,7 @@ interface AddressSearchProps { onFoundLocations?: (locations: FormattedLocation[] | null | undefined) => void; onLoadingLocations?: (isLoading: boolean) => void; onError?: (error: Error | null) => void; + disabled?: boolean; } function AddressSearch({ @@ -169,6 +170,7 @@ function AddressSearch({ onFoundLocations = () => undefined, onLoadingLocations = () => undefined, onError = () => undefined, + disabled = false, }: AddressSearchProps) { const { t } = useI18n(); const spinnerButtonRef = useRef(null); @@ -227,6 +229,7 @@ function AddressSearch({ onChange={onTextInputChange} label={t('in_person_proofing.body.location.po_search.address_search_label')} hint={t('in_person_proofing.body.location.po_search.address_search_hint')} + disabled={disabled} />
diff --git a/app/javascript/packages/document-capture/components/document-capture.tsx b/app/javascript/packages/document-capture/components/document-capture.tsx index 26b2287b441..42e6f2bacc1 100644 --- a/app/javascript/packages/document-capture/components/document-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-capture.tsx @@ -5,6 +5,7 @@ import { FormSteps, PromptOnNavigate } from '@18f/identity-form-steps'; import { VerifyFlowStepIndicator, VerifyFlowPath } from '@18f/identity-verify-flow'; import { useDidUpdateEffect } from '@18f/identity-react-hooks'; import type { FormStep } from '@18f/identity-form-steps'; +import { getConfigValue } from '@18f/identity-config'; import { UploadFormEntriesError } from '../services/upload'; import DocumentsStep from './documents-step'; import InPersonPrepareStep from './in-person-prepare-step'; @@ -60,6 +61,8 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum const { flowPath } = useContext(UploadContext); const { trackSubmitEvent, trackVisitEvent } = useContext(AnalyticsContext); const { inPersonURL, arcgisSearchEnabled } = useContext(InPersonContext); + const appName = getConfigValue('appName'); + useDidUpdateEffect(onStepChange, [stepName]); useEffect(() => { if (stepName) { @@ -112,14 +115,17 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum { name: 'location', form: arcgisSearchEnabled ? InPersonLocationPostOfficeSearchStep : InPersonLocationStep, + title: t('in_person_proofing.headings.po_search.location'), }, { name: 'prepare', form: InPersonPrepareStep, + title: t('in_person_proofing.headings.prepare'), }, flowPath === 'hybrid' && { name: 'switch_back', form: InPersonSwitchBackStep, + title: t('in_person_proofing.headings.switch_back'), }, ].filter(Boolean) as FormStep[]); @@ -137,6 +143,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum pii: submissionError.pii, })(ReviewIssuesStep) : ReviewIssuesStep, + title: t('errors.doc_auth.throttled_heading'), }, ] as FormStep[] ).concat(inPersonSteps) @@ -144,6 +151,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum { name: 'documents', form: DocumentsStep, + title: t('doc_auth.headings.document_capture'), }, ].filter(Boolean) as FormStep[]); @@ -186,6 +194,7 @@ function DocumentCapture({ isAsyncForm = false, onStepChange = () => {} }: Docum onStepChange={setStepName} onStepSubmit={trackSubmitEvent} autoFocus={!!submissionError} + titleFormat={`%{step} - ${appName}`} /> )} diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx index 5983aa4587c..1d645eb35cf 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.spec.tsx @@ -368,4 +368,38 @@ describe('InPersonLocationStep', () => { expect(moreResults).to.be.empty(); }); }); + + context('user deletes text from searchbox after location results load', () => { + beforeEach(() => { + server.use( + rest.post(ADDRESS_SEARCH_URL, (_req, res, ctx) => + res(ctx.json(DEFAULT_RESPONSE), ctx.status(200)), + ), + rest.post(LOCATIONS_URL, (_req, res, ctx) => res(ctx.json([{ name: 'Baltimore' }]))), + ); + }); + + it('allows user to select a location', async () => { + const { findAllByText, findByLabelText, findByText, queryByText } = render( + , + { wrapper }, + ); + await userEvent.type( + await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), + 'Evergreen Terrace Springfield', + ); + + await userEvent.click( + await findByText('in_person_proofing.body.location.po_search.search_button'), + ); + + await userEvent.clear( + await findByLabelText('in_person_proofing.body.location.po_search.address_search_label'), + ); + + await userEvent.click(findAllByText('in_person_proofing.body.location.location_button')[0]); + + expect(await queryByText('in_person_proofing.body.location.inline_error')).to.be.null(); + }); + }); }); diff --git a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx index e16593bc54c..a309c93f5ef 100644 --- a/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx +++ b/app/javascript/packages/document-capture/components/in-person-location-post-office-search-step.tsx @@ -14,15 +14,16 @@ import InPersonLocations, { FormattedLocation } from './in-person-locations'; function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, registerField }) { const { t } = useI18n(); - const [inProgress, setInProgress] = useState(false); - const [isLoadingLocations, setLoadingLocations] = useState(false); - const [autoSubmit, setAutoSubmit] = useState(false); + const [inProgress, setInProgress] = useState(false); + const [isLoadingLocations, setLoadingLocations] = useState(false); + const [autoSubmit, setAutoSubmit] = useState(false); const { setSubmitEventMetadata } = useContext(AnalyticsContext); const [locationResults, setLocationResults] = useState( null, ); const [foundAddress, setFoundAddress] = useState(null); const [apiError, setApiError] = useState(null); + const [disabledAddressSearch, setDisabledAddressSearch] = useState(false); // ref allows us to avoid a memory leak const mountedRef = useRef(false); @@ -43,6 +44,12 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist setSubmitEventMetadata({ selected_location: selectedLocationAddress }); onChange({ selectedLocationAddress }); if (autoSubmit) { + setDisabledAddressSearch(true); + setTimeout(() => { + if (mountedRef.current) { + setDisabledAddressSearch(false); + } + }, 250); return; } // prevent navigation from continuing @@ -52,14 +59,12 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist } const selected = transformKeys(selectedLocation, snakeCase); setInProgress(true); - await request(LOCATIONS_URL, { - json: selected, - method: 'PUT', - }) - .then(() => { - if (!mountedRef.current) { - return; - } + try { + await request(LOCATIONS_URL, { + json: selected, + method: 'PUT', + }); + if (mountedRef.current) { setAutoSubmit(true); setImmediate(() => { // continue with navigation @@ -67,13 +72,12 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist // allow process to be re-triggered in case submission did not work as expected setAutoSubmit(false); }); - }) - .finally(() => { - if (!mountedRef.current) { - return; - } + } + } finally { + if (mountedRef.current) { setInProgress(false); - }); + } + } }, [locationResults, inProgress], ); @@ -93,6 +97,7 @@ function InPersonLocationPostOfficeSearchStep({ onChange, toPreviousStep, regist onFoundLocations={setLocationResults} onLoadingLocations={setLoadingLocations} onError={setApiError} + disabled={disabledAddressSearch} /> {locationResults && foundAddress && !isLoadingLocations && ( { - const { clock } = useSandbox({ useFakeTimers: true }); + const sandbox = useSandbox({ useFakeTimers: true }); + const { clock } = sandbox; const userEvent = baseUserEvent.setup({ advanceTimers: clock.tick }); const longWaitDurationMs = 1000; @@ -118,4 +120,18 @@ describe('SpinnerButtonElement', () => { expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.false(); }); + + it('removes action message timeout when disconnected from the page', async () => { + const wrapper = createWrapper({ actionMessage: 'Verifying...' }); + const button = screen.getByRole('link', { name: 'Click Me' }); + + sandbox.spy(window, 'setTimeout'); + sandbox.spy(window, 'clearTimeout'); + + await userEvent.click(button); + wrapper.parentNode!.removeChild(wrapper); + + const timeoutId = (window.setTimeout as unknown as SinonStub).getCall(0).returnValue; + expect(window.clearTimeout).to.have.been.calledWith(timeoutId); + }); }); diff --git a/app/javascript/packages/spinner-button/spinner-button-element.ts b/app/javascript/packages/spinner-button/spinner-button-element.ts index 62571c8bd96..903fbd9ce28 100644 --- a/app/javascript/packages/spinner-button/spinner-button-element.ts +++ b/app/javascript/packages/spinner-button/spinner-button-element.ts @@ -38,6 +38,10 @@ export class SpinnerButtonElement extends HTMLElement { this.addEventListener('spinner.stop', () => this.toggleSpinner(false)); } + disconnectedCallback() { + window.clearTimeout(this.#longWaitTimeout); + } + toggleSpinner(isVisible: boolean) { const { button, actionMessage } = this.elements; this.classList.toggle('spinner-button--spinner-active', isVisible); diff --git a/app/javascript/packs/application.ts b/app/javascript/packs/application.ts index 5ec39aefa75..227b3ade7f2 100644 --- a/app/javascript/packs/application.ts +++ b/app/javascript/packs/application.ts @@ -2,3 +2,8 @@ import { accordion, banner, skipnav } from 'identity-style-guide'; const components = [accordion, banner, skipnav]; components.forEach((component) => component.on()); +const mainContent = document.getElementById('main-content'); +document.querySelector('.usa-skipnav')?.addEventListener('click', (event) => { + event.preventDefault(); + mainContent?.scrollIntoView(); +}); diff --git a/app/jobs/get_usps_proofing_results_job.rb b/app/jobs/get_usps_proofing_results_job.rb index 90e50e5130e..a6d158d863a 100644 --- a/app/jobs/get_usps_proofing_results_job.rb +++ b/app/jobs/get_usps_proofing_results_job.rb @@ -28,6 +28,7 @@ def enrollment_analytics_attributes(enrollment, complete:) enrollment_id: enrollment.id, minutes_since_last_status_check: enrollment.minutes_since_last_status_check, minutes_since_last_status_update: enrollment.minutes_since_last_status_update, + minutes_since_established: enrollment.minutes_since_established, minutes_to_completion: complete ? enrollment.minutes_since_established : nil, issuer: enrollment.issuer, } @@ -146,6 +147,11 @@ def handle_bad_request_error(err, enrollment) if response_message == IPP_INCOMPLETE_ERROR_MESSAGE # Customer has not been to post office for IPP enrollment_outcomes[:enrollments_in_progress] += 1 + analytics(user: enrollment.user). + idv_in_person_usps_proofing_results_job_enrollment_incomplete( + **enrollment_analytics_attributes(enrollment, complete: false), + response_message: response_message, + ) elsif response_message&.match(IPP_EXPIRED_ERROR_MESSAGE) handle_expired_status_update(enrollment, err.response, response_message) elsif response_message == IPP_INVALID_ENROLLMENT_CODE_MESSAGE % enrollment.enrollment_code @@ -288,8 +294,7 @@ def handle_unexpected_response(enrollment, response_message, reason:, cancel: tr analytics(user: enrollment.user). idv_in_person_usps_proofing_results_job_unexpected_response( - enrollment_code: enrollment.enrollment_code, - enrollment_id: enrollment.id, + **enrollment_analytics_attributes(enrollment, complete: cancel), response_message: response_message, reason: reason, ) diff --git a/app/jobs/reports/daily_dropoffs_report.rb b/app/jobs/reports/daily_dropoffs_report.rb index e756723bc66..cca30f71748 100644 --- a/app/jobs/reports/daily_dropoffs_report.rb +++ b/app/jobs/reports/daily_dropoffs_report.rb @@ -117,7 +117,9 @@ def query_results COALESCE(CASE WHEN doc_auth_logs.verify_submit_count > 0 THEN 1 else null END) ) AS verify_submit , COUNT(doc_auth_logs.verify_phone_view_at) AS phone - , COUNT(doc_auth_logs.verify_phone_submit_at) AS phone_submit + , COUNT( + COALESCE(CASE WHEN doc_auth_logs.verify_phone_submit_count > 0 THEN 1 else null END) + ) AS phone_submit , COUNT(doc_auth_logs.encrypt_view_at) AS encrypt , COUNT(doc_auth_logs.verified_view_at) AS personal_key , COUNT(profiles.id) AS verified diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index b7db25bc899..c70358fb14f 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -85,10 +85,11 @@ def signup_with_your_email end end - def reset_password_instructions(token:) + def reset_password_instructions(token:, request_id:) with_user_locale(user) do @locale = locale_url_param @token = token + @request_id = request_id @pending_profile_requires_verification = user.decorate.pending_profile_requires_verification? @hide_title = @pending_profile_requires_verification mail(to: email_address.email, subject: t('user_mailer.reset_password_instructions.subject')) diff --git a/app/models/otp_requests_tracker.rb b/app/models/otp_requests_tracker.rb deleted file mode 100644 index e95ca79b878..00000000000 --- a/app/models/otp_requests_tracker.rb +++ /dev/null @@ -1,21 +0,0 @@ -class OtpRequestsTracker < ApplicationRecord - def self.find_or_create_with_phone_and_confirmed(phone, phone_confirmed) - create_or_find_by( - phone_fingerprint: Pii::Fingerprinter.fingerprint(phone.strip), - phone_confirmed: phone_confirmed, - ) - end - - def self.atomic_increment(id) - now = Time.zone.now - # The following sql offers superior db performance with one write and no locking overhead - query = sanitize_sql_array( - ['UPDATE otp_requests_trackers ' \ - 'SET otp_send_count = otp_send_count + 1,' \ - 'otp_last_sent_at = ?, updated_at = ? ' \ - 'WHERE id = ?', now, now, id], - ) - OtpRequestsTracker.connection.execute(query) - OtpRequestsTracker.find(id) - end -end diff --git a/app/presenters/openid_connect_user_info_presenter.rb b/app/presenters/openid_connect_user_info_presenter.rb index edd9e71cb15..7f52a4aebc1 100644 --- a/app/presenters/openid_connect_user_info_presenter.rb +++ b/app/presenters/openid_connect_user_info_presenter.rb @@ -21,8 +21,6 @@ def user_info info.merge!(ial2_attributes) if scoper.ial2_scopes_requested? info.merge!(x509_attributes) if scoper.x509_scopes_requested? info[:verified_at] = verified_at if scoper.verified_at_requested? - info[:ial] = identity.ial if identity.ial.present? - info[:aal] = identity.aal if identity.aal.present? scoper.filter(info) end diff --git a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb index d69595a65bd..335e4ce294c 100644 --- a/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb +++ b/app/presenters/two_factor_auth_code/phone_delivery_presenter.rb @@ -45,6 +45,33 @@ def help_text '' end + def troubleshooting_header + t('components.troubleshooting_options.default_heading') + end + + def troubleshooting_options + [ + troubleshoot_change_phone_or_method_option, + { + url: MarketingSite.help_center_article_url( + category: 'get-started', + article: 'authentication-options', + anchor: 'didn-t-receive-your-one-time-code', + ), + text: t('two_factor_authentication.phone_verification.troubleshooting.code_not_received'), + new_tab: true, + }, + { + url: MarketingSite.help_center_article_url( + category: 'get-started', + article: 'authentication-options', + ), + text: t('two_factor_authentication.phone_verification.troubleshooting.learn_more'), + new_tab: true, + }, + ] + end + def cancel_link locale = LinkLocaleResolver.locale if confirmation_for_add_phone || reauthn @@ -56,6 +83,20 @@ def cancel_link private + def troubleshoot_change_phone_or_method_option + if unconfirmed_phone + { + url: phone_setup_path, + text: t('two_factor_authentication.phone_verification.troubleshooting.change_number'), + } + else + { + url: login_two_factor_options_path, + text: t('two_factor_authentication.login_options_link_text'), + } + end + end + attr_reader( :phone_number, :account_reset_token, diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 21a348deb16..a5fa8d1d7bf 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -3125,6 +3125,7 @@ def idv_in_person_usps_proofing_results_job_completed( # @param [String] exception_class # @param [String] exception_message # @param [String] enrollment_code + # @param [Float] minutes_since_established # @param [Float] minutes_since_last_status_check # @param [Float] minutes_since_last_status_update # @param [Float] minutes_to_completion @@ -3145,6 +3146,7 @@ def idv_in_person_usps_proofing_results_job_completed( def idv_in_person_usps_proofing_results_job_exception( reason:, enrollment_id:, + minutes_since_established:, exception_class: nil, exception_message: nil, enrollment_code: nil, @@ -3174,6 +3176,7 @@ def idv_in_person_usps_proofing_results_job_exception( exception_class: exception_class, exception_message: exception_message, enrollment_code: enrollment_code, + minutes_since_established: minutes_since_established, minutes_since_last_status_check: minutes_since_last_status_check, minutes_since_last_status_update: minutes_since_last_status_update, minutes_to_completion: minutes_to_completion, @@ -3249,12 +3252,14 @@ def idv_in_person_email_reminder_job_exception( # Tracks individual enrollments that are updated during GetUspsProofingResultsJob # @param [String] enrollment_code # @param [String] enrollment_id + # @param [Float] minutes_since_established # @param [Boolean] fraud_suspected # @param [Boolean] passed did this enrollment pass or fail? # @param [String] reason why did this enrollment pass or fail? def idv_in_person_usps_proofing_results_job_enrollment_updated( enrollment_code:, enrollment_id:, + minutes_since_established:, fraud_suspected:, passed:, reason:, @@ -3264,6 +3269,7 @@ def idv_in_person_usps_proofing_results_job_enrollment_updated( 'GetUspsProofingResultsJob: Enrollment status updated', enrollment_code: enrollment_code, enrollment_id: enrollment_id, + minutes_since_established: minutes_since_established, fraud_suspected: fraud_suspected, passed: passed, reason: reason, @@ -3300,14 +3306,38 @@ def idv_in_person_email_reminder_job_email_initiated( ) end + # Tracks incomplete enrollments checked via the USPS API + # @param [String] enrollment_code + # @param [String] enrollment_id + # @param [Float] minutes_since_established + # @param [String] response_message + def idv_in_person_usps_proofing_results_job_enrollment_incomplete( + enrollment_code:, + enrollment_id:, + minutes_since_established:, + response_message:, + **extra + ) + track_event( + 'GetUspsProofingResultsJob: Enrollment incomplete', + enrollment_code: enrollment_code, + enrollment_id: enrollment_id, + minutes_since_established: minutes_since_established, + response_message: response_message, + **extra, + ) + end + # Tracks unexpected responses from the USPS API # @param [String] enrollment_code # @param [String] enrollment_id + # @param [Float] minutes_since_established # @param [String] response_message # @param [String] reason why was this error unexpected? def idv_in_person_usps_proofing_results_job_unexpected_response( enrollment_code:, enrollment_id:, + minutes_since_established:, response_message:, reason:, **extra @@ -3316,6 +3346,7 @@ def idv_in_person_usps_proofing_results_job_unexpected_response( 'GetUspsProofingResultsJob: Unexpected response received', enrollment_code: enrollment_code, enrollment_id: enrollment_id, + minutes_since_established: minutes_since_established, response_message: response_message, reason: reason, **extra, diff --git a/app/services/arcgis_api/geocoder.rb b/app/services/arcgis_api/geocoder.rb index 0b80b6b8b7a..421cab6b34e 100644 --- a/app/services/arcgis_api/geocoder.rb +++ b/app/services/arcgis_api/geocoder.rb @@ -6,7 +6,8 @@ class Geocoder keyword_init: true ) Location = Struct.new(:latitude, :longitude, keyword_init: true) - API_TOKEN_CACHE_KEY = :arcgis_api_token + API_TOKEN_HOST = URI(IdentityConfig.store.arcgis_api_generate_token_url).host + API_TOKEN_CACHE_KEY = "arcgis_api_token:#{API_TOKEN_HOST}" # These are option URL params that tend to apply to multiple endpoints # https://developers.arcgis.com/rest/geocode/api-reference/geocoding-find-address-candidates.htm#ESRI_SECTION2_38613C3FCB12462CAADD55B2905140BF @@ -35,12 +36,6 @@ class Geocoder ].join(','), }.freeze - ROOT_URL = IdentityConfig.store.arcgis_api_root_url - SUGGEST_ENDPOINT = "#{ROOT_URL}/servernh/rest/services/GSA/USA/GeocodeServer/suggest" - ADDRESS_CANDIDATES_ENDPOINT = - "#{ROOT_URL}/servernh/rest/services/GSA/USA/GeocodeServer/findAddressCandidates" - GENERATE_TOKEN_ENDPOINT = "#{ROOT_URL}/portal/sharing/rest/generateToken" - KNOWN_FIND_ADDRESS_CANDIDATES_PARAMETERS = [ :magicKey, # Generated from /suggest; identifier used to retrieve full address record :SingleLine, # Unvalidated address-like text string used to search for geocoded addresses @@ -60,7 +55,7 @@ def suggest(text) } parse_suggestions( - faraday.get(SUGGEST_ENDPOINT, params, dynamic_headers) do |req| + faraday.get(IdentityConfig.store.arcgis_api_suggest_url, params, dynamic_headers) do |req| req.options.context = { service_name: 'arcgis_geocoder_suggest' } end.body, ) @@ -88,7 +83,10 @@ def find_address_candidates(**options) } parse_address_candidates( - faraday.get(ADDRESS_CANDIDATES_ENDPOINT, params, dynamic_headers) do |req| + faraday.get( + IdentityConfig.store.arcgis_api_find_address_candidates_url, params, + dynamic_headers + ) do |req| req.options.context = { service_name: 'arcgis_geocoder_find_address_candidates' } end.body, ) @@ -195,7 +193,10 @@ def request_token f: 'json', } - faraday.post(GENERATE_TOKEN_ENDPOINT, URI.encode_www_form(body)) do |req| + faraday.post( + IdentityConfig.store.arcgis_api_generate_token_url, + URI.encode_www_form(body), + ) do |req| req.options.context = { service_name: 'usps_token' } end.body end diff --git a/app/services/arcgis_api/mock/geocoder.rb b/app/services/arcgis_api/mock/geocoder.rb index 6e869388e2d..d27b5a6f610 100644 --- a/app/services/arcgis_api/mock/geocoder.rb +++ b/app/services/arcgis_api/mock/geocoder.rb @@ -14,7 +14,7 @@ def faraday private def stub_generate_token(stub) - stub.post(GENERATE_TOKEN_ENDPOINT) do |env| + stub.post(IdentityConfig.store.arcgis_api_generate_token_url) do |env| [ 200, { 'Content-Type': 'application/json' }, @@ -28,7 +28,7 @@ def stub_generate_token(stub) end def stub_suggestions(stub) - stub.get(SUGGEST_ENDPOINT) do |env| + stub.get(IdentityConfig.store.arcgis_api_suggest_url) do |env| [ 200, { 'Content-Type': 'application/json' }, @@ -38,7 +38,7 @@ def stub_suggestions(stub) end def stub_address_candidates(stub) - stub.get(ADDRESS_CANDIDATES_ENDPOINT) do |env| + stub.get(IdentityConfig.store.arcgis_api_find_address_candidates_url) do |env| [ 200, { 'Content-Type': 'application/json' }, diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb index e84d9a819f3..4a67c4d96bd 100644 --- a/app/services/idv/flows/doc_auth_flow.rb +++ b/app/services/idv/flows/doc_auth_flow.rb @@ -26,8 +26,9 @@ class DocAuthFlow < Flow::BaseFlow { name: :getting_started }, { name: :verify_id }, { name: :verify_info }, - { name: :secure_account }, + *([name: :secure_account] if !IdentityConfig.store.gpo_personal_key_after_otp), { name: :get_a_letter }, + *([name: :secure_account] if IdentityConfig.store.gpo_personal_key_after_otp), ].freeze OPTIONAL_SHOW_STEPS = { diff --git a/app/services/idv/steps/send_link_step.rb b/app/services/idv/steps/send_link_step.rb index 06e99aa333f..1d1e29a5c5c 100644 --- a/app/services/idv/steps/send_link_step.rb +++ b/app/services/idv/steps/send_link_step.rb @@ -29,8 +29,22 @@ def call build_telephony_form_response(telephony_result) end + def extra_view_variables + { + idv_phone_form: build_form, + } + end + private + def build_form + Idv::PhoneForm.new( + previous_params: {}, + user: current_user, + delivery_methods: [:sms], + ) + end + def build_telephony_form_response(telephony_result) FormResponse.new( success: telephony_result.success?, @@ -77,11 +91,7 @@ def sp_or_app_name def form_submit params = permit(:phone) params[:otp_delivery_preference] = 'sms' - Idv::PhoneForm.new( - previous_params: {}, - user: current_user, - delivery_methods: [:sms], - ).submit(params) + build_form.submit(params) end def formatted_destination_phone diff --git a/app/services/marketing_site.rb b/app/services/marketing_site.rb index c3a29b9a2b2..55313387e1a 100644 --- a/app/services/marketing_site.rb +++ b/app/services/marketing_site.rb @@ -81,12 +81,12 @@ def self.security_url URI.join(BASE_URL, locale_segment, 'security/').to_s end - def self.help_center_article_url(category:, article:) + def self.help_center_article_url(category:, article:, anchor: '') if !valid_help_center_article?(category: category, article: article) raise ArgumentError.new("Unknown help center article category #{category}/#{article}") end - - URI.join(BASE_URL, locale_segment, "help/#{category}/#{article}/").to_s + anchor_text = anchor.present? ? "##{anchor}" : '' + URI.join(BASE_URL, locale_segment, "help/#{category}/#{article}/#{anchor_text}").to_s end def self.valid_help_center_article?(category:, article:) diff --git a/app/services/otp_rate_limiter.rb b/app/services/otp_rate_limiter.rb index 4afde203a54..b0aa75d31d7 100644 --- a/app/services/otp_rate_limiter.rb +++ b/app/services/otp_rate_limiter.rb @@ -15,19 +15,14 @@ def exceeded_otp_send_limit? end def max_requests_reached? - return throttle.throttled? if IdentityConfig.store.redis_throttle_otp_rate_limiter_read_enabled - - entry_for_current_phone.otp_send_count > otp_maxretry_times + throttle.throttled? end def rate_limit_period_expired? - return throttle.expired? if IdentityConfig.store.redis_throttle_otp_rate_limiter_read_enabled - otp_last_sent_at.present? && (otp_last_sent_at + otp_findtime) < Time.zone.now + throttle.expired? end def reset_count_and_otp_last_sent_at - entry_for_current_phone.update(otp_last_sent_at: Time.zone.now, otp_send_count: 0) - throttle.reset! end @@ -36,30 +31,21 @@ def lock_out_user end def increment - # DO NOT MEMOIZE - @entry = OtpRequestsTracker.atomic_increment(entry_for_current_phone.id) throttle.increment! - nil end def otp_last_sent_at - if IdentityConfig.store.redis_throttle_otp_rate_limiter_read_enabled - throttle.attempted_at - else - entry_for_current_phone.otp_last_sent_at - end + throttle.attempted_at + end + + def throttle + @throttle ||= Throttle.new(throttle_type: :phone_otp, target: throttle_key) end private attr_reader :phone, :user, :phone_confirmed - # rubocop:disable Naming/MemoizedInstanceVariableName - def entry_for_current_phone - @entry ||= OtpRequestsTracker.find_or_create_with_phone_and_confirmed(phone, phone_confirmed) - end - # rubocop:enable Naming/MemoizedInstanceVariableName - def otp_findtime IdentityConfig.store.otp_delivery_blocklist_findtime.minutes end @@ -72,10 +58,6 @@ def phone_fingerprint @phone_fingerprint ||= Pii::Fingerprinter.fingerprint(PhoneFormatter.format(phone)) end - def throttle - @throttle ||= Throttle.new(throttle_type: :phone_otp, target: throttle_key) - end - def throttle_key "#{phone_fingerprint}:#{phone_confirmed}" end diff --git a/app/services/request_password_reset.rb b/app/services/request_password_reset.rb index 330b459a960..aa2b588d369 100644 --- a/app/services/request_password_reset.rb +++ b/app/services/request_password_reset.rb @@ -30,6 +30,7 @@ def send_reset_password_instructions token = user.set_reset_password_token UserMailer.with(user: user, email_address: email_address_record).reset_password_instructions( token: token, + request_id: request_id, ).deliver_now_or_later event = PushNotification::RecoveryActivatedEvent.new(user: user) diff --git a/app/services/usps_in_person_proofing/mock/fixtures.rb b/app/services/usps_in_person_proofing/mock/fixtures.rb index da574339eb4..51ca638275c 100644 --- a/app/services/usps_in_person_proofing/mock/fixtures.rb +++ b/app/services/usps_in_person_proofing/mock/fixtures.rb @@ -13,6 +13,10 @@ def self.request_facilities_response load_response_fixture('request_facilities_response.json') end + def self.request_facilities_response_with_duplicates + load_response_fixture('request_facilities_response_with_duplicates.json') + end + def self.request_show_usps_location_response load_response_fixture('request_show_usps_location_response.json') end diff --git a/app/services/usps_in_person_proofing/mock/responses/request_facilities_response_with_duplicates.json b/app/services/usps_in_person_proofing/mock/responses/request_facilities_response_with_duplicates.json new file mode 100644 index 00000000000..563c13e4c6d --- /dev/null +++ b/app/services/usps_in_person_proofing/mock/responses/request_facilities_response_with_duplicates.json @@ -0,0 +1,224 @@ +{ + "postOffices": [ + { + "parking": "Street", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.05 mi", + "streetAddress": "3775 INDUSTRIAL BLVD", + "city": "WEST SACRAMENTO", + "phone": "916-556-3406", + "name": "INDUSTRIAL WEST SACRAMENTO", + "zip4": "9998", + "state": "CA", + "zip5": "95799" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "0.05 mi", + "streetAddress": "3775 INDUSTRIAL BLVD", + "city": "WEST SACRAMENTO", + "phone": "916-373-8157", + "name": "WEST SACRAMENTO P\u0026DC", + "zip4": "0102", + "state": "CA", + "zip5": "95799" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 5:00 PM" + }, + { + "saturdayHours": "9:00 AM - 5:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "2.22 mi", + "streetAddress": "1601 MERKLEY AVE", + "city": "WEST SACRAMENTO", + "name": "WEST SACRAMENTO", + "phone": "916-556-3406", + "state": "CA", + "zip4": "9998", + "zip5": "95691" + }, + { + "parking": "Street", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "2.77 mi", + "streetAddress": "900 SACRAMENTO AVE", + "city": "WEST SACRAMENTO", + "phone": "916-556-3406", + "name": "BRODERICK", + "zip4": "9998", + "state": "CA", + "zip5": "95605" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "3.87 mi", + "streetAddress": "660 J ST STE 170", + "city": "SACRAMENTO", + "phone": "916-498-9145", + "name": "DOWNTOWN PLAZA", + "zip4": "9996", + "state": "CA", + "zip5": "95814" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "4.53 mi", + "streetAddress": "2121 BROADWAY", + "city": "SACRAMENTO", + "name": "BROADWAY", + "phone": "916-227-6503", + "zip4": "9998", + "state": "CA", + "zip5": "95818" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "4.55 mi", + "streetAddress": "5930 S LAND PARK DR", + "city": "SACRAMENTO", + "phone": "916-262-3107", + "name": "LAND PARK", + "zip4": "9998", + "state": "CA", + "zip5": "95822" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "5.33 mi", + "streetAddress": "1618 ALHAMBRA BLVD", + "city": "SACRAMENTO", + "phone": "916-227-6503", + "name": "FORT SUTTER", + "zip4": "9998", + "state": "CA", + "zip5": "95816" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "5.49 mi", + "streetAddress": "2929 35TH ST", + "city": "SACRAMENTO", + "phone": "916-227-6509", + "name": "OAK PARK", + "zip4": "9998", + "state": "CA", + "zip5": "95817" + }, + { + "parking": "Lot", + "hours": [ + { + "weekdayHours": "9:00 AM - 6:00 PM" + }, + { + "saturdayHours": "9:00 AM - 3:00 PM" + }, + { + "sundayHours": "Closed" + } + ], + "distance": "6.63 mi", + "streetAddress": "4750 J ST", + "city": "SACRAMENTO", + "phone": "916-227-6503", + "name": "CAMELLIA", + "zip4": "9998", + "state": "CA", + "zip5": "95819" + } + ] +} diff --git a/app/services/usps_in_person_proofing/proofer.rb b/app/services/usps_in_person_proofing/proofer.rb index 2153fb7f271..a2d6a0a61bd 100644 --- a/app/services/usps_in_person_proofing/proofer.rb +++ b/app/services/usps_in_person_proofing/proofer.rb @@ -18,11 +18,12 @@ def request_facilities(location) zipCode: location.zip_code, }.to_json - parse_facilities( + facilities = parse_facilities( faraday.post(url, body, dynamic_headers) do |req| req.options.context = { service_name: 'usps_facilities' } end.body, ) + dedupe_facilities(facilities) end # Temporary function to return a static set of facilities @@ -218,5 +219,11 @@ def parse_facilities(facilities) ) end end + + def dedupe_facilities(facilities) + facilities.uniq do |facility| + [facility.address, facility.city, facility.state, facility.zip_code_5] + end + end end end diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index c23be3588e6..f57684bf32d 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -1,5 +1,7 @@ <% title t('titles.passwords.change') %> +<% request_id = params[:request_id] || sp_session[:request_id] %> + <%= render PageHeadingComponent.new.with_content(t('headings.passwords.change')) %>

<%= t('instructions.password.password_key') %>

@@ -19,6 +21,7 @@ required: true, }, ) %> + <%= hidden_field_tag('request_id', request_id) %> <%= render 'devise/shared/password_strength', forbidden_passwords: @forbidden_passwords %> <%= f.submit t('forms.passwords.edit.buttons.submit'), class: 'display-block margin-y-5' %> <% end %> diff --git a/app/views/idv/come_back_later/show.html.erb b/app/views/idv/come_back_later/show.html.erb index fc6a469223d..4f3436972c4 100644 --- a/app/views/idv/come_back_later/show.html.erb +++ b/app/views/idv/come_back_later/show.html.erb @@ -17,18 +17,25 @@ <%= render PageHeadingComponent.new(class: 'text-center').with_content(t('idv.titles.come_back_later')) %> +
+ <%= t('idv.messages.come_back_later_html', app_name: APP_NAME) %> +
+

- <%= t('idv.messages.come_back_later', app_name: APP_NAME) %> + <%= t('idv.messages.come_back_later_password_html') %> +

+

<% if decorated_session.sp_name.present? %> <%= t('idv.messages.come_back_later_sp_html', sp: decorated_session.sp_name) %> <% else %> <%= t('idv.messages.come_back_later_no_sp_html', app_name: APP_NAME) %> <% end %>

+
<% if decorated_session.sp_name.present? %> <%= link_to( - t('idv.buttons.continue_plain'), + t('idv.cancel.actions.exit', app_name: APP_NAME), return_to_sp_cancel_path(location: :come_back_later), class: 'usa-button usa-button--big usa-button--wide', ) %> @@ -39,4 +46,5 @@ class: 'usa-button usa-button--big usa-button--wide', ) %> <% end %> +
diff --git a/app/views/idv/doc_auth/send_link.html.erb b/app/views/idv/doc_auth/send_link.html.erb index 2ad9512e160..1e49331ba97 100644 --- a/app/views/idv/doc_auth/send_link.html.erb +++ b/app/views/idv/doc_auth/send_link.html.erb @@ -16,7 +16,8 @@

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

<%= simple_form_for( - :doc_auth, + idv_phone_form, + as: :doc_auth, url: url_for, method: 'PUT', html: { autocomplete: 'off' }, diff --git a/app/views/two_factor_authentication/otp_verification/show.html.erb b/app/views/two_factor_authentication/otp_verification/show.html.erb index 71925e45e2b..9569dc12a94 100644 --- a/app/views/two_factor_authentication/otp_verification/show.html.erb +++ b/app/views/two_factor_authentication/otp_verification/show.html.erb @@ -56,14 +56,12 @@ ).with_content(t('links.two_factor_authentication.send_another_code')) %> <% end %> -<% if @presenter.unconfirmed_phone? %> -
- <%= t('instructions.mfa.wrong_number') %>
- <%= link_to(t('forms.two_factor.try_again'), phone_setup_path) %> -
-<% else %> - <%= render 'shared/fallback_links', presenter: @presenter %> -<% end %> + +<%= render( + 'shared/troubleshooting_options', + heading: @presenter.troubleshooting_header, + options: @presenter.troubleshooting_options, + ) %> <% if MfaPolicy.new(current_user).two_factor_enabled? %> <%= render 'shared/cancel', link: @presenter.cancel_link %> diff --git a/app/views/user_mailer/reset_password_instructions.html.erb b/app/views/user_mailer/reset_password_instructions.html.erb index c109b8090b7..89826627c92 100644 --- a/app/views/user_mailer/reset_password_instructions.html.erb +++ b/app/views/user_mailer/reset_password_instructions.html.erb @@ -33,7 +33,7 @@
<%= link_to t('user_mailer.reset_password_instructions.link_text'), - edit_user_password_url(reset_password_token: @token, locale: @locale), + edit_user_password_url(reset_password_token: @token, locale: @locale, request_id: @request_id), target: '_blank', class: 'float-center', align: 'center', rel: 'noopener' %>
@@ -48,8 +48,8 @@

- <%= link_to edit_user_password_url(reset_password_token: @token, locale: @locale), - edit_user_password_url(reset_password_token: @token, locale: @locale), + <%= link_to edit_user_password_url(reset_password_token: @token, locale: @locale, request_id: @request_id), + edit_user_password_url(reset_password_token: @token, locale: @locale, request_id: @request_id), target: '_blank', rel: 'noopener' %>

diff --git a/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb b/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb index 99624795266..fbecd121669 100644 --- a/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb +++ b/app/views/user_mailer/shared/_in_person_ready_to_verify.html.erb @@ -84,7 +84,7 @@ <%= t('date.day_names')[6] %>: <%= @presenter.selected_location_hours(:sunday) %>
- <%= @presenter.selected_location_details[:phone] %> + <%= @presenter.selected_location_details['phone'] %>
<% end %> diff --git a/config/application.rb b/config/application.rb index 25fdd4bb518..a1bfbecfdc8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -14,6 +14,7 @@ require_relative '../lib/identity_job_log_subscriber' require_relative '../lib/email_delivery_observer' require_relative '../lib/good_job_connection_pool_size' +require_relative '../lib/identity_cors' Bundler.require(*Rails.groups) @@ -110,25 +111,10 @@ class Application < Rails::Application require 'secure_cookies' config.middleware.insert_after ActionDispatch::Static, SecureCookies - # rubocop:disable Metrics/BlockLength config.middleware.insert_before 0, Rack::Cors do allow do origins do |source, _env| - next if source == IdentityConfig.store.domain_name - - redirect_uris = Rails.cache.fetch( - 'all_service_provider_redirect_uris', - expires_in: IdentityConfig.store.all_redirect_uris_cache_duration_minutes.minutes, - ) do - ServiceProvider.pluck(:redirect_uris).flatten.compact - end - - redirect_uris.find do |uri| - split_uri = uri.split('//') - protocol = split_uri[0] - domain = split_uri[1].split('/')[0] if split_uri.size > 1 - source == "#{protocol}//#{domain}" - end.present? + IdentityCors.allowed_redirect_uri?(source) end resource '/.well-known/openid-configuration', headers: :any, methods: [:get] resource '/api/openid_connect/certs', headers: :any, methods: [:get] @@ -140,24 +126,11 @@ class Application < Rails::Application end allow do - allowed_origins = [ - 'https://www.login.gov', - 'https://login.gov', - 'https://handbook.login.gov', - %r{^https://federalist-[0-9a-f-]+\.app\.cloud\.gov$}, - ] - - if Rails.env.development? || Rails.env.test? - allowed_origins << %r{https?://localhost(:\d+)?$} - allowed_origins << %r{https?://127\.0\.0\.1(:\d+)?$} - end - - origins allowed_origins + origins IdentityCors.allowed_origins_static_sites resource '/api/analytics-events', headers: :any, methods: [:get] resource '/api/country-support', headers: :any, methods: [:get] end end - # rubocop:enable Metrics/BlockLength if !IdentityConfig.store.enable_rate_limiting # Rack::Attack auto-includes itself as a Railtie, so we need to diff --git a/config/application.yml.default b/config/application.yml.default index 1d4ce53eb64..341df2666b3 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -41,11 +41,13 @@ acuant_get_results_timeout: 1.0 acuant_create_document_timeout: 1.0 add_email_link_valid_for_hours: 24 address_identity_proofing_supported_country_codes: '["AS", "GU", "MP", "PR", "US", "VI"]' -arcgis_api_root_url: 'https://gis.gsa.gov' arcgis_api_username: '' arcgis_api_password: '' -arcgis_search_enabled: false +arcgis_search_enabled: true arcgis_mock_fallback: true +arcgis_api_generate_token_url: 'https://gis.gsa.gov/portal/sharing/rest/generateToken' +arcgis_api_suggest_url: 'https://gis.gsa.gov/servernh/rest/services/GSA/USA/GeocodeServer/suggest' +arcgis_api_find_address_candidates_url: 'https://gis.gsa.gov/servernh/rest/services/GSA/USA/GeocodeServer/findAddressCandidates' asset_host: '' async_wait_timeout_seconds: 60 async_stale_job_timeout_seconds: 300 @@ -110,6 +112,7 @@ good_job_max_threads: 5 good_job_queues: 'default:5;low:1;*' good_job_queue_select_limit: 5_000 gpo_designated_receiver_pii: '{}' +gpo_personal_key_after_otp: false hide_phone_mfa_signup: false identity_pki_disabled: false identity_pki_local_dev: false @@ -189,7 +192,7 @@ logins_per_ip_period: 60 logo_upload_enabled: false mailer_domain_name: http://localhost:3000 max_auth_apps_per_account: 2 -max_bad_passwords: 100 +max_bad_passwords: 5 max_bad_passwords_window_in_seconds: 60 max_emails_per_account: 12 max_mail_events: 4 @@ -243,7 +246,6 @@ rails_mailer_previews_enabled: false reauthn_window: 120 recovery_code_length: 4 redis_irs_attempt_api_url: redis://localhost:6379/2 -redis_throttle_otp_rate_limiter_read_enabled: false redis_throttle_url: redis://localhost:6379/1 redis_url: redis://localhost:6379/0 redis_pool_size: 10 @@ -378,7 +380,6 @@ development: rails_mailer_previews_enabled: true rack_timeout_service_timeout_seconds: 9_999_999_999 recurring_jobs_disabled_names: "[]" - redis_throttle_otp_rate_limiter_read_enabled: true s3_report_bucket_prefix: '' s3_report_public_bucket_prefix: '' saml_endpoint_configs: '[{"suffix":"2021","secret_key_passphrase":"trust-but-verify"},{"suffix":"2022","secret_key_passphrase":"trust-but-verify"}]' @@ -539,7 +540,6 @@ test: piv_cac_verify_token_secret: 3ac13bfa23e22adae321194c083e783faf89469f6f85dcc0802b27475c94b5c3891b5657bd87d0c1ad65de459166440512f2311018db90d57b15d8ab6660748f poll_rate_for_verify_in_seconds: 0 recurring_jobs_disabled_names: '["disabled job"]' - redis_throttle_otp_rate_limiter_read_enabled: true reg_confirmed_email_max_attempts: 3 reg_unconfirmed_email_max_attempts: 4 reg_unconfirmed_email_window_in_minutes: 70 diff --git a/config/locales/idv/en.yml b/config/locales/idv/en.yml index 44c0fc3317b..1ed6b750a6c 100644 --- a/config/locales/idv/en.yml +++ b/config/locales/idv/en.yml @@ -131,9 +131,14 @@ en: verified information, please %{link}. activated_link: contact us clear_and_start_over: Clear my information and start over - come_back_later: Once your letter arrives, sign into %{app_name}, and enter your - one-time code when prompted. + come_back_later_html:

Letters typically take 3 to 7 business days to + arrive
through USPS First Class Mail.

Once your + letter arrives, sign into %{app_name}, and enter your one-time code when + prompted.

come_back_later_no_sp_html: You can return to your %{app_name} account for now. + come_back_later_password_html: Don’t forget your password.
If you reset your password, the one-time code in your letter will no + longer work and you’ll have to verify your identity again. come_back_later_sp_html: You can return to %{sp} for now. confirm: You have encrypted your verified data gpo: @@ -183,7 +188,7 @@ en: ssn: Social Security number (SSN) titles: activated: Your identity has already been verified - come_back_later: Come back soon + come_back_later: Your letter is on the way mail: resend: Want another letter? verify: Want a letter? diff --git a/config/locales/idv/es.yml b/config/locales/idv/es.yml index 7209fd3b991..3dc54c1f347 100644 --- a/config/locales/idv/es.yml +++ b/config/locales/idv/es.yml @@ -140,9 +140,14 @@ es: información verificada, por favor, %{link}. activated_link: Contáctenos clear_and_start_over: Borrar mi información y empezar de nuevo - come_back_later: Una vez llegue tu carta, regístrate en %{app_name} e introduce - tu código único cuando se te solicite. + come_back_later_html:

Las cartas suelen tardar de 3 a 7 días hábiles + en llegar
a través de USPS First Class Mail.

Una + vez que reciba la carta, inicie sesión en %{app_name} e introduzca el + código único cuando se le solicite.

come_back_later_no_sp_html: Ahora puedes volver a tu cuenta de %{app_name}. + come_back_later_password_html: No olvide su contraseña.
+ Si restablece su contraseña, el código único de su carta ya no + funcionará y tendrá que verificar su identidad de nuevo. come_back_later_sp_html: Ahora puedes volver a %{sp}. confirm: Usted ha encriptado sus datos verificados. gpo: @@ -197,7 +202,7 @@ es: ssn: Número de Seguro Social (SSN, sigla en inglés) titles: activated: Ya se verificó tu identidad. - come_back_later: Vuelve pronto + come_back_later: Su carta está en camino mail: resend: '¿Desea otra carta?' verify: '¿Desea una carta?' diff --git a/config/locales/idv/fr.yml b/config/locales/idv/fr.yml index a930f99afad..236d4b34b67 100644 --- a/config/locales/idv/fr.yml +++ b/config/locales/idv/fr.yml @@ -145,12 +145,18 @@ fr: information vérifiée, veuillez %{link}. activated_link: communiquer avec nous clear_and_start_over: Supprimer mes données et recommencer - come_back_later: Une fois que votre lettre vous sera parvenue, veuillez vous - connecter à %{app_name}, puis saisissez votre code à usage unique - lorsque vous y serez invité. + come_back_later_html:

Les lettres prennent généralement trois à sept + jours ouvrables pour arriver
par le biais du courriel de + première classe de l’USPS.

Une fois votre lettre arrivée, + connectez-vous à %{app_name} et entrez votre code à usage unique lorsque + vous y êtes invité.

come_back_later_no_sp_html: Vous pouvez revenir à votre compte %{app_name} pour le moment. - come_back_later_sp_html: Vous pouvez revenir à %{sp} pour le moment. + come_back_later_password_html: N’oubliez pas votre mot de + passe.
Si vous réinitialisez votre mot de passe, le code + à usage unique figurant dans votre lettre ne correspondra plus et vous + serez obligé de vérifier à nouveau votre identité. + come_back_later_sp_html: Vous pouvez retourner à l’agence %{sp} pour le moment. confirm: Vous avez crypté vos données vérifiées gpo: address_on_file_html: Nous enverrons une lettre avec un code à usage @@ -210,7 +216,7 @@ fr: ssn: Numéro de sécurité sociale (SSN) titles: activated: Votre identité a déjà été vérifiée - come_back_later: Revenez vite + come_back_later: Votre lettre est en route mail: resend: Vous voulez une autre lettre? verify: Vous voulez une lettre? diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index a1d57b577c0..5d69fc1ca62 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -114,6 +114,11 @@ en: phone_fee_disclosure: Message and data rates may apply. phone_info_html: We’ll send you a one-time code each time you sign in. phone_label: Phone number + phone_verification: + troubleshooting: + change_number: Use another phone number + code_not_received: I didn’t receive my one-time code + learn_more: Learn more about authentication options piv_cac_fallback: question: Don’t have your PIV or CAC available? piv_cac_header_text: Present your PIV/CAC diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index bc3224a1a4c..85ff04cfd97 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -123,6 +123,11 @@ es: phone_info_html: Le enviaremos un código de un solo uso cada vez que ingrese. phone_label: Número de teléfono + phone_verification: + troubleshooting: + change_number: Utilice otro número de teléfono. + code_not_received: No recibí mi código de un solo uso. + learn_more: Más información sobre las opciones de autenticación. piv_cac_fallback: question: '¿No tienes su PIV o CAC disponibles?' piv_cac_header_text: Presenta tu PIV/CAC diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index 009cd4fd858..983e7efba37 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -128,6 +128,11 @@ fr: phone_info_html: Nous vous enverrons un code à usage unique à chaque fois que vous vous connecterez. phone_label: Numéro de téléphone + phone_verification: + troubleshooting: + change_number: Utilisez un autre numéro de téléphone + code_not_received: Je n’ai pas reçu mon code à usage unique + learn_more: En savoir plus sur les options d’authentification piv_cac_fallback: question: Votre PIV ou CAC n’est pas disponible? piv_cac_header_text: Veuillez présenter votre carte PIV/CAC diff --git a/docs/frontend.md b/docs/frontend.md index 647f4a4f4c2..10ef2b15461 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -192,7 +192,7 @@ yarn test To run a single test file: ``` -npx mocha spec/javascripts/app/utils/ms-formatter_spec.js +yarn mocha app/javascript/packages/analytics/index.spec.ts ``` Using `npx`, you can also pass any @@ -201,7 +201,7 @@ Using `npx`, you can also pass any For example, to watch a file and rerun tests after any change: ``` -npx mocha spec/javascripts/app/utils/ms-formatter_spec.js --watch +yarn mocha app/javascript/packages/analytics/index.spec.ts --watch ``` #### ESLint diff --git a/lib/identity_config.rb b/lib/identity_config.rb index c06adb8dcb6..e99a3fd7e74 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -105,11 +105,13 @@ def self.build_store(config_map) config.add(:async_stale_job_timeout_seconds, type: :integer) config.add(:attribute_encryption_key, type: :string) config.add(:attribute_encryption_key_queue, type: :json) - config.add(:arcgis_api_root_url, type: :string) config.add(:arcgis_api_username, type: :string) config.add(:arcgis_api_password, type: :string) config.add(:arcgis_search_enabled, type: :boolean) config.add(:arcgis_mock_fallback, type: :boolean) + config.add(:arcgis_api_generate_token_url, type: :string) + config.add(:arcgis_api_suggest_url, type: :string) + config.add(:arcgis_api_find_address_candidates_url, type: :string) config.add(:aws_http_retry_limit, type: :integer) config.add(:aws_http_retry_max_delay, type: :integer) config.add(:aws_http_timeout, type: :integer) @@ -187,6 +189,7 @@ def self.build_store(config_map) config.add(:good_job_queues, type: :string) config.add(:good_job_queue_select_limit, type: :integer) config.add(:gpo_designated_receiver_pii, type: :json, options: { symbolize_names: true }) + config.add(:gpo_personal_key_after_otp, type: :boolean) config.add(:hide_phone_mfa_signup, type: :boolean) config.add(:hmac_fingerprinter_key, type: :string) config.add(:hmac_fingerprinter_key_queue, type: :json) @@ -342,7 +345,6 @@ def self.build_store(config_map) config.add(:recurring_jobs_disabled_names, type: :json) config.add(:redis_irs_attempt_api_url) config.add(:redis_throttle_url) - config.add(:redis_throttle_otp_rate_limiter_read_enabled, type: :boolean) config.add(:redis_url) config.add(:redis_pool_size, type: :integer) config.add(:redis_session_pool_size, type: :integer) diff --git a/lib/identity_cors.rb b/lib/identity_cors.rb new file mode 100644 index 00000000000..3286e78aba5 --- /dev/null +++ b/lib/identity_cors.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class IdentityCors + FEDERALIST_REGEX = %r{\Ahttps://federalist-[0-9a-f-]+\.pages\.cloud\.gov\z} + STATIC_SITE_ALLOWED_ORIGINS = [ + 'https://www.login.gov', + 'https://login.gov', + 'https://handbook.login.gov', + FEDERALIST_REGEX, + ].freeze + + def self.allowed_origins_static_sites + return STATIC_SITE_ALLOWED_ORIGINS unless Rails.env.development? || Rails.env.test? + allowed_origins = STATIC_SITE_ALLOWED_ORIGINS.dup + allowed_origins << %r{https?://localhost(:\d+)?\z} + allowed_origins << %r{https?://127\.0\.0\.1(:\d+)?\z} + + allowed_origins + end + + def self.allowed_redirect_uri?(source) + return false if source == "https://#{IdentityConfig.store.domain_name}" + + redirect_uris = Rails.cache.fetch( + 'all_service_provider_redirect_uris_cors', + expires_in: IdentityConfig.store.all_redirect_uris_cache_duration_minutes.minutes, + ) do + ServiceProvider.pluck(:redirect_uris).flatten.compact.map do |uri| + protocol, domain_path = uri.split('//', 2) + domain, _path = domain_path&.split('/', 2) + "#{protocol}//#{domain}" + end.uniq + end + + redirect_uris.any? { |uri| uri == source } + end +end diff --git a/spec/components/base_component_spec.rb b/spec/components/base_component_spec.rb index 1e980650884..32e9ddfc70d 100644 --- a/spec/components/base_component_spec.rb +++ b/spec/components/base_component_spec.rb @@ -23,7 +23,7 @@ def call context 'with sidecar script' do class ExampleComponentWithScript < BaseComponent def call - render(NestedExampleComponentWithScript.new) + '' end def self._sidecar_files(extensions) @@ -34,18 +34,68 @@ def self._sidecar_files(extensions) end end - class NestedExampleComponentWithScript < ExampleComponentWithScript + class ExampleComponentWithScriptRenderingOtherComponentWithScript < BaseComponent def call - '' + render(ExampleComponentWithScript.new) + end + + def self._sidecar_files(extensions) + if extensions.include?('js') + ['/components/example_component_with_script_rendering_other_component_with_script.js'] + else + super(extensions) + end + end + end + + class NestedExampleComponentWithScript < ExampleComponentWithScript + def self._sidecar_files(extensions) + if extensions.include?('js') + ['/components/nested_example_component_with_script.js'] + else + super(extensions) + end end end it 'adds script to class variable when rendered' do - expect(view_context).to receive(:enqueue_component_scripts).twice. - with('example_component_with_script_js', 'example_component_with_script_ts') + expect(view_context).to receive(:enqueue_component_scripts).with( + 'example_component_with_script_js', + 'example_component_with_script_ts', + ) render_inline(ExampleComponentWithScript.new) end + + it 'adds own and parent scripts to class variable when rendered' do + expect(view_context).to receive(:enqueue_component_scripts).with( + 'nested_example_component_with_script', + 'example_component_with_script_js', + 'example_component_with_script_ts', + ) + + render_inline(NestedExampleComponentWithScript.new) + end + + it 'adds own and scripts of any other component it renders' do + call = 0 + expect(view_context).to receive(:enqueue_component_scripts).twice do |*args| + call += 1 + case call + when 1 + expect(args).to eq [ + 'example_component_with_script_rendering_other_component_with_script', + ] + when 2 + expect(args).to eq [ + 'example_component_with_script_js', + 'example_component_with_script_ts', + ] + end + end + + render_inline(ExampleComponentWithScriptRenderingOtherComponentWithScript.new) + end end describe '#unique_id' do diff --git a/spec/components/phone_input_component_spec.rb b/spec/components/phone_input_component_spec.rb index 921db8291cf..7b3ec7ef717 100644 --- a/spec/components/phone_input_component_spec.rb +++ b/spec/components/phone_input_component_spec.rb @@ -6,7 +6,7 @@ let(:lookup_context) { ActionView::LookupContext.new(ActionController::Base.view_paths) } let(:view_context) { ActionView::Base.new(lookup_context, {}, controller) } let(:user) { build_stubbed(:user) } - let(:form_object) { NewPhoneForm.new(user) } + let(:form_object) { NewPhoneForm.new(user:) } let(:form_builder) do SimpleForm::FormBuilder.new(form_object.model_name.param_key, form_object, view_context, {}) end diff --git a/spec/controllers/idv/gpo_verify_controller_spec.rb b/spec/controllers/idv/gpo_verify_controller_spec.rb index e1f9b711508..5dabc273dea 100644 --- a/spec/controllers/idv/gpo_verify_controller_spec.rb +++ b/spec/controllers/idv/gpo_verify_controller_spec.rb @@ -128,6 +128,14 @@ expect(response).to redirect_to(sign_up_completed_url) end + it 'redirects to the personal key page if new gpo flow is enabled' do + allow(IdentityConfig.store).to receive(:gpo_personal_key_after_otp).and_return(true) + + action + + expect(response).to redirect_to(idv_personal_key_url) + end + it 'dispatches account verified alert' do expect(UserAlerts::AlertUserAboutAccountVerified).to receive(:call) diff --git a/spec/controllers/idv/personal_key_controller_spec.rb b/spec/controllers/idv/personal_key_controller_spec.rb index 4a26b0a066b..6d3bdd47bd5 100644 --- a/spec/controllers/idv/personal_key_controller_spec.rb +++ b/spec/controllers/idv/personal_key_controller_spec.rb @@ -125,6 +125,24 @@ def index get :show expect(flash[:success]).to eq t('idv.messages.confirm') end + + context 'user selected gpo verification' do + before do + subject.idv_session.address_verification_mechanism = 'gpo' + end + + it 'sets flash.now[:success]' do + get :show + expect(flash[:success]).to eq t('idv.messages.mail_sent') + end + + it 'does not show a flash in new gpo flow' do + allow(IdentityConfig.store).to receive(:gpo_personal_key_after_otp).and_return(true) + + get :show + expect(flash[:success]).to eq nil + end + end end describe '#update' do @@ -183,6 +201,19 @@ def index expect(response).to redirect_to idv_come_back_later_path end + context 'with gpo personal key after verification' do + it 'redirects to sign up completed_url for a sp' do + allow(IdentityConfig.store).to receive(:gpo_personal_key_after_otp). + and_return(true) + allow(subject).to receive(:pending_profile?).and_return(false) + subject.session[:sp] = { ial2: true } + + patch :update + + expect(response).to redirect_to sign_up_completed_url + end + end + context 'with in person profile' do before do ProofingComponent.create(user: user, document_check: Idp::Constants::Vendors::USPS) diff --git a/spec/controllers/idv/review_controller_spec.rb b/spec/controllers/idv/review_controller_spec.rb index 710ee00625b..c9d0b44f423 100644 --- a/spec/controllers/idv/review_controller_spec.rb +++ b/spec/controllers/idv/review_controller_spec.rb @@ -251,6 +251,32 @@ def show expect(flash.now[:success]).to be_nil end end + + context 'doc_auth_verify_info_controller_enabled is set to true' do + before do + allow(IdentityConfig.store).to receive(:doc_auth_verify_info_controller_enabled). + and_return(true) + end + + it 'redirects to the verify info controller if the user has not completed it' do + controller.idv_session.resolution_successful = nil + + get :new + + expect(response).to redirect_to(idv_verify_info_url) + end + + it 'redirects to the root if the user is not authenticated' do + allow(controller).to receive(:user_fully_authenticated?).and_return(false) + allow(controller).to receive(:user_session).and_call_original + allow(controller).to receive(:confirm_two_factor_authenticated).and_call_original + allow(controller).to receive(:current_user).and_call_original + + get :new + + expect(response).to redirect_to(root_url) + end + end end describe '#create' do @@ -638,6 +664,13 @@ def show expect(profile).to_not be_active end + + it 'redirects to come back later page' do + allow(IdentityConfig.store).to receive(:gpo_personal_key_after_otp).and_return(true) + put :create, params: { user: { password: ControllerHelper::VALID_PASSWORD } } + + expect(response).to redirect_to idv_come_back_later_url + end end end end diff --git a/spec/controllers/idv/verify_info_controller_spec.rb b/spec/controllers/idv/verify_info_controller_spec.rb index 5cc47051935..0cbdf490a5a 100644 --- a/spec/controllers/idv/verify_info_controller_spec.rb +++ b/spec/controllers/idv/verify_info_controller_spec.rb @@ -11,9 +11,10 @@ :flow_path => 'standard' } end + let(:user) { create(:user) } + before do allow(subject).to receive(:flow_session).and_return(flow_session) - user = build(:user, :with_phone, with: { phone: '+1 (415) 555-0130' }) stub_idv_steps_before_verify_step(user) end @@ -77,6 +78,14 @@ expect(@analytics).to have_received(:track_event).with(analytics_name, analytics_args) end + it 'updates DocAuthLog verify_view_count' do + doc_auth_log = DocAuthLog.create(user_id: user.id) + + expect { get :show }.to( + change { doc_auth_log.reload.verify_view_count }.from(0).to(1), + ) + end + context 'when the user has already verified their info' do it 'redirects to the review controller' do controller.idv_session.profile_confirmation = true @@ -145,6 +154,14 @@ ) end + it 'updates DocAuthLog verify_submit_count' do + doc_auth_log = DocAuthLog.create(user_id: user.id) + + expect { put :update }.to( + change { doc_auth_log.reload.verify_submit_count }.from(0).to(1), + ) + end + context 'when the user is ssn throttled' do before do Throttle.new( diff --git a/spec/controllers/users/phone_setup_controller_spec.rb b/spec/controllers/users/phone_setup_controller_spec.rb index 12385393b60..9e05824a949 100644 --- a/spec/controllers/users/phone_setup_controller_spec.rb +++ b/spec/controllers/users/phone_setup_controller_spec.rb @@ -23,7 +23,7 @@ expect(@analytics).to receive(:track_event). with('User Registration: phone setup visited', enabled_mfa_methods_count: 0) - expect(NewPhoneForm).to receive(:new).with(user, setup_voice_preference: false) + expect(NewPhoneForm).to receive(:new).with(user:, setup_voice_preference: false) get :index diff --git a/spec/controllers/users/verify_password_controller_spec.rb b/spec/controllers/users/verify_password_controller_spec.rb index 45da44b7327..34c1db19235 100644 --- a/spec/controllers/users/verify_password_controller_spec.rb +++ b/spec/controllers/users/verify_password_controller_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' describe Users::VerifyPasswordController do - let(:user) { create(:user, profiles: profiles, personal_key: personal_key) } + let(:key) { 'key' } let(:profiles) { [] } - let(:recovery_hash) { { personal_key: personal_key } } - let(:personal_key) { 'key' } + let(:recovery_hash) { { personal_key: key } } + let(:user) { create(:user, profiles: profiles, **recovery_hash) } before do stub_sign_in(user) @@ -22,9 +22,6 @@ context 'with password reset profile' do let(:profiles) { [create(:profile, :password_reset)] } - let(:response_ok) { FormResponse.new(success: true, errors: {}, extra: { personal_key: key }) } - let(:response_bad) { FormResponse.new(success: false, errors: {}) } - let(:key) { 'key' } context 'without personal key flag set' do describe '#new' do @@ -45,7 +42,7 @@ context 'with personal key flag set' do before do allow(subject.reactivate_account_session).to receive(:validated_personal_key?). - and_return(personal_key) + and_return(key) end describe '#new' do @@ -58,16 +55,30 @@ describe '#update' do let(:form) { instance_double(VerifyPasswordForm) } - let(:pii) { { dob: Time.zone.today } } + let(:user_params) { { user: { password: user.password } } } before do + stub_attempts_tracker + allow(@irs_attempts_api_tracker).to receive( + :logged_in_profile_change_reauthentication_submitted, + ) + allow(@irs_attempts_api_tracker).to receive(:idv_personal_key_generated) expect(controller).to receive(:verify_password_form).and_return(form) end context 'with valid password' do + let(:response_ok) { FormResponse.new(success: true, errors: {}, extra: recovery_hash) } + before do allow(form).to receive(:submit).and_return(response_ok) - put :update, params: { user: { password: user.password } } + put :update, params: user_params + end + + it 'tracks the appropriate attempts api events' do + expect(@irs_attempts_api_tracker).to have_received( + :logged_in_profile_change_reauthentication_submitted, + ).with({ success: true }) + expect(@irs_attempts_api_tracker).to have_received(:idv_personal_key_generated) end it 'redirects to the account page' do @@ -80,14 +91,26 @@ end context 'without valid password' do + let(:pii) { { dob: Time.zone.today } } + let(:response_bad) { FormResponse.new(success: false, errors: {}) } + render_views - it 'renders the new template' do + before do allow(form).to receive(:submit).and_return(response_bad) allow(controller).to receive(:decrypted_pii).and_return(pii) - put :update, params: { user: { password: user.password } } + put :update, params: user_params + end + it 'tracks the appropriate attempts api event' do + expect(@irs_attempts_api_tracker).to have_received( + :logged_in_profile_change_reauthentication_submitted, + ).with({ success: false }) + expect(@irs_attempts_api_tracker).not_to have_received(:idv_personal_key_generated) + end + + it 'renders the new template' do expect(response).to render_template(:new) end end diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 27521660167..ea9e9a5e475 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -91,7 +91,7 @@ 'IdV: doc auth image upload vendor submitted' => hash_including(success: true, flow_path: 'standard', attention_with_barcode: true, doc_auth_result: 'Attention'), 'IdV: verify in person troubleshooting option clicked' => { flow_path: 'standard' }, 'IdV: in person proofing location visited' => { flow_path: 'standard' }, - 'IdV: in person proofing location submitted' => { flow_path: 'standard', selected_location: 'BALTIMORE' }, + 'IdV: in person proofing location submitted' => { flow_path: 'standard', selected_location: '606 E JUNEAU AVE, MILWAUKEE, WI, 53202-9998' }, 'IdV: in person proofing prepare visited' => { flow_path: 'standard' }, 'IdV: in person proofing prepare submitted' => { flow_path: 'standard' }, 'IdV: in person proofing state_id visited' => { step: 'state_id', flow_path: 'standard', step_count: 1, analytics_id: 'In Person Proofing', irs_reproofing: false }, diff --git a/spec/features/idv/clearing_and_restarting_spec.rb b/spec/features/idv/clearing_and_restarting_spec.rb index 05e6d9d5603..e0ea19bdbc0 100644 --- a/spec/features/idv/clearing_and_restarting_spec.rb +++ b/spec/features/idv/clearing_and_restarting_spec.rb @@ -9,7 +9,7 @@ before do start_idv_from_sp complete_idv_steps_with_gpo_before_confirmation_step(user) - acknowledge_and_confirm_personal_key + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp end context 'before signing out' do diff --git a/spec/features/idv/doc_auth/send_link_step_spec.rb b/spec/features/idv/doc_auth/send_link_step_spec.rb index 3e557e1adde..eb3419f0903 100644 --- a/spec/features/idv/doc_auth/send_link_step_spec.rb +++ b/spec/features/idv/doc_auth/send_link_step_spec.rb @@ -18,6 +18,11 @@ let(:fake_analytics) { FakeAnalytics.new } let(:fake_attempts_tracker) { IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new } + it "defaults phone to user's 2fa phone numebr" do + field = page.find_field(t('two_factor_authentication.phone_label')) + expect(field.value).to eq('+1 202-555-1212') + end + it 'proceeds to the next page with valid info' do expect_any_instance_of(IrsAttemptsApi::Tracker).to receive(:track_event).with( :idv_phone_upload_link_sent, diff --git a/spec/features/idv/in_person_spec.rb b/spec/features/idv/in_person_spec.rb index 22ac2b005e6..021ab7ec7de 100644 --- a/spec/features/idv/in_person_spec.rb +++ b/spec/features/idv/in_person_spec.rb @@ -21,6 +21,7 @@ it 'allows the user to continue down the happy path', allow_browser_log: true do sign_in_and_2fa_user(user) begin_in_person_proofing(user) + search_for_post_office # location page bethesda_location = page.find_all('.location-collection-item')[1] @@ -100,7 +101,7 @@ expect(page).to have_content( t('in_person_proofing.body.barcode.deadline', deadline: deadline), ) - expect(page).to have_content('BETHESDA') + expect(page).to have_content('MILWAUKEE') expect(page).to have_content( "#{t('date.day_names')[6]}: #{t('in_person_proofing.body.barcode.retail_hours_closed')}", ) @@ -120,7 +121,8 @@ # location page expect_in_person_step_indicator_current_step(t('step_indicator.flows.idv.find_a_post_office')) - expect(page).to have_content(t('in_person_proofing.headings.location')) + expect(page).to have_content(t('in_person_proofing.headings.po_search.location')) + search_for_post_office bethesda_location = page.find_all('.location-collection-item')[1] bethesda_location.click_button(t('in_person_proofing.body.location.location_button')) @@ -222,7 +224,7 @@ expect(page).to have_content(t('in_person_proofing.headings.barcode')) expect(page).to have_content(Idv::InPerson::EnrollmentCodeFormatter.format(enrollment_code)) expect(page).to have_content(t('in_person_proofing.body.barcode.deadline', deadline: deadline)) - expect(page).to have_content('BETHESDA') + expect(page).to have_content('MILWAUKEE') expect(page).to have_content( "#{t('date.day_names')[6]}: #{t('in_person_proofing.body.barcode.retail_hours_closed')}", ) @@ -251,7 +253,8 @@ begin_in_person_proofing # location page - expect(page).to have_content(t('in_person_proofing.headings.location')) + expect(page).to have_content(t('in_person_proofing.headings.po_search.location')) + search_for_post_office bethesda_location = page.find_all('.location-collection-item')[1] bethesda_location.click_button(t('in_person_proofing.body.location.location_button')) @@ -259,7 +262,9 @@ expect(page).to have_content(t('in_person_proofing.headings.prepare')) click_button t('forms.buttons.back') - expect(page).to have_content(t('in_person_proofing.headings.location')) + expect(page).to have_content(t('in_person_proofing.headings.po_search.location')) + + search_for_post_office expect(page).to have_css('.location-collection-item', wait: 10) click_button t('forms.buttons.back') @@ -329,7 +334,7 @@ attach_and_submit_images click_link t('in_person_proofing.body.cta.button') - + search_for_post_office bethesda_location = page.find_all('.location-collection-item')[1] bethesda_location.click_button(t('in_person_proofing.body.location.location_button')) @@ -349,7 +354,7 @@ complete_review_step(user) acknowledge_and_confirm_personal_key - expect(page).to have_content('BETHESDA') + expect(page).to have_content('MILWAUKEE') end end end @@ -371,7 +376,7 @@ expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) complete_review_step expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.secure_account')) - acknowledge_and_confirm_personal_key + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp expect_in_person_gpo_step_indicator_current_step(t('step_indicator.flows.idv.get_a_letter')) expect(page).to have_content(t('idv.titles.come_back_later')) @@ -398,7 +403,7 @@ click_on t('idv.troubleshooting.options.verify_by_mail') click_on t('idv.buttons.mail.send') complete_review_step - acknowledge_and_confirm_personal_key + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp click_idv_continue click_on t('account.index.verification.reactivate_button') click_on t('idv.messages.clear_and_start_over') diff --git a/spec/features/idv/steps/gpo_otp_verification_step_spec.rb b/spec/features/idv/steps/gpo_otp_verification_step_spec.rb index 9c8d2ff00b8..12d4a325dbf 100644 --- a/spec/features/idv/steps/gpo_otp_verification_step_spec.rb +++ b/spec/features/idv/steps/gpo_otp_verification_step_spec.rb @@ -72,6 +72,30 @@ end end + context 'with gpo personal key after verification' do + it 'shows the user a personal key after verification' do + allow(IdentityConfig.store).to receive(:gpo_personal_key_after_otp). + and_return(true) + sign_in_live_with_2fa(user) + + expect(current_path).to eq idv_gpo_verify_path + expect(page).to have_content t('idv.messages.gpo.resend') + + gpo_confirmation_code + fill_in t('forms.verify_profile.name'), with: otp + click_button t('forms.verify_profile.submit') + + profile.reload + + expect(page).to have_current_path(idv_personal_key_path) + + expect(profile.active).to be(true) + expect(profile.deactivation_reason).to be(nil) + + expect(user.events.account_verified.size).to eq 1 + end + end + context 'with gpo feature disabled' do before do allow(IdentityConfig.store).to receive(:enable_gpo_verification?).and_return(true) diff --git a/spec/features/idv/steps/gpo_step_spec.rb b/spec/features/idv/steps/gpo_step_spec.rb index 4ea9c2d160e..78c54d4b6fd 100644 --- a/spec/features/idv/steps/gpo_step_spec.rb +++ b/spec/features/idv/steps/gpo_step_spec.rb @@ -50,7 +50,7 @@ def complete_idv_and_return_to_gpo_step click_on t('idv.buttons.mail.send') fill_in 'Password', with: user_password click_continue - acknowledge_and_confirm_personal_key + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp visit root_path click_on t('idv.buttons.cancel') first(:link, t('links.sign_out')).click diff --git a/spec/features/idv/steps/review_step_spec.rb b/spec/features/idv/steps/review_step_spec.rb index 3b90bd99784..75cd6674a9f 100644 --- a/spec/features/idv/steps/review_step_spec.rb +++ b/spec/features/idv/steps/review_step_spec.rb @@ -81,6 +81,15 @@ expect(profile.active?).to eq false end + it 'sends you to the come_back_later page after review step' do + allow(IdentityConfig.store).to receive(:gpo_personal_key_after_otp).and_return(true) + fill_in 'Password', with: user_password + click_continue + + expect(page).to have_content(t('idv.titles.come_back_later')) + expect(current_path).to eq idv_come_back_later_path + end + context 'with an sp' do it 'sends a letter with a reference the sp' do fill_in 'Password', with: user_password diff --git a/spec/features/irs_attempts_api/event_tracking_spec.rb b/spec/features/irs_attempts_api/event_tracking_spec.rb index 662da28e382..94ff714dcf4 100644 --- a/spec/features/irs_attempts_api/event_tracking_spec.rb +++ b/spec/features/irs_attempts_api/event_tracking_spec.rb @@ -126,4 +126,53 @@ expect(events.count).to eq(0) end end + + scenario 'reset password from an IRS with new browser session and request_id tracks events' do + freeze_time do + user = create(:user, :signed_up) + visit_idp_from_ial1_oidc_sp( + client_id: service_provider.issuer, + irs_attempts_api_session_id: 'test-session-id', + ) + + visit root_path + fill_forgot_password_form(user) + set_new_browser_session + click_reset_password_link_from_email + fill_reset_password_form + + events = irs_attempts_api_tracked_events(timestamp: Time.zone.now) + expected_event_types = %w[forgot-password-email-sent forgot-password-email-confirmed + forgot-password-new-password-submitted] + received_event_types = events.map(&:event_type) + + expect(events.count).to eq received_event_types.count + expect(received_event_types).to match_array(expected_event_types) + end + end + + # rubocop:disable Layout/LineLength + scenario 'reset password from an IRS with new browser session and without request_id does not track event' do + freeze_time do + user = create(:user, :signed_up) + visit_idp_from_ial1_oidc_sp( + client_id: service_provider.issuer, + irs_attempts_api_session_id: 'test-session-id', + ) + + visit root_path + fill_forgot_password_form(user) + set_new_browser_session + click_reset_password_link_from_email + fill_reset_password_form(without_request_id: true) + + events = irs_attempts_api_tracked_events(timestamp: Time.zone.now) + expected_event_types = %w[forgot-password-email-sent forgot-password-email-confirmed] + received_event_types = events.map(&:event_type) + + expect(events.count).to eq received_event_types.count + expect(received_event_types).to match_array(expected_event_types) + end + end + # rubocop:enable Layout/LineLength end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 08890f1ed4c..b29b985bfb8 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -962,7 +962,6 @@ def oidc_end_client_secret_jwt(prompt: nil, user: nil, redirs_to: nil) expect(decoded_id_token[:email]).to eq(user.email) expect(decoded_id_token[:given_name]).to eq('John') expect(decoded_id_token[:social_security_number]).to eq('111223333') - expect(decoded_id_token[:ial]).to eq(2) access_token = token_response[:access_token] expect(access_token).to be_present diff --git a/spec/features/saml/ial2_sso_spec.rb b/spec/features/saml/ial2_sso_spec.rb index 78c1f714f6a..608bca8c411 100644 --- a/spec/features/saml/ial2_sso_spec.rb +++ b/spec/features/saml/ial2_sso_spec.rb @@ -29,8 +29,8 @@ def perform_id_verification_with_gpo_without_confirming_code(user) click_on t('idv.buttons.mail.send') fill_in t('idv.form.password'), with: user.password click_continue - acknowledge_and_confirm_personal_key - click_link t('idv.buttons.continue_plain') + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp + click_link t('idv.cancel.actions.exit', app_name: APP_NAME) end def expected_gpo_return_to_sp_url @@ -44,8 +44,8 @@ def update_mailing_address click_on t('idv.buttons.mail.resend') fill_in t('idv.form.password'), with: user.password click_continue - acknowledge_and_confirm_personal_key - click_link t('idv.buttons.continue_plain') + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp + click_link t('idv.cancel.actions.exit', app_name: APP_NAME) end def sign_out_user diff --git a/spec/forms/new_phone_form_spec.rb b/spec/forms/new_phone_form_spec.rb index 01105092a8c..a9cb258bfad 100644 --- a/spec/forms/new_phone_form_spec.rb +++ b/spec/forms/new_phone_form_spec.rb @@ -11,14 +11,31 @@ otp_delivery_preference: 'sms', } end - subject(:form) { NewPhoneForm.new(user) } + subject(:form) { NewPhoneForm.new(user:) } it_behaves_like 'a phone form' describe 'phone validation' do - it do - should validate_inclusion_of(:international_code). - in_array(PhoneNumberCapabilities::INTERNATIONAL_CODES.keys) + context 'with valid international code' do + it 'is valid' do + PhoneNumberCapabilities::INTERNATIONAL_CODES.keys.each do |code| + result = subject.submit(params.clone.merge(international_code: code)) + + expect(result.to_h[:error_details]).not_to match( + hash_including(international_code: include(:inclusion)), + ) + end + end + end + + context 'with invalid international code' do + it 'is invalid' do + result = subject.submit(params.clone.merge(international_code: 'INVALID')) + + expect(result.to_h[:error_details]).to match( + hash_including(international_code: include(:inclusion)), + ) + end end it 'validates that the number matches the requested international code' do @@ -52,7 +69,7 @@ it 'does not update the user phone attribute' do user = create(:user) - subject = NewPhoneForm.new(user) + subject = NewPhoneForm.new(user:) params[:phone] = '+1 504 444 1643' subject.submit(params) @@ -375,7 +392,7 @@ end context 'with setup_voice_preference present' do - subject(:form) { NewPhoneForm.new(user, setup_voice_preference: true) } + subject(:form) { NewPhoneForm.new(user:, setup_voice_preference: true) } it 'is true' do expect(form.delivery_preference_voice?).to eq(true) diff --git a/spec/jobs/get_usps_proofing_results_job_spec.rb b/spec/jobs/get_usps_proofing_results_job_spec.rb index cca9fd1e99c..c2d0d81c951 100644 --- a/spec/jobs/get_usps_proofing_results_job_spec.rb +++ b/spec/jobs/get_usps_proofing_results_job_spec.rb @@ -24,6 +24,7 @@ minutes_since_last_status_check: 15.0, minutes_since_last_status_update: 2.days.in_minutes, minutes_to_completion: 3.days.in_minutes, + minutes_since_established: 3.days.in_minutes, passed: passed, primary_id_type: response['primaryIdType'], proofing_city: response['proofingCity'], @@ -92,6 +93,7 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Exception raised', hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), enrollment_code: pending_enrollment.enrollment_code, enrollment_id: pending_enrollment.id, exception_class: exception_class, @@ -127,6 +129,7 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Exception raised', hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), response_present: false, exception_class: error_type.to_s, ), @@ -136,6 +139,7 @@ RSpec.describe GetUspsProofingResultsJob do include UspsIppHelper + include ApproximatingHelper let(:reprocess_delay_minutes) { 2.0 } let(:request_delay_ms) { 0 } @@ -151,6 +155,9 @@ to receive(:get_usps_proofing_results_job_request_delay_milliseconds). and_return(request_delay_ms) stub_request_token + if respond_to?(:pending_enrollment) + pending_enrollment.update(enrollment_established_at: 3.days.ago) + end end describe '#perform' do @@ -287,7 +294,10 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Exception raised', - hash_including(exception_message: error_message), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + exception_message: error_message, + ), ) end end @@ -442,7 +452,10 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', - hash_including(reason: 'Successful status update'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + reason: 'Successful status update', + ), ) expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Success or failure email initiated', @@ -473,6 +486,9 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + ), ) expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Success or failure email initiated', @@ -503,6 +519,9 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + ), ) expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Success or failure email initiated', @@ -533,7 +552,10 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', - hash_including(reason: 'Unsupported ID type'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + reason: 'Unsupported ID type', + ), ) end end @@ -556,7 +578,10 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', - hash_including(reason: 'Enrollment has expired'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + reason: 'Enrollment has expired', + ), ) end end @@ -582,7 +607,10 @@ expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', - hash_including(reason: 'Enrollment has expired'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + reason: 'Enrollment has expired', + ), ) expect(job_analytics).to have_logged_event( @@ -609,7 +637,10 @@ expect(pending_enrollment.reload.cancelled?).to be_truthy expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Unexpected response received', - hash_including(reason: 'Invalid enrollment code'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + reason: 'Invalid enrollment code', + ), ) end end @@ -630,7 +661,10 @@ expect(pending_enrollment.reload.cancelled?).to be_truthy expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Unexpected response received', - hash_including(reason: 'Invalid applicant unique id'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + reason: 'Invalid applicant unique id', + ), ) end end @@ -664,7 +698,10 @@ expect(pending_enrollment.pending?).to be_truthy expect(job_analytics).to have_logged_event( 'GetUspsProofingResultsJob: Exception raised', - hash_including(status: 'Not supported'), + hash_including( + minutes_since_established: range_approximating(3.days.in_minutes, vary_right: 5), + status: 'Not supported', + ), ) end end @@ -758,12 +795,15 @@ it 'updates the timestamp but does not update the status or log a message' do freeze_time do pending_enrollment.update( + enrollment_established_at: Time.zone.now - 3.days, status_check_attempted_at: Time.zone.now - 1.day, status_updated_at: Time.zone.now - 1.day, ) + job.perform(Time.zone.now) pending_enrollment.reload + expect(pending_enrollment.enrollment_established_at).to eq(Time.zone.now - 3.days) expect(pending_enrollment.status_updated_at).to eq(Time.zone.now - 1.day) expect(pending_enrollment.status_check_attempted_at).to eq(Time.zone.now) end @@ -774,6 +814,11 @@ expect(job_analytics).not_to have_logged_event( 'GetUspsProofingResultsJob: Enrollment status updated', ) + + expect(job_analytics).to have_logged_event( + 'GetUspsProofingResultsJob: Enrollment incomplete', + hash_including(minutes_since_established: 3.days.in_minutes), + ) end end diff --git a/spec/jobs/reports/daily_dropoffs_report_spec.rb b/spec/jobs/reports/daily_dropoffs_report_spec.rb index 357c94928e2..12a488b2632 100644 --- a/spec/jobs/reports/daily_dropoffs_report_spec.rb +++ b/spec/jobs/reports/daily_dropoffs_report_spec.rb @@ -96,7 +96,7 @@ verify_view_at: timestamp, verify_submit_count: 1, verify_phone_view_at: timestamp, - verify_phone_submit_at: timestamp, + verify_phone_submit_count: 1, encrypt_view_at: timestamp, verified_view_at: timestamp, ) diff --git a/spec/lib/identity_cors_spec.rb b/spec/lib/identity_cors_spec.rb new file mode 100644 index 00000000000..5c4e1e5360f --- /dev/null +++ b/spec/lib/identity_cors_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe IdentityCors do + before { Rails.cache.clear } + after { Rails.cache.clear } + + describe '.allowed_redirect_uri?' do + it 'returns true if the origin is a redirect_uri in a service provider' do + create(:service_provider, redirect_uris: ['http://fake.example.com/authentication/result']) + IdentityCors.allowed_redirect_uri?('http://fake.example.com') + end + + it 'returns false if the origin is not a redirect_uri in a service provider' do + IdentityCors.allowed_redirect_uri?('http://localhost:9999999999') + end + end +end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 0e1fc3d60ff..f15c07b2210 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -28,7 +28,7 @@ def signup_with_your_email def reset_password_instructions UserMailer.with(user: user, email_address: email_address_record).reset_password_instructions( - token: SecureRandom.hex, + token: SecureRandom.hex, request_id: SecureRandom.hex, ) end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index c31c0471968..39f58c6e340 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -561,7 +561,7 @@ def expect_email_body_to_have_help_and_contact_links create( :in_person_enrollment, :pending, - selected_location_details: { name: 'FRIENDSHIP' }, + selected_location_details: { name: 'FRIENDSHIP', phone: '202-842-3332' }, status_updated_at: Time.zone.now - 2.hours, ) end @@ -572,6 +572,12 @@ def expect_email_body_to_have_help_and_contact_links ) end + it 'renders the phone number' do + expect(mail.html_part.body).to have_content( + '202-842-3332', + ) + end + it_behaves_like 'a system email' it_behaves_like 'an email that respects user email locale preference' end diff --git a/spec/models/otp_requests_tracker_spec.rb b/spec/models/otp_requests_tracker_spec.rb deleted file mode 100644 index 75230f999e0..00000000000 --- a/spec/models/otp_requests_tracker_spec.rb +++ /dev/null @@ -1,63 +0,0 @@ -require 'rails_helper' - -describe OtpRequestsTracker do - let(:phone) { '+1 703 555 1212' } - let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(phone) } - - describe '.find_or_create_with_phone_and_confirmed' do - context 'match found' do - it 'returns the existing record and does not change it' do - OtpRequestsTracker.create( - phone_fingerprint: phone_fingerprint, - phone_confirmed: true, - otp_send_count: 3, - otp_last_sent_at: Time.zone.now - 1.hour, - ) - - existing = OtpRequestsTracker.where(phone_fingerprint: phone_fingerprint).first - - expect { OtpRequestsTracker.find_or_create_with_phone_and_confirmed(phone, true) }. - to_not change(OtpRequestsTracker, :count) - expect { OtpRequestsTracker.find_or_create_with_phone_and_confirmed(phone, true) }. - to_not change(existing, :otp_send_count) - expect { OtpRequestsTracker.find_or_create_with_phone_and_confirmed(phone, true) }. - to_not change(existing, :otp_last_sent_at) - end - end - - context 'match not found' do - it 'creates new record with otp_send_count = 0' do - expect { OtpRequestsTracker.find_or_create_with_phone_and_confirmed(phone, true) }. - to change(OtpRequestsTracker, :count).by(1) - - existing = OtpRequestsTracker.where(phone_fingerprint: phone_fingerprint).first - - expect(existing.otp_send_count).to eq 0 - end - end - end - - describe '.atomic_increment' do - it 'updates otp_last_sent_at' do - old_ort = OtpRequestsTracker.create( - phone_fingerprint: phone_fingerprint, - otp_send_count: 3, - phone_confirmed: true, - otp_last_sent_at: Time.zone.now - 1.hour, - ) - new_ort = OtpRequestsTracker.atomic_increment(old_ort.id) - expect(new_ort.otp_last_sent_at).to be > old_ort.otp_last_sent_at - end - - it 'increments the otp_send_count' do - old_ort = OtpRequestsTracker.create( - phone_fingerprint: phone_fingerprint, - otp_send_count: 3, - phone_confirmed: true, - otp_last_sent_at: Time.zone.now, - ) - new_ort = OtpRequestsTracker.atomic_increment(old_ort.id) - expect(new_ort.otp_send_count - 1).to eq(old_ort.otp_send_count) - end - end -end diff --git a/spec/requests/api_cors_spec.rb b/spec/requests/api_cors_spec.rb index 3fa743e82e2..9b2c10c1134 100644 --- a/spec/requests/api_cors_spec.rb +++ b/spec/requests/api_cors_spec.rb @@ -36,7 +36,7 @@ end context 'origin is federalist preview' do - let(:http_origin) { 'https://federalist-abcdef.app.cloud.gov' } + let(:http_origin) { 'https://federalist-abcdef.pages.cloud.gov' } it 'allows origin' do aggregate_failures do diff --git a/spec/requests/openid_connect_cors_spec.rb b/spec/requests/openid_connect_cors_spec.rb index 24e355c054f..743c59f0a16 100644 --- a/spec/requests/openid_connect_cors_spec.rb +++ b/spec/requests/openid_connect_cors_spec.rb @@ -164,7 +164,7 @@ describe 'domain name as the origin' do it 'leaves the Access-Control-Allow-Origin header blank' do get openid_connect_configuration_path, - headers: { 'HTTP_ORIGIN' => IdentityConfig.store.domain_name.dup } + headers: { 'HTTP_ORIGIN' => "https://#{IdentityConfig.store.domain_name}" } aggregate_failures do expect(response).to be_ok diff --git a/spec/services/encryption/aes_cipher_spec.rb b/spec/services/encryption/aes_cipher_spec.rb index 1b9ca5cd330..59eb8d17063 100644 --- a/spec/services/encryption/aes_cipher_spec.rb +++ b/spec/services/encryption/aes_cipher_spec.rb @@ -36,7 +36,7 @@ cipher = subject.class.encryption_cipher expect(cipher).to be_kind_of(OpenSSL::Cipher) - expect(cipher.name).to eq 'id-aes256-GCM' + expect(cipher.name).to eq OpenSSL::Cipher.new('aes-256-gcm').name end end end diff --git a/spec/services/frontend_logger_spec.rb b/spec/services/frontend_logger_spec.rb index 0ee377968a6..c9344fa129e 100644 --- a/spec/services/frontend_logger_spec.rb +++ b/spec/services/frontend_logger_spec.rb @@ -7,11 +7,13 @@ def example_method_handler(ok:, **rest) end end - class ExampleAnalytics < FakeAnalytics - include ExampleAnalyticsEvents + let(:analytics_class) do + Class.new(FakeAnalytics) do + include ExampleAnalyticsEvents + end end + let(:analytics) { analytics_class.new } - let(:analytics) { ExampleAnalytics.new } let(:event_map) do { 'method' => ExampleAnalyticsEvents.instance_method(:example_method_handler), diff --git a/spec/services/idv/analytics_events_enhancer_spec.rb b/spec/services/idv/analytics_events_enhancer_spec.rb index 61cff7a8831..af3eda5aaac 100644 --- a/spec/services/idv/analytics_events_enhancer_spec.rb +++ b/spec/services/idv/analytics_events_enhancer_spec.rb @@ -1,23 +1,24 @@ require 'rails_helper' describe Idv::AnalyticsEventsEnhancer do - class ExampleAnalytics - include AnalyticsEvents - prepend Idv::AnalyticsEventsEnhancer + let(:user) { build(:user) } + let(:analytics_class) do + Class.new(FakeAnalytics) do + include AnalyticsEvents + prepend Idv::AnalyticsEventsEnhancer - def idv_final(**kwargs) - @called_kwargs = kwargs - end + def idv_final(**kwargs) + @called_kwargs = kwargs + end - attr_reader :user, :called_kwargs + attr_reader :user, :called_kwargs - def initialize(user:) - @user = user + def initialize(user:) + @user = user + end end end - - let(:user) { build(:user) } - let(:analytics) { ExampleAnalytics.new(user: user) } + let(:analytics) { analytics_class.new(user: user) } it 'includes decorated methods' do expect(analytics.methods).to include(*described_class::DECORATED_METHODS) diff --git a/spec/services/idv/steps/send_link_step_spec.rb b/spec/services/idv/steps/send_link_step_spec.rb index 1ac97d58339..dcf7b0bbed7 100644 --- a/spec/services/idv/steps/send_link_step_spec.rb +++ b/spec/services/idv/steps/send_link_step_spec.rb @@ -68,6 +68,16 @@ and_return(irs_attempts_api_tracker) end + describe '#extra_view_variables' do + it 'includes form' do + expect(step.extra_view_variables).to include( + { + idv_phone_form: be_an_instance_of(Idv::PhoneForm), + }, + ) + end + end + describe 'the return value from #call' do let(:response) { step.call } diff --git a/spec/services/marketing_site_spec.rb b/spec/services/marketing_site_spec.rb index b77196acfc0..d37ad764717 100644 --- a/spec/services/marketing_site_spec.rb +++ b/spec/services/marketing_site_spec.rb @@ -128,6 +128,7 @@ describe '.help_center_article_url' do let(:category) {} let(:article) {} + let(:anchor) {} let(:result) { MarketingSite.help_center_article_url(category: category, article: article) } context 'with invalid article' do @@ -149,6 +150,21 @@ ) end end + + context 'with anchor' do + let(:category) { 'verify-your-identity' } + let(:article) { 'accepted-state-issued-identification' } + let(:anchor) { 'test-anchor-url' } + let(:result) do + MarketingSite.help_center_article_url(category: category, article: article, anchor: anchor) + end + + it 'returns article URL' do + expect(result).to eq( + 'https://www.login.gov/help/verify-your-identity/accepted-state-issued-identification/#test-anchor-url', + ) + end + end end describe '.valid_help_center_article?' do diff --git a/spec/services/otp_rate_limiter_spec.rb b/spec/services/otp_rate_limiter_spec.rb index d8f390c6750..88c2ba2f0f4 100644 --- a/spec/services/otp_rate_limiter_spec.rb +++ b/spec/services/otp_rate_limiter_spec.rb @@ -10,9 +10,6 @@ OtpRateLimiter.new(phone: phone, user: current_user, phone_confirmed: true) end let(:phone_fingerprint) { Pii::Fingerprinter.fingerprint(phone) } - let(:rate_limited_phone) do - OtpRequestsTracker.find_by(phone_fingerprint: phone_fingerprint, phone_confirmed: false) - end describe '#exceeded_otp_send_limit?' do it 'is false by default' do @@ -65,17 +62,11 @@ otp_rate_limiter.increment expect { otp_rate_limiter.increment }. - to change { rate_limited_phone.reload.otp_send_count }.from(1).to(2) + to change { otp_rate_limiter.throttle.attempts }.from(1).to(2) end end describe '#lock_out_user' do - before do - otp_rate_limiter.increment - rate_limited_phone.otp_last_sent_at = 5.minutes.ago - rate_limited_phone.otp_send_count = 0 - end - it 'sets the second_factor_locked_at' do expect(current_user.second_factor_locked_at).to be_nil diff --git a/spec/services/usps_in_person_proofing/proofer_spec.rb b/spec/services/usps_in_person_proofing/proofer_spec.rb index c19e7727161..bca90d0a4dd 100644 --- a/spec/services/usps_in_person_proofing/proofer_spec.rb +++ b/spec/services/usps_in_person_proofing/proofer_spec.rb @@ -106,21 +106,37 @@ def check_facility(facility) end describe '#request_facilities' do - it 'returns facilities' do + before do stub_request_token - stub_request_facilities - location = double( + end + let(:location) do + double( 'Location', address: Faker::Address.street_address, city: Faker::Address.city, state: Faker::Address.state_abbr, zip_code: Faker::Address.zip_code, ) + end + it 'returns facilities' do + stub_request_facilities facilities = subject.request_facilities(location) check_facility(facilities[0]) end + + it 'does not return duplicates' do + stub_request_facilities_with_duplicates + facilities = subject.request_facilities(location) + + expect(facilities.length).to eq(9) + expect( + facilities.count do |post_office| + post_office.address == '3775 INDUSTRIAL BLVD' + end, + ).to eq(1) + end end describe '#request_pilot_facilities' do diff --git a/spec/support/approximating_helper.rb b/spec/support/approximating_helper.rb new file mode 100644 index 00000000000..a3e7e338d8f --- /dev/null +++ b/spec/support/approximating_helper.rb @@ -0,0 +1,10 @@ +module ApproximatingHelper + def range_approximating(size, vary_left: nil, vary_right: nil) + left_size = size + left_size += vary_left unless vary_left.nil? + right_size = size + right_size += vary_right unless vary_right.nil? + + left_size..right_size + end +end diff --git a/spec/support/features/idv_helper.rb b/spec/support/features/idv_helper.rb index e1d1b47f25e..13cf08e2199 100644 --- a/spec/support/features/idv_helper.rb +++ b/spec/support/features/idv_helper.rb @@ -39,6 +39,10 @@ def click_idv_continue click_spinner_button_and_wait t('forms.buttons.continue') end + def click_idv_exit + click_spinner_button_and_wait t('idv.cancel.actions.exit', app_name: APP_NAME) + end + def click_idv_send_security_code click_spinner_button_and_wait t('forms.buttons.send_one_time_code') end diff --git a/spec/support/features/in_person_helper.rb b/spec/support/features/in_person_helper.rb index bccb12b833e..dd5ec2eb272 100644 --- a/spec/support/features/in_person_helper.rb +++ b/spec/support/features/in_person_helper.rb @@ -46,9 +46,16 @@ def begin_in_person_proofing(_user = nil) click_link t('in_person_proofing.body.cta.button') end - def complete_location_step(_user = nil) + def search_for_post_office + fill_in t('in_person_proofing.body.location.po_search.address_search_label'), + with: GOOD_ADDRESS1 + click_button(t('in_person_proofing.body.location.po_search.search_button')) # Wait for page to load before selecting location - expect(page).to have_css('.location-collection-item') + expect(page).to have_css('.location-collection-item', wait: 10) + end + + def complete_location_step(_user = nil) + search_for_post_office first('.location-collection-item'). click_button(t('in_person_proofing.body.location.location_button')) end diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb index e7e2df844a3..b9c21c962aa 100644 --- a/spec/support/features/session_helper.rb +++ b/spec/support/features/session_helper.rb @@ -657,5 +657,37 @@ def set_new_browser_session # For when we want to login from a new browser to avoid the default 'remember device' behavior Capybara.reset_session! end + + def fill_forgot_password_form(user) + click_link t('links.passwords.forgot') + fill_in t('account.index.email'), with: user.email + click_button t('forms.buttons.continue') + + expect(current_path).to eq forgot_password_path + end + + def click_reset_password_link_from_email + expect(last_email.subject).to eq t('user_mailer.reset_password_instructions.subject') + expect(last_email.html_part.body).to include MarketingSite.help_url + expect(last_email.html_part.body).to have_content( + t( + 'user_mailer.reset_password_instructions.footer', + expires: (Devise.reset_password_within / 3600), + ), + ) + open_last_email + click_email_link_matching(/reset_password_token/) + + expect(page.html).not_to include(t('notices.dap_participation')) + expect(current_path).to eq edit_user_password_path + end + + def fill_reset_password_form(without_request_id: nil) + fill_in t('forms.passwords.edit.labels.password'), with: 'newVal!dPassw0rd' + find_field('request_id', type: :hidden).set(nil) if without_request_id + click_button t('forms.passwords.edit.buttons.submit') + + expect(current_path).to eq new_user_session_path + end end end diff --git a/spec/support/idv_examples/clearing_and_restarting.rb b/spec/support/idv_examples/clearing_and_restarting.rb index bfc41de3039..8da95bfe913 100644 --- a/spec/support/idv_examples/clearing_and_restarting.rb +++ b/spec/support/idv_examples/clearing_and_restarting.rb @@ -27,11 +27,11 @@ end fill_in 'Password', with: user.password click_idv_continue - acknowledge_and_confirm_personal_key + acknowledge_and_confirm_personal_key unless IdentityConfig.store.gpo_personal_key_after_otp gpo_confirmation = GpoConfirmation.order(created_at: :desc).first - expect(page).to have_content(t('idv.messages.come_back_later', app_name: APP_NAME)) + expect(page).to have_content(t('idv.titles.come_back_later')) expect(page).to have_current_path(idv_come_back_later_path) expect(user.reload.decorate.identity_verified?).to eq(false) expect(user.pending_profile?).to eq(true) diff --git a/spec/support/usps_ipp_helper.rb b/spec/support/usps_ipp_helper.rb index 24d1ea9461e..5259b7e1487 100644 --- a/spec/support/usps_ipp_helper.rb +++ b/spec/support/usps_ipp_helper.rb @@ -15,6 +15,14 @@ def stub_request_facilities ) end + def stub_request_facilities_with_duplicates + stub_request(:post, %r{/ivs-ippaas-api/IPPRest/resources/rest/getIppFacilityList}).to_return( + status: 200, + body: UspsInPersonProofing::Mock::Fixtures.request_facilities_response_with_duplicates, + headers: { 'content-type' => 'application/json' }, + ) + end + def stub_request_enroll stub_request(:post, %r{/ivs-ippaas-api/IPPRest/resources/rest/optInIPPApplicant}).to_return( status: 200, diff --git a/spec/views/idv/come_back_later/show.html.erb_spec.rb b/spec/views/idv/come_back_later/show.html.erb_spec.rb index 83004c5d39f..6d449dab733 100644 --- a/spec/views/idv/come_back_later/show.html.erb_spec.rb +++ b/spec/views/idv/come_back_later/show.html.erb_spec.rb @@ -15,7 +15,7 @@ it 'renders a return to SP button' do render expect(rendered).to have_link( - t('idv.buttons.continue_plain'), + t('idv.cancel.actions.exit', app_name: APP_NAME), href: return_to_sp_cancel_path(location: :come_back_later), ) end @@ -39,7 +39,7 @@ it 'renders a return to account button' do render expect(rendered).to have_link( - t('idv.buttons.continue_plain', sp: sp_name), + t('idv.buttons.continue_plain'), href: account_path, ) end diff --git a/spec/views/phone_setup/index.html.erb_spec.rb b/spec/views/phone_setup/index.html.erb_spec.rb index 7f1f223a877..1e7f5d1a7a9 100644 --- a/spec/views/phone_setup/index.html.erb_spec.rb +++ b/spec/views/phone_setup/index.html.erb_spec.rb @@ -6,7 +6,7 @@ allow(view).to receive(:current_user).and_return(user) - @new_phone_form = NewPhoneForm.new(user) + @new_phone_form = NewPhoneForm.new(user:) @presenter = SetupPresenter.new( current_user: user, diff --git a/spec/views/two_factor_authentication/otp_verification/show.html.erb_spec.rb b/spec/views/two_factor_authentication/otp_verification/show.html.erb_spec.rb index 7237bce56da..561ac9b87d0 100644 --- a/spec/views/two_factor_authentication/otp_verification/show.html.erb_spec.rb +++ b/spec/views/two_factor_authentication/otp_verification/show.html.erb_spec.rb @@ -277,5 +277,37 @@ ) end end + + context 'troubleshooting options content' do + context 'when phone is unconfirmed' do + it 'has option to change phone number' do + data = presenter_data.merge(unconfirmed_phone: true) + + @presenter = TwoFactorAuthCode::PhoneDeliveryPresenter.new( + data: data, + view: view, + service_provider: nil, + ) + + render + + expect(rendered).to have_link( + t('two_factor_authentication.phone_verification.troubleshooting.change_number'), + href: phone_setup_path, + ) + end + end + + context 'when phone is confirmed' do + it 'has option to select different authentication method' do + render + + expect(rendered).to have_link( + t('two_factor_authentication.login_options_link_text'), + href: login_two_factor_options_path, + ) + end + end + end end end diff --git a/spec/views/users/phones/add.html.erb_spec.rb b/spec/views/users/phones/add.html.erb_spec.rb index aead0b19307..64ec39a8661 100644 --- a/spec/views/users/phones/add.html.erb_spec.rb +++ b/spec/views/users/phones/add.html.erb_spec.rb @@ -7,7 +7,7 @@ before do user = build_stubbed(:user) - @new_phone_form = NewPhoneForm.new(user) + @new_phone_form = NewPhoneForm.new(user:) end context 'phone vendor outage' do diff --git a/spec/views/users/shared/_otp_delivery_preference_selection.html.erb_spec.rb b/spec/views/users/shared/_otp_delivery_preference_selection.html.erb_spec.rb index 8bc86936219..0180a00c2c7 100644 --- a/spec/views/users/shared/_otp_delivery_preference_selection.html.erb_spec.rb +++ b/spec/views/users/shared/_otp_delivery_preference_selection.html.erb_spec.rb @@ -4,7 +4,7 @@ let(:user) { build_stubbed(:user) } subject(:rendered) do - render 'users/shared/otp_delivery_preference_selection', form_obj: NewPhoneForm.new(user) + render 'users/shared/otp_delivery_preference_selection', form_obj: NewPhoneForm.new(user:) end it 'renders enabled sms option' do