From 78236e6c7baeba9404e8a900bcbd438d6644915e Mon Sep 17 00:00:00 2001 From: Zach Margolis Date: Thu, 25 Feb 2021 11:26:28 -0800 Subject: [PATCH] Revert "Merge pull request #4714 from 18F/stages/rc-2021-02-25" This reverts commit e539c88a72f4b930a9aaceaff1d4d47d258814e4, reversing changes made to ae0189c16111a742a02a419aaaf7fef76609eed1. --- .circleci/config.yml | 12 +- .eslintrc | 12 +- Gemfile | 5 +- Gemfile.lock | 23 +- README.md | 2 - app/assets/images/wait.gif | Bin 0 -> 7312 bytes app/controllers/analytics_controller.rb | 33 +++ app/controllers/application_controller.rb | 19 +- .../concerns/fully_authenticatable.rb | 1 - .../concerns/idv/document_capture_concern.rb | 15 -- .../concerns/verify_sp_attributes_concern.rb | 4 + app/controllers/idv/cac_controller.rb | 5 + .../idv/cancellations_controller.rb | 20 +- app/controllers/idv/capture_doc_controller.rb | 18 +- .../idv/capture_doc_status_controller.rb | 4 +- app/controllers/idv/doc_auth_controller.rb | 13 +- .../idv/image_uploads_controller.rb | 45 +--- app/controllers/idv_controller.rb | 5 +- .../authorization_controller.rb | 10 +- .../openid_connect/logout_controller.rb | 3 + app/controllers/saml_idp_controller.rb | 3 +- .../users/piv_cac_login_controller.rb | 2 +- app/controllers/users/sessions_controller.rb | 6 +- app/helpers/go_back_helper.rb | 22 -- app/helpers/session_timeout_warning_helper.rb | 41 +++- app/javascript/app/platform-authenticator.js | 20 ++ .../components/acuant-capture.jsx | 60 +----- .../components/file-input.jsx | 2 +- .../document-capture/context/acuant.jsx | 31 +-- .../document-capture/context/analytics.jsx | 2 +- .../with-background-encrypted-upload.jsx | 13 +- app/javascript/packages/polyfill/index.js | 8 +- app/javascript/packages/polyfill/package.json | 1 + app/javascript/packs/application.js | 1 + app/javascript/packs/document-capture.jsx | 61 +++--- app/javascript/packs/ial2-consent-button.js | 5 +- .../packs/session-expire-session.js | 10 - app/javascript/packs/session-timeout-ping.js | 135 ------------ app/javascript/packs/submit-with-spinner.js | 8 + app/models/agency.rb | 1 - app/models/letter_requests_to_usps_ftp_log.rb | 4 - app/models/null_service_provider.rb | 5 - app/models/service_provider.rb | 6 - app/presenters/cancellation_presenter.rb | 1 - app/presenters/idv/usps_presenter.rb | 8 +- app/services/agency_seeder.rb | 1 + app/services/analytics.rb | 4 +- .../data_requests/write_cloudwatch_logs.rb | 51 ++--- app/services/db/sp_cost/add_sp_cost.rb | 10 +- app/services/doc_auth_router.rb | 10 + .../document_capture_session_async_result.rb | 2 - .../encryption/multi_region_kms_client.rb | 2 +- app/services/flow/flow_state_machine.rb | 45 +--- app/services/identity_linker.rb | 1 - .../idv/actions/cancel_link_sent_action.rb | 9 - .../idv/actions/cancel_send_link_action.rb | 9 - .../idv/actions/verify_document_action.rb | 10 +- .../actions/verify_document_status_action.rb | 10 +- app/services/idv/flows/cac_flow.rb | 1 + app/services/idv/flows/capture_doc_flow.rb | 2 - app/services/idv/flows/doc_auth_flow.rb | 2 - app/services/idv/steps/cac/success_step.rb | 9 + app/services/idv/steps/doc_auth_base_step.rb | 8 +- app/services/idv/steps/link_sent_step.rb | 5 +- app/services/idv/steps/verify_base_step.rb | 10 +- .../monthly_usps_letter_requests_report.rb | 22 -- app/services/sp_handoff_bounce.rb | 13 -- .../add_handoff_time_to_session.rb | 7 + app/services/sp_handoff_bounce/is_bounced.rb | 11 + app/services/store_sp_metadata_in_session.rb | 1 - app/services/usps_confirmation_uploader.rb | 7 +- .../confirm_delete_account/show.html.erb | 2 +- .../delete_account/show.html.erb | 2 +- app/views/devise/sessions/new.html.erb | 2 +- app/views/idv/address/new.html.erb | 4 +- app/views/idv/cac/success.html.erb | 22 ++ app/views/idv/doc_auth/_back.html.erb | 31 --- app/views/idv/doc_auth/_spinner.html.erb | 11 + .../doc_auth/_submit_with_spinner.html.erb | 6 + app/views/idv/doc_auth/link_sent.html.erb | 2 +- app/views/idv/doc_auth/send_link.html.erb | 2 +- app/views/idv/doc_auth/upload.html.erb | 2 +- app/views/idv/doc_auth/welcome.html.erb | 3 +- .../idv/shared/_document_capture.html.erb | 9 +- app/views/idv/usps/index.html.erb | 5 +- app/views/layouts/base.html.erb | 24 +-- .../session_timeout/_expire_session.html.erb | 7 - .../session_timeout/_expire_session.js.erb | 7 + app/views/session_timeout/_ping.html.erb | 11 - app/views/session_timeout/_ping.js.erb | 84 ++++++++ .../account_reset_request.html.erb | 2 +- app/views/users/delete/show.html.erb | 1 - bin/smoke_test | 19 +- config/application.yml.default | 31 +-- .../acuant_simulator_config_validation.rb | 37 ++++ config/initializers/job_configurations.rb | 8 - config/initializers/saml_idp.rb | 4 +- config/locales/account_reset/en.yml | 5 +- config/locales/account_reset/es.yml | 9 +- config/locales/account_reset/fr.yml | 5 +- config/locales/cac_proofing/en.yml | 1 + config/locales/cac_proofing/es.yml | 1 + config/locales/cac_proofing/fr.yml | 1 + config/locales/image_description/en.yml | 1 + config/locales/image_description/es.yml | 1 + config/locales/image_description/fr.yml | 1 + config/locales/user_mailer/en.yml | 25 +-- config/locales/user_mailer/es.yml | 27 +-- config/locales/user_mailer/fr.yml | 30 ++- config/locales/users/en.yml | 2 - config/locales/users/es.yml | 2 - config/locales/users/fr.yml | 2 - config/routes.rb | 16 +- config/service_providers.localdev.yml | 21 -- ...185311_remove_user_id_index_from_events.rb | 5 - ...7_add_uniqueness_to_agency_abbreviation.rb | 7 - ...23232534_add_transaction_id_to_sp_costs.rb | 5 - ...245131_letter_requests_to_usps_ftp_logs.rb | 9 - db/schema.rb | 15 +- lib/feature_management.rb | 4 + lib/lambda_jobs/git_ref.rb | 2 +- mac-test-passphrase-prompt.png | Bin 0 -> 50358 bytes package.json | 2 +- .../config/initializers/job_configurations.rb | 14 -- .../idv/document_capture_concern_spec.rb | 28 --- .../idv/capture_doc_controller_spec.rb | 21 ++ .../idv/doc_auth_controller_spec.rb | 64 ++---- .../idv/image_uploads_controller_spec.rb | 154 +------------ .../authorization_controller_spec.rb | 1 - spec/controllers/saml_idp_controller_spec.rb | 4 +- .../sign_up/completions_controller_spec.rb | 4 +- .../actions/cancel_link_sent_action_spec.rb | 17 -- .../actions/cancel_send_link_action_spec.rb | 17 -- spec/features/idv/cac/success_step_spec.rb | 20 ++ spec/features/idv/cac/use_cac_step_spec.rb | 11 +- spec/features/idv/cac/verify_step_spec.rb | 8 +- .../idv/clearing_and_restarting_spec.rb | 26 +++ .../idv/doc_auth/address_step_spec.rb | 6 - .../doc_auth/document_capture_step_spec.rb | 13 -- .../idv/doc_auth/link_sent_step_spec.rb | 11 +- .../idv/doc_auth/send_link_step_spec.rb | 7 +- .../idv/doc_auth/welcome_step_spec.rb | 8 - .../doc_capture/document_capture_step_spec.rb | 53 +---- spec/features/idv/hybrid_flow_spec.rb | 62 ------ .../phone_otp_delivery_selection_step_spec.rb | 13 -- spec/features/idv/steps/phone_step_spec.rb | 15 -- spec/features/idv/steps/usps_step_spec.rb | 17 +- .../openid_connect/openid_connect_spec.rb | 8 + .../reports/authorization_count_spec.rb | 203 ------------------ ...onthly_usps_letter_requests_report_spec.rb | 37 ---- spec/features/saml/ial1_sso_spec.rb | 1 + spec/features/sp_cost_tracking_spec.rb | 9 +- .../multiple_tabs_spec.rb | 9 + spec/features/users/sign_in_spec.rb | 23 +- spec/helpers/go_back_helper_spec.rb | 87 -------- .../components/acuant-capture-canvas-spec.jsx | 18 +- .../components/acuant-capture-spec.jsx | 198 +++++++---------- .../components/document-capture-spec.jsx | 103 ++++++--- .../components/file-image-spec.jsx | 34 +-- .../components/file-input-spec.jsx | 49 +++-- .../components/selfie-capture-spec.jsx | 6 +- .../components/selfie-step-spec.jsx | 52 ++--- .../document-capture/context/acuant-spec.jsx | 202 +++++++---------- .../with-background-encrypted-upload-spec.jsx | 12 +- .../packs/submit-with-spinner-spec.js | 41 ++++ spec/javascripts/spec_helper.js | 10 - spec/javascripts/support/crypto.js | 33 --- spec/javascripts/support/dom.js | 12 +- spec/javascripts/support/file.js | 51 ----- spec/mailers/user_mailer_spec.rb | 5 +- spec/models/agency_spec.rb | 1 - spec/models/null_service_provider_spec.rb | 8 - spec/models/service_provider_spec.rb | 12 -- spec/monitor_spec_helper.rb | 31 --- spec/presenters/idv/usps_presenter_spec.rb | 8 +- spec/requests/frontend_analytics_spec.rb | 63 ++++++ spec/services/agency_seeder_spec.rb | 2 + .../write_cloudwatch_logs_spec.rb | 97 --------- spec/services/doc_auth_router_spec.rb | 13 ++ .../encryption/contextless_kms_client_spec.rb | 16 +- spec/services/encryption/kms_client_spec.rb | 44 +--- .../multi_region_kms_client_spec.rb | 68 +++--- spec/services/identity_linker_spec.rb | 4 +- spec/services/idv/agent_spec.rb | 30 +-- .../store_sp_metadata_in_session_spec.rb | 2 - .../usps_confirmation_uploader_spec.rb | 8 +- spec/support/aws_kms_client.rb | 29 +-- spec/support/fake_analytics.rb | 3 +- spec/support/features/cac_proofing_helper.rb | 4 + spec/support/features/doc_auth_helper.rb | 13 -- spec/support/saml_auth_helper.rb | 20 -- .../shared_examples/account_creation.rb | 2 + spec/support/shared_examples/ial2_consent.rb | 9 +- spec/support/shared_examples/sign_in.rb | 21 ++ .../views/idv/doc_auth/_back.html.erb_spec.rb | 40 ---- .../shared/_document_capture.html.erb_spec.rb | 31 +-- spec/views/idv/usps/index.html.erb_spec.rb | 69 +----- spec/views/users/delete/show.html.erb_spec.rb | 1 - tsconfig.json | 3 +- yarn.lock | 67 +++--- 200 files changed, 1381 insertions(+), 2639 deletions(-) create mode 100644 app/assets/images/wait.gif delete mode 100644 app/controllers/concerns/idv/document_capture_concern.rb delete mode 100644 app/helpers/go_back_helper.rb create mode 100644 app/javascript/app/platform-authenticator.js delete mode 100644 app/javascript/packs/session-expire-session.js delete mode 100644 app/javascript/packs/session-timeout-ping.js create mode 100644 app/javascript/packs/submit-with-spinner.js delete mode 100644 app/models/letter_requests_to_usps_ftp_log.rb delete mode 100644 app/services/idv/actions/cancel_link_sent_action.rb delete mode 100644 app/services/idv/actions/cancel_send_link_action.rb create mode 100644 app/services/idv/steps/cac/success_step.rb delete mode 100644 app/services/reports/monthly_usps_letter_requests_report.rb delete mode 100644 app/services/sp_handoff_bounce.rb create mode 100644 app/services/sp_handoff_bounce/add_handoff_time_to_session.rb create mode 100644 app/services/sp_handoff_bounce/is_bounced.rb create mode 100644 app/views/idv/cac/success.html.erb delete mode 100644 app/views/idv/doc_auth/_back.html.erb create mode 100644 app/views/idv/doc_auth/_spinner.html.erb create mode 100644 app/views/idv/doc_auth/_submit_with_spinner.html.erb delete mode 100644 app/views/session_timeout/_expire_session.html.erb create mode 100644 app/views/session_timeout/_expire_session.js.erb delete mode 100644 app/views/session_timeout/_ping.html.erb create mode 100644 app/views/session_timeout/_ping.js.erb create mode 100644 config/initializers/acuant_simulator_config_validation.rb delete mode 100644 db/migrate/20210218185311_remove_user_id_index_from_events.rb delete mode 100644 db/migrate/20210223011217_add_uniqueness_to_agency_abbreviation.rb delete mode 100644 db/migrate/20210223232534_add_transaction_id_to_sp_costs.rb delete mode 100644 db/migrate/202102245131_letter_requests_to_usps_ftp_logs.rb create mode 100644 mac-test-passphrase-prompt.png delete mode 100644 spec/controllers/concerns/idv/document_capture_concern_spec.rb delete mode 100644 spec/features/idv/actions/cancel_link_sent_action_spec.rb delete mode 100644 spec/features/idv/actions/cancel_send_link_action_spec.rb create mode 100644 spec/features/idv/cac/success_step_spec.rb delete mode 100644 spec/features/idv/hybrid_flow_spec.rb delete mode 100644 spec/features/reports/authorization_count_spec.rb delete mode 100644 spec/features/reports/monthly_usps_letter_requests_report_spec.rb create mode 100644 spec/features/two_factor_authentication/multiple_tabs_spec.rb delete mode 100644 spec/helpers/go_back_helper_spec.rb create mode 100644 spec/javascripts/packs/submit-with-spinner-spec.js delete mode 100644 spec/javascripts/support/crypto.js delete mode 100644 spec/javascripts/support/file.js create mode 100644 spec/requests/frontend_analytics_spec.rb delete mode 100644 spec/services/data_requests/write_cloudwatch_logs_spec.rb delete mode 100644 spec/views/idv/doc_auth/_back.html.erb_spec.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index cfa5be2cef3..9a31b401136 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -105,13 +105,6 @@ commands: fail_only: true failure_message: ":smokeybear::red_circle: Smoke tests failed in environment: $MONITOR_ENV" include_project_field: false - store-smoketest-results: - steps: - - store_test_results: - path: tmp/capybara - - store_artifacts: - path: tmp/capybara - destination: capybara jobs: build: @@ -221,7 +214,6 @@ jobs: command: | bin/smoke_test --remote --no-source-env - notify-slack-smoke-test-status - - store-smoketest-results smoketest-int: working_directory: ~/identity-idp executor: ruby_browsers @@ -237,7 +229,6 @@ jobs: command: | bin/smoke_test --remote --no-source-env - notify-slack-smoke-test-status - - store-smoketest-results smoketest-staging: working_directory: ~/identity-idp executor: ruby_browsers @@ -253,7 +244,6 @@ jobs: command: | bin/smoke_test --remote --no-source-env - notify-slack-smoke-test-status - - store-smoketest-results smoketest-prod: working_directory: ~/identity-idp executor: ruby_browsers @@ -267,7 +257,7 @@ jobs: command: | bin/smoke_test --remote --no-source-env - notify-slack-smoke-test-status - - store-smoketest-results + workflows: version: 2 release: diff --git a/.eslintrc b/.eslintrc index 3e4269b137b..ed988201927 100644 --- a/.eslintrc +++ b/.eslintrc @@ -25,7 +25,6 @@ "indent": "off", "max-classes-per-file": "off", "newline-per-chained-call": "off", - "no-console": "error", "no-empty": ["error", { "allowEmptyCatch": true }], "no-param-reassign": ["off", "never"], "no-confusing-arrow": "off", @@ -37,12 +36,12 @@ "message": "Use CustomEvent constructor with polyfill for Internet Explorer" }, { - "selector": "AssignmentExpression[left.property.name='href'][right.type=/(Template)?Literal/]", - "message": "Do not assign window.location.href to a string or string template to avoid losing i18n parameters" + "selector": "ArrayExpression > SpreadElement", + "message": "Don't use array spread, issue with IE 11 (use Array.from instead)" }, { - "selector": "CallExpression[callee.object.name=/^(it|describe|context)$/][callee.property.name='only'] > MemberExpression", - "message": "Test exclusivity should not be committed" + "selector": "AssignmentExpression[left.property.name='href'][right.type=/(Template)?Literal/]", + "message": "Do not assign window.location.href to a string or string template to avoid losing i18n parameters" } ], "no-unused-expressions": "off", @@ -76,7 +75,8 @@ { "files": "spec/javascripts/**/*", "rules": { - "react/jsx-props-no-spreading": "off" + "react/jsx-props-no-spreading": "off", + "no-restricted-syntax": "off" } } ] diff --git a/Gemfile b/Gemfile index 269666ddbd1..30a5010e00d 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem 'faraday' gem 'foundation_emails' gem 'hiredis' gem 'http_accept_language' -gem 'identity-doc-auth', github: '18F/identity-doc-auth', tag: 'v0.4.0' +gem 'identity-doc-auth', github: '18F/identity-doc-auth', tag: 'v0.3.3' gem 'identity-hostdata', github: '18F/identity-hostdata', tag: 'v0.4.3' require File.join(__dir__, 'lib', 'lambda_jobs', 'git_ref.rb') gem 'identity-idp-functions', github: '18F/identity-idp-functions', ref: LambdaJobs::GIT_REF @@ -109,7 +109,6 @@ group :test do gem 'rack_session_access', '>= 0.2.0' gem 'rack-test', '>= 1.1.0' gem 'rails-controller-testing', '>= 1.0.4' - gem 'rspec-retry' gem 'shoulda-matchers', '~> 4.0', require: false gem 'timecop' gem 'webdrivers', '~> 4.0' @@ -118,6 +117,6 @@ group :test do end group :production do - gem 'aamva', github: '18F/identity-aamva-api-client-gem', tag: 'v3.6.0' + gem 'aamva', github: '18F/identity-aamva-api-client-gem', tag: 'v3.4.1' gem 'lexisnexis', github: '18F/identity-lexisnexis-api-client-gem', tag: 'v2.5.1.pre' end diff --git a/Gemfile.lock b/Gemfile.lock index 8aad49c7fa5..be60bb9d55a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,22 +1,21 @@ GIT remote: https://github.com/18F/identity-aamva-api-client-gem.git - revision: dbf3d2e102603530a29cb43308b9aa639efaea1f - tag: v3.6.0 + revision: 149b5b480f0319ec39410e497bb4bbffd1652014 + tag: v3.4.1 specs: - aamva (3.6.0) + aamva (3.4.1) dotenv faraday hashie - proofer (>= 2.7.1) retries xmldsig GIT remote: https://github.com/18F/identity-doc-auth.git - revision: 164a507fd17ecffe4ef2289120d89156358b3a80 - tag: v0.4.0 + revision: 4e1e09d7e5eb673dfbc1e301feacc74859d25d29 + tag: v0.3.3 specs: - identity-doc-auth (0.4.0) + identity-doc-auth (0.3.3) activesupport faraday @@ -30,11 +29,10 @@ GIT GIT remote: https://github.com/18F/identity-idp-functions.git - revision: eb8aa1657173af64fd9fcad2ab4df2a5741eb51d - ref: eb8aa1657173af64fd9fcad2ab4df2a5741eb51d + revision: 8c16776e19b211d15bda7246d99ff95155d60c11 + ref: 8c16776e19b211d15bda7246d99ff95155d60c11 specs: - identity-idp-functions (0.11.0) - aamva (>= 3.5.0) + identity-idp-functions (0.10.0) aws-sdk-s3 (>= 1.73) aws-sdk-ssm (>= 1.55) retries (>= 0.0.5) @@ -573,8 +571,6 @@ GEM rspec-expectations (~> 3.9) rspec-mocks (~> 3.9) rspec-support (~> 3.9) - rspec-retry (0.6.2) - rspec-core (> 3.3) rspec-support (3.10.0) rubocop (1.4.2) parallel (~> 1.10) @@ -801,7 +797,6 @@ DEPENDENCIES rotp (~> 6.1) rqrcode rspec-rails (~> 4.0) - rspec-retry rubocop (~> 1.4.0) rubocop-rails (>= 2.5.2) ruby-progressbar diff --git a/README.md b/README.md index 603f18672c6..53bcf042807 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,6 @@ We recommend using [Homebrew](https://brew.sh/), [rbenv](https://github.com/rben MONITOR_ENV=INT ./bin/smoke_test --remote ``` - For remote smoke tests, we save a screenshot of failed test scenarios to help debugging in `tmp/capybara`, and they are exported to CircleCI as build artifacts as well. - #### Speeding up local development and testing To automatically run the test that corresponds to the file you are editing, diff --git a/app/assets/images/wait.gif b/app/assets/images/wait.gif new file mode 100644 index 0000000000000000000000000000000000000000..209efd816c002a23803b2f6efe675d93be519699 GIT binary patch literal 7312 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 literal 0 HcmV?d00001 diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb index d25ed58d006..866b616e704 100644 --- a/app/controllers/analytics_controller.rb +++ b/app/controllers/analytics_controller.rb @@ -2,6 +2,39 @@ class AnalyticsController < ApplicationController skip_before_action :verify_authenticity_token def create + results.each do |event, result| + next if result.nil? + + analytics.track_event(event, result.to_h) + end head :ok end + + private + + def results + { + Analytics::FRONTEND_BROWSER_CAPABILITIES => platform_authenticator_result, + } + end + + def platform_authenticator_result + return unless current_user + return if platform_authenticator_results_saved? || platform_authenticator_available?.nil? + + session[:platform_authenticator_analytics_saved] = true + extra = { platform_authenticator: platform_authenticator_available? } + FormResponse.new(success: true, errors: {}, extra: extra) + end + + def platform_authenticator_available? + @platform_authenticator_available ||= begin + available = params.dig(:platform_authenticator, :available) + available == 'true' if %w[true false].include?(available) + end + end + + def platform_authenticator_results_saved? + session[:platform_authenticator_analytics_saved] == true + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9bd9a780fd5..0904edeefcb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -274,24 +274,15 @@ def set_locale I18n.locale = LocaleChooser.new(params[:locale], request).locale end - def sp_session_ial - sp_session[:ial] - end - - def sp_session_ial_1_or_2 - return 1 if sp_session[:ial].blank? - sp_session[:ial] > 1 ? 2 : 1 - end - def increment_monthly_auth_count return unless current_user&.id issuer = sp_session[:issuer] - return if issuer.blank? || !first_auth_of_session?(issuer, sp_session_ial) - MonthlySpAuthCount.increment(current_user.id, issuer, sp_session_ial_1_or_2) + return if issuer.blank? || !first_auth_of_session?(issuer) + MonthlySpAuthCount.increment(current_user.id, issuer, sp_session[:ial2] ? 2 : 1) end - def first_auth_of_session?(issuer, ial) - authenticated_to_sp_token = "auth_counted_ial#{ial}_#{issuer}" + def first_auth_of_session?(issuer) + authenticated_to_sp_token = "auth-counted-#{issuer}" authenticated_to_sp = user_session[authenticated_to_sp_token] return if authenticated_to_sp user_session[authenticated_to_sp_token] = true @@ -353,7 +344,7 @@ def analytics_exception_info(exception) end def add_sp_cost(token) - Db::SpCost::AddSpCost.call(sp_session[:issuer].to_s, sp_session_ial_1_or_2, token) + Db::SpCost::AddSpCost.call(sp_session[:issuer].to_s, sp_session[:ial2] ? 2 : 1, token) end def mobile? diff --git a/app/controllers/concerns/fully_authenticatable.rb b/app/controllers/concerns/fully_authenticatable.rb index 3b746f8b833..bd0ce49dbcb 100644 --- a/app/controllers/concerns/fully_authenticatable.rb +++ b/app/controllers/concerns/fully_authenticatable.rb @@ -1,7 +1,6 @@ module FullyAuthenticatable def delete_branded_experience ServiceProviderRequestProxy.delete(request_id) - session.delete(:sp) end def request_id diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb deleted file mode 100644 index 1902b85fc4d..00000000000 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Idv - module DocumentCaptureConcern - def override_document_capture_step_csp - return if params[:step] != 'document_capture' - - SecureHeaders.append_content_security_policy_directives( - request, - # required to run wasm until wasm-eval is available - script_src: ['\'unsafe-eval\''], - # required for retrieving image dimensions from uploaded images - img_src: ['blob:'], - ) - end - end -end diff --git a/app/controllers/concerns/verify_sp_attributes_concern.rb b/app/controllers/concerns/verify_sp_attributes_concern.rb index 47b29173740..3a29feddc61 100644 --- a/app/controllers/concerns/verify_sp_attributes_concern.rb +++ b/app/controllers/concerns/verify_sp_attributes_concern.rb @@ -74,4 +74,8 @@ def requested_attributes_verified? sp_session[:requested_attributes] - @sp_session_identity.verified_attributes.to_a ).empty? end + + def sp_session_ial + sp_session[:ial2] ? 2 : 1 + end end diff --git a/app/controllers/idv/cac_controller.rb b/app/controllers/idv/cac_controller.rb index fc19cd3b819..ae005d2edde 100644 --- a/app/controllers/idv/cac_controller.rb +++ b/app/controllers/idv/cac_controller.rb @@ -2,6 +2,7 @@ module Idv class CacController < ApplicationController include PivCacConcern + before_action :render_404_if_disabled before_action :confirm_two_factor_authenticated before_action :cac_callback @@ -25,6 +26,10 @@ def redirect_to_piv_cac_service private + def render_404_if_disabled + render_not_found unless AppConfig.env.cac_proofing_enabled == 'true' + end + def cac_callback return unless request.path == idv_cac_step_path(:present_cac) && params[:token] diff --git a/app/controllers/idv/cancellations_controller.rb b/app/controllers/idv/cancellations_controller.rb index 95aa6690bd2..9c326347cfc 100644 --- a/app/controllers/idv/cancellations_controller.rb +++ b/app/controllers/idv/cancellations_controller.rb @@ -1,7 +1,6 @@ module Idv class CancellationsController < ApplicationController include IdvSession - include GoBackHelper before_action :confirm_two_factor_authenticated before_action :confirm_idv_needed @@ -9,7 +8,7 @@ class CancellationsController < ApplicationController def new properties = ParseControllerFromReferer.new(request.referer).call analytics.track_event(Analytics::IDV_CANCELLATION, properties) - @go_back_path = go_back_path || idv_path + @go_back_path = go_back_path end def destroy @@ -25,5 +24,22 @@ def reset_doc_auth user_session.delete('idv/doc_auth') user_session['idv'] = { params: {}, step_attempts: { phone: 0 } } end + + def go_back_path + referer_path || idv_path + end + + def referer_path + referer_string = request.env['HTTP_REFERER'] + return if referer_string.blank? + referer_uri = URI.parse(referer_string) + return if referer_uri.scheme == 'javascript' + return unless referer_uri.host == AppConfig.env.domain_name + extract_path_and_query_from_uri(referer_uri) + end + + def extract_path_and_query_from_uri(uri) + [uri.path, uri.query].compact.join('?') + end end end diff --git a/app/controllers/idv/capture_doc_controller.rb b/app/controllers/idv/capture_doc_controller.rb index 6d7ab0125a7..cff36b91832 100644 --- a/app/controllers/idv/capture_doc_controller.rb +++ b/app/controllers/idv/capture_doc_controller.rb @@ -1,11 +1,9 @@ module Idv class CaptureDocController < ApplicationController before_action :ensure_user_id_in_session + before_action :add_unsafe_eval_to_capture_steps include Flow::FlowStateMachine - include Idv::DocumentCaptureConcern - - before_action :override_document_capture_step_csp FSM_SETTINGS = { step_url: :idv_capture_doc_step_url, @@ -27,6 +25,16 @@ def ensure_user_id_in_session process_result(result) end + def add_unsafe_eval_to_capture_steps + return unless current_step == 'document_capture' + + # required to run wasm until wasm-eval is available + SecureHeaders.append_content_security_policy_directives( + request, + script_src: ['\'unsafe-eval\''], + ) + end + def process_result(result) if result.success? reset_session @@ -52,9 +60,5 @@ def token def document_capture_session_uuid params['document-capture-session'] end - - def analytics_user - user_id_from_token ? User.find(user_id_from_token) : super - end end end diff --git a/app/controllers/idv/capture_doc_status_controller.rb b/app/controllers/idv/capture_doc_status_controller.rb index 42cac305011..b73f4a71e97 100644 --- a/app/controllers/idv/capture_doc_status_controller.rb +++ b/app/controllers/idv/capture_doc_status_controller.rb @@ -16,9 +16,7 @@ def document_capture_session_poll_render_result document_capture_session = DocumentCaptureSession.find_by(uuid: session_uuid) return { plain: 'Unauthorized', status: :unauthorized } unless document_capture_session - result = document_capture_session.load_result || - document_capture_session.load_doc_auth_async_result - + result = document_capture_session.load_result return { plain: 'Pending', status: :accepted } if result.blank? return { plain: 'Unauthorized', status: :unauthorized } unless result.success? { plain: 'Complete', status: :ok } diff --git a/app/controllers/idv/doc_auth_controller.rb b/app/controllers/idv/doc_auth_controller.rb index c4a71aad335..1151587e570 100644 --- a/app/controllers/idv/doc_auth_controller.rb +++ b/app/controllers/idv/doc_auth_controller.rb @@ -4,12 +4,11 @@ class DocAuthController < ApplicationController before_action :redirect_if_mail_bounced before_action :redirect_if_pending_profile before_action :extend_timeout_using_meta_refresh_for_select_paths + before_action :add_unsafe_eval_to_capture_steps include IdvSession # remove if we retire the non docauth LOA3 flow include Flow::FlowStateMachine - include Idv::DocumentCaptureConcern - before_action :override_document_capture_step_csp before_action :update_if_skipping_upload FSM_SETTINGS = { @@ -50,5 +49,15 @@ def do_meta_refresh(meta_refresh_count) def flow_session user_session['idv/doc_auth'] end + + def add_unsafe_eval_to_capture_steps + return unless params[:step] == 'document_capture' + + # required to run wasm until wasm-eval is available + SecureHeaders.append_content_security_policy_directives( + request, + script_src: ['\'unsafe-eval\''], + ) + end end end diff --git a/app/controllers/idv/image_uploads_controller.rb b/app/controllers/idv/image_uploads_controller.rb index e452ee771f0..a6e06a76ae6 100644 --- a/app/controllers/idv/image_uploads_controller.rb +++ b/app/controllers/idv/image_uploads_controller.rb @@ -5,16 +5,10 @@ class ImageUploadsController < ApplicationController respond_to :json def create - image_form_response = image_form.submit - analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - image_form_response.to_h, - ) - + form_response = image_form.submit client_response = nil - doc_pii_form_response = nil - if image_form_response.success? + if form_response.success? client_response = doc_auth_client.post_images( front_image: image_form.front.read, back_image: image_form.back.read, @@ -26,24 +20,16 @@ def create if client_response.success? doc_pii_form_response = Idv::DocPiiForm.new(client_response.pii_from_doc).submit - analytics.track_event( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - doc_pii_form_response.to_h.merge(user_id: user_uuid), - ) + form_response = form_response.merge(doc_pii_form_response) store_pii(client_response) if client_response.success? && doc_pii_form_response.success? - - # merge in the image_form_response to pick up the remaining_attempts - doc_pii_form_response = image_form_response.merge(doc_pii_form_response) end end + analytics.track_event(Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, form_response.to_h) + presenter = ImageUploadResponsePresenter.new( form: image_form, - form_response: presenter_response( - image_form_response: image_form_response, - client_response: client_response, - doc_pii_form_response: doc_pii_form_response, - ), + form_response: presenter_response(form_response, client_response), url_options: url_options, ) @@ -72,7 +58,7 @@ def update_analytics(client_response) update_funnel(client_response) analytics.track_event( Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - client_response.to_h.merge(user_id: user_uuid), + client_response.to_h.merge(user_id: image_form.document_capture_session.user.uuid), ) end @@ -94,20 +80,9 @@ def add_costs(client_response) call(client_response) end - def presenter_response(image_form_response:, client_response:, doc_pii_form_response:) - # image form wasn't valid - return image_form_response unless image_form_response.success? - - # doc_pii_form exists, but wasn't valid - if doc_pii_form_response.present? && !doc_pii_form_response.success? - return doc_pii_form_response - end - - client_response - end - - def user_uuid - image_form.document_capture_session.user.uuid + def presenter_response(form_response, client_response) + return client_response if form_response.success? && client_response.present? + form_response end end end diff --git a/app/controllers/idv_controller.rb b/app/controllers/idv_controller.rb index 0018113c4bd..421e3f325f6 100644 --- a/app/controllers/idv_controller.rb +++ b/app/controllers/idv_controller.rb @@ -61,7 +61,8 @@ def active_profile? end def proof_with_cac? - Db::EmailAddress::HasGovOrMil.call(current_user) || - current_user.piv_cac_configurations.any? + AppConfig.env.cac_proofing_enabled == 'true' && + (Db::EmailAddress::HasGovOrMil.call(current_user) || + current_user.piv_cac_configurations.any?) end end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index 946501f888f..34cad9a6011 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -32,7 +32,7 @@ def check_sp_active end def check_sp_handoff_bounced - return unless SpHandoffBounce.is_bounced?(session) + return unless SpHandoffBounce::IsBounced.call(sp_session) analytics.track_event(Analytics::SP_HANDOFF_BOUNCED_DETECTED) redirect_to bounced_url true @@ -56,7 +56,7 @@ def link_identity_to_service_provider def handle_successful_handoff track_events - SpHandoffBounce.add_handoff_time_to_session(session) + SpHandoffBounce::AddHandoffTimeToSession.call(sp_session) redirect_to @authorize_form.success_redirect_uri delete_branded_experience end @@ -130,13 +130,15 @@ def store_request end def pii_requested_but_locked? - sp_session && sp_session_ial > 1 && + FeatureManagement.allow_piv_cac_login? && + sp_session && sp_session_ial > 1 && UserDecorator.new(current_user).identity_verified? && user_session[:decrypted_pii].blank? end def track_events - analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: sp_session_ial) + ial = sp_session[:ial2] ? 2 : 1 + analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: ial) Db::SpReturnLog::AddReturn.call(request_id, current_user.id) increment_monthly_auth_count add_sp_cost(:authentication) diff --git a/app/controllers/openid_connect/logout_controller.rb b/app/controllers/openid_connect/logout_controller.rb index f4cdc83e21e..fadd49041bc 100644 --- a/app/controllers/openid_connect/logout_controller.rb +++ b/app/controllers/openid_connect/logout_controller.rb @@ -1,5 +1,8 @@ module OpenidConnect class LogoutController < ApplicationController + include SecureHeadersConcern + + before_action :apply_secure_headers_override, only: [:index] def index @logout_form = OpenidConnectLogoutForm.new(params) diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index 9c519bcbcf5..c6818c74526 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -102,7 +102,8 @@ def render_template_for(message, action_url, type) end def track_events - analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: sp_session_ial) + ial = sp_session[:ial2] ? 2 : 1 + analytics.track_event(Analytics::SP_REDIRECT_INITIATED, ial: ial) Db::SpReturnLog::AddReturn.call(request_id, current_user.id) increment_monthly_auth_count add_sp_cost(:authentication) diff --git a/app/controllers/users/piv_cac_login_controller.rb b/app/controllers/users/piv_cac_login_controller.rb index 8a6ea0d2c8b..ee04cabb181 100644 --- a/app/controllers/users/piv_cac_login_controller.rb +++ b/app/controllers/users/piv_cac_login_controller.rb @@ -85,7 +85,7 @@ def request_is_ial2? end def request_ial - sp_session ? sp_session_ial_1_or_2 : 1 + sp_session ? sp_session_ial : 1 end def process_invalid_submission diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a52892c9bc2..29d42b344b6 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -21,7 +21,7 @@ def new ) @request_id = request_id_if_valid - @ial = sp_session ? sp_session_ial_1_or_2 : 1 + @ial = sp_session ? sp_session_ial : 1 session[:ial2_with_no_sp_campaign] = campaign if sp_session.blank? && params[:ial] == '2' super end @@ -173,6 +173,10 @@ def request_id params.fetch(:request_id, '') end + def sp_session_ial + sp_session[:ial2] ? 2 : 1 + end + def redirect_to_2fa_or_pending_reset if pending_account_reset_request.present? redirect_to account_reset_pending_url diff --git a/app/helpers/go_back_helper.rb b/app/helpers/go_back_helper.rb deleted file mode 100644 index bc8b149643f..00000000000 --- a/app/helpers/go_back_helper.rb +++ /dev/null @@ -1,22 +0,0 @@ -module GoBackHelper - def go_back_path - referer_string = request.referer - return if referer_string.blank? - referer_uri = URI.parse(referer_string) - return if referer_uri.scheme == 'javascript' - return unless referer_uri.host == app_host - extract_path_and_query_from_uri(referer_uri) - end - - private - - def extract_path_and_query_from_uri(uri) - [uri.path, uri.query].compact.join('?') - end - - def app_host - AppConfig.env.domain_name.split(':')[0] - end -end - -ActionView::Base.send :include, GoBackHelper diff --git a/app/helpers/session_timeout_warning_helper.rb b/app/helpers/session_timeout_warning_helper.rb index 395a20405e1..1dae0a8e7d0 100644 --- a/app/helpers/session_timeout_warning_helper.rb +++ b/app/helpers/session_timeout_warning_helper.rb @@ -1,13 +1,13 @@ module SessionTimeoutWarningHelper - def session_timeout_frequency + def frequency (AppConfig.env.session_check_frequency || 150).to_i end - def session_timeout_start + def start (AppConfig.env.session_check_delay || 30).to_i end - def session_timeout_warning + def warning (AppConfig.env.session_timeout_warning_seconds || 30).to_i end @@ -18,15 +18,44 @@ def timeout_refresh_path )&.html_safe # rubocop:disable Rails/OutputSafety end + def auto_session_timeout_js + nonced_javascript_tag do + render partial: 'session_timeout/ping', + formats: [:js], + locals: { + timeout_url: timeout_url, + warning: warning, + start: start, + frequency: frequency, + modal: modal, + } + end + end + + # rubocop:disable Rails/HelperInstanceVariable + def auto_session_expired_js + return if @skip_session_expiration + + session_timeout_in = Devise.timeout_in + nonced_javascript_tag do + render( + partial: 'session_timeout/expire_session', + formats: [:js], + locals: { session_timeout_in: session_timeout_in }, + ) + end + end + # rubocop:enable Rails/HelperInstanceVariable + def time_left_in_session distance_of_time_in_words( - session_timeout_warning, + warning, 0, two_words_connector: " #{I18n.t('datetime.dotiw.two_words_connector')} ", ) end - def session_modal + def modal if user_fully_authenticated? FullySignedInModalPresenter.new(time_left_in_session) else @@ -34,3 +63,5 @@ def session_modal end end end + +ActionView::Base.send :include, SessionTimeoutWarningHelper diff --git a/app/javascript/app/platform-authenticator.js b/app/javascript/app/platform-authenticator.js new file mode 100644 index 00000000000..5cf9657e364 --- /dev/null +++ b/app/javascript/app/platform-authenticator.js @@ -0,0 +1,20 @@ +function postPlatformAuthenticator(userIntent) { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/analytics', true); + xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + xhr.send(`platform_authenticator[available]=${userIntent}`); +} +function platformAuthenticator() { + if (document.querySelector('[data-platform-authenticator-enabled]')) { + if (!window.PublicKeyCredential) { + postPlatformAuthenticator(false); + return; + } + window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(function ( + userIntent, + ) { + postPlatformAuthenticator(userIntent); + }); + } +} +document.addEventListener('DOMContentLoaded', platformAuthenticator); diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index 1bf5badbf2e..698cbb9bf24 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -32,14 +32,14 @@ import './acuant-capture.scss'; */ /** - * @typedef {"acuant"|"upload"} ImageSource + * @typedef {"acuant"} ImageSource */ /** * @typedef ImageAnalyticsPayload * - * @prop {number?} width Image width, or null if unknown. - * @prop {number?} height Image height, or null if unknown. + * @prop {number} width + * @prop {number} height * @prop {string?} mimeType Mime type, or null if unknown. * @prop {ImageSource} source Method by which image was added. */ @@ -125,27 +125,6 @@ function getDocumentTypeLabel(documentType) { } } -/** - * @param {File} file Image file. - * - * @return {Promise<{width: number?, height: number?}>} - */ -function getImageDimensions(file) { - let objectURL; - return file.type.indexOf('image/') === 0 - ? new Promise((resolve) => { - objectURL = window.URL.createObjectURL(file); - const image = new window.Image(); - image.onload = () => resolve({ width: image.width, height: image.height }); - image.onerror = () => resolve({ width: null, height: null }); - image.src = objectURL; - }).then(({ width, height }) => { - window.URL.revokeObjectURL(objectURL); - return { width, height }; - }) - : Promise.resolve({ width: null, height: null }); -} - /** * Returns an element serving as an enhanced FileInput, supporting direct capture using Acuant SDK * in supported devices. @@ -166,7 +145,7 @@ function AcuantCapture( }, ref, ) { - const { isReady, isAcuantLoaded, isError, isCameraSupported } = useContext(AcuantContext); + const { isReady, isError, isCameraSupported } = useContext(AcuantContext); const { isMockClient } = useContext(UploadContext); const { addPageAction } = useContext(AnalyticsContext); const inputRef = useRef(/** @type {?HTMLInputElement} */ (null)); @@ -198,32 +177,6 @@ function AcuantCapture( onChange(nextValue); } - /** - * Handler for file input change events. - * - * @param {File?} nextValue Next value, if set. - */ - function onUpload(nextValue) { - if (nextValue) { - getImageDimensions(nextValue).then(({ width, height }) => { - /** @type {ImageAnalyticsPayload} */ - const analyticsPayload = { - width, - height, - mimeType: nextValue.type, - source: 'upload', - }; - - addPageAction({ - label: `IdV: ${analyticsPrefix} added`, - payload: analyticsPayload, - }); - }); - } - - onChangeAndResetError(nextValue); - } - /** * Responds to a click by starting capture if supported in the environment, or triggering the * default file picker prompt. The click event may originate from the file input itself, or @@ -235,8 +188,7 @@ function AcuantCapture( if (event.target === inputRef.current) { const shouldStartEnvironmentCapture = hasCapture && capture !== 'user' && !isForceUploading.current; - const shouldStartSelfieCapture = - isAcuantLoaded && capture === 'user' && !isForceUploading.current; + const shouldStartSelfieCapture = capture === 'user' && !isForceUploading.current; if (!allowUpload || shouldStartSelfieCapture || shouldStartEnvironmentCapture) { event.preventDefault(); @@ -357,7 +309,7 @@ function AcuantCapture( value={value} errorMessage={ownErrorMessage ?? errorMessage} onClick={startCaptureOrTriggerUpload} - onChange={onUpload} + onChange={onChangeAndResetError} onError={() => setOwnErrorMessage(null)} />
diff --git a/app/javascript/packages/document-capture/components/file-input.jsx b/app/javascript/packages/document-capture/components/file-input.jsx index b798f43db10..08c96cb5340 100644 --- a/app/javascript/packages/document-capture/components/file-input.jsx +++ b/app/javascript/packages/document-capture/components/file-input.jsx @@ -23,7 +23,7 @@ import usePrevious from '../hooks/use-previous'; * @prop {Blob|string|null|undefined} value Current value. * @prop {ReactNode=} errorMessage Error to show. * @prop {(event:ReactMouseEvent)=>void=} onClick Input click handler. - * @prop {(nextValue:File?)=>void=} onChange Input change handler. + * @prop {(nextValue:Blob?)=>void=} onChange Input change handler. * @prop {(message:ReactNode)=>void=} onError Callback to trigger if upload error occurs. */ diff --git a/app/javascript/packages/document-capture/context/acuant.jsx b/app/javascript/packages/document-capture/context/acuant.jsx index 94fc7a9dba7..c5932ed02d5 100644 --- a/app/javascript/packages/document-capture/context/acuant.jsx +++ b/app/javascript/packages/document-capture/context/acuant.jsx @@ -1,5 +1,4 @@ -import { createContext, useContext, useMemo, useEffect, useState } from 'react'; -import DeviceContext from './device'; +import { createContext, useMemo, useEffect, useState } from 'react'; /** @typedef {import('react').ReactNode} ReactNode */ @@ -49,7 +48,6 @@ import DeviceContext from './device'; const AcuantContext = createContext({ isReady: false, - isAcuantLoaded: false, isError: false, isCameraSupported: /** @type {boolean?} */ (null), credentials: /** @type {string?} */ (null), @@ -65,26 +63,18 @@ function AcuantContextProvider({ endpoint = null, children, }) { - const { isMobile } = useContext(DeviceContext); - // Only mobile devices should load the Acuant SDK. Consider immediately ready otherwise. - const [isReady, setIsReady] = useState(!isMobile); - const [isAcuantLoaded, setIsAcuantLoaded] = useState(false); + const [isReady, setIsReady] = useState(false); const [isError, setIsError] = useState(false); - // If the user is on a mobile device, it can't be known that the camera is supported until after - // Acuant SDK loads, so assign a value of `null` as representing this unknown state. Other device - // types should treat camera as unsupported, since it's not relevant for Acuant SDK usage. - const [isCameraSupported, setIsCameraSupported] = useState(isMobile ? null : false); - const value = useMemo( - () => ({ isReady, isAcuantLoaded, isError, isCameraSupported, endpoint, credentials }), - [isReady, isAcuantLoaded, isError, isCameraSupported, endpoint, credentials], - ); + const [isCameraSupported, setIsCameraSupported] = useState(/** @type {?boolean} */ (null)); + const value = useMemo(() => ({ isReady, isError, isCameraSupported, endpoint, credentials }), [ + isReady, + isError, + isCameraSupported, + endpoint, + credentials, + ]); useEffect(() => { - // If state is already ready (via consideration of device type), skip loading Acuant SDK. - if (isReady) { - return; - } - // Acuant SDK expects this global to be assigned at the time the script is // loaded, which is why the script element is manually appended to the DOM. const originalOnAcuantSdkLoaded = /** @type {AcuantGlobal} */ (window).onAcuantSdkLoaded; @@ -98,7 +88,6 @@ function AcuantContextProvider({ /** @type {AcuantGlobal} */ (window).AcuantCamera.isCameraSupported, ); setIsReady(true); - setIsAcuantLoaded(true); }, onFail: () => setIsError(true), }, diff --git a/app/javascript/packages/document-capture/context/analytics.jsx b/app/javascript/packages/document-capture/context/analytics.jsx index bdd3b446883..1f07f854f92 100644 --- a/app/javascript/packages/document-capture/context/analytics.jsx +++ b/app/javascript/packages/document-capture/context/analytics.jsx @@ -5,7 +5,7 @@ import { createContext } from 'react'; /** * @typedef PageAction * - * @property {string=} key Short, camel-cased, dot-namespaced key describing event. + * @property {string} key Short, camel-cased, dot-namespaced key describing event. * @property {string} label Long-form, human-readable label describing event action. * @property {Payload} payload Additional payload arguments to log with action. */ diff --git a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx index ef8f4d2e6e3..a99869e1875 100644 --- a/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx +++ b/app/javascript/packages/document-capture/higher-order/with-background-encrypted-upload.jsx @@ -3,17 +3,17 @@ import UploadContext from '../context/upload'; import AnalyticsContext from '../context/analytics'; /** - * Returns a promise resolving to an ArrayBuffer representation of the given Blob object. + * Returns a promise resolving to an DataView representation of the given Blob object. * * @param {Blob} blob Blob object. * - * @return {Promise} + * @return {Promise} */ -export function blobToArrayBuffer(blob) { +export function blobToDataView(blob) { return new Promise((resolve, reject) => { const reader = new window.FileReader(); reader.onload = ({ target }) => { - resolve(/** @type {ArrayBuffer} */ (target?.result)); + resolve(new DataView(/** @type {ArrayBuffer} */ (target?.result))); }; reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(blob); @@ -31,15 +31,12 @@ export function blobToArrayBuffer(blob) { */ export async function encrypt(key, iv, value) { const data = - typeof value === 'string' ? new TextEncoder().encode(value) : await blobToArrayBuffer(value); + typeof value === 'string' ? new TextEncoder().encode(value) : await blobToDataView(value); return window.crypto.subtle.encrypt( /** @type {AesGcmParams} */ ({ name: 'AES-GCM', iv, - // Normally, it would not be expected to assign this value, since the property is optional and - // the default is 128. However, if not specified, Internet Explorer will throw an error. - tagLength: 128, }), key, data, diff --git a/app/javascript/packages/polyfill/index.js b/app/javascript/packages/polyfill/index.js index a7b97185da1..cb0e7d1241f 100644 --- a/app/javascript/packages/polyfill/index.js +++ b/app/javascript/packages/polyfill/index.js @@ -6,17 +6,21 @@ */ /** - * @typedef {"fetch"|"classlist"|"crypto"|"custom-event"} SupportedPolyfills + * @typedef {"fetch"|"element-closest"|"classlist"|"crypto"|"custom-event"} SupportedPolyfills */ /** - * @type {Record} + * @type {Record} */ const POLYFILLS = { fetch: { test: () => 'fetch' in window, load: () => import(/* webpackChunkName: "whatwg-fetch" */ 'whatwg-fetch'), }, + 'element-closest': { + test: () => !!Element.prototype.closest, + load: () => import(/* webpackChunkName: "element-closest" */ 'element-closest'), + }, classlist: { test: () => 'classList' in Element.prototype, load: () => import(/* webpackChunkName: "classlist-polyfill" */ 'classlist-polyfill'), diff --git a/app/javascript/packages/polyfill/package.json b/app/javascript/packages/polyfill/package.json index 482a09301af..2058b332186 100644 --- a/app/javascript/packages/polyfill/package.json +++ b/app/javascript/packages/polyfill/package.json @@ -5,6 +5,7 @@ "dependencies": { "classlist-polyfill": "^1.2.0", "custom-event-polyfill": "^1.0.7", + "element-closest": "^3.0.2", "webcrypto-shim": "^0.1.6", "whatwg-fetch": "^3.4.0" } diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c8e98e128a0..b3ecb14e07d 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -6,5 +6,6 @@ require('../app/form-field-format'); require('../app/radio-btn'); require('../app/print-personal-key'); require('../app/i18n-dropdown'); +require('../app/platform-authenticator'); require('../app/accessible-forms'); require('../app/ssn-field'); diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index 97f23f90b2a..7057b1b03ec 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -98,10 +98,7 @@ const device = { /** @type {import('@18f/identity-document-capture/context/analytics').AddPageAction} */ function addPageAction(action) { - const { newrelic } = /** @type {DocumentCaptureGlobal} */ (window); - if (action.key && newrelic) { - newrelic.addPageAction(action.key, action.payload); - } + /** @type {DocumentCaptureGlobal} */ (window).newrelic?.addPageAction(action.key, action.payload); window.fetch(logEndpoint, { method: 'POST', @@ -140,36 +137,36 @@ loadPolyfills(['fetch', 'crypto']).then(async () => { window.fetch(keepAliveEndpoint, { method: 'POST', headers: { 'X-CSRF-Token': csrf } }); render( - - + - - - - - + + + + + - - - - - - - , + + + + + + + , appRoot, ); }); diff --git a/app/javascript/packs/ial2-consent-button.js b/app/javascript/packs/ial2-consent-button.js index b02bd76855c..d237407e2eb 100644 --- a/app/javascript/packs/ial2-consent-button.js +++ b/app/javascript/packs/ial2-consent-button.js @@ -1,9 +1,10 @@ function toggleButton() { - const continueButton = document.querySelector('button[type="submit"]'); + const continueButton = document.querySelector('input[value="Continue"]'); const checkbox = document.querySelector('input[name="ial2_consent_given"]'); function sync() { - continueButton.classList.toggle('btn-disabled', !checkbox.checked); + continueButton.disabled = !checkbox.checked; + continueButton.classList.toggle('btn-disabled', continueButton.disabled); } sync(); diff --git a/app/javascript/packs/session-expire-session.js b/app/javascript/packs/session-expire-session.js deleted file mode 100644 index e92090085cd..00000000000 --- a/app/javascript/packs/session-expire-session.js +++ /dev/null @@ -1,10 +0,0 @@ -const expireConfig = document.getElementById('js-expire-session'); - -if (expireConfig && expireConfig.dataset.sessionTimeoutIn) { - const sessionTimeoutIn = parseInt(expireConfig.dataset.sessionTimeoutIn, 10) * 1000; - const timeoutRefreshPath = expireConfig.dataset.timeoutRefreshPath || ''; - - setTimeout(() => { - document.location.href = timeoutRefreshPath; - }, sessionTimeoutIn); -} diff --git a/app/javascript/packs/session-timeout-ping.js b/app/javascript/packs/session-timeout-ping.js deleted file mode 100644 index fe705f4153c..00000000000 --- a/app/javascript/packs/session-timeout-ping.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @typedef NewRelicAgent - * - * @prop {(name:string,attributes:object)=>void} addPageAction Log page action to New Relic. - */ - -/** - * @typedef LoginGov - * - * @prop {(any)=>void} Modal - * @prop {(string)=>void} autoLogout - * @prop {(el:HTMLElement?,timeLeft:number,endTime:number,interval?:number)=>void} countdownTimer - */ - -/** - * @typedef NewRelicGlobals - * - * @prop {NewRelicAgent=} newrelic New Relic agent. - */ - -/** - * @typedef LoginGovGlobals - * - * @prop {LoginGov} LoginGov - */ - -/** - * @typedef {typeof window & NewRelicGlobals & LoginGovGlobals} LoginGovGlobal - */ - -const login = /** @type {LoginGovGlobal} */ (window).LoginGov; - -const warningEl = document.getElementById('session-timeout-cntnr'); - -const defaultTime = '60'; - -const frequency = parseInt(warningEl?.dataset.frequency || defaultTime, 10) * 1000; -const warning = parseInt(warningEl?.dataset.warning || defaultTime, 10) * 1000; -const start = parseInt(warningEl?.dataset.start || defaultTime, 10) * 1000; -const timeoutUrl = warningEl?.dataset.timeoutUrl; -const warningInfo = warningEl?.dataset.warningInfoHtml || ''; -warningEl?.insertAdjacentHTML('afterbegin', warningInfo); -const initialTime = new Date(); - -const modal = new login.Modal({ el: '#session-timeout-msg' }); -const keepaliveEl = document.getElementById('session-keepalive-btn'); -/** @type {HTMLMetaElement?} */ -const csrfEl = document.querySelector('meta[name="csrf-token"]'); - -let csrfToken = ''; -if (csrfEl) { - csrfToken = csrfEl.content; -} - -let countdownInterval; - -function notifyNewRelic(request, error, actionName) { - /** @type {LoginGovGlobal} */ (window).newrelic?.addPageAction('Session Ping Error', { - action_name: actionName, - request_status: request.status, - time_elapsed_ms: new Date().valueOf() - initialTime.valueOf(), - error: error.message, - }); -} - -function success(data) { - let timeRemaining = data.remaining * 1000; - const timeTimeout = new Date().getTime() + timeRemaining; - const showWarning = timeRemaining < warning; - - if (!data.live) { - login.autoLogout(timeoutUrl); - return; - } - - if (showWarning && !modal.shown) { - modal.show(); - - if (countdownInterval) { - clearInterval(countdownInterval); - } - countdownInterval = login.countdownTimer( - document.getElementById('countdown'), - timeRemaining, - timeTimeout, - ); - } - - if (!showWarning && modal.shown) { - modal.hide(); - } - - if (timeRemaining < frequency) { - timeRemaining = timeRemaining < 0 ? 0 : timeRemaining; - // Disable reason: circular dependency between ping and success - // eslint-disable-next-line no-use-before-define - setTimeout(ping, timeRemaining); - } -} - -function ping() { - const request = new XMLHttpRequest(); - request.open('GET', '/active', true); - - request.onload = function () { - try { - success(JSON.parse(request.responseText)); - } catch (error) { - notifyNewRelic(request, error, 'ping'); - } - }; - - request.send(); - setTimeout(ping, frequency); -} - -function keepalive() { - const request = new XMLHttpRequest(); - request.open('POST', '/sessions/keepalive', true); - request.setRequestHeader('X-CSRF-Token', csrfToken); - - request.onload = function () { - try { - success(JSON.parse(request.responseText)); - modal.hide(); - } catch (error) { - notifyNewRelic(request, error, 'keepalive'); - } - }; - - request.send(); -} - -keepaliveEl?.addEventListener('click', keepalive, false); -setTimeout(ping, start); diff --git a/app/javascript/packs/submit-with-spinner.js b/app/javascript/packs/submit-with-spinner.js new file mode 100644 index 00000000000..9eca956cc3f --- /dev/null +++ b/app/javascript/packs/submit-with-spinner.js @@ -0,0 +1,8 @@ +import { loadPolyfills } from '@18f/identity-polyfill'; + +loadPolyfills(['element-closest']).then(() => { + const spinner = /** @type {HTMLDivElement} */ (document.getElementById('submit-spinner')); + spinner.closest('form')?.addEventListener('submit', () => { + spinner.className = ''; + }); +}); diff --git a/app/models/agency.rb b/app/models/agency.rb index a8f67ad49bd..58f99e1ec9a 100644 --- a/app/models/agency.rb +++ b/app/models/agency.rb @@ -4,5 +4,4 @@ class Agency < ApplicationRecord has_many :service_providers, inverse_of: :agency # rubocop:enable Rails/HasManyOrHasOneDependent validates :name, presence: true - validates :abbreviation, uniqueness: { case_sensitive: false, allow_nil: true } end diff --git a/app/models/letter_requests_to_usps_ftp_log.rb b/app/models/letter_requests_to_usps_ftp_log.rb deleted file mode 100644 index 17a163e0d74..00000000000 --- a/app/models/letter_requests_to_usps_ftp_log.rb +++ /dev/null @@ -1,4 +0,0 @@ -class LetterRequestsToUspsFtpLog < ApplicationRecord - validates :ftp_at, presence: true - validates :letter_requests_count, presence: true -end diff --git a/app/models/null_service_provider.rb b/app/models/null_service_provider.rb index 2f96ee7eb19..1ed8e41613c 100644 --- a/app/models/null_service_provider.rb +++ b/app/models/null_service_provider.rb @@ -26,7 +26,6 @@ class NullServiceProvider iaa_start_date ial2_quota id - identities launch_date logo metadata_url @@ -73,10 +72,6 @@ def redirect_uris [] end - def identities - [] - end - def liveness_checking_required false end diff --git a/app/models/service_provider.rb b/app/models/service_provider.rb index a8fb68c0f8e..27700151df5 100644 --- a/app/models/service_provider.rb +++ b/app/models/service_provider.rb @@ -6,12 +6,6 @@ class ServiceProvider < ApplicationRecord belongs_to :agency - # rubocop:disable Rails/HasManyOrHasOneDependent - has_many :identities, inverse_of: :service_provider_record, - foreign_key: 'service_provider', - primary_key: 'issuer' - # rubocop:enable Rails/HasManyOrHasOneDependent - # Do not define validations in this model. # See https://github.com/18F/identity_validations include IdentityValidations::ServiceProviderValidation diff --git a/app/presenters/cancellation_presenter.rb b/app/presenters/cancellation_presenter.rb index 56fc839bbe3..5117f48adab 100644 --- a/app/presenters/cancellation_presenter.rb +++ b/app/presenters/cancellation_presenter.rb @@ -23,7 +23,6 @@ def cancellation_warnings t('users.delete.bullet_1', app: APP_NAME), t('users.delete.bullet_2_loa1'), t('users.delete.bullet_3', app: APP_NAME), - t('users.delete.bullet_4', app: APP_NAME), ] end diff --git a/app/presenters/idv/usps_presenter.rb b/app/presenters/idv/usps_presenter.rb index 8d7807adfd5..9cc3162cfc8 100644 --- a/app/presenters/idv/usps_presenter.rb +++ b/app/presenters/idv/usps_presenter.rb @@ -14,7 +14,7 @@ def title end def byline - if usps_mail_bounced? + if current_user.decorate.usps_mail_bounced? I18n.t('idv.messages.usps.new_address') else I18n.t('idv.messages.usps.address_on_file') @@ -25,8 +25,10 @@ def button letter_already_sent? ? I18n.t('idv.buttons.mail.resend') : I18n.t('idv.buttons.mail.send') end - def fallback_back_path - user_needs_address_otp_verification? ? verify_account_path : idv_phone_path + def cancel_path + return verify_account_path if user_needs_address_otp_verification? + + idv_cancel_path end def usps_mail_bounced? diff --git a/app/services/agency_seeder.rb b/app/services/agency_seeder.rb index 5c6259349f2..4dd5cf9e99a 100644 --- a/app/services/agency_seeder.rb +++ b/app/services/agency_seeder.rb @@ -13,6 +13,7 @@ def initialize( def run agencies.each do |agency_id, config| agency = Agency.find_by(id: agency_id) + config.delete('abbreviation') if agency agency.update!(config) else diff --git a/app/services/analytics.rb b/app/services/analytics.rb index 4107e3b1b6b..5a5e2954c4d 100644 --- a/app/services/analytics.rb +++ b/app/services/analytics.rb @@ -84,6 +84,7 @@ def browser_attributes CAPTURE_DOC = 'Capture Doc'.freeze # visited or submitted is appended DOC_AUTH = 'Doc Auth'.freeze # visited or submitted is appended DOC_AUTH_ASYNC = 'Doc Auth Async'.freeze + IN_PERSON_PROOFING = 'In Person Proofing'.freeze # visited or submitted is appended EMAIL_AND_PASSWORD_AUTH = 'Email and Password Authentication'.freeze EMAIL_DELETION_REQUEST = 'Email Deletion Requested'.freeze EMAIL_LANGUAGE_VISITED = 'Email Language: Visited'.freeze @@ -95,6 +96,7 @@ def browser_attributes EXPIRED_LETTERS = 'Expired Letters'.freeze FORGET_ALL_BROWSERS_SUBMITTED = 'Forget All Browsers Submitted'.freeze FORGET_ALL_BROWSERS_VISITED = 'Forget All Browsers Visited'.freeze + FRONTEND_BROWSER_CAPABILITIES = 'Frontend: Browser capabilities'.freeze IAL2_RECOVERY = 'IAL2 Recovery'.freeze # visited or submitted is appended IAL2_RECOVERY_REQUEST = 'IAL2 Recovery Request'.freeze IAL2_RECOVERY_REQUEST_VISITED = 'IAL2 Recovery Request Visited'.freeze @@ -108,7 +110,6 @@ def browser_attributes IDV_COME_BACK_LATER_VISIT = 'IdV: come back later visited'.freeze IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM = 'IdV: doc auth image upload form submitted'.freeze IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR = 'IdV: doc auth image upload vendor submitted'.freeze - IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION = 'IdV: doc auth image upload vendor pii validation'.freeze IDV_MAX_ATTEMPTS_EXCEEDED = 'IdV: max attempts exceeded'.freeze IDV_FINAL = 'IdV: final resolution'.freeze IDV_FORGOT_PASSWORD = 'IdV: forgot password visited'.freeze @@ -135,7 +136,6 @@ def browser_attributes IDV_USPS_ADDRESS_VISITED = 'IdV: USPS address visited'.freeze IDV_VERIFICATION_ATTEMPT_CANCELLED = 'IdV: verification attempt cancelled'.freeze INVALID_AUTHENTICITY_TOKEN = 'Invalid Authenticity Token'.freeze - IN_PERSON_PROOFING = 'In Person Proofing'.freeze # visited or submitted is appended LAMBDA_RESULT_RESOLUTION_PROOF_RESULT = 'Lambda Resolution Proof Result Received'.freeze LAMBDA_RESULT_ADDRESS_PROOF_RESULT = 'Lambda Address Proof Result Received'.freeze LAMBDA_RESULT_DOCUMENT_PROOF_RESULT = 'Lambda Document Proof Result Received'.freeze diff --git a/app/services/data_requests/write_cloudwatch_logs.rb b/app/services/data_requests/write_cloudwatch_logs.rb index 801ea944f8e..486d77c8a58 100644 --- a/app/services/data_requests/write_cloudwatch_logs.rb +++ b/app/services/data_requests/write_cloudwatch_logs.rb @@ -5,7 +5,6 @@ class WriteCloudwatchLogs event_name success multi_factor_auth_method - multi_factor_id service_provider ip_address user_agent @@ -19,17 +18,23 @@ def initialize(cloudwatch_results, output_dir) end def call - CSV.open(File.join(output_dir, 'logs.csv'), 'w') do |csv| - csv << HEADERS - cloudwatch_results.each do |row| - csv << build_row(row) - end + output_file.puts(HEADERS.join(',')) + cloudwatch_results.each do |row| + write_row(row) end + output_file.close end private - def build_row(row) + def output_file + @output_file ||= begin + output_path = File.join(output_dir, 'logs.csv') + File.open(output_path, 'w') + end + end + + def write_row(row) data = JSON.parse(row.message) timestamp = data.dig('time') @@ -38,34 +43,18 @@ def build_row(row) multi_factor_auth_method = data.dig( 'properties', 'event_properties', 'multi_factor_auth_method' ) - - mfa_key = case multi_factor_auth_method - when 'sms', 'voice' - 'phone_configuration_id' - when 'piv_cac' - 'piv_cac_configuration_id' - when 'webauthn' - 'webauthn_configuration_id' - when 'totp' - 'auth_app_configuration_id' - end - - row_id = data.dig('properties', 'event_properties', mfa_key) - multi_factor_id = row_id && "#{mfa_key}:#{row_id}" service_provider = data.dig('properties', 'service_provider') ip_address = data.dig('properties', 'user_ip') user_agent = data.dig('properties', 'user_agent') - [ - timestamp, - event_name, - success, - multi_factor_auth_method, - multi_factor_id, - service_provider, - ip_address, - user_agent, - ] + output_file.puts( + CSV.generate_line( + [ + timestamp, event_name, success, multi_factor_auth_method, + service_provider, ip_address, user_agent + ], + ), + ) end end end diff --git a/app/services/db/sp_cost/add_sp_cost.rb b/app/services/db/sp_cost/add_sp_cost.rb index bb99808ece3..6d465231122 100644 --- a/app/services/db/sp_cost/add_sp_cost.rb +++ b/app/services/db/sp_cost/add_sp_cost.rb @@ -20,20 +20,14 @@ class SpCostTypeError < StandardError; end voice ].freeze - def self.call(issuer, ial, token, transaction_id: nil) + def self.call(issuer, ial, token) return if token.blank? unless TOKEN_WHITELIST.include?(token.to_sym) NewRelic::Agent.notice_error(SpCostTypeError.new(token.to_s)) return end agency_id = (issuer.present? && ServiceProvider.find_by(issuer: issuer)&.agency_id) || 0 - ::SpCost.create( - issuer: issuer.to_s, - ial: ial, - agency_id: agency_id, - cost_type: token, - transaction_id: transaction_id, - ) + ::SpCost.create(issuer: issuer.to_s, ial: ial, agency_id: agency_id, cost_type: token) end end end diff --git a/app/services/doc_auth_router.rb b/app/services/doc_auth_router.rb index 924ad647d96..b51d1e42c63 100644 --- a/app/services/doc_auth_router.rb +++ b/app/services/doc_auth_router.rb @@ -219,7 +219,17 @@ def self.notify_exception(exception, custom_params = nil) end end + # + # The `acuant_simulator` config is deprecated. The logic to switch vendors + # based on its value can be removed once FORCE_ACUANT_CONFIG_UPGRADE in + # acuant_simulator_config_validation.rb has been set to true for at least + # a deploy cycle. + # def self.doc_auth_vendor vendor_from_config = AppConfig.env.doc_auth_vendor + if vendor_from_config.blank? + return AppConfig.env.acuant_simulator == 'true' ? 'mock' : 'acuant' + end + vendor_from_config end end diff --git a/app/services/document_capture_session_async_result.rb b/app/services/document_capture_session_async_result.rb index c7e95648b9f..3f505d2e5d0 100644 --- a/app/services/document_capture_session_async_result.rb +++ b/app/services/document_capture_session_async_result.rb @@ -22,8 +22,6 @@ def done? status == DocumentCaptureSessionAsyncResult::DONE end - alias_method :success?, :done? - def in_progress? status == DocumentCaptureSessionAsyncResult::IN_PROGRESS end diff --git a/app/services/encryption/multi_region_kms_client.rb b/app/services/encryption/multi_region_kms_client.rb index 4cf87338bdb..a566c1b96ef 100644 --- a/app/services/encryption/multi_region_kms_client.rb +++ b/app/services/encryption/multi_region_kms_client.rb @@ -59,7 +59,7 @@ def encrypt_legacy(key_id, plaintext, encryption_context) def find_available_region(regions) regions.each do |region, cipher| region_client = @aws_clients[region] - return CipherData.new(region_client, Base64.strict_decode64(cipher)) if region_client + return CipherData.new(region_client, cipher) if region_client end raise EncryptionError, 'No supported region found in ciphertext' end diff --git a/app/services/flow/flow_state_machine.rb b/app/services/flow/flow_state_machine.rb index 49085955360..40e8ea93ac2 100644 --- a/app/services/flow/flow_state_machine.rb +++ b/app/services/flow/flow_state_machine.rb @@ -22,10 +22,7 @@ def update step = current_step result = flow.handle(step) if @analytics_id - increment_step_name_counts analytics.track_event(analytics_submitted, result.to_h.merge(analytics_properties)) - # keeping the old event names for backward compatibility - analytics.track_event(old_analytics_submitted, result.to_h.merge(analytics_properties)) end register_update_step(step, result) if flow.json @@ -47,12 +44,7 @@ def current_step end def track_step_visited - if @analytics_id - increment_step_name_counts - analytics.track_event(analytics_visited, analytics_properties) - # keeping the old event names for backward compatibility - analytics.track_event(old_analytics_visited, analytics_properties) - end + analytics.track_event(analytics_visited, analytics_properties) if @analytics_id Funnel::DocAuth::RegisterStep.new(user_id, issuer).call(current_step, :view, true) register_campaign end @@ -124,11 +116,7 @@ def call_optional_show_step(step) result = optional_show_step.new(@flow).base_call if @analytics_id - optional_properties = result.to_h.merge(step: optional_show_step) - - analytics.track_event(analytics_optional_step, optional_properties) - # keeping the old event names for backward compatibility - analytics.track_event(old_analytics_optional_step, optional_properties) + analytics.track_event(analytics_optional_step, result.to_h.merge(step: optional_show_step)) end if next_step.to_s != step @@ -156,50 +144,31 @@ def redirect_to_step(step) end def analytics_submitted - 'IdV: ' + "#{@analytics_id} #{current_step} submitted".downcase - end - - def analytics_visited - 'IdV: ' + "#{@analytics_id} #{current_step} visited".downcase - end - - def analytics_optional_step - 'IdV: ' + "#{@analytics_id} optional #{current_step} submitted".downcase - end - - def old_analytics_submitted @analytics_id + ' submitted' end - def old_analytics_visited + def analytics_visited @analytics_id + ' visited' end - def old_analytics_optional_step + def analytics_optional_step [@analytics_id, 'optional submitted'].join(' ') end def analytics_properties + current_step_name = "#{current_step.to_s}_#{action_name}" + current_flow_step_counts[current_step_name] ||= 0 { step: current_step, - step_count: current_flow_step_counts[current_step_name], + step_count: current_flow_step_counts[current_step_name] += 1, } end - def current_step_name - "#{current_step}_#{action_name}" - end - def current_flow_step_counts current_session["#{@name}_flow_step_counts"] ||= {} - current_session["#{@name}_flow_step_counts"].default = 0 current_session["#{@name}_flow_step_counts"] end - def increment_step_name_counts - current_flow_step_counts[current_step_name] += 1 - end - def next_step flow.next_step end diff --git a/app/services/identity_linker.rb b/app/services/identity_linker.rb index c95ba7b5dbb..13af39c4771 100644 --- a/app/services/identity_linker.rb +++ b/app/services/identity_linker.rb @@ -8,7 +8,6 @@ def initialize(user, provider) end def link_identity(**extra_attrs) - return unless user && provider.present? process_ial(extra_attrs) attributes = merged_attributes(extra_attrs) identity.update!(attributes) diff --git a/app/services/idv/actions/cancel_link_sent_action.rb b/app/services/idv/actions/cancel_link_sent_action.rb deleted file mode 100644 index b8ebc0b2e93..00000000000 --- a/app/services/idv/actions/cancel_link_sent_action.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Idv - module Actions - class CancelLinkSentAction < Idv::Steps::DocAuthBaseStep - def call - mark_step_incomplete(:send_link) - end - end - end -end diff --git a/app/services/idv/actions/cancel_send_link_action.rb b/app/services/idv/actions/cancel_send_link_action.rb deleted file mode 100644 index 8d9fe50ce29..00000000000 --- a/app/services/idv/actions/cancel_send_link_action.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Idv - module Actions - class CancelSendLinkAction < Idv::Steps::DocAuthBaseStep - def call - mark_step_incomplete(:upload) - end - end - end -end diff --git a/app/services/idv/actions/verify_document_action.rb b/app/services/idv/actions/verify_document_action.rb index 3bbdc4ccc77..5443ae189bd 100644 --- a/app/services/idv/actions/verify_document_action.rb +++ b/app/services/idv/actions/verify_document_action.rb @@ -30,13 +30,9 @@ def form end def enqueue_job - verify_document_capture_session = if hybrid_flow_mobile? - document_capture_session - else - create_document_capture_session( - verify_document_capture_session_uuid_key, - ) - end + verify_document_capture_session = create_document_capture_session( + verify_document_capture_session_uuid_key, + ) verify_document_capture_session.requested_at = Time.zone.now verify_document_capture_session.create_doc_auth_session diff --git a/app/services/idv/actions/verify_document_status_action.rb b/app/services/idv/actions/verify_document_status_action.rb index 69b209c0773..6f8cc8b0770 100644 --- a/app/services/idv/actions/verify_document_status_action.rb +++ b/app/services/idv/actions/verify_document_status_action.rb @@ -61,13 +61,9 @@ def process_result(result) def verify_document_capture_session return @verify_document_capture_session if defined?(@verify_document_capture_session) - @verify_document_capture_session = if hybrid_flow_mobile? - document_capture_session - else - DocumentCaptureSession.find_by( - uuid: flow_session[verify_document_capture_session_uuid_key], - ) - end + @verify_document_capture_session = DocumentCaptureSession.find_by( + uuid: flow_session[verify_document_capture_session_uuid_key], + ) end def async_state diff --git a/app/services/idv/flows/cac_flow.rb b/app/services/idv/flows/cac_flow.rb index 9a1a8217c13..f4eb6c15f2e 100644 --- a/app/services/idv/flows/cac_flow.rb +++ b/app/services/idv/flows/cac_flow.rb @@ -8,6 +8,7 @@ class CacFlow < Flow::BaseFlow enter_info: Idv::Steps::Cac::EnterInfoStep, verify: Idv::Steps::Cac::VerifyStep, verify_wait: Idv::Steps::Cac::VerifyWaitStep, + success: Idv::Steps::Cac::SuccessStep, }.freeze OPTIONAL_SHOW_STEPS = { diff --git a/app/services/idv/flows/capture_doc_flow.rb b/app/services/idv/flows/capture_doc_flow.rb index 680d4747a88..401946b0468 100644 --- a/app/services/idv/flows/capture_doc_flow.rb +++ b/app/services/idv/flows/capture_doc_flow.rb @@ -8,8 +8,6 @@ class CaptureDocFlow < Flow::BaseFlow ACTIONS = { reset: Idv::Actions::ResetAction, - verify_document: Idv::Actions::VerifyDocumentAction, - verify_document_status: Idv::Actions::VerifyDocumentStatusAction, }.freeze def initialize(controller, session, _name) diff --git a/app/services/idv/flows/doc_auth_flow.rb b/app/services/idv/flows/doc_auth_flow.rb index 8711b4e5784..82fce9e343a 100644 --- a/app/services/idv/flows/doc_auth_flow.rb +++ b/app/services/idv/flows/doc_auth_flow.rb @@ -17,8 +17,6 @@ class DocAuthFlow < Flow::BaseFlow }.freeze ACTIONS = { - cancel_send_link: Idv::Actions::CancelSendLinkAction, - cancel_link_sent: Idv::Actions::CancelLinkSentAction, reset: Idv::Actions::ResetAction, redo_ssn: Idv::Actions::RedoSsnAction, verify_document: Idv::Actions::VerifyDocumentAction, diff --git a/app/services/idv/steps/cac/success_step.rb b/app/services/idv/steps/cac/success_step.rb new file mode 100644 index 00000000000..5361d311a42 --- /dev/null +++ b/app/services/idv/steps/cac/success_step.rb @@ -0,0 +1,9 @@ +module Idv + module Steps + module Cac + class SuccessStep < DocAuthBaseStep + def call; end + end + end + end +end diff --git a/app/services/idv/steps/doc_auth_base_step.rb b/app/services/idv/steps/doc_auth_base_step.rb index c55a1e68c87..f21be9308d6 100644 --- a/app/services/idv/steps/doc_auth_base_step.rb +++ b/app/services/idv/steps/doc_auth_base_step.rb @@ -50,10 +50,6 @@ def user_id_from_token flow_session[:doc_capture_user_id] end - def hybrid_flow_mobile? - user_id_from_token.present? - end - def throttled_response redirect_to throttled_url IdentityDocAuth::Response.new( @@ -75,9 +71,9 @@ def user_id current_user ? current_user.id : user_id_from_token end - def add_cost(token, transaction_id: nil) + def add_cost(token) issuer = sp_session[:issuer].to_s - Db::SpCost::AddSpCost.call(issuer, 2, token, transaction_id: transaction_id) + Db::SpCost::AddSpCost.call(issuer, 2, token) Db::ProofingCost::AddUserProofingCost.call(user_id, token) end diff --git a/app/services/idv/steps/link_sent_step.rb b/app/services/idv/steps/link_sent_step.rb index 47ac432a929..06b607d1363 100644 --- a/app/services/idv/steps/link_sent_step.rb +++ b/app/services/idv/steps/link_sent_step.rb @@ -31,10 +31,7 @@ def take_photo_with_phone_successful? end def document_capture_session_result - @document_capture_session_result ||= ( - document_capture_session&.load_result || - document_capture_session&.load_doc_auth_async_result - ) + @document_capture_session_result ||= document_capture_session&.load_result end def mark_steps_complete diff --git a/app/services/idv/steps/verify_base_step.rb b/app/services/idv/steps/verify_base_step.rb index 29283fe78d2..8aa9f5fffa4 100644 --- a/app/services/idv/steps/verify_base_step.rb +++ b/app/services/idv/steps/verify_base_step.rb @@ -54,14 +54,8 @@ def idv_result_to_form_response(idv_result) def add_proofing_costs(results) vendors = results[:context][:stages] vendors.each do |hash| - if hash[:state_id] - # transaction_id comes from TransactionLocatorId - add_cost(:aamva, transaction_id: hash[:transaction_id]) - end - if hash[:resolution] - # transaction_id comes from ConversationId - add_cost(:lexis_nexis_resolution, transaction_id: hash[:transaction_id]) - end + add_cost(:aamva) if hash[:state_id] + add_cost(:lexis_nexis_resolution) if hash[:resolution] end end diff --git a/app/services/reports/monthly_usps_letter_requests_report.rb b/app/services/reports/monthly_usps_letter_requests_report.rb deleted file mode 100644 index 29acfe0cce4..00000000000 --- a/app/services/reports/monthly_usps_letter_requests_report.rb +++ /dev/null @@ -1,22 +0,0 @@ -require 'login_gov/hostdata' - -module Reports - class MonthlyUspsLetterRequestsReport < BaseReport - REPORT_NAME = 'monthly-usps-letter-requests-report'.freeze - - def call - daily_results = transaction_with_timeout do - ::LetterRequestsToUspsFtpLog.where(ftp_at: first_of_this_month..end_of_today) - end - totals = calculate_totals(daily_results) - save_report(REPORT_NAME, {total_letter_requests: totals, - daily_letter_requests: daily_results}.to_json) - end - - private - - def calculate_totals(daily_results) - daily_results.inject(0) {|sum, rec| sum + rec['letter_requests_count'].to_i } - end - end -end diff --git a/app/services/sp_handoff_bounce.rb b/app/services/sp_handoff_bounce.rb deleted file mode 100644 index ffcf919bab0..00000000000 --- a/app/services/sp_handoff_bounce.rb +++ /dev/null @@ -1,13 +0,0 @@ -class SpHandoffBounce - def self.is_bounced?(session) - start_time = session[:sp_handoff_start_time] - return if start_time.blank? - tz = Time.zone - start_time = tz.parse(start_time) if start_time.class == String - tz.now <= (start_time + AppConfig.env.sp_handoff_bounce_max_seconds.to_i.seconds) - end - - def self.add_handoff_time_to_session(session) - session[:sp_handoff_start_time] = Time.zone.now - end -end diff --git a/app/services/sp_handoff_bounce/add_handoff_time_to_session.rb b/app/services/sp_handoff_bounce/add_handoff_time_to_session.rb new file mode 100644 index 00000000000..feaa37377eb --- /dev/null +++ b/app/services/sp_handoff_bounce/add_handoff_time_to_session.rb @@ -0,0 +1,7 @@ +module SpHandoffBounce + class AddHandoffTimeToSession + def self.call(session) + session[:sp_handoff_start_time] = Time.zone.now + end + end +end diff --git a/app/services/sp_handoff_bounce/is_bounced.rb b/app/services/sp_handoff_bounce/is_bounced.rb new file mode 100644 index 00000000000..a0673c48804 --- /dev/null +++ b/app/services/sp_handoff_bounce/is_bounced.rb @@ -0,0 +1,11 @@ +module SpHandoffBounce + class IsBounced + def self.call(session) + start_time = session[:sp_handoff_start_time] + return if start_time.blank? + tz = Time.zone + start_time = tz.parse(start_time) if start_time.class == String + tz.now <= (start_time + AppConfig.env.sp_handoff_bounce_max_seconds.to_i.seconds) + end + end +end diff --git a/app/services/store_sp_metadata_in_session.rb b/app/services/store_sp_metadata_in_session.rb index e5f6865a673..38454c71a3a 100644 --- a/app/services/store_sp_metadata_in_session.rb +++ b/app/services/store_sp_metadata_in_session.rb @@ -35,7 +35,6 @@ def sp_request def update_session session[:sp] = { issuer: sp_request.issuer, - ial: ial_context.ial, ial2: ial_context.ial2_requested?, ial2_strict: ial_context.ial2_strict_requested?, ialmax: ial_context.ialmax_requested?, diff --git a/app/services/usps_confirmation_uploader.rb b/app/services/usps_confirmation_uploader.rb index c2e60709dec..2878bccce9f 100644 --- a/app/services/usps_confirmation_uploader.rb +++ b/app/services/usps_confirmation_uploader.rb @@ -1,14 +1,9 @@ class UspsConfirmationUploader - def initialize - @now = Time.zone.now - end - def run confirmations = UspsConfirmation.all export = generate_export(confirmations) upload_export(export) clear_confirmations(confirmations) - LetterRequestsToUspsFtpLog.create(ftp_at: @now, letter_requests_count: confirmations.count) rescue StandardError => error NewRelic::Agent.notice_error(error) end @@ -32,7 +27,7 @@ def clear_confirmations(confirmations) end def remote_path - timestamp = @now.strftime('%Y%m%d') + timestamp = Time.zone.now.strftime('%Y%m%d') File.join(env.usps_upload_sftp_directory, "batch#{timestamp}.psv") end diff --git a/app/views/account_reset/confirm_delete_account/show.html.erb b/app/views/account_reset/confirm_delete_account/show.html.erb index e1a255e8410..f57931ea938 100644 --- a/app/views/account_reset/confirm_delete_account/show.html.erb +++ b/app/views/account_reset/confirm_delete_account/show.html.erb @@ -5,7 +5,7 @@ alt: '', class: 'absolute top-n24 left-0 right-0 margin-x-auto') %>

<%= t('account_reset.confirm_delete_account.title') %>

-

<%= t('account_reset.confirm_delete_account.info_html', app: APP_NAME, email: email) %>

+

<%= t('account_reset.confirm_delete_account.info_html', email: email) %>

<%= t('account_reset.confirm_delete_account.cta_html', link: link_to(t('account_reset.confirm_delete_account.link_text'), sign_up_email_path)) %>

diff --git a/app/views/account_reset/delete_account/show.html.erb b/app/views/account_reset/delete_account/show.html.erb index 24119796876..eb1d6b24275 100644 --- a/app/views/account_reset/delete_account/show.html.erb +++ b/app/views/account_reset/delete_account/show.html.erb @@ -4,7 +4,7 @@ <%= t('account_reset.delete_account.title') %>

- <%= t('account_reset.delete_account.info', app: APP_NAME) %> + <%= t('account_reset.delete_account.info') %>


diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 02d433fb4b7..fef025acf80 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -39,7 +39,7 @@ ) %>

<% end %> -<% if @ial && desktop_device? %> +<% if FeatureManagement.allow_piv_cac_login? && @ial && desktop_device? %>
<%= link_to( t('account.login.piv_cac'), diff --git a/app/views/idv/address/new.html.erb b/app/views/idv/address/new.html.erb index 3f058c20891..acf16805944 100644 --- a/app/views/idv/address/new.html.erb +++ b/app/views/idv/address/new.html.erb @@ -37,4 +37,6 @@ <% end %>
-<%= render 'idv/doc_auth/back', step: 'verify' %> +
+ <%= link_to t('links.cancel'), idv_cancel_path, class: 'h5' %> +
diff --git a/app/views/idv/cac/success.html.erb b/app/views/idv/cac/success.html.erb new file mode 100644 index 00000000000..269df24d8d5 --- /dev/null +++ b/app/views/idv/cac/success.html.erb @@ -0,0 +1,22 @@ +<% title t('cac_proofing.titles.cac_proofing') %> +
+ <%= t('cac_proofing.step', step: 2) %> +
+ +<%= image_tag(asset_url('state-id-confirm@3x.png'), width: 210) %> + +

+ <%= t('cac_proofing.headings.success') %> +

+ +
+ +
+ <%= button_to(t('forms.buttons.continue'), url_for, method: :put, + class: 'btn btn-primary btn-wide sm-col-6 col-12') %> +
+ +<%= validated_form_for('', url: url_for, method: 'PUT', + html: { autocomplete: 'off', role: 'form', class: 'margin-top-2' }) do |f| %> +<% end %> +<%= render 'idv/cac/start_over_or_cancel' %> diff --git a/app/views/idv/doc_auth/_back.html.erb b/app/views/idv/doc_auth/_back.html.erb deleted file mode 100644 index 0bef687810e..00000000000 --- a/app/views/idv/doc_auth/_back.html.erb +++ /dev/null @@ -1,31 +0,0 @@ -<%# -Renders a "Back" link to return to a previous step, given by one of action or step local variables. -If neither are passed, redirects to the previous screen using HTTP referer. An optional fallback -path can be passed in case the HTTP header is not specified or is invalid. If none of the above -yield a useable URL, nothing will be rendered. - -locals: -* action: (Optional) Flow action to call to return to the previous step. -* step: (Optional) Name of step to which user should be returned. -* fallback_path: (Optional) Path to redirect absent action, step, and HTTP referer. -%> -<% -text = '‹ ' + t('forms.buttons.back') -step = local_assigns[:action] || local_assigns[:step] -path = step ? idv_doc_auth_step_path(step: step) : go_back_path -path ||= local_assigns[:fallback_path] -%> -<% if path %> -
- <% if local_assigns[:action] %> - <%= button_to( - text, - path, - method: :put, - class: 'btn btn-link' - ) %> - <% else %> - <%= link_to(text, path) %> - <% end %> -
-<% end %> diff --git a/app/views/idv/doc_auth/_spinner.html.erb b/app/views/idv/doc_auth/_spinner.html.erb new file mode 100644 index 00000000000..3e832ffd5dd --- /dev/null +++ b/app/views/idv/doc_auth/_spinner.html.erb @@ -0,0 +1,11 @@ +
+ +
diff --git a/app/views/idv/doc_auth/_submit_with_spinner.html.erb b/app/views/idv/doc_auth/_submit_with_spinner.html.erb new file mode 100644 index 00000000000..ff80876cf8e --- /dev/null +++ b/app/views/idv/doc_auth/_submit_with_spinner.html.erb @@ -0,0 +1,6 @@ + +<%= render 'idv/doc_auth/spinner' %> +
+<%= javascript_packs_tag_once 'submit-with-spinner' %> diff --git a/app/views/idv/doc_auth/link_sent.html.erb b/app/views/idv/doc_auth/link_sent.html.erb index 374357fc5df..2c5ae586e09 100644 --- a/app/views/idv/doc_auth/link_sent.html.erb +++ b/app/views/idv/doc_auth/link_sent.html.erb @@ -38,7 +38,7 @@ %> -<%= render 'idv/doc_auth/back', action: 'cancel_link_sent' %> +<%= render 'idv/doc_auth/start_over_or_cancel' %> <% if FeatureManagement.doc_capture_polling_enabled? %> <%= javascript_packs_tag_once 'doc_capture_polling' %> diff --git a/app/views/idv/doc_auth/send_link.html.erb b/app/views/idv/doc_auth/send_link.html.erb index 6fc5f489a0f..0fade994cb0 100644 --- a/app/views/idv/doc_auth/send_link.html.erb +++ b/app/views/idv/doc_auth/send_link.html.erb @@ -39,4 +39,4 @@ <% end %> -<%= render 'idv/doc_auth/back', action: 'cancel_send_link' %> +<%= render 'idv/doc_auth/start_over_or_cancel' %> diff --git a/app/views/idv/doc_auth/upload.html.erb b/app/views/idv/doc_auth/upload.html.erb index 4bf187bec2c..74976737b4b 100644 --- a/app/views/idv/doc_auth/upload.html.erb +++ b/app/views/idv/doc_auth/upload.html.erb @@ -36,7 +36,7 @@

<% end %> -<% if desktop_device? %> +<% if AppConfig.env.cac_proofing_enabled == 'true' && desktop_device? %>

<%= t('doc_auth.info.use_cac') %> <%= link_to(t('doc_auth.info.use_cac_link'), idv_cac_step_path(step: :choose_method)) %> diff --git a/app/views/idv/doc_auth/welcome.html.erb b/app/views/idv/doc_auth/welcome.html.erb index bd4a478c32f..d578aa2d5b8 100644 --- a/app/views/idv/doc_auth/welcome.html.erb +++ b/app/views/idv/doc_auth/welcome.html.erb @@ -102,8 +102,7 @@ <%= new_window_link_to t('doc_auth.instructions.learn_more'), 'https://login.gov/policy/' %> - <%= f.button :button, t('doc_auth.buttons.continue'), type: :submit, - class: 'btn btn-primary btn-wide sm-col-6 col-6' %> + <%= f.button :submit, t('doc_auth.buttons.continue'), class: 'btn btn-primary btn-wide sm-col-6 col-6' %> <% end %>
diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 0075f9c2ad7..1d7eb3f7291 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -18,10 +18,10 @@ mock_client: (DocAuthRouter.doc_auth_vendor == 'mock').presence, document_capture_session_uuid: flow_session[:document_capture_session_uuid], endpoint: FeatureManagement.document_capture_async_uploads_enabled? ? - send(@step_url, step: :verify_document) : + idv_doc_auth_step_path(step: :verify_document) : api_verify_images_url, status_endpoint: FeatureManagement.document_capture_async_uploads_enabled? ? - send(@step_url, step: :verify_document_status) : + idv_doc_auth_step_path(step: :verify_document_status) : nil, status_poll_interval_ms: AppConfig.env.poll_rate_for_verify_in_seconds.to_i * 1000, sp_name: sp_name, @@ -133,10 +133,7 @@ <%# ---- Submit ----- %>

- -
+ <%= render 'idv/doc_auth/submit_with_spinner' %>
<% end %> <%# end validated_form_for %> diff --git a/app/views/idv/usps/index.html.erb b/app/views/idv/usps/index.html.erb index 29c87f89157..3f3db6e1a30 100644 --- a/app/views/idv/usps/index.html.erb +++ b/app/views/idv/usps/index.html.erb @@ -16,4 +16,7 @@ <% end %> -<%= render 'idv/doc_auth/back', fallback_path: @presenter.fallback_back_path %> +
+ <%= button_to(t('idv.messages.clear_and_start_over'), idv_session_path, method: :delete, class: 'btn btn-link margin-bottom-1') %> + <%= link_to(t('links.cancel'), @presenter.cancel_path, class: 'h5') %> +
diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 992ffb65a5b..d3c08c16388 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -73,26 +73,18 @@ <%= render 'shared/footer_lite' %> - <% if current_user # Render the JS snipped that collects platform authenticator analytics %> -
- <%= render partial: 'session_timeout/ping', - locals: { - timeout_url: timeout_url, - warning: session_timeout_warning, - start: session_timeout_start, - frequency: session_timeout_frequency, - modal: session_modal, - } %> - <% elsif !@skip_session_expiration %> - <%= render partial: 'session_timeout/expire_session', - locals: { - session_timeout_in: Devise.timeout_in, - } %> - <% end %> +
<%= javascript_packs_tag_once 'application', prepend: true %> <%= render_javascript_pack_once_tags %> + <% if current_user # Render the JS snipped that collects platform authenticator analytics %> +
+ <%= auto_session_timeout_js %> + <% else %> + <%= auto_session_expired_js %> + <% end %> + <%= render 'shared/dap_analytics' if AppConfig.env.participate_in_dap == 'true' && !session_with_trust? %> diff --git a/app/views/session_timeout/_expire_session.html.erb b/app/views/session_timeout/_expire_session.html.erb deleted file mode 100644 index 709ad682a8b..00000000000 --- a/app/views/session_timeout/_expire_session.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= tag.div id: 'js-expire-session', - data: { - session_timeout_in: session_timeout_in, - timeout_refresh_path: timeout_refresh_path - } %> - -<%= javascript_packs_tag_once 'session-expire-session' %> diff --git a/app/views/session_timeout/_expire_session.js.erb b/app/views/session_timeout/_expire_session.js.erb new file mode 100644 index 00000000000..318143d1c37 --- /dev/null +++ b/app/views/session_timeout/_expire_session.js.erb @@ -0,0 +1,7 @@ +var sessionTimeoutIn = <%= session_timeout_in %> * 1000; + +function refreshPage() { + document.location = "<%= j timeout_refresh_path %>"; +} + +setTimeout(refreshPage, sessionTimeoutIn); diff --git a/app/views/session_timeout/_ping.html.erb b/app/views/session_timeout/_ping.html.erb deleted file mode 100644 index 4bb082c8808..00000000000 --- a/app/views/session_timeout/_ping.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%= tag.div id: 'session-timeout-cntnr', - data: { - timeout_url: timeout_url, - warning: warning, - start: start, - frequency: frequency, - warning_info_html: render(partial: 'session_timeout/warning', - locals: { modal: modal }), - } %> - -<%= javascript_packs_tag_once 'session-timeout-ping' %> diff --git a/app/views/session_timeout/_ping.js.erb b/app/views/session_timeout/_ping.js.erb new file mode 100644 index 00000000000..f81c9e2891c --- /dev/null +++ b/app/views/session_timeout/_ping.js.erb @@ -0,0 +1,84 @@ +var frequency = <%= frequency %> * 1000; +var warning = <%= warning %> * 1000; +var start = <%= start %> * 1000; +var timeoutUrl = "<%= j timeout_url %>"; +var warning_info = "<%= j render('session_timeout/warning', locals: { modal: modal }) %>"; +var warningEl = document.getElementById('session-timeout-cntnr'); +warningEl.insertAdjacentHTML('afterbegin', warning_info); + +var modal = new window.LoginGov.Modal({ el: '#session-timeout-msg' }); +var keepaliveEl = document.getElementById('session-keepalive-btn'); +var csrfEl = document.querySelector('meta[name="csrf-token"]') + +var csrfToken = ""; +if (csrfEl) { + csrfToken = csrfEl.content +} + +keepaliveEl.addEventListener('click', keepalive, false); + +var pingTimeout; +var countdownInterval; + +function ping() { + var request = new XMLHttpRequest(); + request.open('GET', '/active', true); + + request.onload = function() { + if (request.status >= 200 && request.status < 400) { + success(JSON.parse(request.responseText)); + } + }; + + request.send(); + pingTimeout = setTimeout(ping, frequency) +} + +function success(data) { + var el = document.getElementById('session-timeout-msg'), + cntnr = document.getElementById('session-timeout-cntnr'); + + var time_remaining = data.remaining * 1000, + time_timeout = new Date().getTime() + time_remaining, + show_warning = time_remaining < warning + + if (!data.live) { + window.LoginGov.autoLogout(timeoutUrl); + return; + } + + if (show_warning && !modal.shown) { + modal.show(); + + if(countdownInterval) { + clearInterval(countdownInterval); + } + countdownInterval = window.LoginGov.countdownTimer( + document.getElementById('countdown'), time_remaining, time_timeout + ); + } + + if (!show_warning && modal.shown) modal.hide(); + + if (time_remaining < frequency){ + time_remaining = time_remaining < 0 ? 0 : time_remaining + ping_timeout = setTimeout(ping, time_remaining) + } +} + +function keepalive() { + var request = new XMLHttpRequest(); + request.open('POST', '/sessions/keepalive', true); + request.setRequestHeader('X-CSRF-Token', csrfToken); + + request.onload = function() { + if (request.status >= 200 && request.status < 400) { + success(JSON.parse(request.responseText)); + modal.hide(); + } + }; + + request.send(); +} + +setTimeout(ping, start); diff --git a/app/views/user_mailer/account_reset_request.html.erb b/app/views/user_mailer/account_reset_request.html.erb index 182223c1b8e..fb779664275 100644 --- a/app/views/user_mailer/account_reset_request.html.erb +++ b/app/views/user_mailer/account_reset_request.html.erb @@ -1,5 +1,5 @@

- <%= t('user_mailer.account_reset_request.intro_html', app: link_to(APP_NAME, AppConfig.env.mailer_domain_name, class: 'gray')) %> + <%= t('user_mailer.account_reset_request.intro', app: link_to(APP_NAME, AppConfig.env.mailer_domain_name, class: 'gray')) %>

diff --git a/app/views/users/delete/show.html.erb b/app/views/users/delete/show.html.erb index 763228118cc..d206a482bfe 100644 --- a/app/views/users/delete/show.html.erb +++ b/app/views/users/delete/show.html.erb @@ -12,7 +12,6 @@

  • <%= t('users.delete.bullet_1', app: APP_NAME) %>
  • <%= current_user.decorate.delete_account_bullet_key %>
  • <%= t('users.delete.bullet_3', app: APP_NAME) %>
  • -
  • <%= t('users.delete.bullet_4', app: APP_NAME) %>
  • <%= validated_form_for(current_user, url: account_delete_path, diff --git a/bin/smoke_test b/bin/smoke_test index 0be0eff8bd4..c6e662ecca6 100755 --- a/bin/smoke_test +++ b/bin/smoke_test @@ -4,7 +4,7 @@ set -euo pipefail params="" spec_helper="rails_helper" should_source_env=1 -only_failures="" +retry_count=3 function help() { cat < { Reports::UspsReport.new.call }, ) - -# Send Monthly USPS Letter Requests Report to S3 -JobRunner::Runner.add_config JobRunner::JobConfiguration.new( - name: 'Monthly USPS letter requests report', - interval: 24 * 60 * 60, # 24 hours - timeout: 300, - callback: -> { Reports::MonthlyUspsLetterRequestsReport.new.call }, -) diff --git a/config/initializers/saml_idp.rb b/config/initializers/saml_idp.rb index 31c8d507736..702703be34c 100644 --- a/config/initializers/saml_idp.rb +++ b/config/initializers/saml_idp.rb @@ -13,8 +13,8 @@ # config.verify_authnrequest_sig = true # Organization contact information - config.organization_name = 'login.gov' - config.organization_url = 'https://login.gov' + config.organization_name = '18F' + config.organization_url = 'http://18f.gsa.gov' config.base_saml_location = "#{api_base}/saml" config.attribute_service_location = "#{api_base}/saml/attributes" config.single_service_post_location = "#{api_base}/saml/auth" diff --git a/config/locales/account_reset/en.yml b/config/locales/account_reset/en.yml index 7c64d798859..2817003b2dd 100644 --- a/config/locales/account_reset/en.yml +++ b/config/locales/account_reset/en.yml @@ -24,9 +24,8 @@ en: are_you_sure: Are you sure you want to delete your account? info: Deleting your account should be your last resort if you are locked out of your account. You will not be able to recover any information linked to - your account. We will notify the agencies you access with %{app} that you - no longer have an account. Once your account is deleted, you can create a - new one using the same email address. + your account. Once your account is deleted, you can create a new one using + the same email address. title: Deleting your account should be your last resort pending: cancel_request: Cancel request diff --git a/config/locales/account_reset/es.yml b/config/locales/account_reset/es.yml index 41069142bf6..d3ed6b40036 100644 --- a/config/locales/account_reset/es.yml +++ b/config/locales/account_reset/es.yml @@ -22,11 +22,10 @@ es: a su registro número de teléfono. delete_account: are_you_sure: "¿Seguro que quieres eliminar tu cuenta?" - info: Eliminar su cuenta debe ser su último recurso si su cuenta está bloqueada. - No podrá recuperar ninguna información vinculada a su cuenta. Notificaremos - a las agencias a las que acceda con %{app} que ya no tiene una cuenta. Cuando - su cuenta sea eliminada podrá crear una nueva usando la misma dirección de - correo electrónico. + info: Eliminar su cuenta debe ser su último recurso si está bloqueado de tu + cuenta No podrá recuperar ninguna información vinculada a su cuenta. Una vez + que se elimine su cuenta, puede crear una nueva usando la misma dirección + de correo electrónico. title: Eliminar tu cuenta debería ser tu último recurso pending: cancel_request: Cancelar petición diff --git a/config/locales/account_reset/fr.yml b/config/locales/account_reset/fr.yml index 8fa6288c9fe..5da3e2331aa 100644 --- a/config/locales/account_reset/fr.yml +++ b/config/locales/account_reset/fr.yml @@ -25,9 +25,8 @@ fr: are_you_sure: Êtes-vous sûr de vouloir supprimer votre compte? info: La suppression de votre compte devrait être votre dernier recours si vous êtes en lock-out de votre compte Vous ne pourrez pas récupérer les informations - liées à ton compte. Nous informerons les agences auxquelles vous accédez avec - %{app} que vous ne plus avoir un compte. Une fois votre compte supprimé, vous - pouvez en créer un nouveau en utilisant la même adresse e-mail. + liées à ton compte. Une fois votre compte supprimé, vous pouvez en créer un + nouveau en utilisant la même adresse e-mail. title: La suppression de votre compte devrait être votre dernier recours pending: cancel_request: Demande d'annulation diff --git a/config/locales/cac_proofing/en.yml b/config/locales/cac_proofing/en.yml index 0feaf335c80..7fa25e918f0 100644 --- a/config/locales/cac_proofing/en.yml +++ b/config/locales/cac_proofing/en.yml @@ -16,6 +16,7 @@ en: choose_method: How would you like to verify your identity? enter_info: Enter in your information present_cac: Present your PIV/CAC card to verify your account + success: We've verified your information and PIV/CAC card. verify: Verify your information welcome: We need to verify your identity info: diff --git a/config/locales/cac_proofing/es.yml b/config/locales/cac_proofing/es.yml index bec69df515d..e2d9bccdab0 100644 --- a/config/locales/cac_proofing/es.yml +++ b/config/locales/cac_proofing/es.yml @@ -15,6 +15,7 @@ es: choose_method: "¿Cómo le gustaría verificar su identidad?" enter_info: Ingrese su información present_cac: Presente su tarjeta PIV / CAC para verificar su cuenta + success: Hemos verificado su información y su tarjeta PIV / CAC. verify: Verifica tu información welcome: Nosotros necesitamos verificar tu identidad info: diff --git a/config/locales/cac_proofing/fr.yml b/config/locales/cac_proofing/fr.yml index a66dabdf057..35cd61e411a 100644 --- a/config/locales/cac_proofing/fr.yml +++ b/config/locales/cac_proofing/fr.yml @@ -17,6 +17,7 @@ fr: choose_method: Comment souhaitez-vous vérifier votre identité? enter_info: Entrez vos informations present_cac: Presente su tarjeta PIV / CAC para verificar su cuenta + success: Nous avons vérifié vos informations et votre carte PIV / CAC. verify: Vérifiez vos informations welcome: Nous devons vérifier votre identité info: diff --git a/config/locales/image_description/en.yml b/config/locales/image_description/en.yml index 937df2a9774..c90a078f1c6 100644 --- a/config/locales/image_description/en.yml +++ b/config/locales/image_description/en.yml @@ -2,5 +2,6 @@ en: image_description: camera_mobile_phone: Camera flashing on a mobile phone + spinner: Loading spinner totp_qrcode: QR code for authenticator app us_flag: US flag diff --git a/config/locales/image_description/es.yml b/config/locales/image_description/es.yml index 619c89d36fe..44a0413b389 100644 --- a/config/locales/image_description/es.yml +++ b/config/locales/image_description/es.yml @@ -2,5 +2,6 @@ es: image_description: camera_mobile_phone: Cámara parpadeando en un teléfono móvil + spinner: Indicador de carga totp_qrcode: Código QR para la aplicación de autenticación us_flag: Bandera de estados unidos diff --git a/config/locales/image_description/fr.yml b/config/locales/image_description/fr.yml index aa8cb9f7c4e..47487020658 100644 --- a/config/locales/image_description/fr.yml +++ b/config/locales/image_description/fr.yml @@ -2,5 +2,6 @@ fr: image_description: camera_mobile_phone: Appareil photo clignotant sur un téléphone mobile + spinner: Indicateur de chargement totp_qrcode: Code QR pour l'application d'authentification us_flag: Drapeau américain diff --git a/config/locales/user_mailer/en.yml b/config/locales/user_mailer/en.yml index 4a915635ea7..ba6bbaef8e8 100644 --- a/config/locales/user_mailer/en.yml +++ b/config/locales/user_mailer/en.yml @@ -19,25 +19,20 @@ en: button: Yes, continue deleting cancel_link_text: please cancel help_html: If you don’t want to delete your account, %{cancel_account_reset}. - intro_html: Your 24 hour waiting period has ended. Please complete step 2 of - the process.

    If you’ve been unable to locate your authentication methods, - select "confirm deletion" to delete your %{app} account.

    In the future, - if you need to access participating government websites who use %{app}, you - can create a new %{app} account using the same email address after your account - is deleted.

    - subject: Delete your login.gov account + intro_html: You are receiving this email because you requested to delete and + reset your login.gov account.

    Deleting your account should be your + last resort if you are locked out of your account. You will not be able to + recover any information linked to your account. Once your account is deleted, + you can create a new one using the same email address.

    Are you sure + you want to delete your account? + subject: Delete your account account_reset_request: cancel: Don't want to delete your account? Sign in to your login.gov account to cancel. header: Your account will be deleted in 24 hours - intro_html: 'As a security measure, %{app} requires a two-step process to delete - your account:

    Step One: There is a 24 hour waiting period if you have - lost access to your authentication methods and need to delete your account. - If you locate your authentication methods, you can sign in to your %{app} - account to cancel this request.

    Step Two: After your 24 hour waiting - period, you will receive an email that will ask you to confirm the deletion - of your %{app} account. Your account will not be deleted until you confirm.' - subject: How to delete your login.gov account + intro: To ensure quality and security, the account deletion process takes 24 + hours. You will receive a confirmation email once this process is completed. + subject: Delete your account add_email: footer: This link will expire in %{confirmation_period}. header: Thanks for adding an email. Please click the link below or copy and diff --git a/config/locales/user_mailer/es.yml b/config/locales/user_mailer/es.yml index 062be784fcd..d1c2340c868 100644 --- a/config/locales/user_mailer/es.yml +++ b/config/locales/user_mailer/es.yml @@ -20,26 +20,21 @@ es: button: Sí, continúa eliminando cancel_link_text: por favor cancele help_html: Si no desea eliminar su cuenta, %{cancel_account_reset}. - intro_html: Su período de espera de 24 horas ha finalizado. Complete el paso - 2 del proceso.

    Si no ha podido localizar sus métodos de autenticación, - seleccione "confirmar eliminación" para eliminar su cuenta de %{app}.

    - En el futuro, si necesita acceder a los sitios web gubernamentales participantes - que utilizan %{app}, puede crear una nueva cuenta %{app} con la misma dirección - de correo electrónico después de que se elimine su cuenta.

    - subject: Elimina tu cuenta login.gov + intro_html: Recibes este correo electrónico porque solicitaste eliminar y restablecer + su cuenta de login.gov

    Eliminar tu cuenta debería ser tu último recurso + si está bloqueado de su cuenta. No podrás recuperar cualquier información + vinculada a su cuenta. Una vez que su cuenta es eliminada, usted puede crear + uno nuevo usando la misma dirección de correo electrónico.

    ¿Estás seguro + de que ¿Desea eliminar su cuenta? + subject: Eliminar su cuenta account_reset_request: cancel: "¿No quieres eliminar tu cuenta? Inicie sesión en su cuenta login.gov para cancelar." header: Su cuenta será eliminada en 24 horas - intro_html: 'Como medida de seguridad, %{app} requiere un proceso de dos pasos - para eliminar su cuenta:

    Paso uno: hay un período de espera de 24 - horas si ha perdido el acceso a sus métodos de autenticación y necesita eliminar - su cuenta. Si encuentra sus métodos de autenticación, puede iniciar sesión - en su cuenta %{app} para cancelar esta solicitud.

    Paso dos: Después - de su período de espera de 24 horas, recibirá un correo electrónico que le - pedirá que confirme la eliminación de su cuenta %{app}. Su cuenta no se eliminará - hasta que confirme.' - subject: Cómo eliminar su cuenta de login.gov + intro: Para garantizar la calidad y la seguridad, el proceso de eliminación + de la cuenta lleva 24 horas. Recibirá un correo electrónico de confirmación + una vez que se complete este proceso. + subject: Eliminar su cuenta add_email: footer: Este enlace expira en %{confirmation_period}. header: Gracias por enviar su correo electrónico. Haga clic en el enlace debajo diff --git a/config/locales/user_mailer/fr.yml b/config/locales/user_mailer/fr.yml index 109b8299cbf..355bd30f848 100644 --- a/config/locales/user_mailer/fr.yml +++ b/config/locales/user_mailer/fr.yml @@ -20,28 +20,22 @@ fr: button: Oui, continuez la suppression cancel_link_text: veuillez annuler help_html: Si vous ne souhaitez pas supprimer votre compte, %{cancel_account_reset}. - intro_html: Votre période d'attente de 24 heures est terminée. Veuillez terminer - l'étape 2 du processus.

    Si vous ne parvenez pas à localiser vos méthodes - d'authentification, sélectionnez "confirmer la suppression" pour supprimer - votre compte %{app}.

    À l'avenir, si vous devez accéder aux sites Web - gouvernementaux participants qui utilisent %{app}, vous pouvez créer un nouveau - compte %{app} en utilisant la même adresse e-mail après la suppression de - votre compte.

    - subject: Supprimer votre compte login.gov + intro_html: Vous recevez cet e-mail parce que vous avez demandé à supprimer + et à réinitialiser votre compte login.gov.

    Supprimer votre compte + devrait être votre dernier recours si vous êtes exclu de votre compte. Vous + ne serez pas en mesure de récupérer toute information liée à votre compte. + Une fois votre compte supprimé, vous pouvez en créer un nouveau en utilisant + la même adresse email.

    Es-tu sûr de toi voulez-vous supprimer votre + compte? + subject: Supprimer votre compte account_reset_request: cancel: Vous ne voulez pas supprimer votre compte? Connectez-vous à votre compte login.gov pour annuler. header: Votre compte sera supprimé dans 24 heures - intro_html: 'Par mesure de sécurité, %{app} nécessite un processus en deux étapes - pour supprimer votre compte:

    Étape 1: Il y a une période d''attente - de 24 heures si vous avez perdu l''accès à vos méthodes d''authentification - et devez supprimer votre compte. Si vous trouvez vos méthodes d''authentification, - vous pouvez vous connecter à votre compte %{app} pour annuler cette demande. -

    Deuxième étape: après votre période d''attente de 24 heures, vous - recevrez un e-mail qui vous demandera de confirmer la suppression de votre - compte %{app}. Votre compte ne sera pas supprimé tant que vous ne l''aurez - pas confirmé.' - subject: Comment supprimer votre compte login.gov + intro: Pour garantir la qualité et la sécurité, le processus de suppression + de compte prend 24 heures. Vous recevrez un e-mail de confirmation une fois + ce processus terminé. + subject: Supprimer votre compte add_email: footer: Ce lien expirera dans %{confirmation_period}. header: Merci d'avoir ajouté un email. S'il vous plaît cliquez sur le lien ci-dessous diff --git a/config/locales/users/en.yml b/config/locales/users/en.yml index 1ec8d9bd081..95640baeb6a 100644 --- a/config/locales/users/en.yml +++ b/config/locales/users/en.yml @@ -12,8 +12,6 @@ en: bullet_2_loa3: "%{app} will delete your email address, password, phone number, name, address, date of birth and Social Security number from our system." bullet_3: You won't be able to securely access your information using %{app}. - bullet_4: We will notify the agencies you access with %{app} that you no longer - have an account heading: Are you sure you want to delete your account? instructions: Enter your password to confirm that you want to delete your account. subheading: If you delete your account diff --git a/config/locales/users/es.yml b/config/locales/users/es.yml index 7fcddb0e817..6a1674cfefc 100644 --- a/config/locales/users/es.yml +++ b/config/locales/users/es.yml @@ -13,8 +13,6 @@ es: bullet_2_loa3: "%{app} borrará su email, contraseña, número de teléfono, nombre, dirección, fecha de nacimiento y número de Seguro Social de nuestro sistema." bullet_3: Usted no podrá tener acceso seguro a su información usando %{app} - bullet_4: Notificaremos a las agencias a las que acceda con %{app} que no ya - tengo una cuenta heading: "¿Está seguro que desea eliminar su cuenta?" instructions: Ingrese su contraseña para confirmar que desea eliminar su cuenta. subheading: Si elimina su cuenta diff --git a/config/locales/users/fr.yml b/config/locales/users/fr.yml index ecfed8eb471..53dbcd73a58 100644 --- a/config/locales/users/fr.yml +++ b/config/locales/users/fr.yml @@ -15,8 +15,6 @@ fr: et votre numéro de sécurité sociale de notre système." bullet_3: Vous ne pourrez pas accéder en toute sécurité à vos informations en utilisant %{app}. - bullet_4: Nous informerons les agences auxquelles vous accédez avec %{app} que - vous ne plus avoir un compte heading: Êtes-vous sûr de vouloir supprimer votre compte? instructions: Saisissez votre mot de passe pour confirmer que vous souhaitez supprimer votre compte. diff --git a/config/routes.rb b/config/routes.rb index 5a20bfbfbaa..1009f038607 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,13 +75,15 @@ get '/active' => 'users/sessions#active' post '/sessions/keepalive' => 'users/sessions#keepalive' - get '/login/piv_cac' => 'users/piv_cac_login#new' - get '/login/piv_cac_account_not_found' => 'users/piv_cac_login#account_not_found' - get '/login/piv_cac_did_not_work' => 'users/piv_cac_login#did_not_work' - get '/login/piv_cac_temporary_error' => 'users/piv_cac_login#temporary_error' - get '/login/present_piv_cac' => 'users/piv_cac_login#redirect_to_piv_cac_service' - get '/login/password' => 'password_capture#new', as: :capture_password - post '/login/password' => 'password_capture#create' + if FeatureManagement.allow_piv_cac_login? + get '/login/piv_cac' => 'users/piv_cac_login#new' + get '/login/piv_cac_account_not_found' => 'users/piv_cac_login#account_not_found' + get '/login/piv_cac_did_not_work' => 'users/piv_cac_login#did_not_work' + get '/login/piv_cac_temporary_error' => 'users/piv_cac_login#temporary_error' + get '/login/present_piv_cac' => 'users/piv_cac_login#redirect_to_piv_cac_service' + get '/login/password' => 'password_capture#new', as: :capture_password + post '/login/password' => 'password_capture#create' + end get '/account_reset/request' => 'account_reset/request#show' post '/account_reset/request' => 'account_reset/request#create' diff --git a/config/service_providers.localdev.yml b/config/service_providers.localdev.yml index 40d4351b4e4..7673db457c5 100644 --- a/config/service_providers.localdev.yml +++ b/config/service_providers.localdev.yml @@ -111,15 +111,6 @@ test: friendly_name: 'Test SP' allow_prompt_login: true - 'https://rp3.serviceprovider.com/auth/saml/metadata': - acs_url: 'http://example.com/test/saml/decode_assertion' - assertion_consumer_logout_service_url: 'http://example.com/test/saml/decode_slo_request' - block_encryption: 'aes256-cbc' - cert: 'saml_test_sp' - ial: 2 - friendly_name: 'Test SP' - allow_prompt_login: true - 'http://test.host': acs_url: 'http://test.host/test/saml/decode_assertion' block_encryption: 'aes256-cbc' @@ -183,18 +174,6 @@ test: ial: 2 allow_prompt_login: true - 'urn:gov:gsa:openidconnect:sp:server_two': - agency_id: 2 - redirect_uris: - - 'http://localhost:7654/auth/result' - - 'https://example.com' - - 'http://www.example.com/test/oidc' - cert: 'saml_test_sp' - friendly_name: 'Test SP' - assertion_consumer_logout_service_url: '' - ial: 2 - allow_prompt_login: true - 'urn:gov:gsa:openidconnect:sp:server_requiring_aal3': agency_id: 2 redirect_uris: diff --git a/db/migrate/20210218185311_remove_user_id_index_from_events.rb b/db/migrate/20210218185311_remove_user_id_index_from_events.rb deleted file mode 100644 index 58f4b19197d..00000000000 --- a/db/migrate/20210218185311_remove_user_id_index_from_events.rb +++ /dev/null @@ -1,5 +0,0 @@ -class RemoveUserIdIndexFromEvents < ActiveRecord::Migration[6.1] - def change - remove_index :events, name: "index_events_on_user_id" - end -end diff --git a/db/migrate/20210223011217_add_uniqueness_to_agency_abbreviation.rb b/db/migrate/20210223011217_add_uniqueness_to_agency_abbreviation.rb deleted file mode 100644 index b9b832f9457..00000000000 --- a/db/migrate/20210223011217_add_uniqueness_to_agency_abbreviation.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddUniquenessToAgencyAbbreviation < ActiveRecord::Migration[6.1] - disable_ddl_transaction! - - def change - add_index :agencies, :abbreviation, unique: true, algorithm: :concurrently - end -end diff --git a/db/migrate/20210223232534_add_transaction_id_to_sp_costs.rb b/db/migrate/20210223232534_add_transaction_id_to_sp_costs.rb deleted file mode 100644 index 46e1585e632..00000000000 --- a/db/migrate/20210223232534_add_transaction_id_to_sp_costs.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddTransactionIdToSpCosts < ActiveRecord::Migration[6.1] - def change - add_column :sp_costs, :transaction_id, :string, null: true - end -end diff --git a/db/migrate/202102245131_letter_requests_to_usps_ftp_logs.rb b/db/migrate/202102245131_letter_requests_to_usps_ftp_logs.rb deleted file mode 100644 index b284bbab2e9..00000000000 --- a/db/migrate/202102245131_letter_requests_to_usps_ftp_logs.rb +++ /dev/null @@ -1,9 +0,0 @@ -class LetterRequestsToUspsFtpLogs < ActiveRecord::Migration[6.1] - def change - create_table :letter_requests_to_usps_ftp_logs do |t| - t.timestamp :ftp_at, null: false - t.integer :letter_requests_count, null: false - end - add_index :letter_requests_to_usps_ftp_logs, %i[ftp_at] - end -end diff --git a/db/schema.rb b/db/schema.rb index 47d6865fd04..0a00fc39608 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_02_23_232534) do +ActiveRecord::Schema.define(version: 2021_02_03_002937) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -44,7 +44,6 @@ create_table "agencies", force: :cascade do |t| t.string "name", null: false t.string "abbreviation" - t.index ["abbreviation"], name: "index_agencies_on_abbreviation", unique: true t.index ["name"], name: "index_agencies_on_name", unique: true end @@ -168,12 +167,12 @@ t.integer "choose_method_view_count", default: 0 t.datetime "present_cac_view_at" t.integer "present_cac_view_count", default: 0 + t.integer "present_cac_submit_count", default: 0 + t.integer "present_cac_error_count", default: 0 t.datetime "enter_info_view_at" t.integer "enter_info_view_count", default: 0 t.datetime "success_view_at" t.integer "success_view_count", default: 0 - t.integer "present_cac_submit_count", default: 0 - t.integer "present_cac_error_count", default: 0 t.datetime "selfie_view_at" t.integer "selfie_view_count", default: 0 t.integer "selfie_submit_count", default: 0 @@ -241,6 +240,7 @@ t.index ["device_id", "created_at"], name: "index_events_on_device_id_and_created_at" t.index ["disavowal_token_fingerprint"], name: "index_events_on_disavowal_token_fingerprint" t.index ["user_id", "created_at"], name: "index_events_on_user_id_and_created_at" + t.index ["user_id"], name: "index_events_on_user_id" end create_table "iaa_gtcs", force: :cascade do |t| @@ -349,12 +349,6 @@ t.index ["job_name", "finish_time"], name: "index_job_runs_on_job_name_and_finish_time" end - create_table "letter_requests_to_usps_ftp_logs", force: :cascade do |t| - t.datetime "ftp_at", null: false - t.integer "letter_requests_count", null: false - t.index ["ftp_at"], name: "index_letter_requests_to_usps_ftp_logs_on_ftp_at" - end - create_table "monthly_auth_counts", force: :cascade do |t| t.string "issuer", null: false t.string "year_month", null: false @@ -574,7 +568,6 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "ial" - t.string "transaction_id" t.index ["created_at"], name: "index_sp_costs_on_created_at" end diff --git a/lib/feature_management.rb b/lib/feature_management.rb index ed660856df9..d0ee513585a 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -16,6 +16,10 @@ def self.identity_pki_disabled? !env.piv_cac_verify_token_url end + def self.allow_piv_cac_login? + AppConfig.env.login_with_piv_cac == 'true' + end + def self.development_and_identity_pki_disabled? # This controls if we try to hop over to identity-pki or just throw up # a screen asking for a Subject or one of a list of error conditions. diff --git a/lib/lambda_jobs/git_ref.rb b/lib/lambda_jobs/git_ref.rb index 2b694db66d6..951a7925b96 100644 --- a/lib/lambda_jobs/git_ref.rb +++ b/lib/lambda_jobs/git_ref.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module LambdaJobs - GIT_REF = 'eb8aa1657173af64fd9fcad2ab4df2a5741eb51d' + GIT_REF = '8c16776e19b211d15bda7246d99ff95155d60c11' end diff --git a/mac-test-passphrase-prompt.png b/mac-test-passphrase-prompt.png new file mode 100644 index 0000000000000000000000000000000000000000..649e1bf02704399a53bc46022aadf54e7df5678e GIT binary patch literal 50358 zcmZ^~b95$evo;#rcAnU_t%)bLojlPKYhv5BCbsRF7!x}a+s^sD@4NT@_F3OKy;k?C zuBxl9y05zb>DAqlDoQfQ2m}aVU|`5{vXbAyz`(=*u@m56{<-dVmY;xuAz|7`NT|q3 zNB~q^94&3^Ex^EJBU80twbdqY^7OvL!84(O(-nG@Xr1&>5Zi}PiqfKE1Vs@Ws5>!L zMEs~YMu>>A12?Z(NtNn1B*jtJ{4)}SoXSI2aAvnHu+8J~>2sLF@s;Uw>B|8|yH!Lyh=JEm^lmRfZ{O*E~d=FP3ibxACv@>doc;{PGJOrN7@OgE62_riEp~2u>9YFk5 zS2VecunGp4Lo0UuF-isJ zI6}KaWFxQvmpGPVVH3WjSQf8RajS5WY^Kv;Xnl&fP@}RAtPlFT+J+fi*;JW`e;^JG z67X>uA4Zbe2TQKmP>5;+exyK-?%wWOY_Mt@^s=#UCw1poX9{Wlo`R1`tmSAm(#8`w zT-O6bROm!mDQ48PZnm%W@PeLm{to0t7z`x6hE)IrF(FSv==@mbTH%M=E};ng0SyI! zbP7yW;5baA#oryWVx)x#5FdsfbQUq1pvA=!|44VPxwnCyrz#G`h_}DS+SAv^d>Y=o zaeBTFn?mszFc^E!h%#%!fQvP4a(Imdk!Em+K^+IrL0GJo`sEV?k#q$1dfQdYCbDgv_+Iny;;{bb0r18>wPi~bV zc61R^@wY*YkYI6w&_6yev!{2s7@u0eh|Vmdp+djsdJ4cmiGoL6*Uf_Q_k(2&`kw#5 zbb{RifQ1CnqQNE&Ahqvh0&rdOkPm~Y3ZPN~TZ}0xAoK=MX2BkVAPcaGpv(LDobW-x zXFJT*(f-#ENP!;4P-18p;u6OZFh$XLWH=GTdqJM$;Ih0J!ITu0vM_@(lHL?+k&`v*g-n8*pSHl;SWZ z+=U@3Vk=U-3cQG4+5Uq9LDj^&m2Zu;`= z<>MCjfb>^D$xoH3O8-S0b#ynbmOSqy@9ftw>0$>=JDX#K?jWvvoA|!E0dx*Ecec+i>%qM6D>M|r1^HK3Q z_u9~}sI!epm-J>X5=bKz~^P4P0ETQ)XE9Qs!2+JQ-oh zY`JQwH`zI*>MC=maA$kRfKw5g3_W8SEfr5SFu#=KsAJ`|5M}$jt!?OL7~hTD z&9~Lno&S>YV*YY|<8@-U&u}et{mRKQ+C6g$eE<_Zc2~iy~9r`&)5}F;3IJizR8!;R4fc``t zOa78rqY9v>wDnv3uueXHKi)1wE0ke@m%`Q|%!eG8--%344)8>PN&l9xF(zA7=wh zN5b+?vMkC0dAo@X!e)R4h9+{?grl*ly`EnP(`#7|!UFVN!akfdY~_6EIoVl0A$5Z@ zhxwVM9Cp*yd4kd1N!`7^ZSS!!5<#p3%w*&_%=Qc_p<_8piIvARb3@`YgQtV2O_-LX zImLqEBIT~NFB_?@`Pk4nAMB|4V|mMLMvk9JFQ~ZBj4QopIvK6?W{Cqu7SXS`uuMR0 zah;@|gD2DMz1zJudCc@ymIcFB&(;0>Ab(qWRa*3v4gJer-!lYXXh*5#Z*QsDTK-Ib zQw-^w=&ESnx}$nd=Vw|)v_1tp`Tg9M3Cg)z>>}-6>>R7_beC0X^$pcOJ^hAX(~u{T zO9{7tIUP5qp%Np16jKz76#Y_nQysFAdFnRbJLsEEy;~gJTw^t-eq}xB@#=Zo_1HZ* zEN$twJ}hu=wd$=0x3(?fFKe6wE@?MzKvsj(cYW>e^X>AkTC24z5AUzNVHsf@vD2}! zT`lf)HsaZmijY2K7bjb1eQgf;OT8CUF^7#G#(mabq z2GsD&NjxUc+kN;??prw2ra`&L0zSL?I~@y0e$)BwJI=euQf<*P%pLl+J?Ef~(uIb* z>#Mjv(o2_9+m1co(wg;_t{y9wnH$5pEj6u=y1XtNmy6qn@iQTwJ;#ksz6;B3rsZO+8thdS&YEuB`6W;@|&+%9No$7gUSNv+cks!M8%SPWh z-G$8v$EH>6%tan4F&xqHr`aj#2g|Kt3{u*A<-66C{6c#8=j~^eE3Hk%SL)8v@tKAr z=hkAMo98+kkaxZcZ@m z2NfTJaed**30QY6?2k008I(;h6|52umPo+-TM<;?S4im-MF_>Q(l9CK|tB4 zYlE~E75L2@9hgnb9ZfBmJsq6>$p!-x^yL4?bg%%K06ZP+fv)_XLgfF>;Qz<|mz#wg z@b46moe;UUq6$F5(ZvG5#mvdfN-m55000DC%q{u9OG^J2{GU4^a%&LCiJyhV!^4Bw zgM-=8#fpWEkB^Uqm7Rs1o#~$pCRZ;Y$i$Ng=t}XQBL7`S(!$lu#l{I_;|K)&tFDQu zqZ>$wocv!z|8xAOPYX|*|HA}y{jX*Hvp|-AMOfIFSy}$SU?3aI{~y@DBL9K?dtLvb z6Z}^(eia)}3ws?&8wU%Z>p!gtb8zzt{+s6iiu|8O{})pG{~-DJ{$I%d75QJte|5sI z>|$f_k4XQLLYPgE<^S^iSH2+2zjXRvy8UM;|Mvbf6=4KHmj9WsFoON+kQ^A8D43k2 zn7SwUxgYcojio;-U#E>cc?anSRedcRDMfcyOlZ2*PM+3DU;= z#+_Yn(;Jyzju!&BVc>q$+i#!So4+<(XN0WsdzKA-9(2cO*}biKkfy-EA)%1aSVjB6 z0u9kvFWu~UIsdW$%PA_>-%o@TVf^C9qN3TbjzFab{q5Afg4j9_rs{}lKK zG#&gSkS_-oV+j7gtAazivhMsB-1tYo7aA*T_?@UJugQPcG&V+oA^I2pzd3nPA2aDeW;3+gaho}l%EWapAd83YGyu^myKeq?um!KhB@BicAxye7)C$!Uf-FP zKezZkx86Q~HauLmJ>6UpTW!BFFCNtiEw{0JJRxq@eO)a3f8zVSuUUPho%vt2`(OF_ zjg`HUpC`nNo|*bw9uh#wb=w$AUK5J}%% zOdM&Diq3fA_fgqk%_z(kxwy#sMc^=L6eUf>3irMb65i8)cg&+=+!sIciPsHKymIY3 zY-bUe7tsnZ{4#hw^}9UETYk&hdU`K!xc8q9t-bgV{NT7>e&}g?YI(Ku&Oh_>E!+;c z)W3Q=(AK@OI-7YAt`k{)>fGAaka@rJd0pOKe`@kD@(Xw?-hP{|d}@0Aq@Fo_dka1M z^ce#c9-X}X!}V238twZ?B5}3tV?;6bTGrS5)Y23Gu>7HuKi|gr;p(;x3V4`VGAjO3 ztX=vl6Ip&!3+U;cLG)Yx`tv5tce!5o+4b3U^?=)1{>0S>%KtQcc`v=W|NIbnSng8( z@awrkAD!LWiknR^*4EYD>2ksg<1<6o50RB-3_Oq`R#kB zp^^U)H9#0jv*(sxLGcUC>uX;amg3e_)j~l*fz|ifx;a@4A3r($CZ`bpzc;A9Ae^># z#{PBTW_^7#B@^&V0`=3Zx^%fX6t}shrMNUUCTMb?pTHY@%*pyc)7FQAJGOAiz1ocz zx}KSGMcYs3B|2D`{hv+F%nbD@x%PI%9v+PdGqmtO{!el^ef2{?9!O0M^I@iajK##> zeo{r-^^+qwhrPK84Q(N(GV|n%o+B53>@C2C*rGRW*ob5A;p(mle>c8gVG?f7laq_> z(=UFxnDD7AGcyxN^6DBeSpH0IF_Z1&+mn6nBbSrve3mVT>Tv8+^ZE4ksJvR&nD(ps z^DEHcwbZV;l9T@dZc7j}?BKSJp* z{CwLsSx<=lmuol@0xN}!UQDL74Q=+%s5JF7iR3pE;-~I}LSRbzX)P1uvO(3s*!FFE z-?eV2@>d|YGhKr7Pc$yYTw8&O zva-?RdG{M4<67RPoaES*np^sq4mu`J!#p!-x^FpbN!YaC+wcQ84qJl!b@P%xR@&%J zeovOnv>0?7(a6Ey{<@<~99~%op*RzV%*?K-DNVxptK%&v8=F3#P*M|pCzXo5D@H*LrF zE$_H5d=8>QY@%7pEF3H`k;OR#Y-YA>cFHj?3#Ja~U^?zojMlQ_l^t{~Kd%$8`%$3} zftv_1QeQP4`_uo*bj3rRys{mix%Ke)e344(F)Y}gvqcypIgbyHjCY|f#q=aF(;tal ziOK|#?5v~jivO3#rr%^=rDJGID%FuEurDc_;(1XKPv83P)LG1!+-Y9!fruplZ9=#toowMX2wcR+g%JyuT({Rip?OzZVu;T~@>9%dO4YqOT z%0ih;q3U`-K?El5>FC;MwA{u0><|EeBU|W4LLvXl|&+qIjhYLR`GB(x5OQq zoQIJJ8{WH}gWTCSjqLGJGdbD=b47U*hU`=fx6Y>~AJX6aDXBQdr;9eT zthzQw)4A98w_5whew%fCp9@8){wF4l=<{l7PIsFOtRKI4bA(gF8Ago)=Oj$)O{_4^ ztEX7KHZ0(-o5y%gsGM%Fiq@qj$dStq(~iwO?X^K*JSA!Kip-4migR1)s61b2p1isR zEtSwGe4$QvPLdX0*n*HlJD1jxjqeERi`-7ulm}bjaG(bAI}B4d_vRolgo2m96+a!n zFY{mP258x(;HKStdS|R_i%Ap_<6s6greH>73!?s}8Q#0}Aq{Ez67{^%(T#Q{x6p~0) zLpO9j%A1!jv$(aDcz14Kmi>iIDE=dBCGobUfB84E+vPMIJhIft4ROWf3w-LIfe5%; zQ7gi+1YHClD_G9OYZ63K$Ar>}^OH zx_SjorI=)iV)d+Z4#FQFw{(*~!|ohdxn7;8D}bMU zsBxsiNYU`gXgD}=S%*ZP1YXJ49>vua5E@+kzKu?IQWfRMSJ;Xa+4<~;%#S5f*BQJa z(6Hpu^-P-@G2io|PmgLca9Q{l6l6DkHM*h+nscVH5XJ>;`H3_K2DiT>U!$UkX(@4a z63jc(GvG}#PdhboNw7J0cGf2Pfd;n5a6hMy_WICnFXOnIoj&qAuK2al(-j!^(TEPh z-$?x-{d-Ah4+Iq=7jm(vNec}@+#XKgZ?c}k9Xnm$i|XLpoZhGGpo9 zA?uG5FR6TZXAMTt;T_>J~8^tF(e z0r{%ujxMTe^Lm8?6(Bg9J=}@24cv&3nE2~-cQ@+V#uDCFj!7UJ(gs|Oa_CYvdu>r; z^O?6`y|5j6(&OGcE0%(z4nAhe%v6P#P-Yy(jxIAj99Ct$v>%O|%B}~>!(4hhu@N!F z>=F`9?rQDE9vKac>mW85Ph&lIuA5GsKA{JkqX}Vq9UOJgS8foyWe)rK}q%g^_GxI)PKw-9PZfs7mY!jn)_#Ngx6{&h#RzSQO%T! z5pouWbYY75Kd6>r`v+1Ztj1y{>O_t~34Yw^RME=GOwiAJAw%&y0W*_`%1sJzN={)lW3^j972bpjlDz1j!n=JB#cKG3B2 z2rRAK0j7(T)tP=2wutjL!xS^7E9a%2#v@d`UXyQ1K2WMZPAR1o@WT3p9>H>W$BKR2O_yFNB9NWQ znwEdQM5%9XeRCM$L8vnn!2B5e*l~Y@#PjmP5H{?A$3C3)Jg5wxWtqgq4qes0yDYb6 zZw!Zau7NfGhA1z`Fw!W1JJ?c%GSl-)ed+H+6_X`j|2gJc?R!g7Y5)WKM#1rkwq?); ztG5>rBJUZ{4!xqPg!9%1e>?w;75GT0&t*b6DGTN9_=ZFHWHl5KbJYq992gf%Jhry! zzpNp=@iM8RcbnBtjTcj69(%ytulM?};?>xMhD87;my6mo%-HX5*k=pa;)L=N*j+{4 zlCgbuZD2Bu0MroYFlPQ4L&`r*=gue%_$2ebahA^`t8js!shu zDT*1%OUDKNBz%LAqoC(BwP)efXHT}(utb_RP>e1~HX#52Q?%&t{CJ3W{t1@R zN>}3jvhL=5{fsvXPBJVD!0H0U=@9FAmDq)L#foFp<1A8}DM0Y$dxf_rJ4v;w znPnZj#JRq4cWr=2L>x5jPMqi8xQizazpU}J4Xih{~kRI6^zjVC8aNc{wj^CdjV6(2KALxAAl4)olHS~1g zhPg)!Y+J9v<0U7q+I}W#?ali|Py|!@C1Fw=458^m7zzyllfx?>VnpA{ zNBXDVCwu>hoSOP3xf#ZkQD~Ewo@Mv6U@tjpB6B`~nF^&&NZqq=5V}%Ql8pGN&yaW9 z-^7m@g`|4m)fx#xKBOFQs3q6$F0@o!G>%kfY87_#;@{PFz$6N5dS0ZqC5qcDZe*Ok zBn)V??th~^|A6VjykiR;uI`h9)a^QEIY#G}nzyg33%gp`&G1;#Cfa`CdMr;jUEAm& zvP2O#HATlgiG4V0>(9)-I<+e^9xJ05=gkd%ebprraKYmXO|5VkAI)uJD&wylP-F1} zyE-1w_8(I)C)tWgvPCYN&OmYSgI1WN4eVSmPD-lsM>6ena&M8Drb~RiRWZ_vrGP~x zYpaMdEASc|#CW8PFmhZ2udBD#cb7wZRqTaW7)2R)oY72+FKKSe{_@(^P{nx&PK$=Qu#SK(yC~*S3c>YFYHer^h=G+bHhA_6g}90 zP-LVQG7`&4%H4Xb<<4@TD3=VpH?znp4p|rH z%~y~u*QrD~k-L#q_$?;phH}~Nh7J?zWxOeKW-Q~Y+iFkJxPVEAjVQOwMK*XmNfC%B zMq6rSQ`7+s>seT|1tk%N8Y<<3|M<^aV6tMCv3@Yyc6;u$!*7JCd257cyCN=dIEF&# zFXlvJ@Y|VpM-&x|_-eR@!I8zUW?zq2X!&WsTUv^^6;2G+!NdC#+pk`$X7zOl<67bxfGQFDSVJTX>yQJY`*DIL*{J{%|!7@)!jm+Mxz~+mztDhMUag z#-Z_W7!JHQ)gKX>H`0rgH6K>sjByF7|` z$ae^m(GFnjuw-Q1-(xi!EPZzV&7U(_HL`?Q2d%4yHYb9j3O=pfwkzNXBV%^BaWYoW#M|yQ5R-K{t-Ne`s;5a<~ zDfYaR8c?{ki2j?j9TK*+jDnLMrx@l9qsNa8WQ?^&XuGOG)zV=E zlj}7;A~3r@A>~=bRl0-n(~(U3Zh%06lsJ0G$HVl?##LCaoSPs!V{l14Y%kv07RjJ_ zGp?M|anV7XYbpQ8)#T_h`UaDFaB40NDKugzEiEq)MGcm*FB9r#Y1Yqe;mJ-;6clb8 zr+c`N_+q9H$hZi6yqNPW_cao?4Y88$$!<0M0_zpM%%Rn2OL_sjl~q2I(+IS0U=F;* z0T@0W{-H&u{MxI0zZa~Jn94#nqGGF-0af)7^`vq2V9G8YX8Hq1^Fu!h5&&GSQ9of8 zHPUwyX-J0N@_|KkW$^xwrdod--hmik3_thiA{Vh-e!~^xRDV93VQFw*`_lxKL7t4o z@4Yz+OYvRgm5p-aanM4fSyp%A34^&G9y+2YqM6EzH|CK)U-iln;sr2KL{G9s zWuJeubuLesXgY-Qql5odp51D)tX#RsPsEx+&88@ixEHVy>&R0rW#KA7bZ+3~hCp!q zgOO!=v^f4VH>=;>Jk8cvj&!l2L5qB4J=L}Y-jr|i{$35#@1e?X0Oyen^u2#s#uM=T5y=zc=ol;L0-EN$8b7)0M!slI)fS|Lj|dDs=;aDgvAuMa|r>A8Y)etDZ<@ z{Cs$Fh>4fDz8#y;)E+JpG*z*2vJ)9xw0OEK`FK9u8Tdv~Nly}oXH=kJm5=RjD*xHL zn`txs{Rb1So=sY&jn8r_H$Et&$Mg6koy{|XC4VCDf%9ZL=PV~bLsCr=oWp3nKAATu ziAE!#r$eMOAvq}|SAe@P!V(7)lW-EBP@-@U0)<43(`Rkq&onxuv^1c+F}LrGdc!P{ z<_4V|a1>)Af0ho%I4aF)g@&t4)3ANjD`}}pCFtkJYNbtS&opH{ANzv=KP@*HlNt$G zjA1I>W_Us9%TW2qlL8t{YcYifjb(i0>J4_wr~!?t93w$RrSd&k9b@P3MK}YsLfNPw zvIuqB(ApMsTdglL%XSl(6h8d({h3)rlv`j~xHDWb==xUN#GN~;Dx1yu&^N?q-SjM6 z4^7ww38LGeJ>+nMbhiXK4X)^s&lN<(fA;J8K)!B2+d*Wf#!X~UWDRqB!gsh zb!VbYcxa6DVvv&^hA6}vi=ejcUvap&G@fr%=wOE1 z$pWF~Bi!3YZP)abQ}1-0D^#T@Tr?M~5?F>$-&_HZi8o111?1Tn{#D8Bip^Y!=!-nGWm>At zcRLErxz<0+Kk}G(+O#W2?%SRcfNr{SVdP}1b8Usmhd&lxT;ivzjg_o&In6KtOk;G} zT14*}sazFy=rEemd)$O8nR#4^6@67QqgPz=mR0t3OEk9(U}VSj5r-CS)jLHso^? z?d!T+wuWiu!}l0;@EUxbFN*Ty>#{Ynx_gQHaKv2b(X}M;xrCa<^Bp3VD9FJbO{9+w zA1L9iz@cipy20+%h5BDhFbr)unju{cwQ0J#G_8)CK{+{sP@()u5vPA7-6qQk@w3U( zgb~<8BD}W^42ScpQzzzQCMB|b-7$G0$aegNyYO5lEq`qb=(&}`rrdppD`!Q4m>=N5 z>0MNzWEFK1&&p_S2E|}ohiI=?-0i)qE6T%ij}?(M6`ka-6)UOHu>i?jcRyTmTZuDc z_7G!8LR6d2NNFo%s$oC$L!4(4o}Yo*ji(L8J;=LCVcT1gogiXCs&O%2*Lj6@YJxo( z8ZM}T6N0q|Y$eyPlJvRdJ?kQ0HJ2Kt@QNa%B541(M?tm7vM^d+?YVb7Bk3kY;<9tS ztjPXqgDQpZ7`&<@eiq_~`RU$7B5As21Y;#*YNmyzwPnruZ7rO8{duGba$nm|oDDHq zt_(3#j5{LZ$(HGT#j!DF*6m#SH43wTHh%bZxs!+c$K`fm9Iqpg+w;$yr!o(2Os>I{ z%8fbg@71wcJYr1d`eEmJFCg=5^okiQ&udq42KHws1C3=R7Y|tLVCatI19a5|fhR%37a@Kuvb%9U0gh$m5luRw_d>FRYw`$1$&< z@0SuQKSKQrzuUx@dE)edFr0+1wUf-$g?$QgZ!QjcYte9pppkpiKi-v7m>Mn-Bg3<( zl0xSSxRJ|p#&H-fZJhtct}iVVX}KP3)kEsW7U$OW#uAWJ(4O*GmNr{Da9G1B#py-? z1S1X=?}#GoU>(Kd_Cf)6Iq;vW1Qo-VoaC+N@tLCy045C-yqPHrAz z#14l~HNFHa)j82|e{d}NT1r6O7ekO0wu|+qj7!?`dhK1S#eg;h94xPgdYlu+tll4r zZ~jFb4Va()dG$LzKxR-QH?~VR4!x;FKSfeDpF{IJz3tBvPuD@rc;OgW$Lt9X8npa8 zL@h^B3+n5~nl%afsOJT9SJ$+uMHLI&f$y9aUONj3n2KxV|WIBqJu78;XC8iFV0lY$Z+< zO_Af1EHQ)4IV!3QUG~?!p3A%H=N3^g=GDdEpkCcj6^jTM!&*337Rp~F2fbt6&OJo`xivmDvqGS+=!?H{cI ztn}P?nJ1N$p?REQc^6`+U87-^p3q^92?sTBf+|Y5+T;RKtkN^$GY;H>wTX6Lc%gYCc*8X^T+P)Nnl}dIr*!LS3%Jc196 zmM*43g3)~3&o$di9y5J@Apb`x1KLP`BtJ|=1ei-Jdc}i*J_X^ysN-1c5TCZwLhc43 zWW&A+ghDmZj*>QCXXn7L>S%>A(r^?Ksq`AcB7Acf<7E<08Og%h5;XAiS*@4RKGuL+ zF!Pe{3rTQkUxDtb;yTcTiug;WVhf6D63E}^2K4l@)lMmPcLuYXmx}X}vI30Ja>r_D zYivg!9pi_V;#J{PO%^Zo60?nhn6i}ya}NVoR%m~EPS7%1+4E6g5yV!b$u_AK$~cg+C5CMjR0%Z=iq0EKq?w~S&joeMs?i7ha;pa2Y|hQzHDdF5ULf+F%A9h*e9 zvMI4)bAt8mR;ryP_aJr(l-5elM;D!_oCYN`b9t`g1n=PL49+6$W6IxTK#y>%yn3dtcI`|9dYexBl6tM8bMCOkT@<%SY zSlXVzxiDDFz?*0?+0=^RrYn^;4~eL-ZeC6SFm-`VO_q)un=8TP){v0}#>D&@v&y%Z zEUu*L7BbkCDKF^?hDQ0UC(QS2Bc&LP38(pxAo#_9p}xNCUyo7)G*`#j9H00#VDl|= zi!U3j4&yYRk9V_@-oBpJ!!r?Wh0ftHQo1cb}v_^%Rf|h^i=z$pddGTQZ2}- z{%0V}FV`|fK6^A`&4z}Z!^PNqKdySA?u-Tm6#6*nMlv0o1L``i0LdKQpTZg}$Ft6xXHBV@S`*42k4@il?hcmi-9PU5k z)03qk=3zTKq96`yK)x!oB`0Aj_8|pXDGCXa1ry65UJ`0SYpxv;{>ZqHT?)n+2W`)~ z)AqyV!rB0iO?a}3vN!=TW^8TQH{Zvb@^OZSB2sJ4&Xa?-<_0MoheyW%Z#a^eb2>{U zDRmH*{yAf0yqA79jwc&I6>GX@R^ea6hC<4mXo4*D3&zd_E$t~`JVr6KnCN7-)~k3K z(E^bmJlXFWSu5SLVh46k~;d9EIjz{DxK4V#ttx_ne;F0|PDxoU$u zXhz~5=WA^Q2s}XylJj6{+e#bGFHLOLu^{TT*;%v|y)1GO0ZbZ~CPQKqgH)GDn91DLEIGVw5`U1dF4EW0*2~P-I2^1spaoRQf zVr&8AE&@9+w}?qe!NOW<8~F1L?=`gGpN|J-h~6^`tECg?Bmy?oW&}Ua11lE(LcPQB zJEs};p(zV}DC2BV^pp$FdoYg*(xKx@jSwTTx&AG~T2iW|*awhRRRR`h%U{4KD^!I$ zuj@+G-kFsMFrUMaN|L9cCMI0scGFW{C6!TQAEnH^sD7P*7P&Bv{TDea4BNOCDQHA$ zj(qWo-a76xoV=B8B!u!YtPzQoI-pNFNn?g4e&{>#C$0szaX@U^d+H;}v%q?GMaWIS zl_w!a%u;o45$>{m(w|cCWh&|GX3)_ukm5}p!@VN1@45M9g_HB|op-y@%zUOwu@+M% zB_)kfu*tnpQ2!yUMAC6Xb_&OZcXj!WUi@7jYwjl_vy6=eP8|hat{Ix*det+VBtg2R zG(?l&%X3JIc%tZ4SF=XCg2dm+A;ISmwxK;*VmVY9T8 zIVaB#^f5p^b~1dTh%p-R5RLOuauw_10#mK%Ya7XQd0W04Td05-@(b-fdI*%TC|8rOO zK4zI3qM>kFh-}0Z&zC{!#*W8~nN(jHE)z|1cg~G3bWoalfW*OAK>r16BBW z=gL2SNXiVQ(Po63>1S~Uqj}=roL;%4sBiAYSWJDtSq?diBTlL5-Cz}wHW<_!D8;_f z{GG50zn3~n%4o7yfRL=a~fU_G6OR+J8^Y%n)7SqS%uuWjWka{C8 zo*rWa#A-j;t=i7=&iKtg-Z|kip{t&zS@=~-fwp5EMT(kJAj$C2n%~iv{0WVs8mk<4 zlB;rvgrA>rYyLiug#=HCojODcExq`c*J}0t)MSzdK;%XHOnyZ5&#w$yS$VV4ORD)I zE~zXfkkmdm$@x4|dca-_ByAU#(8UL=pMNs&-g-`;!~vOt}*~j#adI zxRpYAM;~@YbVyV6N6st^y+zV@QrKm#rDOS7coju{5G@dqTaxl!{>EDbEG@V>CAVP4 zs)>hd45#jpjZyK6gH<}iH7rnho?IJAd@0O;9yU*e;R!JVai0>a>onhWkS{WLciEe@ zaCV+6zptpC0yX9&y3>|V4ynOq8|y%C5=E#2a15)kVH{d#^D?`tYw681Z0p40GY|17=chvM_N=PR1C{0In);=}%{4 z<>eBM^$1#gu=vGU943^WtbpE6|xL$cs{<4_66y>_n**FT4 zW)n8P^}-|{RQg)Q8fO%D2|j=MM8aWz79+mDaAXciaac}-K${%Ps13{hU=+oL!VO~R zr_Rf@xy_Ek;&YYW992~Gt5!}H);%U-r~-zT6Xqs%>)?FD=`}E9y~!u2!-bfw{o6v6 zzQM4csz|R$H1KLh(kPck#Unm*^}(HV6i z`HH=apC8&aa~lo&?*#fp#SHh!*q@2SLeHBOgGw>=2nMPocelVvF>X0W&$Fv@L>7v{ z;*O5g$z+|f68}bE+&yXu3184fbAvQAKf@vA)9>d8)Lcz6Rt*H8Y6j#EEIne9V(;97>2+NTaJ=r@6JDtq@vQN<{mi&UrT#F)Fhfamm<=A(#sPVW6 zd5Fa=WMpYxh=FN*&GYK(V$@U$@@q2>bcoBihST?A4*FlnF~>*s019^QA`1QG*g?O6 z0KbJ@n;D{Kw7{m`lg#?huGemWFSOD0mdEsiU+?$^mheQkIr4L5+4x=O^>P!>xR*|J ztMLBP!1epl!zS)eqers43!$SXWnLf3<Sw|o^B@hDUKq1wFm@OZ zD!5;6^V5^@V?yPNFVP<|eOtIE9YsNyoVOzAx|Gsv0qtG$^2%cchMoKzc;eFBHU5G3 zM}KqP1t`3de(9_Vu>JN1lXnEs!E0WsK0VwQ_C2KwFE!z<0?rEMCGT@1HDh$~WW zyu>EBc-fzwTlt+gr_Fd|r}Gb?uYZ-TvPe{?b9h}vIfz;!Z<`gKN0A4jzq+B_Hx-e> zDRVsgdX4Bd0ZBP6Jwm2K8Fb&;pDvr88bF(Gs|YCUN*&?CWX_gr>>Qd!B?rP}lGEvxRC$7;u7luN)4x)vnMz4Ea`8Xq)$%1Z z9xipZ9j~l%FSNqTZ#Q{nb2HCB@i^FF4h{GfwO>`gcs23I>TLee=foBxNvb4SD5|=^=S>Zxv zI_N=`;51w3H4tTMSa7k$cJ8|dIBp;7q(ygg9|x5!P=A0)4vM2lS8Gg{lbra|et+cl z=R$KzaReSO5$Z-8)aGO}HyJuNHH_wh4Xq^velUCHEIi?{GFN>EGBLTZVZF0Nluq>F z&(1Y!&UrefQjuX*`mzRKR@Bc@ZGuFj#|-9L!#1)I6}USrh(B#|+IeX=t63Rj?KYq4 ze-i}KwhQfR%P@YfO|WiZsii$?hs_1QCysU_umy5*w^q!`^5Mv-p~vJQ(?(P#B|Os| z@&(qVA$?!pfQnCG-DrP@5&Rt9W<6`PrZ$vQPnp)Iu&=GHjf_xIgFUJo{rzVCaeAtk zAyHoCBOywQtusk&+Blne7=fANgB#8EhpUxNnx!e74spS;JZ(XNOHDl>@j4s7XaDF* zjDbT!g>LBysfWdL@uO(XyCXR70q%n^!zcq`=fi8Z&$-+sN0dWG&n70hGEu= zgU6IX3Qo$q&0x9xSiH-wOjMrYkEvJ#j0#iX0iGgZSjvG_HjsW){NR$!vN~fa!22CHk}u-GG)CHTonK|I zxwR!YI2qGGOq8BODBt@GvzcvgJaxj>EjCw@SB>*7cM-pui8F)DK?B;Nf^5N97llU` zp8U83$pY^1cKygCBSW1?u%L2!=yx5vS$oce9OxY0H0>z05IROU;#!)7!g;s>i15N0p&u-$X#kgyHjJ|1nKtV{kw2tn z?R~e3@T8HjOXfJ6tqb_)RRQ!~tH7ZdU`jv^-piV;VX)vcP*y%ADhK4s)=-gZfffP= zV%16bq?ej}WrthU#?PeFZUEu%+umLuAUZZuhGehFW=@8*xVT7ci26te>r|>pCPd^2 zimAy;qU8>Yy13VT>!e84?8P!cB2Ua-NVFK{=a7pmMS_D1tVx8?aRUuwAMK^3vJ{mR z29xzFFxu2mAVDl*u9SEwtkC82UNh+#)G_A6!2I@Qi;=uONDqfn5g zojS>PGNud*%PzX+EH1;NnrE4e4k^w6I%Ck;CQL*^gCKnV9{@r?y}znrZCS-kbfaM5 z%#7V-H!Z^O!K|@mCgHN@ucVH#7;_EBr9EBuX{uyVz-IKnEsPA+b|rG~7P+#86E1(o|a0X!W?VKXeu?P7#v5OPAftqanFd({^Tsy zIf@NOcvv8Ay<;9?{2k#E7J%}c6uk8M8Wd|BSp!L@ITp~g21`3cg}UQZ4)c6C-@Z-u zUk4K+lNn;&KQ02{5y8a&d58^kQuyD_kkNtt{7L+oj7A)>q>Wt?%@qENNssF*iH5h_ zwFFa#cq^?)gf$UvfIQeaIk1qYJsh1~7>-QyplHs02VwaBhahm#d=5gGT_l!#Xu<-(QVgDcyMHzIieKV9MANc;xZ5 z2#WGn_I5qg_&8>rw&0n6-GwklcxV{UY+}rC+T0vOU$qq1&lwMEI^*WB6vqn>KaJpV zvoLMqNQMVm;pW?K$MFOEn03ig5kV3O(qo4u!8KxHQ>}$FUVdRAoLL*i-q9Hi6~)Ln zvJc~CO<}*|nB{DW+0zQK?DqR`&&uZ!Y~O*qRy>M9lNMt6)t6)Ir)zMm5O8ItmNL1q z-_BOnZ3!8N01rnT+qRYAL;Y2@a$-UO<}F`@+2e)ItYx(ZPrvjW!YQF^HLck7%_eL;l&eC0 z+Zj(TW+k#LGLa7CY3*2y>^Asv99=Blw&Uv!_~>XYUViui*cfXlX?vh~W*$~P^D!P= zc{d}?EJ^rj8lpsM$tbR8m)(Oa7T$!}6Nl4TXpD*DQd#WnNi2KdN!)Xr^IaxQpUW`LB)s|j1~`T@Peu=Oj!Yby$rK)z-+Lp*42z~Y0+=v1 z0e^e^Io$EcbGY|jH&s5tgYo9OzWEX#d=Za!4+co|IK3I4io{dRc=2Dipe8#J&#rnK zOYdIEJP8Lpea|!S96Eqc*@KKc3MkBM1wLZQ5^sL?vcHokYMHzl�AK?pw)>`9Ty? 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 -Vmi76M{GpY*=;;_Od4uibZ!A1kukPj49SRey1P zuRZr3zWeFgb&xO}BAPiIixH}+w8!7pxzj;JgYfwZ5X5AnQ*Z461jxRK*dh!!_iSpg zSdumVroJrUP@)e7a}#z6X}0SftaS0Bza%rNcNW3v{(aQ$N`V@Y#Vho=f!L)yO zk^*__(xg8&I`g`f5jD(PMhsS`orqW9zXv86p+Q?L2ZD#-q8O; zl5wWq?hOnT!REF44nnO#k69X731&)*eb$IhJimc}@Q#aCIIV)LwRsL(MSr{@UYyMx z&6e5|gdgjdTOKJb{y~WTLPVQ`)7@f-tyA8J1rSPPlN}zPEJY*t~OkMPrrx-%5oym z8(8$i{nv7%<#cA(4>37tQOf4Iy{u zr_g;_uvD7NxUD0!XdE7-&Q8T;gpN2CEbNJ&u>K0{Y@AU-lm zMi?ot){Si#B9fPhcE3n;6Ur+cYdti=U_QruJzJJ{f7`8jJ=wkdB}A}rEqT3Q-M>=!QbAf|{xEICP8usM#>2+*h(~ zpg0x3<~`yR@5*r6b>**p566tPhu!P5NT|#T2-p6_cWZC2CoDMJ^*E!GI>e1~pr>E{ z!_|Ay^5bqXcwu}+ufI9N$^KcB0gxihIh!~Dc7C~{frzkNyKM>D+S`WZr~ZK6?g~3N zwX}bCI|`Z2`9))R;2S%9G}Ykyu%+Jd*w*eBNBskpvYYdR1>~>C(3Y6g{0jf^zMvEj zkVsQ8lk=K+z1!3Y^OC&L-%uBEY0sd|2NZzxAFf){Lqg_8K0$Fl#1%fEQt+esQy_Uw zWw4GgEURsefDPoL{IxXw^JVmc1Qo)&7rTAA3A2I~PiLq>+6>i`KJESuG_q9nO7j_> zdA+co3kt9IQ%=R{B|Sf4>9-0M!lFTh2{dT4*}Z|pW=j;xu7kUw&&G+nF6Orh%ZUc+ zt;WE~tIIHh0fF7w+{UKL1Xbk_x!2z>eA)f=1|@cTa?pf@gYdCPRlmIY>fZH%@Z{cXeC6?2$cr(YDN3^U)z%uI0)5=ZSW8r72@&!?i$M5 zZ_mmLV)kKG*2Oy9rtcRtZS?KcOa@GZ5%%GLZmd~wSfXdQ^vqXR3H2?F~k~*rzO+>)CTJF)IojyQY{NjnDRcGlUemx$BXSN7v@ZTNmgkL;Ol& zH#!0~#)zEnvP0;MFHT;XojrDU;sQWBg<=Ghh5I*4Wo#y?!<{dRhCpa;sO{+{m z1ZR)-@xAvP&X<)fwQ;t$gZ5ijitX@f9%5b3-)Op5WKXCTLu~)Va`&KA5V~SmA{Yd& zv_(8UJNQY~h_QT87ate#=}AC6sCJ)}a83W$^k-#saxN(jV!(24OpEa#Wf@$mdXYuy zQo>lTWC_k!=cAnUMk8#><8~A}o#T&m3`e31!7HX zDw|S}xnA~uWzqZVNm76{`+F_b@sA1*O|ita{0EGv}BWaUrv3t6jL znRERt2sov!yv0vt$K|4PTN!*IqcNcABZ-kiob&rmyISxg^P948^^D`keV=ywti8NBIJ(e1y z7-sV>C`3qVpg5AUR9#SgSs|plKqp(3>^(S8ie2HX7>d@CokJ``K!R=vKf3=5B4G4N zG5}gY6k5r^CLG=kukvfCnoku1#$r(`JM)*DgCZTzC*^Ju7*G$wO zQ6D7N-(SZJ&TyG}mguZ}GLuYyfR70%`Uml?U$p)xdey1ePyysPhjKYHT^(Z0h8o`E{ES&ZeEM{af-tL6gm$AI5rd%ta$WfdLmfb z4#?fW-zUSZd;q9`ua$wDyMJ-w#e~q`uZa>{o2PnCAo6A2R@fr>mdK II;Vst0GBvrZU6uP literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 8b57793cea5..53527122d73 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "zxcvbn": "^4.4.2" }, "devDependencies": { - "@peculiar/webcrypto": "^1.1.6", + "@peculiar/webcrypto": "^1.1.4", "@testing-library/dom": "^7.29.0", "@testing-library/react": "^11.2.2", "@testing-library/react-hooks": "^3.7.0", diff --git a/spec/config/initializers/job_configurations.rb b/spec/config/initializers/job_configurations.rb index 90685433269..ec0105d68ad 100644 --- a/spec/config/initializers/job_configurations.rb +++ b/spec/config/initializers/job_configurations.rb @@ -255,19 +255,5 @@ expect(job.callback.call).to eq 'the report test worked' end - - it 'runs the Monthly USPS letter requests report' do - job = JobRunner::Runner.configurations.find do |c| - c.name == 'Monthly USPS letter requests report' - end - expect(job).to be_instance_of(JobRunner::JobConfiguration) - expect(job.interval).to eq 24 * 60 * 60 - - service = instance_double(Reports::MonthlyUspsLetterRequestsReport) - expect(Reports::MonthlyUspsLetterRequestsReport).to receive(:new).and_return(service) - expect(service).to receive(:call).and_return('the report test worked') - - expect(job.callback.call).to eq 'the report test worked' - end end end diff --git a/spec/controllers/concerns/idv/document_capture_concern_spec.rb b/spec/controllers/concerns/idv/document_capture_concern_spec.rb deleted file mode 100644 index c3ee2df2e02..00000000000 --- a/spec/controllers/concerns/idv/document_capture_concern_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'rails_helper' - -RSpec.describe Idv::DocumentCaptureConcern, type: :controller do - controller ApplicationController do - include Idv::DocumentCaptureConcern - - before_action :override_document_capture_step_csp - - def index; end - end - - describe '#override_document_capture_step_csp' do - it 'sets the headers for the document capture step' do - get :index, params: { step: 'document_capture' } - - csp = response.request.headers.env['secure_headers_request_config'].csp - expect(csp.script_src).to include("'unsafe-eval'") - expect(csp.img_src).to include('blob:') - end - - it 'does not set headers for any other step' do - get :index, params: { step: 'some_other_step' } - - secure_header_config = response.request.headers.env['secure_headers_request_config'] - expect(secure_header_config).to be_nil - end - end -end diff --git a/spec/controllers/idv/capture_doc_controller_spec.rb b/spec/controllers/idv/capture_doc_controller_spec.rb index fcf96ec46ae..10b3a9023d4 100644 --- a/spec/controllers/idv/capture_doc_controller_spec.rb +++ b/spec/controllers/idv/capture_doc_controller_spec.rb @@ -123,6 +123,27 @@ hash_including(step: 'capture_complete', step_count: 2), ) end + + it 'add unsafe-eval to the CSP for capture steps' do + steps = %i[document_capture] + steps.each do |step| + mock_next_step(step) + + get :show, params: { step: step } + + script_src = response.request.headers.env['secure_headers_request_config'].csp.script_src + expect(script_src).to include("'unsafe-eval'") + end + end + + it 'does not add unsafe-eval to the CSP for non-capture steps' do + mock_next_step(:capture_complete) + + get :show, params: { step: 'capture_complete' } + + secure_header_config = response.request.headers.env['secure_headers_request_config'] + expect(secure_header_config).to be_nil + end end end diff --git a/spec/controllers/idv/doc_auth_controller_spec.rb b/spec/controllers/idv/doc_auth_controller_spec.rb index 33aedad31c9..03233c84e1c 100644 --- a/spec/controllers/idv/doc_auth_controller_spec.rb +++ b/spec/controllers/idv/doc_auth_controller_spec.rb @@ -66,41 +66,40 @@ get :show, params: { step: 'welcome' } - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} welcome visited".downcase, result - ) expect(@analytics).to have_received(:track_event).with( Analytics::DOC_AUTH + ' visited', result ) end - it 'tracks analytics for the optional step' do - mock_next_step(:verify_wait) - result = { errors: {}, step: Idv::Steps::VerifyWaitStepShow, success: true } - - get :show, params: { step: 'verify_wait' } - - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} optional verify_wait submitted".downcase, result - ) - expect(@analytics).to have_received(:track_event).with( - Analytics::DOC_AUTH + ' optional submitted', result - ) - end - it 'increments the analytics step counts on subsequent submissions' do get :show, params: { step: 'welcome' } get :show, params: { step: 'welcome' } expect(@analytics).to have_received(:track_event).ordered.with( - Analytics::DOC_AUTH + ' visited', - hash_including(step: 'welcome', step_count: 1), + Analytics::DOC_AUTH + ' visited', hash_including(step: 'welcome', step_count: 1) ) expect(@analytics).to have_received(:track_event).ordered.with( - Analytics::DOC_AUTH + ' visited', - hash_including(step: 'welcome', step_count: 2), + Analytics::DOC_AUTH + ' visited', hash_including(step: 'welcome', step_count: 2) ) end + + it 'add unsafe-eval to the CSP for the doucment capture step' do + mock_next_step(:document_capture) + + get :show, params: { step: :document_capture } + + script_src = response.request.headers.env['secure_headers_request_config'].csp.script_src + expect(script_src).to include("'unsafe-eval'") + end + + it 'does not add unsafe-eval to the CSP for non-capture steps' do + mock_next_step(:ssn) + + get :show, params: { step: 'ssn' } + + secure_header_config = response.request.headers.env['secure_headers_request_config'] + expect(secure_header_config).to be_nil + end end describe '#update' do @@ -112,9 +111,6 @@ put :update, params: {step: 'ssn', doc_auth: { step: 'ssn', ssn: '111-11-1111' } } - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} ssn submitted".downcase, result - ) expect(@analytics).to have_received(:track_event).with( Analytics::DOC_AUTH + ' submitted', result ) @@ -135,14 +131,6 @@ expect(@analytics).to have_received(:track_event).ordered.with( Analytics::DOC_AUTH + ' submitted', hash_including(step: 'ssn', step_count: 2) ) - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} ssn submitted".downcase, - hash_including(step: 'ssn', step_count: 1), - ) - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} ssn submitted".downcase, - hash_including(step: 'ssn', step_count: 2), - ) end it 'progresses from welcome to upload' do @@ -170,9 +158,6 @@ } expect(response).to redirect_to idv_doc_auth_errors_no_camera_url - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} welcome submitted".downcase, result - ) expect(@analytics).to have_received(:track_event).with( Analytics::DOC_AUTH + ' submitted', result ) @@ -368,15 +353,6 @@ message: I18n.t('doc_auth.errors.lexis_nexis.general_error_no_liveness') }], remaining_attempts: AppConfig.env.acuant_max_attempts.to_i, }.to_json) - expect(@analytics).to have_received(:track_event).with( - 'IdV: ' + "#{Analytics::DOC_AUTH} verify_document_status submitted".downcase, { - errors: { pii: [I18n.t('doc_auth.errors.lexis_nexis.general_error_no_liveness')] }, - success: false, - remaining_attempts: AppConfig.env.acuant_max_attempts.to_i, - step: 'verify_document_status', - step_count: 1, - } - ) expect(@analytics).to have_received(:track_event).with( Analytics::DOC_AUTH + ' submitted', { errors: { pii: [I18n.t('doc_auth.errors.lexis_nexis.general_error_no_liveness')] }, diff --git a/spec/controllers/idv/image_uploads_controller_spec.rb b/spec/controllers/idv/image_uploads_controller_spec.rb index 6fba548a80b..04c7dde663e 100644 --- a/spec/controllers/idv/image_uploads_controller_spec.rb +++ b/spec/controllers/idv/image_uploads_controller_spec.rb @@ -14,7 +14,6 @@ document_capture_session_uuid: document_capture_session.uuid, } end - let(:json) { JSON.parse(response.body, symbolize_names: true) } before do Funnel::DocAuth::RegisterStep.new(user.id, '').call('welcome', :view, true) @@ -26,6 +25,7 @@ it 'returns error status when not provided image fields' do action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:success]).to eq(false) expect(json[:errors]).to eq [ @@ -63,6 +63,7 @@ it 'returns an error' do action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:errors]).to eq [ { field: 'front', message: I18n.t('doc_auth.errors.not_a_file') }, @@ -75,6 +76,7 @@ it 'translates errors using the locale param' do action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:errors]).to eq [ { field: 'front', message: I18n.t('doc_auth.errors.not_a_file', locale: 'es') }, @@ -111,6 +113,7 @@ params.delete(:document_capture_session_uuid) action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:success]).to eq(false) expect(json[:errors]).to eq [ @@ -122,6 +125,7 @@ params[:document_capture_session_uuid] = 'bad uuid' action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:success]).to eq(false) expect(json[:errors]).to eq [ @@ -137,6 +141,7 @@ action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json).to eq({ success: false, @@ -151,6 +156,7 @@ action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(429) expect(json).to eq({ success: false, @@ -189,6 +195,7 @@ it 'returns a successful response and modifies the session' do action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(200) expect(json[:success]).to eq(true) expect(document_capture_session.reload.load_result.success?).to eq(true) @@ -215,152 +222,10 @@ user_id: user.uuid, ) - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - success: true, - errors: {}, - user_id: user.uuid, - ) - action expect_funnel_update_counts(user, 1) end - - context 'but doc_pii validation fails' do - let(:first_name) { 'FAKEY' } - let(:last_name) { 'MCFAKERSON' } - let(:state) { 'ND' } - let(:dob) { '10/06/1938' } - - before do - IdentityDocAuth::Mock::DocAuthMockClient.mock_response!( - method: :get_results, - response: IdentityDocAuth::Response.new( - success: true, - errors: {}, - extra: { result: 'Passed', billed: true }, - pii_from_doc: { - first_name: first_name, - last_name: last_name, - state: state, - dob: dob, - }, - ), - ) - end - - context 'due to invalid Name' do - let(:first_name) { nil } - - it 'tracks name validation errors in analytics' do - stub_analytics - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - success: true, - errors: {}, - user_id: user.uuid, - remaining_attempts: AppConfig.env.acuant_max_attempts.to_i - 1, - ) - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - success: true, - errors: {}, - billed: true, - exception: nil, - result: 'Passed', - user_id: user.uuid, - ) - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - success: false, - errors: { - pii: [I18n.t('doc_auth.errors.lexis_nexis.full_name_check')], - }, - user_id: user.uuid, - ) - - action - end - end - - context 'due to invalid State' do - let(:state) { 'Maryland' } - - it 'tracks state validation errors in analytics' do - stub_analytics - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - success: true, - errors: {}, - user_id: user.uuid, - remaining_attempts: AppConfig.env.acuant_max_attempts.to_i - 1, - ) - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - success: true, - errors: {}, - billed: true, - exception: nil, - result: 'Passed', - user_id: user.uuid, - ) - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - success: false, - errors: { - pii: [I18n.t('doc_auth.errors.lexis_nexis.general_error_no_liveness')], - }, - user_id: user.uuid, - ) - - action - end - end - - context 'but doc_pii validation fails due to invalid DOB' do - let(:dob) { nil } - - it 'tracks dob validation errors in analytics' do - stub_analytics - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_FORM, - success: true, - errors: {}, - user_id: user.uuid, - remaining_attempts: AppConfig.env.acuant_max_attempts.to_i - 1, - ) - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_IMAGE_UPLOAD_VENDOR, - success: true, - errors: {}, - billed: true, - exception: nil, - result: 'Passed', - user_id: user.uuid, - ) - - expect(@analytics).to receive(:track_event).with( - Analytics::IDV_DOC_AUTH_SUBMITTED_PII_VALIDATION, - success: false, - errors: { - pii: [I18n.t('doc_auth.errors.lexis_nexis.birth_date_checks')], - }, - user_id: user.uuid, - ) - - action - end - end - end end context 'when image upload fails' do @@ -377,6 +242,7 @@ it 'returns an error response' do action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:success]).to eq(false) expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) @@ -419,6 +285,7 @@ it 'returns error from yaml file' do action + json = JSON.parse(response.body, symbolize_names: true) expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) expect(json[:errors]).to eq [ { @@ -463,6 +330,7 @@ it 'returns error' do action + json = JSON.parse(response.body, symbolize_names: true) expect(response.status).to eq(400) expect(json[:success]).to eq(false) expect(json[:remaining_attempts]).to be_a_kind_of(Numeric) diff --git a/spec/controllers/openid_connect/authorization_controller_spec.rb b/spec/controllers/openid_connect/authorization_controller_spec.rb index 27868dc0dc2..4c49de2fb7f 100644 --- a/spec/controllers/openid_connect/authorization_controller_spec.rb +++ b/spec/controllers/openid_connect/authorization_controller_spec.rb @@ -243,7 +243,6 @@ expect(session[:sp]).to eq( aal_level_requested: nil, piv_cac_requested: false, - ial: 1, ial2: false, ial2_strict: false, ialmax: false, diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 7bc918379a4..1460f8b8f1c 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -50,7 +50,7 @@ get :metadata end - let(:org_name) { 'login.gov' } + let(:org_name) { '18F' } let(:xmldoc) { SamlResponseDoc.new('controller', 'metadata', response) } it 'renders XML inline' do @@ -381,7 +381,6 @@ issuer: saml_settings.issuer, aal_level_requested: aal_level, piv_cac_requested: false, - ial: 1, ial2: false, ial2_strict: false, ialmax: false, @@ -405,7 +404,6 @@ issuer: saml_settings.issuer, aal_level_requested: aal_level, piv_cac_requested: false, - ial: 1, ial2: false, ial2_strict: false, ialmax: false, diff --git a/spec/controllers/sign_up/completions_controller_spec.rb b/spec/controllers/sign_up/completions_controller_spec.rb index f22117aedc6..fbf2d64815f 100644 --- a/spec/controllers/sign_up/completions_controller_spec.rb +++ b/spec/controllers/sign_up/completions_controller_spec.rb @@ -126,7 +126,7 @@ it 'updates verified attributes' do stub_sign_in subject.session[:sp] = { - ial: 1, + ial2: false, request_url: 'http://example.com', requested_attributes: ['email'], } @@ -166,7 +166,7 @@ user = create(:user, profiles: [create(:profile, :verified, :active)]) stub_sign_in(user) subject.session[:sp] = { - ial: 2, + ial2: true, request_url: 'http://example.com', requested_attributes: %w[email first_name verified_at], } diff --git a/spec/features/idv/actions/cancel_link_sent_action_spec.rb b/spec/features/idv/actions/cancel_link_sent_action_spec.rb deleted file mode 100644 index 8b6b1d80398..00000000000 --- a/spec/features/idv/actions/cancel_link_sent_action_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'rails_helper' - -feature 'doc auth cancel link sent action' do - include IdvStepHelper - include DocAuthHelper - - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_link_sent_step - end - - it 'returns to send link step' do - click_doc_auth_back_link - - expect(page).to have_current_path(idv_doc_auth_send_link_step) - end -end diff --git a/spec/features/idv/actions/cancel_send_link_action_spec.rb b/spec/features/idv/actions/cancel_send_link_action_spec.rb deleted file mode 100644 index 82bd3e79bf4..00000000000 --- a/spec/features/idv/actions/cancel_send_link_action_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -require 'rails_helper' - -feature 'doc auth cancel send link action' do - include IdvStepHelper - include DocAuthHelper - - before do - sign_in_and_2fa_user - complete_doc_auth_steps_before_send_link_step - end - - it 'returns to upload step' do - click_doc_auth_back_link - - expect(page).to have_current_path(idv_doc_auth_upload_step) - end -end diff --git a/spec/features/idv/cac/success_step_spec.rb b/spec/features/idv/cac/success_step_spec.rb new file mode 100644 index 00000000000..062758467da --- /dev/null +++ b/spec/features/idv/cac/success_step_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +feature 'cac proofing success step' do + include CacProofingHelper + + before do + sign_in_and_2fa_user + complete_cac_proofing_steps_before_success_step + end + + it 'is on the correct page' do + expect(page).to have_current_path(idv_cac_proofing_success_step) + end + + it 'proceeds to the next page' do + click_continue + + expect(page).to have_current_path(idv_phone_path) + end +end diff --git a/spec/features/idv/cac/use_cac_step_spec.rb b/spec/features/idv/cac/use_cac_step_spec.rb index c33097af8d6..4f92836dc01 100644 --- a/spec/features/idv/cac/use_cac_step_spec.rb +++ b/spec/features/idv/cac/use_cac_step_spec.rb @@ -8,7 +8,7 @@ strip_tags(t('doc_auth.info.use_cac', link: t('doc_auth.info.use_cac_link'))) end - it 'shows cac proofing option if cac proofing on desktop' do + it 'shows cac proofing option if cac proofing is enabled' do sign_in_and_2fa_user complete_doc_auth_steps_before_upload_step @@ -18,6 +18,15 @@ expect(page).to have_current_path(idv_cac_proofing_choose_method_step) end + it 'does not show cac proofing option if cac proofing is disabled' do + allow(AppConfig.env).to receive(:cac_proofing_enabled).and_return('false') + + sign_in_and_2fa_user + complete_doc_auth_steps_before_upload_step + + expect(page).to_not have_content use_cac_content + end + it 'does not show cac proofing option on mobile' do allow(DeviceDetector).to receive(:new).and_return(mobile_device) diff --git a/spec/features/idv/cac/verify_step_spec.rb b/spec/features/idv/cac/verify_step_spec.rb index 1ae799c20e9..ae76522b41a 100644 --- a/spec/features/idv/cac/verify_step_spec.rb +++ b/spec/features/idv/cac/verify_step_spec.rb @@ -15,7 +15,7 @@ expect(page).to have_current_path(idv_cac_proofing_verify_step) click_continue - expect(page).to have_current_path(idv_phone_path) + expect(page).to have_current_path(idv_cac_proofing_success_step) expect(SpCost.count).to eq(1) sp_cost = SpCost.first @@ -58,7 +58,7 @@ expect(page).to have_content t('idv.failure.timeout') allow(DocumentCaptureSession).to receive(:find_by).and_call_original click_continue - expect(page).to have_current_path(idv_phone_path) + expect(page).to have_current_path(idv_cac_proofing_success_step) end end @@ -78,7 +78,7 @@ it 'proceeds to the next page upon confirmation' do click_continue - expect(page).to have_current_path(idv_phone_path) + expect(page).to have_current_path(idv_cac_proofing_success_step) end context 'async timed out' do @@ -96,7 +96,7 @@ expect(page).to have_current_path(idv_cac_proofing_verify_step) allow(DocumentCaptureSession).to receive(:find_by).and_call_original click_continue - expect(page).to have_current_path(idv_phone_path) + expect(page).to have_current_path(idv_cac_proofing_success_step) end end end diff --git a/spec/features/idv/clearing_and_restarting_spec.rb b/spec/features/idv/clearing_and_restarting_spec.rb index 468ad0ec26f..5dbc84751ff 100644 --- a/spec/features/idv/clearing_and_restarting_spec.rb +++ b/spec/features/idv/clearing_and_restarting_spec.rb @@ -31,4 +31,30 @@ it_behaves_like 'clearing and restarting idv' end end + + context 'during USPS step' do + context 'sending a letter before signing out' do + before do + start_idv_from_sp + complete_idv_steps_before_usps_step(user) + end + + it_behaves_like 'clearing and restarting idv' + end + + context 're-sending a letter after signing out' do + before do + start_idv_from_sp + complete_idv_steps_with_usps_before_confirmation_step(user) + click_acknowledge_personal_key + visit account_path + first(:link, t('links.sign_out')).click + start_idv_from_sp + sign_in_live_with_2fa(user) + click_on t('idv.messages.usps.resend') + end + + it_behaves_like 'clearing and restarting idv' + end + end end diff --git a/spec/features/idv/doc_auth/address_step_spec.rb b/spec/features/idv/doc_auth/address_step_spec.rb index e4d5caa7456..9b5bdbf8e17 100644 --- a/spec/features/idv/doc_auth/address_step_spec.rb +++ b/spec/features/idv/doc_auth/address_step_spec.rb @@ -27,10 +27,4 @@ click_idv_continue expect(page).to have_current_path(idv_address_path) end - - it 'allows the user to click back to return to the verify step' do - click_doc_auth_back_link - - expect(page).to have_current_path(idv_doc_auth_verify_step) - end end diff --git a/spec/features/idv/doc_auth/document_capture_step_spec.rb b/spec/features/idv/doc_auth/document_capture_step_spec.rb index c314d92451b..e53a279d1ab 100644 --- a/spec/features/idv/doc_auth/document_capture_step_spec.rb +++ b/spec/features/idv/doc_auth/document_capture_step_spec.rb @@ -59,12 +59,6 @@ result: 'Passed', billed: true, ) - expect(fake_analytics).to have_logged_event( - 'IdV: ' + "#{Analytics::DOC_AUTH} document_capture submitted".downcase, - step: 'document_capture', - result: 'Passed', - billed: true, - ) expect_costing_for_document end @@ -92,13 +86,6 @@ billed: true, success: false, ) - expect(fake_analytics).to have_logged_event( - 'IdV: ' + "#{Analytics::DOC_AUTH} document_capture submitted".downcase, - step: 'document_capture', - result: 'Passed', - billed: true, - success: false, - ) end it 'offers in person option on failure' do diff --git a/spec/features/idv/doc_auth/link_sent_step_spec.rb b/spec/features/idv/doc_auth/link_sent_step_spec.rb index 2ba8ad99f41..9ed903524eb 100644 --- a/spec/features/idv/doc_auth/link_sent_step_spec.rb +++ b/spec/features/idv/doc_auth/link_sent_step_spec.rb @@ -70,15 +70,16 @@ visit current_path end - context 'clicks back link' do + context 'user cancels flow session' do before do - click_doc_auth_back_link + click_on t('links.cancel') + click_on t('forms.buttons.cancel') visit idv_doc_auth_link_sent_step end - it 'redirects to send link step' do - expect(page).to have_current_path(idv_doc_auth_send_link_step) + it 'redirects to welcome step' do + expect(page).to have_current_path(idv_doc_auth_welcome_step) end end @@ -89,8 +90,6 @@ end expect(page).to_not have_css 'meta[http-equiv="refresh"]', visible: false - click_doc_auth_back_link - click_doc_auth_back_link click_on t('doc_auth.buttons.start_over') complete_doc_auth_steps_before_link_sent_step expect(page).to have_css 'meta[http-equiv="refresh"]', visible: false 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 be182f1f50b..89a2635b0ca 100644 --- a/spec/features/idv/doc_auth/send_link_step_spec.rb +++ b/spec/features/idv/doc_auth/send_link_step_spec.rb @@ -62,17 +62,20 @@ it 'throttles sending the link' do user = sign_in_and_2fa_user - complete_doc_auth_steps_before_send_link_step idv_send_link_max_attempts.times do + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_send_link_step expect(page).to_not have_content I18n.t('errors.doc_auth.send_link_throttle') fill_in :doc_auth_phone, with: '415-555-0199' click_idv_continue expect(page).to have_current_path(idv_doc_auth_link_sent_step) - click_doc_auth_back_link + click_on t('doc_auth.buttons.start_over') end + sign_in_and_2fa_user(user) + complete_doc_auth_steps_before_send_link_step fill_in :doc_auth_phone, with: '415-555-0199' click_idv_continue expect(page).to have_current_path(idv_doc_auth_send_link_step) diff --git a/spec/features/idv/doc_auth/welcome_step_spec.rb b/spec/features/idv/doc_auth/welcome_step_spec.rb index b3f6a3613c8..01421dc4190 100644 --- a/spec/features/idv/doc_auth/welcome_step_spec.rb +++ b/spec/features/idv/doc_auth/welcome_step_spec.rb @@ -57,14 +57,6 @@ def expect_doc_auth_first_step expect(fake_analytics).to have_logged_event( Analytics::DOC_AUTH + ' submitted', step: 'upload', step_count: 2, success: true ) - expect(fake_analytics).to have_logged_event( - 'IdV: ' + "#{Analytics::DOC_AUTH} upload visited".downcase, step: 'upload', step_count: 1 - ) - expect(fake_analytics).to have_logged_event( - 'IdV: ' + "#{Analytics::DOC_AUTH} upload submitted".downcase, - step: 'upload', step_count: 2, success: true, - ) - end end diff --git a/spec/features/idv/doc_capture/document_capture_step_spec.rb b/spec/features/idv/doc_capture/document_capture_step_spec.rb index ff449fec2cc..41bccda6b76 100644 --- a/spec/features/idv/doc_capture/document_capture_step_spec.rb +++ b/spec/features/idv/doc_capture/document_capture_step_spec.rb @@ -20,52 +20,13 @@ else visit_idp_from_oidc_sp_with_ial2 end + complete_doc_capture_steps_before_first_step(user) allow_any_instance_of(DeviceDetector).to receive(:device_type).and_return('mobile') end - context 'invalid session' do - let!(:request_uri) { doc_capture_request_uri(user) } - - before do - Capybara.reset_session! - expired_minutes = (AppConfig.env.doc_capture_request_valid_for_minutes.to_i + 1).minutes - document_capture_session = user.document_capture_sessions.last - document_capture_session.requested_at -= expired_minutes - document_capture_session.save! - end - - it 'logs events as an anonymous user' do - allow(Analytics).to receive(:new).and_return(fake_analytics) - expect(Analytics).to receive(:new).with(hash_including(user: instance_of(AnonymousUser))) - visit request_uri - - expect(fake_analytics).to have_logged_event( - Analytics::CAPTURE_DOC, - success: false, - ) - end - end - - context 'valid session' do - it 'logs events as the inherited user' do - allow(Analytics).to receive(:new).and_return(fake_analytics) - expect(Analytics).to receive(:new).with(hash_including(user: user)) - complete_doc_capture_steps_before_first_step(user) - - expect(fake_analytics).to have_logged_event( - Analytics::CAPTURE_DOC + ' visited', - step: 'document_capture', - ) - end - end - context 'when liveness checking is enabled' do let(:liveness_enabled) { 'true' } - before do - complete_doc_capture_steps_before_first_step(user) - end - context 'when the SP does not request strict IAL2' do let(:sp_requests_ial2_strict) { false } @@ -130,8 +91,8 @@ end it 'proceeds to the next page with valid info and logs analytics info' do - allow(Analytics).to receive(:new).and_return(fake_analytics) - expect(Analytics).to receive(:new).with(hash_including(user: user)) + allow_any_instance_of(ApplicationController). + to receive(:analytics).and_return(fake_analytics) attach_and_submit_images @@ -198,10 +159,6 @@ context 'when liveness checking is not enabled' do let(:liveness_enabled) { 'false' } - before do - complete_doc_capture_steps_before_first_step(user) - end - it 'is on the correct_page and shows the document upload options' do expect(current_path).to eq(idv_capture_doc_document_capture_step) expect(page).to have_content(t('doc_auth.headings.document_capture_front')) @@ -278,10 +235,6 @@ end context 'when there is a stored result' do - before do - complete_doc_capture_steps_before_first_step(user) - end - it 'proceeds to the next step if the result was successful' do document_capture_session = user.document_capture_sessions.last response = IdentityDocAuth::Response.new(success: true) diff --git a/spec/features/idv/hybrid_flow_spec.rb b/spec/features/idv/hybrid_flow_spec.rb deleted file mode 100644 index 61d341d6240..00000000000 --- a/spec/features/idv/hybrid_flow_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -require 'rails_helper' - -describe 'Hybrid Flow' do - include IdvHelper - include DocAuthHelper - - before do - allow(FeatureManagement).to receive(:doc_capture_polling_enabled?).and_return(true) - allow(AppConfig.env).to receive(:doc_auth_enable_presigned_s3_urls).and_return('true') - allow(AppConfig.env).to receive(:document_capture_async_uploads_enabled).and_return('true') - allow(LoginGov::Hostdata::EC2).to receive(:load). - and_return(OpenStruct.new(region: 'us-west-2', account_id: '123456789')) - end - - it 'proofs and hands off to mobile', js: true do - user = nil - sms_link = nil - - expect(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| - sms_link = config[:link] - impl.call(config) - end - - perform_in_browser(:desktop) do - user = sign_in_and_2fa_user - complete_doc_auth_steps_before_send_link_step - fill_in :doc_auth_phone, with: '415-555-0199' - click_idv_continue - - expect(page).to have_content(t('doc_auth.headings.text_message')) - end - - expect(sms_link).to be_present - - perform_in_browser(:mobile) do - visit sms_link - attach_and_submit_images - expect(page).to have_text(t('doc_auth.instructions.switch_back')) - end - - perform_in_browser(:desktop) do - expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) - - fill_out_ssn_form_ok - click_idv_continue - - expect(page).to have_content(t('doc_auth.headings.verify')) - click_idv_continue - - fill_out_phone_form_mfa_phone(user) - click_idv_continue - - fill_in :user_password, with: Features::SessionHelper::VALID_PASSWORD - click_idv_continue - - acknowledge_and_confirm_personal_key - - expect(page).to have_current_path(account_path) - expect(page).to have_content(t('headings.account.verified_account')) - end - end -end diff --git a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb index 81b0515c7ae..995520db634 100644 --- a/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb +++ b/spec/features/idv/steps/phone_otp_delivery_selection_step_spec.rb @@ -46,19 +46,6 @@ end end - context 'the user opts to verify by mail instead' do - it 'can return back to the OTP selection screen' do - start_idv_from_sp - complete_idv_steps_before_phone_otp_delivery_selection_step - click_on t('idv.form.activate_by_mail') - - expect(page).to have_content(t('idv.titles.mail.verify')) - - click_doc_auth_back_link - expect(current_path).to eq(idv_otp_delivery_method_path) - end - end - context 'with a non-US number' do let(:bahamas_phone) { '+12423270143' } diff --git a/spec/features/idv/steps/phone_step_spec.rb b/spec/features/idv/steps/phone_step_spec.rb index 2a09acd2989..b18a76d9cec 100644 --- a/spec/features/idv/steps/phone_step_spec.rb +++ b/spec/features/idv/steps/phone_step_spec.rb @@ -157,21 +157,6 @@ context "when the user's information cannot be verified" do it_behaves_like 'fail to verify idv info', :phone - it 'links to verify by mail, from which user can return back to the warning screen' do - start_idv_from_sp - complete_idv_steps_before_phone_step - fill_out_phone_form_fail - click_idv_continue - - expect(page).to have_content(t('idv.failure.phone.warning')) - - click_on t('idv.form.activate_by_mail') - expect(page).to have_content(t('idv.titles.mail.verify')) - - click_doc_auth_back_link - expect(page).to have_content(t('idv.failure.phone.warning')) - end - it 'does not render the link to proof by mail if proofing by mail is disabled' do allow(FeatureManagement).to receive(:enable_usps_verification?).and_return(false) diff --git a/spec/features/idv/steps/usps_step_spec.rb b/spec/features/idv/steps/usps_step_spec.rb index 37042a63d7e..4f32e07b5c2 100644 --- a/spec/features/idv/steps/usps_step_spec.rb +++ b/spec/features/idv/steps/usps_step_spec.rb @@ -12,13 +12,14 @@ expect(page).to have_current_path(idv_review_path) end - it 'allows the user to go back' do + it 'allows the user to clear IdV and restart' do start_idv_from_sp complete_idv_steps_before_usps_step - click_doc_auth_back_link + click_on t('idv.messages.clear_and_start_over') - expect(page).to have_current_path(idv_phone_path) + expect(page).to have_content(t('doc_auth.headings.welcome')) + expect(page).to have_current_path(idv_doc_auth_step_path(step: :welcome)) end context 'the user has sent a letter but not verified an OTP' do @@ -34,9 +35,9 @@ expect(page).to have_current_path(idv_come_back_later_path) end - it 'allows the user to return to usps otp confirmation' do + it 'allows the user to cancel and return to usps otp confirmation' do complete_idv_and_return_to_usps_step - click_doc_auth_back_link + click_link t('links.cancel') expect(page).to have_content(t('forms.verify_profile.title')) expect(page).to have_current_path(verify_account_path) @@ -67,4 +68,10 @@ def expect_user_to_be_unverified(user) expect(profile.deactivation_reason).to eq 'verification_pending' end end + + context 'cancelling IdV' do + it_behaves_like 'cancel at idv step', :usps + it_behaves_like 'cancel at idv step', :usps, :oidc + it_behaves_like 'cancel at idv step', :usps, :saml + end end diff --git a/spec/features/openid_connect/openid_connect_spec.rb b/spec/features/openid_connect/openid_connect_spec.rb index 5f655101d65..c1cd2ca74e5 100644 --- a/spec/features/openid_connect/openid_connect_spec.rb +++ b/spec/features/openid_connect/openid_connect_spec.rb @@ -89,6 +89,7 @@ click_submit_default expect(current_url).to start_with('http://localhost:7654/auth/result') + expect(page.get_rack_session.keys).to include('sp') end it 'auto-allows and includes redirect_uris in CSP headers after an incorrect OTP' do @@ -115,6 +116,7 @@ click_submit_default expect(current_url).to start_with('http://localhost:7654/auth/result') + expect(page.get_rack_session.keys).to include('sp') end end @@ -135,6 +137,11 @@ "http://www.example.com/openid_connect/logout?id_token_hint=#{id_token}", ) + expect(page.response_headers['Content-Security-Policy']).to include( + 'form-action \'self\' gov.gsa.openidconnect.test://result '\ + 'gov.gsa.openidconnect.test://result/signout', + ) + visit account_path expect(page).to_not have_content(t('headings.account.login_info')) expect(page).to have_content(t('headings.sign_in_without_sp')) @@ -481,6 +488,7 @@ continue_as(email) redirect_uri = URI(current_url) expect(redirect_uri.to_s).to start_with('gov.gsa.openidconnect.test://result') + expect(page.get_rack_session.keys).to include('sp') end end end diff --git a/spec/features/reports/authorization_count_spec.rb b/spec/features/reports/authorization_count_spec.rb deleted file mode 100644 index b88c203cee7..00000000000 --- a/spec/features/reports/authorization_count_spec.rb +++ /dev/null @@ -1,203 +0,0 @@ -require 'rails_helper' - -describe 'OpenID Connect' do - include IdvFromSpHelper - include OidcAuthHelper - include DocAuthHelper - - let(:email) { 'test@test.com' } - let(:password) { RequestHelper::VALID_PASSWORD } - let(:today) { Time.zone.today } - let(:client_id_1) { 'urn:gov:gsa:openidconnect:sp:server' } - let(:client_id_2) { 'urn:gov:gsa:openidconnect:sp:server_two' } - let(:issuer_1) { 'https://rp1.serviceprovider.com/auth/saml/metadata' } - let(:issuer_2) { 'https://rp3.serviceprovider.com/auth/saml/metadata' } - - context 'an IAL1 user with an active session' do - before do - create_ial1_user_from_sp(email) - user = User.find_with_email(email) - end - - context 'using oidc' do - it 'does not count second IAL1 auth at same sp' do - visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue - expect_ial1_count_only(client_id_1) - - visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue - expect_ial1_count_only(client_id_1) - end - - it 'counts step up from IAL1 to IAL2 after proofing' do - visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue - expect_ial1_count_only(client_id_1) - - visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - complete_proofing_steps - expect_ial1_and_ial2_count(client_id_1) - end - end - - context 'using saml' do - it 'does not count second IAL1 auth at same sp' do - visit_idp_from_ial1_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial1_count_only(issuer_1) - - visit_idp_from_ial1_saml_sp(issuer: issuer_1) - click_continue - expect_ial1_count_only(issuer_1) - end - - it 'counts step up from IAL1 to IAL2 after proofing' do - visit_idp_from_ial1_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial1_count_only(issuer_1) - - visit_idp_from_ial2_saml_sp(issuer: issuer_1) - complete_proofing_steps - expect_ial1_and_ial2_count(issuer_1) - end - end - end - - - context 'an IAL2 user with an active session' do - before do - create_ial2_user_from_sp(email) - user = User.find_with_email(email) - end - - context 'using oidc' do - - it 'counts IAL1 auth at same sp' do - visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue - expect_ial2_count_only(client_id_1) - - visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue - expect_ial1_and_ial2_count(client_id_1) - end - - it 'does not count second IAL2 auth at same sp' do - visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue - expect_ial2_count_only(client_id_1) - - visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue - expect_ial2_count_only(client_id_1) - end - - it 'counts step up from IAL1 to IAL2 at another sp' do - visit_idp_from_ial1_oidc_sp(client_id: client_id_2) - click_agree_and_continue - expect_ial1_count_only(client_id_2) - - visit_idp_from_ial2_oidc_sp(client_id: client_id_2) - click_agree_and_continue - expect_ial1_and_ial2_count(client_id_2) - end - - it 'counts IAL2 auth at another sp' do - visit_idp_from_ial2_oidc_sp(client_id: client_id_1) - click_continue - expect_ial2_count_only(client_id_1) - - visit_idp_from_ial2_oidc_sp(client_id: client_id_2) - click_agree_and_continue - expect_ial2_count_only(client_id_2) - end - - it 'counts IAL1 auth at another sp' do - visit_idp_from_ial1_oidc_sp(client_id: client_id_1) - click_continue - expect_ial1_and_ial2_count(client_id_1) - - visit_idp_from_ial1_oidc_sp(client_id: client_id_2) - click_agree_and_continue - expect_ial1_count_only(client_id_2) - end - end - - context 'using saml' do - - it 'counts IAL1 auth at same sp' do - visit_idp_from_ial2_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial2_count_only(issuer_1) - - visit_idp_from_ial1_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial1_and_ial2_count(issuer_1) - end - - it 'does not count second IAL2 auth at same sp' do - visit_idp_from_ial2_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial2_count_only(issuer_1) - - visit_idp_from_ial2_saml_sp(issuer: issuer_1) - click_continue - expect_ial2_count_only(issuer_1) - end - - it 'counts step up from IAL1 to IAL2 at same sp' do - visit_idp_from_ial1_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial1_count_only(issuer_1) - - visit_idp_from_ial2_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial1_and_ial2_count(issuer_1) - end - - it 'counts IAL1 auth at another sp' do - visit_idp_from_ial1_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial1_count_only(issuer_1) - - visit_idp_from_ial1_saml_sp(issuer: issuer_2) - click_agree_and_continue - expect_ial1_count_only(issuer_2) - end - - it 'counts IAL2 auth at another sp' do - visit_idp_from_ial2_saml_sp(issuer: issuer_1) - click_agree_and_continue - expect_ial2_count_only(issuer_1) - - visit_idp_from_ial2_saml_sp(issuer: issuer_2) - click_agree_and_continue - expect_ial2_count_only(issuer_2) - end - end - end - - def expect_ial1_count_only(issuer) - expect(ial1_monthly_auth_count(issuer)).to eq(1) - expect(ial2_monthly_auth_count(issuer)).to eq(0) - end - - def expect_ial2_count_only(issuer) - expect(ial1_monthly_auth_count(issuer)).to eq(0) - expect(ial2_monthly_auth_count(issuer)).to eq(1) - end - - def expect_ial1_and_ial2_count(issuer) - expect(ial1_monthly_auth_count(issuer)).to eq(1) - expect(ial2_monthly_auth_count(issuer)).to eq(1) - end - - def ial2_monthly_auth_count(client_id) - Db::MonthlySpAuthCount::SpMonthTotalAuthCounts.call(today, client_id, 2) - end - - def ial1_monthly_auth_count(client_id) - Db::MonthlySpAuthCount::SpMonthTotalAuthCounts.call(today, client_id, 1) - end -end diff --git a/spec/features/reports/monthly_usps_letter_requests_report_spec.rb b/spec/features/reports/monthly_usps_letter_requests_report_spec.rb deleted file mode 100644 index ffd344cffbb..00000000000 --- a/spec/features/reports/monthly_usps_letter_requests_report_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -require 'rails_helper' - -feature 'Monthly usps letter requests report' do - it 'runs when there are not entries' do - results_hash = JSON.parse(Reports::MonthlyUspsLetterRequestsReport.new.call) - expect(results_hash['total_letter_requests']).to eq(0) - expect(results_hash['daily_letter_requests'].count).to eq(0) - end - - it 'runs when there is one ftp' do - LetterRequestsToUspsFtpLog.create(ftp_at: Time.zone.now, letter_requests_count: 3) - - results_hash = JSON.parse(Reports::MonthlyUspsLetterRequestsReport.new.call) - expect(results_hash['total_letter_requests']).to eq(3) - expect(results_hash['daily_letter_requests'].count).to eq(1) - end - - it 'totals correctly when there are two ftps' do - now = Time.zone.now - LetterRequestsToUspsFtpLog.create(ftp_at: now.yesterday, letter_requests_count: 3) - LetterRequestsToUspsFtpLog.create(ftp_at: now, letter_requests_count: 4) - - results_hash = JSON.parse(Reports::MonthlyUspsLetterRequestsReport.new.call) - expect(results_hash['total_letter_requests']).to eq(7) - expect(results_hash['daily_letter_requests'].count).to eq(2) - end - - it 'only reports on the current month' do - now = Time.zone.now - LetterRequestsToUspsFtpLog.create(ftp_at: now - 32.days, letter_requests_count: 3) - LetterRequestsToUspsFtpLog.create(ftp_at: now, letter_requests_count: 4) - - results_hash = JSON.parse(Reports::MonthlyUspsLetterRequestsReport.new.call) - expect(results_hash['total_letter_requests']).to eq(4) - expect(results_hash['daily_letter_requests'].count).to eq(1) - end -end diff --git a/spec/features/saml/ial1_sso_spec.rb b/spec/features/saml/ial1_sso_spec.rb index e9b3e3bd32a..e72d5b186df 100644 --- a/spec/features/saml/ial1_sso_spec.rb +++ b/spec/features/saml/ial1_sso_spec.rb @@ -32,6 +32,7 @@ continue_as(email) expect(current_url).to eq authn_request + expect(page.get_rack_session.keys).to include('sp') end end diff --git a/spec/features/sp_cost_tracking_spec.rb b/spec/features/sp_cost_tracking_spec.rb index 21ce030a414..c4a29f1845d 100644 --- a/spec/features/sp_cost_tracking_spec.rb +++ b/spec/features/sp_cost_tracking_spec.rb @@ -27,10 +27,8 @@ expect_sp_cost_type(1, 2, 'acuant_front_image') expect_sp_cost_type(2, 2, 'acuant_back_image') expect_sp_cost_type(3, 2, 'acuant_result') - expect_sp_cost_type(4, 2, 'lexis_nexis_resolution', - transaction_id: IdentityIdpFunctions::ResolutionMockClient::TRANSACTION_ID) - expect_sp_cost_type(5, 2, 'aamva', - transaction_id: IdentityIdpFunctions::StateIdMockClient::TRANSACTION_ID) + expect_sp_cost_type(4, 2, 'lexis_nexis_resolution') + expect_sp_cost_type(5, 2, 'aamva') expect_sp_cost_type(6, 2, 'lexis_nexis_address') expect_sp_cost_type(7, 2, 'user_added') expect_sp_cost_type(8, 2, 'authentication') @@ -122,13 +120,12 @@ expect_direct_cost_type(0, 'digest') end - def expect_sp_cost_type(sp_cost_index, ial, token, transaction_id: nil) + def expect_sp_cost_type(sp_cost_index, ial, token) sp_cost = sp_costs(sp_cost_index) expect(sp_cost.ial).to eq(ial) expect(sp_cost.issuer).to eq(issuer) expect(sp_cost.agency_id).to eq(agency_id) expect(sp_cost.cost_type).to eq(token) - expect(sp_cost.transaction_id).to(eq(transaction_id)) if transaction_id end def expect_direct_cost_type(sp_cost_index, token) diff --git a/spec/features/two_factor_authentication/multiple_tabs_spec.rb b/spec/features/two_factor_authentication/multiple_tabs_spec.rb new file mode 100644 index 00000000000..0593d891557 --- /dev/null +++ b/spec/features/two_factor_authentication/multiple_tabs_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +feature 'user interacts with 2FA across multiple browser tabs' do + include SpAuthHelper + include SamlAuthHelper + + it_behaves_like 'visiting 2fa when fully authenticated', :oidc + it_behaves_like 'visiting 2fa when fully authenticated', :saml +end diff --git a/spec/features/users/sign_in_spec.rb b/spec/features/users/sign_in_spec.rb index 97676ea2c3b..c99a4416974 100644 --- a/spec/features/users/sign_in_spec.rb +++ b/spec/features/users/sign_in_spec.rb @@ -360,11 +360,11 @@ describe 'session timeout configuration' do it 'uses delay and warning settings whose sum is a multiple of 60' do - expect((session_timeout_start + session_timeout_warning) % 60).to eq 0 + expect((start + warning) % 60).to eq 0 end it 'uses frequency and warning settings whose sum is a multiple of 60' do - expect((session_timeout_frequency + session_timeout_warning) % 60).to eq 0 + expect((frequency + warning) % 60).to eq 0 end end @@ -479,25 +479,6 @@ end end - context 'adds phone number after IAL1 sign in' do - it 'redirects to account page and not the SP' do - user = create(:user, :signed_up) - visit_idp_from_oidc_sp_with_loa1_prompt_login - fill_in_credentials_and_submit(user.email, user.password) - fill_in_code_with_last_phone_otp - click_submit_default - click_agree_and_continue - - visit account_path - click_on "+ #{t('account.index.phone_add')}" - fill_in :new_phone_form_phone, with: '415-555-0199' - click_continue - fill_in_code_with_last_phone_otp - click_submit_default - expect(current_path).to eq account_path - end - end - context 'invalid request_id' do it 'allows the user to sign in and does not try to redirect to any SP' do user = create(:user, :signed_up) diff --git a/spec/helpers/go_back_helper_spec.rb b/spec/helpers/go_back_helper_spec.rb deleted file mode 100644 index 627d97cd317..00000000000 --- a/spec/helpers/go_back_helper_spec.rb +++ /dev/null @@ -1,87 +0,0 @@ -require 'rails_helper' - -RSpec.describe GoBackHelper do - include GoBackHelper - - describe '#go_back_path' do - let(:referer) { nil } - let(:request) { double('request', referer: referer) } - - before do - allow(helper).to receive(:request).and_return(request) - end - - subject { go_back_path } - - context 'no referer' do - let(:referer) { nil } - - it 'is nil' do - expect(subject).to be_nil - end - end - - context 'referer is invalid scheme' do - let(:referer) { 'javascript:alert()' } - - it 'is nil' do - expect(subject).to be_nil - end - end - - context 'referer from different domain' do - let(:referer) { 'https://gsa.gov/' } - - it 'is nil' do - expect(subject).to be_nil - end - end - - context 'referer from same domain' do - let(:referer) { 'https://gsa.gov/' } - - before do - allow(AppConfig.env).to receive(:domain_name).and_return('gsa.gov') - end - - it 'is path from referer' do - expect(subject).to eq('/') - end - end - end - - describe '#extract_path_and_query_from_uri' do - it 'preserves query parameter and path from uri' do - uri = URI.parse('https://gsa.gov/path/to/?with_params=true') - extracted = extract_path_and_query_from_uri(uri) - - expect(extracted).to eq('/path/to/?with_params=true') - end - end - - describe '#app_host' do - let(:domain_name) { nil } - - before do - allow(AppConfig.env).to receive(:domain_name).and_return(domain_name) - end - - subject { app_host } - - context 'without port' do - let(:domain_name) { 'gsa.gov' } - - it 'returns host' do - expect(subject).to eq('gsa.gov') - end - end - - context 'with port' do - let(:domain_name) { 'localhost:8000' } - - it 'returns host' do - expect(subject).to eq('localhost') - end - end - end -end diff --git a/spec/javascripts/packages/document-capture/components/acuant-capture-canvas-spec.jsx b/spec/javascripts/packages/document-capture/components/acuant-capture-canvas-spec.jsx index ab4a7e17ba7..c38856946f6 100644 --- a/spec/javascripts/packages/document-capture/components/acuant-capture-canvas-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/acuant-capture-canvas-spec.jsx @@ -1,4 +1,4 @@ -import { AcuantContextProvider, DeviceContext } from '@18f/identity-document-capture'; +import { Provider as AcuantContextProvider } from '@18f/identity-document-capture/context/acuant'; import AcuantCaptureCanvas from '@18f/identity-document-capture/components/acuant-capture-canvas'; import { render, useAcuant } from '../../../support/document-capture'; @@ -7,11 +7,9 @@ describe('document-capture/components/acuant-capture-canvas', () => { it('waits for initialization', () => { render( - - - - - , + + + , ); // At this point, it's assumed `window.AcuantCameraUI.start` has not been called. This can't be @@ -34,11 +32,9 @@ describe('document-capture/components/acuant-capture-canvas', () => { it('ends on unmount', () => { const { unmount } = render( - - - - - , + + + , ); initialize(); diff --git a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx index f85bcf5580c..61aa5891a7f 100644 --- a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx @@ -10,7 +10,6 @@ import { AcuantContextProvider, AnalyticsContext } from '@18f/identity-document- import DeviceContext from '@18f/identity-document-capture/context/device'; import I18nContext from '@18f/identity-document-capture/context/i18n'; import { render, useAcuant } from '../../../support/document-capture'; -import { getFixtureFile } from '../../../support/file'; const ACUANT_CAPTURE_SUCCESS_RESULT = { image: { @@ -37,11 +36,6 @@ const ACUANT_CAPTURE_BLURRY_RESULT = { describe('document-capture/components/acuant-capture', () => { const { initialize } = useAcuant(); - let validUpload; - before(async () => { - validUpload = await getFixtureFile('doc_auth_images/id-back.jpg'); - }); - context('mobile', () => { it('renders with assumed capture button support while acuant is not ready and on mobile', () => { const { getByText } = render( @@ -221,12 +215,14 @@ describe('document-capture/components/acuant-capture', () => { expect(window.AcuantCameraUI.end.calledOnce).to.be.true(); }); - it('renders retry button when value and capture supported', async () => { - const selfie = await getFixtureFile('doc_auth_images/selfie.jpg'); + it('renders retry button when value and capture supported', () => { const { getByText } = render( - + , ); @@ -241,28 +237,23 @@ describe('document-capture/components/acuant-capture', () => { }); it('renders upload button when value and capture not supported', () => { - const onChange = sinon.spy(); - const onClick = sinon.spy(); - const { getByText, getByLabelText } = render( + const { getByText } = render( - + , ); initialize({ isCameraSupported: false }); - const input = getByLabelText('Image'); - - // Since file input prompt occurs by button click proxy to input, we must fire upload event - // directly at the input. At least ensure that clicking button does "click" input. - input.addEventListener('click', onClick); - userEvent.click(getByText('doc_auth.buttons.upload_picture')); - expect(onClick).to.have.been.calledOnce(); + const button = getByText('doc_auth.buttons.upload_picture'); + expect(button).to.be.ok(); - userEvent.upload(input, validUpload); - expect(onChange).to.have.been.calledWith(validUpload); + userEvent.click(button); }); it('renders error message if capture succeeds but photo glare exceeds threshold', async () => { @@ -505,11 +496,11 @@ describe('document-capture/components/acuant-capture', () => { Upload' }} > - - + + - - + + , ); @@ -526,11 +517,9 @@ describe('document-capture/components/acuant-capture', () => { it('still captures selfie value when upload disallowed', () => { const { getByLabelText } = render( - - - - - , + + + , ); initialize(); @@ -542,108 +531,106 @@ describe('document-capture/components/acuant-capture', () => { expect(window.AcuantCameraUI.start.called).to.be.false(); expect(window.AcuantPassiveLiveness.startSelfieCapture.called).to.be.true(); }); + }); - it('does not show hint if capture is supported', () => { + context('desktop', () => { + it('renders without capture button while acuant is not ready and on desktop', () => { const { getByText } = render( - + , ); - initialize(); - - expect(() => getByText('doc_auth.tips.document_capture_hint')).to.throw(); + expect(() => getByText('doc_auth.buttons.take_picture')).to.throw(); }); - it('shows hint if capture is not supported', () => { + it('optionally disallows upload', () => { const { getByText } = render( - - - - - , + + + + + , ); - initialize({ isSuccess: false }); - - const hint = getByText('doc_auth.tips.document_capture_hint'); + expect(() => getByText('doc_auth.tips.document_capture_hint')).to.throw(); - expect(hint).to.be.ok(); + initialize(); }); + }); - it('captures selfie', async () => { - const onChange = sinon.stub(); - const { getByLabelText } = render( - - - - - , - ); - - initialize({ - startSelfieCapture: sinon.stub().callsArgWithAsync(0, ''), - }); - - const button = getByLabelText('Image'); - const defaultPrevented = !fireEvent.click(button); + it('renders with custom className', () => { + const { container } = render(); - expect(defaultPrevented).to.be.true(); - expect(window.AcuantCameraUI.start.called).to.be.false(); - expect(window.AcuantPassiveLiveness.startSelfieCapture.called).to.be.true(); - await waitFor(() => expect(onChange.calledOnce).to.be.true()); - }); + expect(container.firstChild.classList.contains('my-custom-class')).to.be.true(); }); - context('desktop', () => { - it('does not render acuant capture canvas for environmental capture', () => { - const { getByLabelText } = render( - - - - - , - ); + it('clears a selected value', () => { + const onChange = sinon.spy(); + const { getByLabelText } = render( + + + , + ); - userEvent.click(getByLabelText('Image')); + const input = getByLabelText('Image'); + fireEvent.change(input, { target: { files: [] } }); - // It would be expected that if AcuantCaptureCanvas was rendered, an error would be thrown at - // this point, since it references Acuant globals not loaded. - }); + expect(onChange.getCall(0).args).to.have.lengthOf(1); + expect(onChange.getCall(0).args).to.deep.equal([null]); }); - it('optionally disallows upload', () => { + it('does not show hint if capture is supported', () => { const { getByText } = render( - + , ); + initialize(); + expect(() => getByText('doc_auth.tips.document_capture_hint')).to.throw(); }); - it('renders with custom className', () => { - const { container } = render(); + it('shows hint if capture is not supported', () => { + const { getByText } = render( + + + , + ); - expect(container.firstChild.classList.contains('my-custom-class')).to.be.true(); + initialize({ isSuccess: false }); + + const hint = getByText('doc_auth.tips.document_capture_hint'); + + expect(hint).to.be.ok(); }); - it('clears a selected value', async () => { - const image = await getFixtureFile('doc_auth_images/id-front.jpg'); - const onChange = sinon.spy(); + it('captures selfie', async () => { + const onChange = sinon.stub(); const { getByLabelText } = render( - + , ); - const input = getByLabelText('Image'); - fireEvent.change(input, { target: { files: [] } }); + initialize({ + startSelfieCapture: sinon.stub().callsArgWithAsync(0, ''), + }); - expect(onChange.getCall(0).args).to.have.lengthOf(1); - expect(onChange.getCall(0).args).to.deep.equal([null]); + const button = getByLabelText('Image'); + const defaultPrevented = !fireEvent.click(button); + + expect(defaultPrevented).to.be.true(); + expect(window.AcuantCameraUI.start.called).to.be.false(); + expect(window.AcuantPassiveLiveness.startSelfieCapture.called).to.be.true(); + await waitFor(() => expect(onChange.calledOnce).to.be.true()); }); it('restricts accepted file types', () => { @@ -658,29 +645,4 @@ describe('document-capture/components/acuant-capture', () => { expect(input.getAttribute('accept')).to.equal('image/jpeg,image/png,image/bmp,image/tiff'); }); - - it('logs metrics for manual upload', async () => { - const addPageAction = sinon.mock(); - const { getByLabelText } = render( - - - - - , - ); - - const input = getByLabelText('Image'); - userEvent.upload(input, validUpload); - - await new Promise((resolve) => addPageAction.callsFake(resolve)); - expect(addPageAction).to.have.been.calledWith({ - label: 'IdV: image added', - payload: { - height: sinon.match.number, - mimeType: 'image/jpeg', - source: 'upload', - width: sinon.match.number, - }, - }); - }); }); diff --git a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx index 0778e186bd9..7feaba81381 100644 --- a/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/document-capture-spec.jsx @@ -18,7 +18,6 @@ import DocumentCapture, { import { expect } from 'chai'; import { render, useAcuant, useDocumentCaptureForm } from '../../../support/document-capture'; import { useSandbox } from '../../../support/sinon'; -import { getFixture, getFixtureFile } from '../../../support/file'; describe('document-capture/components/document-capture', () => { const onSubmit = useDocumentCaptureForm(); @@ -30,13 +29,6 @@ describe('document-capture/components/document-capture', () => { } let originalHash; - let validUpload; - let validSelfieBase64; - - before(async () => { - validUpload = await getFixtureFile('doc_auth_images/id-front.jpg'); - validSelfieBase64 = await getFixture('doc_auth_images/selfie.jpg', 'base64'); - }); beforeEach(() => { originalHash = window.location.hash; @@ -92,11 +84,9 @@ describe('document-capture/components/document-capture', () => { it('progresses through steps to completion', async () => { const { getByLabelText, getByText, getAllByText, findAllByText } = render( - - - - - , + + + , ); initialize(); @@ -108,11 +98,11 @@ describe('document-capture/components/document-capture', () => { glare: 70, sharpness: 70, image: { - data: validSelfieBase64, + data: 'data:image/png;base64,', }, }); }); - window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, validSelfieBase64); + window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, ''); // Continue is enabled (but grayed out).Attempting to proceed without providing values will // trigger error messages. @@ -128,7 +118,7 @@ describe('document-capture/components/document-capture', () => { // Providing values should remove errors progressively. fireEvent.change(getByLabelText('doc_auth.headings.document_capture_front'), { target: { - files: [validUpload], + files: [new window.File([''], 'upload.png', { type: 'image/png' })], }, }); await waitFor(() => expect(getAllByText('simple_form.required.text')).to.have.lengthOf(1)); @@ -188,11 +178,20 @@ describe('document-capture/components/document-capture', () => { }, ); + initialize({ isCameraSupported: false }); + window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, ''); + const continueButton = getByText('forms.buttons.continue'); userEvent.click(continueButton); await findAllByText('simple_form.required.text'); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_front'), validUpload); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_back'), validUpload); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(continueButton); @@ -200,7 +199,7 @@ describe('document-capture/components/document-capture', () => { userEvent.click(submitButton); await findAllByText('simple_form.required.text'); const selfieInput = getByLabelText('doc_auth.headings.document_capture_selfie'); - userEvent.upload(selfieInput, validUpload); + fireEvent.click(selfieInput); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(submitButton); @@ -251,11 +250,20 @@ describe('document-capture/components/document-capture', () => { }, ); + initialize({ isCameraSupported: false }); + window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, ''); + const continueButton = getByText('forms.buttons.continue'); userEvent.click(continueButton); await findAllByText('simple_form.required.text'); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_front'), validUpload); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_back'), validUpload); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(continueButton); @@ -263,7 +271,7 @@ describe('document-capture/components/document-capture', () => { userEvent.click(submitButton); await findAllByText('simple_form.required.text'); const selfieInput = getByLabelText('doc_auth.headings.document_capture_selfie'); - userEvent.upload(selfieInput, validUpload); + fireEvent.click(selfieInput); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(submitButton); @@ -286,8 +294,14 @@ describe('document-capture/components/document-capture', () => { // Submit button should be disabled until field errors are resolved. submitButton = getByText('forms.buttons.submit.default'); expect(submitButton.classList.contains('btn-disabled')).to.be.true(); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_front'), validUpload); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_back'), validUpload); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); // Once fields are changed, their notices should be cleared. If all field-specific errors are // addressed, submit should be enabled once more. @@ -334,8 +348,15 @@ describe('document-capture/components/document-capture', () => { }), }); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_front'), validUpload); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_back'), validUpload); + initialize({ isCameraSupported: false }); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); userEvent.click(getByText('forms.buttons.submit.default')); await waitFor(() => window.location.hash === '#teapot'); @@ -401,11 +422,20 @@ describe('document-capture/components/document-capture', () => { , ); + initialize({ isCameraSupported: false }); + window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, ''); + const continueButton = getByText('forms.buttons.continue'); userEvent.click(continueButton); await findAllByText('simple_form.required.text'); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_front'), validUpload); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_back'), validUpload); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(continueButton); @@ -413,7 +443,7 @@ describe('document-capture/components/document-capture', () => { userEvent.click(submitButton); await findAllByText('simple_form.required.text'); const selfieInput = getByLabelText('doc_auth.headings.document_capture_selfie'); - userEvent.upload(selfieInput, validUpload); + fireEvent.click(selfieInput); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(submitButton); @@ -447,11 +477,20 @@ describe('document-capture/components/document-capture', () => { { uploadError }, ); + initialize({ isCameraSupported: false }); + window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, ''); + const continueButton = getByText('forms.buttons.continue'); userEvent.click(continueButton); await findAllByText('simple_form.required.text'); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_front'), validUpload); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_back'), validUpload); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_front'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); + userEvent.upload( + getByLabelText('doc_auth.headings.document_capture_back'), + new window.File([''], 'upload.png', { type: 'image/png' }), + ); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(continueButton); expect(onStepChange.callCount).to.equal(1); @@ -461,7 +500,7 @@ describe('document-capture/components/document-capture', () => { expect(onStepChange.callCount).to.equal(1); await findAllByText('simple_form.required.text'); const selfieInput = getByLabelText('doc_auth.headings.document_capture_selfie'); - userEvent.upload(selfieInput, validUpload); + fireEvent.click(selfieInput); await waitFor(() => expect(() => getAllByText('simple_form.required.text')).to.throw()); userEvent.click(submitButton); expect(onStepChange.callCount).to.equal(1); diff --git a/spec/javascripts/packages/document-capture/components/file-image-spec.jsx b/spec/javascripts/packages/document-capture/components/file-image-spec.jsx index 960330bac45..6644f1de82d 100644 --- a/spec/javascripts/packages/document-capture/components/file-image-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/file-image-spec.jsx @@ -1,15 +1,11 @@ import FileImage from '@18f/identity-document-capture/components/file-image'; import { render } from '../../../support/document-capture'; -import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/file-image', () => { - let file; - before(async () => { - file = await getFixtureFile('doc_auth_images/id-back.jpg'); - }); - it('renders span prior to load', () => { - const { container } = render(); + const { container } = render( + , + ); expect(container.childNodes).to.have.lengthOf(1); const loader = container.childNodes[0]; @@ -21,28 +17,40 @@ describe('document-capture/components/file-image', () => { }); it('renders a given file object as an image', async () => { - const { findByAltText } = render(); + const { findByAltText } = render( + , + ); const image = await findByAltText('image'); - expect(image.getAttribute('src')).to.match(/^data:image\/jpeg;base64,/); + expect(image.getAttribute('src')).to.match(/^data:image\/png;base64,/); expect(Array.from(image.classList.values())).to.have.members(['document-capture-file-image']); }); it('renders a a changed file object as an image', async () => { - const { findByAltText, rerender } = render(); + const { findByAltText, rerender } = render( + , + ); await findByAltText('first image'); - rerender(); + rerender( + , + ); const image = await findByAltText('second image'); - expect(image.getAttribute('src')).to.match(/^data:image\/jpeg;base64,/); + expect(image.getAttribute('src')).to.match(/^data:image\/png;base64,/); }); it('renders with className', async () => { - const { findByAltText } = render(); + const { findByAltText } = render( + , + ); const image = await findByAltText('image'); diff --git a/spec/javascripts/packages/document-capture/components/file-input-spec.jsx b/spec/javascripts/packages/document-capture/components/file-input-spec.jsx index f15971746b6..285a12292da 100644 --- a/spec/javascripts/packages/document-capture/components/file-input-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/file-input-spec.jsx @@ -10,14 +10,8 @@ import FileInput, { } from '@18f/identity-document-capture/components/file-input'; import DeviceContext from '@18f/identity-document-capture/context/device'; import { render } from '../../../support/document-capture'; -import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/file-input', () => { - let file; - before(async () => { - file = await getFixtureFile('doc_auth_images/id-front.jpg'); - }); - describe('getAcceptPattern', () => { it('returns a pattern for audio matching', () => { const accept = 'audio/*'; @@ -147,14 +141,14 @@ describe('document-capture/components/file-input', () => { it('renders a value preview for a blob', async () => { const { container, findByRole, getByLabelText } = render( - , + , ); const preview = await findByRole('img', { hidden: true }); const input = getByLabelText('File'); expect(input).to.be.ok(); - expect(preview.getAttribute('src')).to.match(/^data:image\/jpeg;base64,/); + expect(preview.getAttribute('src')).to.match(/^data:image\/png;base64,/); expect(container.querySelector('.usa-file-input__preview-heading').textContent).to.equal( 'doc_auth.forms.change_file', ); @@ -162,32 +156,29 @@ describe('document-capture/components/file-input', () => { it('renders a value preview for a file', async () => { const { container, findByRole, getByLabelText } = render( - , + , ); const preview = await findByRole('img', { hidden: true }); const input = getByLabelText('File'); expect(input).to.be.ok(); - expect(preview.getAttribute('src')).to.match(/^data:image\/jpeg;base64,/); + expect(preview.getAttribute('src')).to.match(/^data:image\/png;base64,/); expect(container.querySelector('.usa-file-input__preview-heading').textContent).to.equal( - 'doc_auth.forms.selected_file: id-front.jpg doc_auth.forms.change_file', + 'doc_auth.forms.selected_file: demo.png doc_auth.forms.change_file', ); }); it('renders a value preview for a data URL', async () => { const { container, findByRole, getByLabelText } = render( - , + , ); const preview = await findByRole('img', { hidden: true }); const input = getByLabelText('File'); expect(input).to.be.ok(); - expect(preview.getAttribute('src')).to.match(/^data:image\/png;base64,/); + expect(preview.getAttribute('src')).to.match(/^data:image\/jpeg;base64,/); expect(container.querySelector('.usa-file-input__preview-heading').textContent).to.equal( 'doc_auth.forms.change_file', ); @@ -210,6 +201,7 @@ describe('document-capture/components/file-input', () => { }); it('calls onChange with next value', () => { + const file = new window.File([''], 'upload.png', { type: 'image/png' }); const onChange = sinon.stub(); const { getByLabelText } = render(); @@ -220,19 +212,21 @@ describe('document-capture/components/file-input', () => { }); it('allows changing the selected value', () => { - const file2 = new window.File([file], 'file2.jpg'); + const file1 = new window.File([''], 'upload1.png', { type: 'image/png' }); + const file2 = new window.File([''], 'upload2.png', { type: 'image/png' }); const onChange = sinon.stub(); const { getByLabelText } = render(); const input = getByLabelText('File'); - userEvent.upload(input, file); + userEvent.upload(input, file1); userEvent.upload(input, file2); - expect(onChange.getCall(0).args[0]).to.equal(file); + expect(onChange.getCall(0).args[0]).to.equal(file1); expect(onChange.getCall(1).args[0]).to.equal(file2); }); it('allows clearing the selected value', () => { + const file = new window.File([''], 'upload1.png', { type: 'image/png' }); const onChange = sinon.stub(); const { getByLabelText } = render(); @@ -266,7 +260,7 @@ describe('document-capture/components/file-input', () => { , ); @@ -295,6 +289,7 @@ describe('document-capture/components/file-input', () => { }); it('shows an error state', () => { + const file = new window.File([''], 'upload.png', { type: 'image/png' }); const onChange = sinon.stub(); const onError = sinon.stub(); const { getByLabelText, getByText } = render( @@ -309,6 +304,7 @@ describe('document-capture/components/file-input', () => { }); it('allows customization of invalid file type error message', () => { + const file = new window.File([''], 'upload.png', { type: 'image/png' }); const onChange = sinon.stub(); const onError = sinon.stub(); const { getByLabelText, getByText } = render( @@ -329,6 +325,7 @@ describe('document-capture/components/file-input', () => { }); it('shows an error from rendering parent', () => { + const file = new window.File([''], 'upload.png', { type: 'image/png' }); const onChange = sinon.stub(); const onError = sinon.stub(); const props = { label: 'File', accept: ['text/*'], onChange, onError }; @@ -348,17 +345,18 @@ describe('document-capture/components/file-input', () => { }); it('shows an updated state', () => { - const file2 = new window.File([file], 'file2.jpg'); + const file1 = new window.File([''], 'upload.png', { type: 'image/png' }); + const file2 = new window.File([''], 'upload.png', { type: 'image/png' }); const { getByText, rerender } = render(); expect(() => getByText('forms.file_input.file_updated')).to.throw(); - rerender(); + rerender(); expect(() => getByText('forms.file_input.file_updated')).to.throw(); - rerender(); + rerender(); expect(() => getByText('forms.file_input.file_updated')).to.throw(); @@ -372,13 +370,14 @@ describe('document-capture/components/file-input', () => { }); it('allows customization of updated file text', () => { - const file2 = new window.File([file], 'file2.jpg'); + const file1 = new window.File([''], 'upload.png', { type: 'image/png' }); + const file2 = new window.File([''], 'upload.png', { type: 'image/png' }); const { getByText, rerender } = render(); expect(() => getByText('Updated')).to.throw(); - rerender(); + rerender(); expect(() => getByText('Updated')).to.throw(); diff --git a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx index be0e12b2c4f..38b77d7c832 100644 --- a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx @@ -5,7 +5,6 @@ import { I18nContext } from '@18f/identity-document-capture'; import SelfieCapture from '@18f/identity-document-capture/components/selfie-capture'; import { render } from '../../../support/document-capture'; import { useSandbox } from '../../../support/sinon'; -import { getFixtureFile } from '../../../support/file'; describe('document-capture/components/selfie-capture', () => { // Since DOM globals are stubbed with sandbox, ensure that cleanup is the first task, as otherwise @@ -26,10 +25,7 @@ describe('document-capture/components/selfie-capture', () => { ); const track = { stop: sinon.stub() }; - let value; - before(async () => { - value = await getFixtureFile('doc_auth_images/selfie.jpg'); - }); + const value = new window.File([], 'image.png', { type: 'image/png' }); let originalMediaDevices; let originalMediaStream; diff --git a/spec/javascripts/packages/document-capture/components/selfie-step-spec.jsx b/spec/javascripts/packages/document-capture/components/selfie-step-spec.jsx index 270afc6c469..3963c8ff7ae 100644 --- a/spec/javascripts/packages/document-capture/components/selfie-step-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/selfie-step-spec.jsx @@ -1,49 +1,27 @@ import userEvent from '@testing-library/user-event'; import { waitFor } from '@testing-library/dom'; import sinon from 'sinon'; -import { AcuantContextProvider, DeviceContext } from '@18f/identity-document-capture'; +import { AcuantContextProvider } from '@18f/identity-document-capture'; import SelfieStep from '@18f/identity-document-capture/components/selfie-step'; import { render, useAcuant } from '../../../support/document-capture'; describe('document-capture/components/selfie-step', () => { - context('mobile', () => { - const { initialize } = useAcuant(); + const { initialize } = useAcuant(); - it('calls onChange callback with uploaded image', async () => { - const onChange = sinon.stub(); - const { getByLabelText } = render( - - - - - , - ); - initialize(); - window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, '8J+Riw=='); + it('calls onChange callback with uploaded image', async () => { + const onChange = sinon.stub(); + const { getByLabelText } = render( + + + , + ); + initialize(); + window.AcuantPassiveLiveness.startSelfieCapture.callsArgWithAsync(0, '8J+Riw=='); - userEvent.click(getByLabelText('doc_auth.headings.document_capture_selfie')); + userEvent.click(getByLabelText('doc_auth.headings.document_capture_selfie')); - await waitFor(() => - expect(onChange.getCall(0).args[0].selfie).to.equal('data:image/jpeg;base64,8J+Riw=='), - ); - }); - }); - - context('desktop', () => { - it('calls onChange callback with uploaded image', async () => { - const onChange = sinon.stub(); - const { getByLabelText } = render( - - - - - , - ); - - const file = new window.File([], 'image.png', { type: 'image/png' }); - userEvent.upload(getByLabelText('doc_auth.headings.document_capture_selfie'), file); - - await waitFor(() => expect(onChange.getCall(0).args[0].selfie).to.equal(file)); - }); + await waitFor(() => + expect(onChange.getCall(0).args[0].selfie).to.equal('data:image/jpeg;base64,8J+Riw=='), + ); }); }); diff --git a/spec/javascripts/packages/document-capture/context/acuant-spec.jsx b/spec/javascripts/packages/document-capture/context/acuant-spec.jsx index 769eff53aea..e4f8d9765ee 100644 --- a/spec/javascripts/packages/document-capture/context/acuant-spec.jsx +++ b/spec/javascripts/packages/document-capture/context/acuant-spec.jsx @@ -1,6 +1,5 @@ import { useContext } from 'react'; import { renderHook } from '@testing-library/react-hooks'; -import { DeviceContext } from '@18f/identity-document-capture'; import AcuantContext, { Provider as AcuantContextProvider, } from '@18f/identity-document-capture/context/acuant'; @@ -17,7 +16,6 @@ describe('document-capture/context/acuant', () => { expect(result.current).to.eql({ isReady: false, - isAcuantLoaded: false, isError: false, isCameraSupported: null, credentials: null, @@ -25,153 +23,99 @@ describe('document-capture/context/acuant', () => { }); }); - context('desktop', () => { - it('does not append script element', () => { - render( - - - , - ); + it('appends script element', () => { + render(); - const script = document.querySelector('script[src="about:blank"]'); + const script = document.querySelector('script[src="about:blank"]'); - expect(script).to.not.be.ok(); + expect(script).to.be.ok(); + }); + + it('provides context from provider crendentials', () => { + const { result } = renderHook(() => useContext(AcuantContext), { + wrapper: ({ children }) => ( + + {children} + + ), }); - it('provides context as ready, unsupported', () => { - const { result } = renderHook(() => useContext(AcuantContext), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).to.eql({ - isReady: true, - isAcuantLoaded: false, - isError: false, - isCameraSupported: false, - credentials: null, - endpoint: null, - }); + expect(result.current).to.eql({ + isReady: false, + isError: false, + isCameraSupported: null, + credentials: 'a', + endpoint: 'b', }); }); - context('mobile', () => { - it('appends script element', () => { - render( - - - , - ); + it('provides ready context when successfully loaded', () => { + const { result } = renderHook(() => useContext(AcuantContext), { + wrapper: ({ children }) => ( + {children} + ), + }); - const script = document.querySelector('script[src="about:blank"]'); + window.AcuantJavascriptWebSdk = { + initialize: (_credentials, _endpoint, { onSuccess }) => onSuccess(), + }; + window.AcuantCamera = { isCameraSupported: true }; + window.onAcuantSdkLoaded(); - expect(script).to.be.ok(); + expect(result.current).to.eql({ + isReady: true, + isError: false, + isCameraSupported: true, + credentials: null, + endpoint: null, }); + }); - it('provides context from provider crendentials', () => { - const { result } = renderHook(() => useContext(AcuantContext), { - wrapper: ({ children }) => ( - - - {children} - - - ), - }); - - expect(result.current).to.eql({ - isReady: false, - isAcuantLoaded: false, - isError: false, - isCameraSupported: null, - credentials: 'a', - endpoint: 'b', - }); + it('has camera availability at time of ready', () => { + const { result } = renderHook(() => useContext(AcuantContext), { + wrapper: ({ children }) => ( + {children} + ), }); - it('provides ready context when successfully loaded', () => { - const { result } = renderHook(() => useContext(AcuantContext), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - window.AcuantJavascriptWebSdk = { - initialize: (_credentials, _endpoint, { onSuccess }) => onSuccess(), - }; - window.AcuantCamera = { isCameraSupported: true }; - window.onAcuantSdkLoaded(); - - expect(result.current).to.eql({ - isReady: true, - isAcuantLoaded: true, - isError: false, - isCameraSupported: true, - credentials: null, - endpoint: null, - }); - }); + window.AcuantJavascriptWebSdk = { + initialize: (_credentials, _endpoint, { onSuccess }) => onSuccess(), + }; + window.AcuantCamera = { isCameraSupported: true }; + window.onAcuantSdkLoaded(); + + expect(result.current.isCameraSupported).to.be.true(); + }); - it('has camera availability at time of ready', () => { - const { result } = renderHook(() => useContext(AcuantContext), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - window.AcuantJavascriptWebSdk = { - initialize: (_credentials, _endpoint, { onSuccess }) => onSuccess(), - }; - window.AcuantCamera = { isCameraSupported: true }; - window.onAcuantSdkLoaded(); - - expect(result.current.isCameraSupported).to.be.true(); + it('provides error context when failed to loaded', () => { + const { result } = renderHook(() => useContext(AcuantContext), { + wrapper: ({ children }) => ( + {children} + ), }); - it('provides error context when failed to loaded', () => { - const { result } = renderHook(() => useContext(AcuantContext), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - window.AcuantJavascriptWebSdk = { - initialize: (_credentials, _endpoint, { onFail }) => onFail(), - }; - window.onAcuantSdkLoaded(); - - expect(result.current).to.eql({ - isReady: false, - isAcuantLoaded: false, - isError: true, - isCameraSupported: null, - credentials: null, - endpoint: null, - }); + window.AcuantJavascriptWebSdk = { + initialize: (_credentials, _endpoint, { onFail }) => onFail(), + }; + window.onAcuantSdkLoaded(); + + expect(result.current).to.eql({ + isReady: false, + isError: true, + isCameraSupported: null, + credentials: null, + endpoint: null, }); + }); - it('cleans up after itself on unmount', () => { - const { unmount } = render( - - - , - ); + it('cleans up after itself on unmount', () => { + const { unmount } = render(); - unmount(); + unmount(); - const script = document.querySelector('script[src="about:blank"]'); + const script = document.querySelector('script[src="about:blank"]'); - expect(script).not.to.be.ok(); - expect(window.AcuantJavascriptWebSdk).to.be.undefined(); - }); + expect(script).not.to.be.ok(); + expect(window.AcuantJavascriptWebSdk).to.be.undefined(); }); }); diff --git a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx index 609797b4753..833de9bf34a 100644 --- a/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx +++ b/spec/javascripts/packages/document-capture/higher-order/with-background-encrypted-upload-spec.jsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import sinon from 'sinon'; import { UploadContextProvider, AnalyticsContext } from '@18f/identity-document-capture'; import withBackgroundEncryptedUpload, { - blobToArrayBuffer, + blobToDataView, encrypt, } from '@18f/identity-document-capture/higher-order/with-background-encrypted-upload'; import { useSandbox } from '../../../support/sinon'; @@ -33,13 +33,13 @@ function isArrayBufferEqual(a, b) { describe('document-capture/higher-order/with-background-encrypted-upload', () => { const sandbox = useSandbox(); - describe('blobToArrayBuffer', () => { + describe('blobToDataView', () => { it('converts blob to data view', async () => { const data = new window.File(['Hello world'], 'demo.text', { type: 'text/plain' }); const expected = new Uint8Array([72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]).buffer; - const actual = await blobToArrayBuffer(data); - expect(isArrayBufferEqual(actual, expected)).to.be.true(); + const dataView = await blobToDataView(data); + expect(isArrayBufferEqual(dataView.buffer, expected)).to.be.true(); }); it('rejects on filereader error', async () => { @@ -50,9 +50,7 @@ describe('document-capture/higher-order/with-background-encrypted-upload', () => }); try { - await blobToArrayBuffer( - new window.File(['Hello world'], 'demo.text', { type: 'text/plain' }), - ); + await blobToDataView(new window.File(['Hello world'], 'demo.text', { type: 'text/plain' })); } catch (actualError) { expect(actualError).to.equal(error); } diff --git a/spec/javascripts/packs/submit-with-spinner-spec.js b/spec/javascripts/packs/submit-with-spinner-spec.js new file mode 100644 index 00000000000..4d403bf3ce8 --- /dev/null +++ b/spec/javascripts/packs/submit-with-spinner-spec.js @@ -0,0 +1,41 @@ +import { screen } from '@testing-library/dom'; + +describe('submit-with-spinner', () => { + async function initialize({ withForm = true } = {}) { + const parent = withForm + ? document.body.appendChild(document.createElement('form')) + : document.body; + + parent.innerHTML = ` + +
    + +
    + `; + + delete require.cache[require.resolve('../../../app/javascript/packs/submit-with-spinner')]; + await import('../../../app/javascript/packs/submit-with-spinner'); + } + + it('gracefully handles absence of form', async () => { + await initialize({ withForm: false }); + }); + + it('should show spinner on form submit', async () => { + await initialize(); + + // JSDOM doesn't support submitting a form natively. + // See: https://github.com/jsdom/jsdom/issues/123 + const form = document.querySelector('form'); + const event = new window.Event('submit', { target: form }); + form.dispatchEvent(event); + + const spinner = screen.getByAltText('Loading spinner'); + + expect(spinner.parentNode.classList.contains('hidden')).to.be.false(); + }); +}); diff --git a/spec/javascripts/spec_helper.js b/spec/javascripts/spec_helper.js index bce98e3f6d8..b4cbe3e2ac9 100644 --- a/spec/javascripts/spec_helper.js +++ b/spec/javascripts/spec_helper.js @@ -4,8 +4,6 @@ import dirtyChai from 'dirty-chai'; import sinonChai from 'sinon-chai'; import { createDOM, useCleanDOM } from './support/dom'; import { chaiConsoleSpy, useConsoleLogSpy } from './support/console'; -import { createObjectURLAsDataURL } from './support/file'; -import { useBrowserCompatibleEncrypt } from './support/crypto'; chai.use(dirtyChai); chai.use(sinonChai); @@ -19,13 +17,6 @@ const dom = createDOM(); global.window = dom.window; global.window.fetch = () => Promise.reject(new Error('Fetch must be stubbed')); global.window.crypto = new Crypto(); // In the future (Node >=15), use native webcrypto: https://nodejs.org/api/webcrypto.html -global.window.URL.createObjectURL = createObjectURLAsDataURL; -global.window.URL.revokeObjectURL = () => {}; -Object.defineProperty(global.window.Image.prototype, 'src', { - set() { - this.onload(); - }, -}); global.navigator = window.navigator; global.document = window.document; global.Document = window.Document; @@ -35,4 +26,3 @@ global.self = window; useCleanDOM(); useConsoleLogSpy(); -useBrowserCompatibleEncrypt(); diff --git a/spec/javascripts/support/crypto.js b/spec/javascripts/support/crypto.js deleted file mode 100644 index b091a4dee86..00000000000 --- a/spec/javascripts/support/crypto.js +++ /dev/null @@ -1,33 +0,0 @@ -import sinon from 'sinon'; - -/** - * Test lifecycle hook which ensures that any call to `crypto.subtle.encrypt` using the AES-GCM - * algorithm should always include an explicit `tagLength`, despite specification allowing for its - * omission, due to browser-specific incompatibilities. - * - * This may be removed in the future if the upstream polyfill handles this incompatibility. - * - * @see https://github.com/vibornoff/webcrypto-shim/pull/44 - */ -export function useBrowserCompatibleEncrypt() { - let originalEncrypt; - - beforeEach(() => { - originalEncrypt = window.crypto.subtle.encrypt; - const stub = sinon.stub().callsFake(originalEncrypt); - stub - .withArgs( - sinon.match({ - name: 'AES-GCM', - tagLength: undefined, - }), - ) - .throws(new TypeError('Always pass numeric `tagLength`, even if default of `128`.')); - - window.crypto.subtle.encrypt = stub; - }); - - afterEach(() => { - window.crypto.subtle.encrypt = originalEncrypt; - }); -} diff --git a/spec/javascripts/support/dom.js b/spec/javascripts/support/dom.js index 2c549d1a50d..6bdb87c35d9 100644 --- a/spec/javascripts/support/dom.js +++ b/spec/javascripts/support/dom.js @@ -10,18 +10,8 @@ export function createDOM() { const dom = new JSDOM('', { url: 'http://example.test', resources: new (class extends ResourceLoader { - /** - * @param {string} url - * @param {import('jsdom').FetchOptions} options - */ // eslint-disable-next-line class-methods-use-this - fetch(url, options) { - if (url.startsWith('data:') && options.element instanceof window.HTMLImageElement) { - const [header, content] = url.split(','); - const isBase64 = header.endsWith(';base64'); - return Promise.resolve(Buffer.from(content, isBase64 ? 'base64' : 'utf-8')); - } - + fetch(url) { return url === 'about:blank' ? Promise.resolve(Buffer.from('')) : Promise.reject(new Error('Failed to load')); diff --git a/spec/javascripts/support/file.js b/spec/javascripts/support/file.js deleted file mode 100644 index 963296500ab..00000000000 --- a/spec/javascripts/support/file.js +++ /dev/null @@ -1,51 +0,0 @@ -import { join, basename, extname } from 'path'; -import { promises as fs } from 'fs'; - -/** - * Rough approximation of assumed mime type by file extension. This is very incomplete, and assumes - * files which would be used as fixtures. For a more complete implementation, consider pulling in a - * package like `mime-types`. - * - * @see https://www.npmjs.com/package/mime-types - * - * @type {Record} - */ -const MIME_TYPES_BY_EXTENSION = { - '.jpg': 'image/jpeg', -}; - -/** - * @typedef {File & {rawBuffer: Buffer}} LoginGovTestFile - */ - -/** - * @param {string} fixturePath - * @param {BufferEncoding=} encoding - * - * @return {Promise} - */ -export function getFixture(fixturePath, encoding) { - const path = join(__dirname, '../../fixtures', fixturePath); - return fs.readFile(path, encoding); -} - -/** - * @param {string} fixturePath Path relative fixtures directory. - * - * @return {Promise} - */ -export async function getFixtureFile(fixturePath) { - const rawBuffer = /** @type {Buffer} */ (await getFixture(fixturePath)); - const type = MIME_TYPES_BY_EXTENSION[extname(fixturePath)]; - const file = new window.File([rawBuffer], basename(fixturePath), { type }); - return Object.assign(file, { rawBuffer }); -} - -/** - * @param {LoginGovTestFile} file - * - * @return {string} Data URL - */ -export function createObjectURLAsDataURL(file) { - return `data:${file.type};base64,${file.rawBuffer.toString('base64')}`; -} diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 38bfef46931..14c27187fef 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -297,8 +297,7 @@ def expect_email_body_to_have_help_and_contact_links reset_text = t('user_mailer.account_reset_granted.cancel_link_text') expect(mail.html_part.body).to have_content( strip_tags( - t('user_mailer.account_reset_request.intro_html', app: APP_NAME, - cancel_account_reset: reset_text), + t('user_mailer.account_reset_request.intro', cancel_account_reset: reset_text), ), ) end @@ -336,7 +335,7 @@ def expect_email_body_to_have_help_and_contact_links it 'renders the body' do expect(mail.html_part.body).to \ - have_content(strip_tags(t('user_mailer.account_reset_granted.intro_html', app: APP_NAME))) + have_content(strip_tags(t('user_mailer.account_reset_granted.intro_html'))) end end diff --git a/spec/models/agency_spec.rb b/spec/models/agency_spec.rb index ff7dfe68979..fadef7c5765 100644 --- a/spec/models/agency_spec.rb +++ b/spec/models/agency_spec.rb @@ -8,6 +8,5 @@ let(:agency) { build_stubbed(:agency) } it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_uniqueness_of(:abbreviation).case_insensitive.allow_nil } end end diff --git a/spec/models/null_service_provider_spec.rb b/spec/models/null_service_provider_spec.rb index 828b992df2b..7b67d825092 100644 --- a/spec/models/null_service_provider_spec.rb +++ b/spec/models/null_service_provider_spec.rb @@ -76,20 +76,12 @@ end end - describe '#identities' do - it 'returns empty array' do - expect(subject.identities).to eq([]) - end - end - context 'matching methods on ServiceProvider' do it 'has all the methods that ServiceProvider has' do sp_methods = ServiceProvider.instance_methods(false) ignored_methods = %i[ autosave_associated_records_for_agency - autosave_associated_records_for_identities belongs_to_counter_cache_after_update - validate_associated_records_for_identities ] null_sp_methods = NullServiceProvider.instance_methods diff --git a/spec/models/service_provider_spec.rb b/spec/models/service_provider_spec.rb index 5274129e0d6..5b05c056ac9 100644 --- a/spec/models/service_provider_spec.rb +++ b/spec/models/service_provider_spec.rb @@ -136,18 +136,6 @@ end end - describe 'associations' do - subject { service_provider } - - it { is_expected.to belong_to(:agency) } - it do - is_expected.to have_many(:identities). - inverse_of(:service_provider_record). - with_foreign_key('service_provider'). - with_primary_key('issuer') - end - end - describe '#issuer' do it 'returns the constructor value' do expect(service_provider.issuer).to eq 'http://localhost:3000' diff --git a/spec/monitor_spec_helper.rb b/spec/monitor_spec_helper.rb index 2cecf767b19..90e97e78674 100644 --- a/spec/monitor_spec_helper.rb +++ b/spec/monitor_spec_helper.rb @@ -3,7 +3,6 @@ require 'capybara/rspec' require 'webdrivers/chromedriver' require 'active_support/all' -require 'rspec/retry' Time.zone ||= ActiveSupport::TimeZone['UTC'] @@ -28,42 +27,12 @@ config.color = true config.order = :random - # show retry status in spec process - config.verbose_retry = true - # show exception that triggers a retry if verbose_retry is set to true - config.display_try_failure_messages = true - # config.infer_spec_type_from_file_location is a Rails-only feature, # so we do it ourselves. config.define_derived_metadata(file_path: %r{/spec/features/monitor}) do |metadata| metadata[:type] = :feature metadata[:js] = true - - # Can be overridden with RSPEC_RETRY_RETRY_COUNT - metadata[:retry] = 3 end config.example_status_persistence_file_path = './tmp/rspec-examples.txt' - - count = 1 - config.after do |example| - next if !example.exception - - spec_name = example.description.strip.tr(' ', '_').dasherize.downcase - - dirname = "tmp/capybara/#{count}-#{spec_name}" - FileUtils.mkdir_p(dirname) - - page.driver.browser.save_screenshot(File.join(dirname, 'screenshot.png')) - File.open(File.join(dirname, 'page.html'), 'w') { |f| f.puts page.html } - File.open(File.join(dirname, 'info.txt'), 'w') do |info| - info.puts "example name: #{example.description}" - info.puts "example location: #{example.location}" - info.puts - info.puts "current path: #{page.current_path}" - info.puts "exception: #{example.exception.class} #{example.exception.message}" - end - - count += 1 - end end diff --git a/spec/presenters/idv/usps_presenter_spec.rb b/spec/presenters/idv/usps_presenter_spec.rb index 87834f2451d..8f4ddca8b51 100644 --- a/spec/presenters/idv/usps_presenter_spec.rb +++ b/spec/presenters/idv/usps_presenter_spec.rb @@ -46,17 +46,17 @@ end end - describe '#fallback_back_path' do + describe '#cancel_path' do context 'when the user has a pending profile' do it 'returns the verify account path' do create(:profile, user: user, deactivation_reason: :verification_pending) - expect(subject.fallback_back_path).to eq('/account/verify') + expect(subject.cancel_path).to eq('/account/verify') end end context 'when the user does not have a pending profile' do - it 'returns the idv phone path' do - expect(subject.fallback_back_path).to eq('/verify/phone') + it 'returns the idv cancel path' do + expect(subject.cancel_path).to eq('/verify/cancel') end end end diff --git a/spec/requests/frontend_analytics_spec.rb b/spec/requests/frontend_analytics_spec.rb new file mode 100644 index 00000000000..aa07ab93663 --- /dev/null +++ b/spec/requests/frontend_analytics_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +describe 'frontend analytics requests' do + describe 'platform authenticators' do + let(:analytics) { FakeAnalytics.new } + + before do + allow(analytics).to receive(:track_event) + allow(Analytics).to receive(:new).and_return(analytics) + end + + it 'does not log anything if the user is not authed' do + expect(analytics).to_not receive(:track_event). + with(Analytics::FRONTEND_BROWSER_CAPABILITIES, any_args) + + post analytics_path, params: { platform_authenticator: { available: true } } + end + + it 'logs true if the platform authenticator is available' do + sign_in_user + + post analytics_path, params: { platform_authenticator: { available: true } } + + expect(analytics).to have_received(:track_event). + with(Analytics::FRONTEND_BROWSER_CAPABILITIES, hash_including(platform_authenticator: true)) + end + + it 'logs false if the platform authenticator is not available' do + sign_in_user + + post analytics_path, params: { platform_authenticator: { available: false } } + + expect(analytics).to have_received(:track_event). + with( + Analytics::FRONTEND_BROWSER_CAPABILITIES, + hash_including(platform_authenticator: false), + ) + end + + it 'only logs 1 platform authenticator event per session' do + sign_in_user + + post analytics_path, params: { platform_authenticator: { available: true } } + post analytics_path, params: { platform_authenticator: { available: true } } + + expect(analytics).to have_received(:track_event). + with( + Analytics::FRONTEND_BROWSER_CAPABILITIES, + hash_including(platform_authenticator: true), + ). + once + end + + it 'logs ignores garbage values' do + sign_in_user + + post analytics_path, params: { platform_authenticator: { available: 'blah blah blah' } } + + expect(analytics).to_not have_received(:track_event). + with(Analytics::FRONTEND_BROWSER_CAPABILITIES, any_args) + end + end +end diff --git a/spec/services/agency_seeder_spec.rb b/spec/services/agency_seeder_spec.rb index 798b3bc3671..62692b066af 100644 --- a/spec/services/agency_seeder_spec.rb +++ b/spec/services/agency_seeder_spec.rb @@ -16,6 +16,8 @@ subject(:run) { instance.run } + # This implictly validates that the `abbreviation` attribute in the YAML is + # ignored it 'inserts agencies into the database from agencies.yml' do expect { run }.to change(Agency, :count) end diff --git a/spec/services/data_requests/write_cloudwatch_logs_spec.rb b/spec/services/data_requests/write_cloudwatch_logs_spec.rb deleted file mode 100644 index 8ecdddf2edb..00000000000 --- a/spec/services/data_requests/write_cloudwatch_logs_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -require 'rails_helper' - -RSpec.describe DataRequests::WriteCloudwatchLogs do - let(:now) { Time.zone.now } - - def build_result_row(event_properties = {}) - DataRequests::FetchCloudwatchLogs::ResultRow.new( - Time.zone.now, - { - time: now.iso8601, - name: 'Some Log: Event', - properties: { - event_properties: { - success: true, - multi_factor_auth_method: 'sms', - phone_configuration_id: '12345', - }.merge(event_properties), - service_provider: 'some:service:provider', - user_ip: '0.0.0.0', - user_agent: 'Chrome', - }, - }.to_json, - ) - end - - let(:cloudwatch_results) do - [ - build_result_row, - ] - end - - around do |ex| - Dir.mktmpdir do |dir| - @output_dir = dir - ex.run - end - end - - subject(:writer) do - DataRequests::WriteCloudwatchLogs.new(cloudwatch_results, @output_dir) - end - - describe '#call' do - it 'writes the logs to output_dir/logs.csv' do - writer.call - - row = CSV.read(File.join(@output_dir, 'logs.csv'), headers: true).first - - expect(row['timestamp']).to eq(now.iso8601) - expect(row['event_name']).to eq('Some Log: Event') - expect(row['success']).to eq('true') - expect(row['multi_factor_auth_method']).to eq('sms') - expect(row['multi_factor_id']).to eq('phone_configuration_id:12345') - expect(row['service_provider']).to eq('some:service:provider') - expect(row['ip_address']).to eq('0.0.0.0') - expect(row['user_agent']).to eq('Chrome') - end - - context 'missing data' do - let(:cloudwatch_results) do - [ - DataRequests::FetchCloudwatchLogs::ResultRow.new(now, {}.to_json), - ] - end - - it 'does not blow up' do - expect { writer.call }.to_not raise_error - end - end - - context 'various multi factor ids' do - let(:cloudwatch_results) do - [ - build_result_row(multi_factor_auth_method: 'sms', phone_configuration_id: '1111'), - build_result_row(multi_factor_auth_method: 'voice', phone_configuration_id: '2222'), - build_result_row(multi_factor_auth_method: 'piv_cac', piv_cac_configuration_id: '3333'), - build_result_row(multi_factor_auth_method: 'webauthn', webauthn_configuration_id: '4444'), - build_result_row(multi_factor_auth_method: 'totp', auth_app_configuration_id: '5555'), - ] - end - - it 'unpacks all multi factor ids' do - writer.call - - csv = CSV.read(File.join(@output_dir, 'logs.csv'), headers: true) - - expect(csv.map { |row| [row['multi_factor_auth_method'], row['multi_factor_id']] }). - to eq([%w[sms phone_configuration_id:1111], - %w[voice phone_configuration_id:2222], - %w[piv_cac piv_cac_configuration_id:3333], - %w[webauthn webauthn_configuration_id:4444], - %w[totp auth_app_configuration_id:5555], - ]) - end - end - end -end diff --git a/spec/services/doc_auth_router_spec.rb b/spec/services/doc_auth_router_spec.rb index 2a62d167399..089cc20f3b7 100644 --- a/spec/services/doc_auth_router_spec.rb +++ b/spec/services/doc_auth_router_spec.rb @@ -4,10 +4,21 @@ describe '.client' do before do allow(AppConfig.env).to receive(:doc_auth_vendor).and_return(doc_auth_vendor) + allow(AppConfig.env).to receive(:acuant_simulator).and_return(acuant_simulator) + end + + context 'legacy mock configuration' do + let(:doc_auth_vendor) { '' } + let(:acuant_simulator) { 'true' } + + it 'is the mock client' do + expect(DocAuthRouter.client).to be_a(IdentityDocAuth::Mock::DocAuthMockClient) + end end context 'for acuant' do let(:doc_auth_vendor) { 'acuant' } + let(:acuant_simulator) { '' } it 'is a translation-proxied acuant client' do expect(DocAuthRouter.client).to be_a(DocAuthRouter::AcuantErrorTranslatorProxy) @@ -17,6 +28,7 @@ context 'for lexisnexis' do let(:doc_auth_vendor) { 'lexisnexis' } + let(:acuant_simulator) { '' } it 'is a translation-proxied lexisnexis client' do expect(DocAuthRouter.client).to be_a(DocAuthRouter::LexisNexisTranslatorProxy) @@ -26,6 +38,7 @@ context 'other config' do let(:doc_auth_vendor) { 'unknown' } + let(:acuant_simulator) { '' } it 'errors' do expect { DocAuthRouter.client }.to raise_error(RuntimeError) diff --git a/spec/services/encryption/contextless_kms_client_spec.rb b/spec/services/encryption/contextless_kms_client_spec.rb index 7d8fd4c475e..3abca21aa1a 100644 --- a/spec/services/encryption/contextless_kms_client_spec.rb +++ b/spec/services/encryption/contextless_kms_client_spec.rb @@ -103,20 +103,8 @@ allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor) stub_mapped_aws_kms_client( - [ - { - plaintext: long_kms_plaintext[0..long_kms_plaintext_chunksize - 1], - ciphertext: 'chunk1', - key_id: AppConfig.env.aws_kms_key_id, - region: AppConfig.env.aws_region, - }, - { - plaintext: long_kms_plaintext[long_kms_plaintext_chunksize..-1], - ciphertext: 'chunk2', - key_id: AppConfig.env.aws_kms_key_id, - region: AppConfig.env.aws_region, - }, - ], + long_kms_plaintext[0..long_kms_plaintext_chunksize - 1] => 'chunk1', + long_kms_plaintext[long_kms_plaintext_chunksize..-1] => 'chunk2', ) allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled) end diff --git a/spec/services/encryption/kms_client_spec.rb b/spec/services/encryption/kms_client_spec.rb index 2e1d8ec803b..cc42ccee314 100644 --- a/spec/services/encryption/kms_client_spec.rb +++ b/spec/services/encryption/kms_client_spec.rb @@ -2,20 +2,11 @@ describe Encryption::KmsClient do before do - # rubocop:disable Layout/LineLength stub_mapped_aws_kms_client( - [ - { plaintext: 'a' * 3000, ciphertext: 'us-north-1:kms1', key_id: key_id, region: 'us-north-1' }, - { plaintext: 'a' * 3000, ciphertext: 'us-south-1:kms1', key_id: key_id, region: 'us-south-1' }, - - { plaintext: 'b' * 3000, ciphertext: 'us-north-1:kms2', key_id: key_id, region: 'us-north-1' }, - { plaintext: 'b' * 3000, ciphertext: 'us-south-1:kms2', key_id: key_id, region: 'us-south-1' }, - - { plaintext: 'c' * 3000, ciphertext: 'us-north-1:kms3', key_id: key_id, region: 'us-north-1' }, - { plaintext: 'c' * 3000, ciphertext: 'us-south-1:kms3', key_id: key_id, region: 'us-south-1' }, - ], + 'a' * 3000 => 'kms1', + 'b' * 3000 => 'kms2', + 'c' * 3000 => 'kms3', ) - # rubocop:enable Layout/LineLength encryptor = Encryption::Encryptors::AesEncryptor.new { @@ -33,12 +24,8 @@ allow(Encryption::Encryptors::AesEncryptor).to receive(:new).and_return(encryptor) allow(FeatureManagement).to receive(:kms_multi_region_enabled?).and_return(kms_multi_region_enabled) # rubocop:disable Layout/LineLength allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled) - allow(AppConfig.env).to receive(:aws_kms_regions).and_return(aws_kms_regions.to_json) - allow(AppConfig.env).to receive(:aws_region).and_return(aws_region) - allow(AppConfig.env).to receive(:aws_kms_key_id).and_return(key_id) end - let(:key_id) { 'key1' } let(:plaintext) { 'a' * 3000 + 'b' * 3000 + 'c' * 3000 } let(:encryption_context) { { 'context' => 'attribute-bundle', 'user_id' => '123-abc-456-def' } } @@ -50,29 +37,21 @@ ) end - let(:aws_region) { 'us-north-1' } - let(:aws_kms_regions) { %w[us-north-1 us-south-1] } - + let(:kms_regions) { %w[us-west-2 us-east-1] } let(:kms_multi_region_enabled) { true } let(:kms_regionalized_ciphertext) do - 'KMSc' + %w[kms1 kms2 kms3].map do |kms| - payload = { - regions: { - 'us-north-1' => Base64.strict_encode64("us-north-1:#{kms}"), - 'us-south-1' => Base64.strict_encode64("us-south-1:#{kms}"), - }, - } - Base64.strict_encode64(payload.to_json) + 'KMSc' + %w[kms1 kms2 kms3].map do |c| + region_hash = {} + kms_regions.each do |r| + region_hash[r] = Base64.strict_encode64(c) + end + Base64.strict_encode64({ regions: region_hash }.to_json) end.to_json end let(:kms_legacy_ciphertext) do - 'KMSc' + %w[ - us-north-1:kms1 - us-north-1:kms2 - us-north-1:kms3 - ].map { |c| Base64.strict_encode64(c) }.to_json + 'KMSc' + %w[kms1 kms2 kms3].map { |c| Base64.strict_encode64(c) }.to_json end let(:local_ciphertext) do @@ -91,7 +70,6 @@ end context 'with multi region disabled' do let(:kms_multi_region_enabled) { false } - it 'encrypts with KMS legacy single region' do result = subject.encrypt(plaintext, encryption_context) expect(result).to eq(kms_legacy_ciphertext) diff --git a/spec/services/encryption/multi_region_kms_client_spec.rb b/spec/services/encryption/multi_region_kms_client_spec.rb index a4387f83a4d..c46cd26c620 100644 --- a/spec/services/encryption/multi_region_kms_client_spec.rb +++ b/spec/services/encryption/multi_region_kms_client_spec.rb @@ -1,53 +1,49 @@ require 'rails_helper' describe Encryption::MultiRegionKmsClient do - let(:kms_enabled) { true } - let(:kms_multi_region_enabled) { true } - let(:aws_kms_regions) { %w[us-north-1 us-south-1] } - let(:aws_region) { 'us-north-1' } - before do - allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled) - allow(FeatureManagement).to receive(:kms_multi_region_enabled?). - and_return(kms_multi_region_enabled) - allow(AppConfig.env).to receive(:aws_kms_regions).and_return(aws_kms_regions.to_json) - allow(AppConfig.env).to receive(:aws_region).and_return(aws_region) - stub_mapped_aws_kms_client( - [ - { plaintext: plaintext, ciphertext: 'k1:us-north-1', key_id: 'key1', region: 'us-north-1' }, - { plaintext: plaintext, ciphertext: 'k1:us-south-1', key_id: 'key1', region: 'us-south-1' }, - ], + 'a' * 3000 => 'kms1', + 'b' * 3000 => 'kms2', ) + + allow(FeatureManagement).to receive(:use_kms?).and_return(kms_enabled) + allow(FeatureManagement).to receive(:kms_multi_region_enabled?).and_return(kms_multi_region_enabled) # rubocop:disable Layout/LineLength end - let(:plaintext) { 'a' * 3000 } + let(:first_plaintext) { 'a' * 3000 } + let(:second_plaintext) { 'b' * 3000 } let(:encryption_context) { { 'context' => 'attribute-bundle', 'user_id' => '123-abc-456-def' } } + let(:kms_regions) { %w[us-west-2 us-east-1] } + let(:current_aws_region) { 'us-east-1' } + let(:regionalized_kms_ciphertext) do - { - regions: { - 'us-north-1' => Base64.strict_encode64('k1:us-north-1'), - 'us-south-1' => Base64.strict_encode64('k1:us-south-1'), - }, - }.to_json + region_hash = {} + kms_regions.each do |r| + region_hash[r] = Base64.strict_encode64('kms1') + end + { regions: region_hash }.to_json end - let(:legacy_kms_ciphertext) { 'k1:us-north-1' } + let(:legacy_kms_ciphertext) { 'kms1' } - describe '#encrypt' do - let(:aws_key_id) { 'key1' } + let(:kms_enabled) { true } + let(:kms_multi_region_enabled) { true } + + let(:aws_key_id) { AppConfig.env.aws_kms_key_id } + describe '#encrypt' do context 'with multi region enabled' do it 'encrypts with KMS' do - result = subject.encrypt(aws_key_id, plaintext, encryption_context) + result = subject.encrypt(aws_key_id, first_plaintext, encryption_context) expect(result).to eq(regionalized_kms_ciphertext) end end context 'with multi region disabled' do let(:kms_multi_region_enabled) { false } it 'encrypts with KMS' do - result = subject.encrypt(aws_key_id, plaintext, encryption_context) + result = subject.encrypt(aws_key_id, first_plaintext, encryption_context) expect(result).to eq(legacy_kms_ciphertext) end end @@ -57,23 +53,23 @@ context 'with a multi region ciphertext' do it 'decrypts the ciphertext with KMS' do result = subject.decrypt(regionalized_kms_ciphertext, encryption_context) - expect(result).to eq(plaintext) + expect(result).to eq(first_plaintext) end end context 'with a legacy ciphertext' do it 'decrypts the ciphertext with KMS' do result = subject.decrypt(legacy_kms_ciphertext, encryption_context) - expect(result).to eq(plaintext) + expect(result).to eq(first_plaintext) end end it 'decrypts successfully if the default region is not present' do non_default_ciphertext = { - regions: { 'us-north-1' => Base64.strict_encode64('k1:us-north-1') }, + regions: { 'us-east-1' => Base64.strict_encode64('kms1') }, }.to_json result = subject.decrypt(non_default_ciphertext, encryption_context) - expect(result).to eq(plaintext) + expect(result).to eq(first_plaintext) end it 'errors if none of the encryption regions are present' do @@ -92,22 +88,22 @@ partially_valid_ciphertext = { regions: { foo: 'kms1', - 'us-south-1': Base64.strict_encode64('k1:us-south-1'), + 'us-west-2': 'kms2', }, }.to_json result = subject.decrypt(partially_valid_ciphertext, encryption_context) - expect(result).to eq(plaintext) + expect(result).to eq(second_plaintext) end it 'decrypts in default region where multiple regions present' do multi_region_ciphertext = { regions: { - 'us-north-1': Base64.strict_encode64('k1:us-north-1'), - 'us-south-1': Base64.strict_encode64('k1:us-south-1'), + 'us-east-1': Base64.strict_encode64('kms1'), + 'us-west-2': Base64.strict_encode64('kms2'), }, }.to_json result = subject.decrypt(multi_region_ciphertext, encryption_context) - expect(result).to eq(plaintext) + expect(result).to eq(first_plaintext) end end end diff --git a/spec/services/identity_linker_spec.rb b/spec/services/identity_linker_spec.rb index 6d33c12c304..fcbff021a1d 100644 --- a/spec/services/identity_linker_spec.rb +++ b/spec/services/identity_linker_spec.rb @@ -113,9 +113,9 @@ to raise_error(ArgumentError) end - it 'does not link to an identity record if the provider is nil' do + it 'fails when given a nil provider' do linker = IdentityLinker.new(user, nil) - expect(linker.link_identity).to eq(nil) + expect { linker.link_identity }.to raise_error(ActiveRecord::RecordInvalid) end it 'can link two different clients to the same rails_session_id' do diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 8094fd30cdd..46a25fd6c8a 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -24,29 +24,17 @@ ) result = document_capture_session.load_proofing_result.result expect(result[:errors][:ssn]).to eq ['Unverified SSN.'] - expect(result[:context][:stages]).to_not include( - state_id: 'StateIdMock', - transaction_id: IdentityIdpFunctions::StateIdMockClient::TRANSACTION_ID, - ) + expect(result[:context][:stages]).to_not include({ state_id: 'StateIdMock' }) end it 'does proof state_id if resolution succeeds' do - agent = Idv::Agent.new( - ssn: '444-55-8888', - first_name: Faker::Name.first_name, - zipcode: '11111', - state_id_number: '123456789', - state_id_type: 'drivers_license', - state_id_jurisdiction: 'MD', - ) + agent = Idv::Agent.new({ ssn: '444-55-8888', first_name: Faker::Name.first_name, + zipcode: '11111' }) agent.proof_resolution( document_capture_session, should_proof_state_id: true, trace_id: trace_id ) result = document_capture_session.load_proofing_result.result - expect(result[:context][:stages]).to include( - state_id: 'StateIdMock', - transaction_id: IdentityIdpFunctions::StateIdMockClient::TRANSACTION_ID, - ) + expect(result[:context][:stages]).to include({ state_id: 'StateIdMock' }) end context 'proofing partial date of birth' do @@ -75,10 +63,7 @@ ) result = document_capture_session.load_proofing_result.result expect(result[:errors][:ssn]).to eq ['Unverified SSN.'] - expect(result[:context][:stages]).to_not include( - state_id: 'StateIdMock', - transaction_id: IdentityIdpFunctions::StateIdMockClient::TRANSACTION_ID, - ) + expect(result[:context][:stages]).to_not include({ state_id: 'StateIdMock' }) end it 'does not proof state_id if resolution succeeds' do @@ -88,10 +73,7 @@ document_capture_session, should_proof_state_id: false, trace_id: trace_id ) result = document_capture_session.load_proofing_result.result - expect(result[:context][:stages]).to_not include( - state_id: 'StateIdMock', - transaction_id: IdentityIdpFunctions::StateIdMockClient::TRANSACTION_ID, - ) + expect(result[:context][:stages]).to_not include({ state_id: 'StateIdMock' }) end end diff --git a/spec/services/store_sp_metadata_in_session_spec.rb b/spec/services/store_sp_metadata_in_session_spec.rb index 529de5ab622..86e06465b86 100644 --- a/spec/services/store_sp_metadata_in_session_spec.rb +++ b/spec/services/store_sp_metadata_in_session_spec.rb @@ -42,7 +42,6 @@ issuer: 'issuer', aal_level_requested: nil, piv_cac_requested: false, - ial: 1, ial2: false, ial2_strict: false, ialmax: false, @@ -82,7 +81,6 @@ issuer: 'issuer', aal_level_requested: 3, piv_cac_requested: false, - ial: 2, ial2: true, ial2_strict: false, ialmax: false, diff --git a/spec/services/usps_confirmation_uploader_spec.rb b/spec/services/usps_confirmation_uploader_spec.rb index 8e11e86d6ca..b1317c14bd0 100644 --- a/spec/services/usps_confirmation_uploader_spec.rb +++ b/spec/services/usps_confirmation_uploader_spec.rb @@ -75,18 +75,12 @@ subject { uploader.run } context 'when successful' do - it 'uploads the psv, creates a file, uploads it via SFTP, and deletes and logs it after' do + it 'uploads the psv created by creates a file, uploads it via SFTP, and deletes it after' do expect(uploader).to receive(:generate_export).with(confirmations).and_return(export) expect(uploader).to receive(:upload_export).with(export) expect(uploader).to receive(:clear_confirmations).with(confirmations) subject - - logs = LetterRequestsToUspsFtpLog.all - expect(logs.count).to eq(1) - log = logs.first - expect(log.ftp_at).to be_present - expect(log.letter_requests_count).to eq(1) end end diff --git a/spec/support/aws_kms_client.rb b/spec/support/aws_kms_client.rb index da8ddf43349..ce500e3b405 100644 --- a/spec/support/aws_kms_client.rb +++ b/spec/support/aws_kms_client.rb @@ -10,28 +10,17 @@ def stub_aws_kms_client(random_key = random_str, ciphered_key = random_str) [random_key, ciphered_key] end - # Configs is an array of: - # [{ ciphertext:, plaintext:, key_id:, region: }] - def stub_mapped_aws_kms_client(configs) - encryptor = proc do |context| - config = configs.find do |c| - c.slice(:key_id, :plaintext) == context.params.slice(:key_id, :plaintext) && - c[:region] == context.client.config.region - end - { ciphertext_blob: config[:ciphertext], key_id: config[:key_id] } - end - - decryptor = proc do |context| - config = configs.find do |c| - c[:ciphertext] == context.params[:ciphertext_blob] - end - { plaintext: config[:plaintext], key_id: config[:key_id] } - end - + def stub_mapped_aws_kms_client(forward = {}) + reverse = forward.invert + aws_key_id = AppConfig.env.aws_kms_key_id Aws.config[:kms] = { stub_responses: { - encrypt: encryptor, - decrypt: decryptor, + encrypt: lambda { |context| + { ciphertext_blob: forward[context.params[:plaintext]], key_id: aws_key_id } + }, + decrypt: lambda { |context| + { plaintext: reverse[context.params[:ciphertext_blob]], key_id: aws_key_id } + }, }, } end diff --git a/spec/support/fake_analytics.rb b/spec/support/fake_analytics.rb index 10d56f1e4e9..22a1bd98b4d 100644 --- a/spec/support/fake_analytics.rb +++ b/spec/support/fake_analytics.rb @@ -2,11 +2,10 @@ class FakeAnalytics attr_reader :events def initialize - @events = Hash.new + @events = Hash.new { |hash, key| hash[key] = [] } end def track_event(event, attributes = {}) - events[event] ||= [] events[event] << attributes nil end diff --git a/spec/support/features/cac_proofing_helper.rb b/spec/support/features/cac_proofing_helper.rb index c8222f04115..1b9839ad763 100644 --- a/spec/support/features/cac_proofing_helper.rb +++ b/spec/support/features/cac_proofing_helper.rb @@ -23,6 +23,10 @@ def idv_cac_proofing_verify_wait_step idv_cac_step_path(step: :verify_wait) end + def idv_cac_proofing_success_step + idv_cac_step_path(step: :success) + end + def complete_cac_proofing_steps_before_choose_method_step visit idv_cac_proofing_choose_method_step end diff --git a/spec/support/features/doc_auth_helper.rb b/spec/support/features/doc_auth_helper.rb index ed878e263e8..8f77506f535 100644 --- a/spec/support/features/doc_auth_helper.rb +++ b/spec/support/features/doc_auth_helper.rb @@ -34,10 +34,6 @@ def fill_out_ssn_form_fail fill_in 'doc_auth_ssn', with: '' end - def click_doc_auth_back_link - click_on '‹ ' + t('forms.buttons.back') - end - def idv_doc_auth_welcome_step idv_doc_auth_step_path(step: :welcome) end @@ -137,15 +133,6 @@ def complete_all_doc_auth_steps(expect_accessible: false) click_idv_continue end - def complete_proofing_steps - complete_all_doc_auth_steps - click_continue - fill_in 'Password', with: RequestHelper::VALID_PASSWORD - click_continue - click_acknowledge_personal_key - click_agree_and_continue - end - def mock_doc_auth_no_name_pii(method) pii_with_no_name = IdentityDocAuth::Mock::ResultResponseBuilder::DEFAULT_PII_FROM_DOC.dup pii_with_no_name[:last_name] = nil diff --git a/spec/support/saml_auth_helper.rb b/spec/support/saml_auth_helper.rb index 3ae46ba51f7..42aff59a6d7 100644 --- a/spec/support/saml_auth_helper.rb +++ b/spec/support/saml_auth_helper.rb @@ -308,26 +308,6 @@ def visit_saml_auth_path ) end - def visit_idp_from_ial2_saml_sp(**args) - settings = ial2_with_bundle_saml_settings - settings.security[:embed_sign] = false - settings.issuer = args[:issuer] if args[:issuer] - settings.name_identifier_format = Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT - saml_authn_request = auth_request.create(settings) - visit saml_authn_request - saml_authn_request - end - - def visit_idp_from_ial1_saml_sp(**args) - settings = ial1_with_verified_at_saml_settings - settings.security[:embed_sign] = false - settings.issuer = args[:issuer] if args[:issuer] - settings.name_identifier_format = Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT - saml_authn_request = auth_request.create(settings) - visit saml_authn_request - saml_authn_request - end - private def link_user_to_identity(user, link, settings) diff --git a/spec/support/shared_examples/account_creation.rb b/spec/support/shared_examples/account_creation.rb index 556cddcfabd..dff1d2258a6 100644 --- a/spec/support/shared_examples/account_creation.rb +++ b/spec/support/shared_examples/account_creation.rb @@ -150,6 +150,7 @@ expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') end + expect(page.get_rack_session.keys).to include('sp') end perform_in_browser(:one) do @@ -170,6 +171,7 @@ expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') end + expect(page.get_rack_session.keys).to include('sp') end end end diff --git a/spec/support/shared_examples/ial2_consent.rb b/spec/support/shared_examples/ial2_consent.rb index 4566c49a81f..46cb6ef50fa 100644 --- a/spec/support/shared_examples/ial2_consent.rb +++ b/spec/support/shared_examples/ial2_consent.rb @@ -1,14 +1,11 @@ shared_examples 'ial2 consent with js' do - it 'shows the notice if the user clicks continue without giving consent' do - expect(page).to have_button('Continue') - click_continue - - expect(page).to have_content(t('errors.doc_auth.consent_form')) + it 'does not allow the user to continue without checking the checkbox' do + expect(page).to have_button('Continue', disabled: true) end it 'allows the user to continue after checking the checkbox' do find('span[class="indicator"]').set(true) - expect(page).to have_button('Continue') + expect(page).to have_button('Continue', disabled: false) click_continue expect_doc_auth_upload_step diff --git a/spec/support/shared_examples/sign_in.rb b/spec/support/shared_examples/sign_in.rb index 57f4f966254..7f1d5cf76b4 100644 --- a/spec/support/shared_examples/sign_in.rb +++ b/spec/support/shared_examples/sign_in.rb @@ -41,6 +41,27 @@ end end +shared_examples 'visiting 2fa when fully authenticated' do |sp| + before { Timecop.freeze Time.zone.now } + after { Timecop.return } + + it 'redirects to SP after visiting a 2fa screen when fully authenticated', email: true do + ial1_sign_in_with_personal_key_goes_to_sp(sp) + + visit login_two_factor_options_path + + click_continue + continue_as + expect(current_url).to eq @saml_authn_request if sp == :saml + + if sp == :oidc + redirect_uri = URI(current_url) + + expect(redirect_uri.to_s).to start_with('http://localhost:7654/auth/result') + end + end +end + shared_examples 'signing in as IAL2 with personal key' do |sp| before { Timecop.freeze Time.zone.now } after { Timecop.return } diff --git a/spec/views/idv/doc_auth/_back.html.erb_spec.rb b/spec/views/idv/doc_auth/_back.html.erb_spec.rb deleted file mode 100644 index 8a0a99b18a5..00000000000 --- a/spec/views/idv/doc_auth/_back.html.erb_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'rails_helper' - -describe 'idv/doc_auth/_back.html.erb' do - it 'renders with action' do - render 'idv/doc_auth/back', action: 'redo_ssn' - - expect(rendered).to have_selector("form[action='#{idv_doc_auth_step_path(step: 'redo_ssn')}']") - expect(rendered).to have_selector('input[name="_method"][value="put"]', visible: false) - expect(rendered).to have_selector("[type='submit'][value='#{'‹ ' + t('forms.buttons.back')}']") - end - - it 'renders with step' do - render 'idv/doc_auth/back', step: 'verify' - - expect(rendered).to have_selector("a[href='#{idv_doc_auth_step_path(step: 'verify')}']") - expect(rendered).to have_content('‹ ' + t('forms.buttons.back')) - end - - it 'renders with back path' do - allow(view).to receive(:go_back_path).and_return('/example') - - render 'idv/doc_auth/back' - - expect(rendered).to have_selector('a[href="/example"]') - expect(rendered).to have_content('‹ ' + t('forms.buttons.back')) - end - - it 'renders fallback path' do - render 'idv/doc_auth/back', fallback_path: '/example' - - expect(rendered).to have_selector('a[href="/example"]') - expect(rendered).to have_content('‹ ' + t('forms.buttons.back')) - end - - it 'renders nothing if there is no back path' do - render 'idv/doc_auth/back' - - expect(rendered).to be_empty - end -end diff --git a/spec/views/idv/shared/_document_capture.html.erb_spec.rb b/spec/views/idv/shared/_document_capture.html.erb_spec.rb index c4fac83d728..7fe5c10fe7e 100644 --- a/spec/views/idv/shared/_document_capture.html.erb_spec.rb +++ b/spec/views/idv/shared/_document_capture.html.erb_spec.rb @@ -11,29 +11,17 @@ let(:selfie_image_upload_url) { nil } before do + allow(view).to receive(:flow_session).and_return(flow_session) + allow(view).to receive(:sp_name).and_return(sp_name) + allow(view).to receive(:failure_to_proof_url).and_return(failure_to_proof_url) + allow(view).to receive(:front_image_upload_url).and_return(front_image_upload_url) + allow(view).to receive(:back_image_upload_url).and_return(back_image_upload_url) + allow(view).to receive(:selfie_image_upload_url).and_return(selfie_image_upload_url) allow(view).to receive(:url_for).and_return('https://example.com/') - - allow(FeatureManagement).to receive(:document_capture_async_uploads_enabled?). - and_return(async_uploads_enabled) - - assign(:step_url, :idv_doc_auth_step_url) - end - - subject(:render_partial) do - render partial: 'idv/shared/document_capture', locals: { - flow_session: flow_session, - sp_name: sp_name, - failure_to_proof_url: failure_to_proof_url, - front_image_upload_url: front_image_upload_url, - back_image_upload_url: back_image_upload_url, - selfie_image_upload_url: selfie_image_upload_url, - } end describe 'async upload urls' do context 'when async upload is disabled' do - let(:async_uploads_enabled) { false } - it 'does not modify CSP connect_src headers' do allow(SecureHeaders).to receive(:append_content_security_policy_directives).with(any_args) expect(SecureHeaders).to receive(:append_content_security_policy_directives).with( @@ -41,12 +29,11 @@ connect_src: [], ) - render_partial + render end end - context 'when async upload are enabled' do - let(:async_uploads_enabled) { true } + context 'when async upload is enabled' do let(:front_image_upload_url) { 'https://s3.example.com/bucket/a?X-Amz-Security-Token=UAOL2' } let(:back_image_upload_url) { 'https://s3.example.com/bucket/b?X-Amz-Security-Token=UAOL2' } let(:selfie_image_upload_url) { 'https://s3.example.com/bucket/c?X-Amz-Security-Token=UAOL2' } @@ -62,7 +49,7 @@ ], ) - render_partial + render end end end diff --git a/spec/views/idv/usps/index.html.erb_spec.rb b/spec/views/idv/usps/index.html.erb_spec.rb index 80bf24f813a..bf03775ee15 100644 --- a/spec/views/idv/usps/index.html.erb_spec.rb +++ b/spec/views/idv/usps/index.html.erb_spec.rb @@ -1,68 +1,21 @@ require 'rails_helper' describe 'idv/usps/index.html.erb' do - let(:usps_mail_bounced) { false } - let(:letter_already_sent) { false } - let(:user_needs_address_otp_verification) { false } - let(:go_back_path) { nil } - let(:presenter) do + it 'calls UspsPresenter#title, #button, and #cancel_path' do user = build_stubbed(:user, :signed_up) - Idv::UspsPresenter.new(user, {}) - end + usps_mail_service = Idv::UspsMail.new(user) - before do - allow(view).to receive(:go_back_path).and_return(go_back_path) + usps_presenter = instance_double(Idv::UspsPresenter) + allow(Idv::UspsPresenter).to receive(:new).with(usps_mail_service). + and_return(usps_presenter) + @presenter = usps_presenter - allow(presenter).to receive(:usps_mail_bounced?).and_return(usps_mail_bounced) - allow(presenter).to receive(:letter_already_sent?).and_return(letter_already_sent) - allow(presenter).to receive(:user_needs_address_otp_verification?). - and_return(user_needs_address_otp_verification) + expect(usps_presenter).to receive(:title) + expect(usps_presenter).to receive(:button) + expect(usps_presenter).to receive(:cancel_path) + expect(usps_presenter).to receive(:byline) + expect(usps_presenter).to receive(:usps_mail_bounced?) - @presenter = presenter render end - - it 'prompts to send letter' do - expect(rendered).to have_content(I18n.t('idv.titles.mail.verify')) - expect(rendered).to have_button(I18n.t('idv.buttons.mail.send')) - end - - it 'renders fallback link to return to phone verify path' do - expect(rendered).to have_link('‹ ' + t('forms.buttons.back'), href: idv_phone_path) - end - - context 'has page to go back to' do - let(:go_back_path) { idv_otp_verification_path } - - it 'renders back link to return to previous path' do - expect(rendered).to have_link('‹ ' + t('forms.buttons.back'), href: go_back_path) - end - end - - context 'usps mail bounced' do - let(:usps_mail_bounced) { true } - - it 'renders address form to resend letter' do - expect(rendered).to have_content(I18n.t('idv.messages.usps.new_address')) - expect(rendered).to have_field(t('idv.form.address1')) - expect(rendered).to have_button(I18n.t('idv.buttons.mail.resend')) - end - end - - context 'letter already sent' do - let(:letter_already_sent) { true } - - it 'prompts to send another letter' do - expect(rendered).to have_content(I18n.t('idv.titles.mail.resend')) - expect(rendered).to have_button(I18n.t('idv.buttons.mail.resend')) - end - end - - context 'user needs address otp verification' do - let(:user_needs_address_otp_verification) { true } - - it 'renders fallback link to return to verify path' do - expect(rendered).to have_link('‹ ' + t('forms.buttons.back'), href: verify_account_path) - end - end end diff --git a/spec/views/users/delete/show.html.erb_spec.rb b/spec/views/users/delete/show.html.erb_spec.rb index 89e00f3bbe4..46f8f24d671 100644 --- a/spec/views/users/delete/show.html.erb_spec.rb +++ b/spec/views/users/delete/show.html.erb_spec.rb @@ -22,7 +22,6 @@ expect(rendered).to have_content(t('users.delete.bullet_1', app: APP_NAME)) expect(rendered).to have_content(user.decorate.delete_account_bullet_key) expect(rendered).to have_content(t('users.delete.bullet_3', app: APP_NAME)) - expect(rendered).to have_content(t('users.delete.bullet_4', app: APP_NAME)) end it 'displays bullets for loa1' do diff --git a/tsconfig.json b/tsconfig.json index 6b8ef0be529..2ad5010b8cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,8 +18,7 @@ "app/javascript/packs/form-validation.js", "app/javascript/packs/intl-tel-input.js", "app/javascript/packs/spinner-button.js", - "app/javascript/packs/session-expire-session.js", - "app/javascript/packs/session-timeout-ping.js" + "app/javascript/packs/submit-with-spinner.js" ], "exclude": ["**/fixtures", "**/*.spec.js"] } diff --git a/yarn.lock b/yarn.lock index 8e8bf1e9a64..f267b3722b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -997,7 +997,7 @@ dependencies: mkdirp "^1.0.4" -"@peculiar/asn1-schema@^2.0.27": +"@peculiar/asn1-schema@^2.0.12", "@peculiar/asn1-schema@^2.0.26": version "2.0.27" resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.27.tgz#1ee3b2b869ff3200bcc8ec60e6c87bd5a6f03fe0" integrity sha512-1tIx7iL3Ma3HtnNS93nB7nhyI0soUJypElj9owd4tpMrRDmeJ8eZubsdq1sb0KSaCs5RqZNoABCP6m5WtnlVhQ== @@ -1014,16 +1014,16 @@ dependencies: tslib "^2.0.0" -"@peculiar/webcrypto@^1.1.6": - version "1.1.6" - resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.1.6.tgz#484bb58be07149e19e873861b585b0d5e4f83b7b" - integrity sha512-xcTjouis4Y117mcsJslWAGypwhxtXslkVdRp7e3tHwtuw0/xCp1te8RuMMv/ia5TsvxomcyX/T+qTbRZGLLvyA== +"@peculiar/webcrypto@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.1.4.tgz#cbbe2195e5e6f879780bdac9a66bcbaca75c483c" + integrity sha512-gEVxfbseFDV0Za3AmjTrRB+wigEMOejHDzoo571e8/YWD33Ejmk0XPF3+G+VaN8+5C5IWZx4CPvxQZ7mF2dvNA== dependencies: - "@peculiar/asn1-schema" "^2.0.27" + "@peculiar/asn1-schema" "^2.0.26" "@peculiar/json-schema" "^1.1.12" - pvtsutils "^1.1.2" - tslib "^2.1.0" - webcrypto-core "^1.2.0" + pvtsutils "^1.1.1" + tslib "^2.0.3" + webcrypto-core "^1.1.8" "@rails/webpacker@^5.2.1": version "5.2.1" @@ -3524,6 +3524,11 @@ element-closest@^2.0.1: resolved "https://registry.yarnpkg.com/element-closest/-/element-closest-2.0.2.tgz#72a740a107453382e28df9ce5dbb5a8df0f966ec" integrity sha1-cqdAoQdFM4LijfnOXbtajfD5Zuw= +element-closest@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/element-closest/-/element-closest-3.0.2.tgz#3814a69a84f30e48e63eaf57341f4dbf4227d2aa" + integrity sha512-JxKQiJKX0Zr5Q2/bCaTx8P+UbfyMET1OQd61qu5xQFeWr1km3fGaxelSJtnfT27XQ5Uoztn2yIyeamAc/VX13g== + elliptic@^6.5.3: version "6.5.3" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" @@ -4951,9 +4956,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== internal-ip@^4.3.0: version "4.3.0" @@ -6085,9 +6090,9 @@ multicast-dns@^6.0.1: thunky "^1.0.2" nan@^2.12.1, nan@^2.13.2: - version "2.14.2" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" - integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== + version "2.14.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" + integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== nanoid@3.1.12: version "3.1.12" @@ -7670,12 +7675,12 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pvtsutils@^1.1.1, pvtsutils@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.1.2.tgz#483d72f4baa5e354466e68ff783ce8a9e2810030" - integrity sha512-Yfm9Dsk1zfEpOWCaJaHfqtNXAFWNNHMFSCLN6jTnhuCCBCC2nqge4sAgo7UrkRBoAAYIL8TN/6LlLoNfZD/b5A== +pvtsutils@^1.0.11, pvtsutils@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.1.1.tgz#22c2d7689139d2c36d7ef3ac3d5e29bcd818d38a" + integrity sha512-Evbhe6L4Sxwu4SPLQ4LQZhgfWDQO3qa1lju9jM5cxsQp8vE10VipcSmo7hiJW48TmiHgVLgDtC2TL6/+ND+IVg== dependencies: - tslib "^2.1.0" + tslib "^2.0.3" pvutils@latest: version "1.0.17" @@ -9172,10 +9177,10 @@ tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" - integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" + integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== tty-browserify@0.0.0: version "0.0.0" @@ -9478,16 +9483,16 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" -webcrypto-core@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.2.0.tgz#44fda3f9315ed6effe9a1e47466e0935327733b5" - integrity sha512-p76Z/YLuE4CHCRdc49FB/ETaM4bzM3roqWNJeGs+QNY1fOTzKTOVnhmudW1fuO+5EZg6/4LG9NJ6gaAyxTk9XQ== +webcrypto-core@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.1.8.tgz#91720c07f4f2edd181111b436647ea5a282af0a9" + integrity sha512-hKnFXsqh0VloojNeTfrwFoRM4MnaWzH6vtXcaFcGjPEu+8HmBdQZnps3/2ikOFqS8bJN1RYr6mI2P/FJzyZnXg== dependencies: - "@peculiar/asn1-schema" "^2.0.27" + "@peculiar/asn1-schema" "^2.0.12" "@peculiar/json-schema" "^1.1.12" asn1js "^2.0.26" - pvtsutils "^1.1.2" - tslib "^2.1.0" + pvtsutils "^1.0.11" + tslib "^2.0.1" webcrypto-shim@^0.1.6: version "0.1.6"