diff --git a/Gemfile.lock b/Gemfile.lock index 7ace4ca235d..51d46a199c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -706,7 +706,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.6.0) uniform_notifier (1.16.0) - uri (1.0.2) + uri (1.0.3) useragent (0.16.11) view_component (3.21.0) activesupport (>= 5.2.0, < 8.1) diff --git a/app/controllers/concerns/two_factor_authenticatable_methods.rb b/app/controllers/concerns/two_factor_authenticatable_methods.rb index 8b8bce52aac..d57fb9ca8f9 100644 --- a/app/controllers/concerns/two_factor_authenticatable_methods.rb +++ b/app/controllers/concerns/two_factor_authenticatable_methods.rb @@ -29,18 +29,24 @@ def handle_verification_for_authentication_context(result:, auth_method:, extra_ if result.success? handle_valid_verification_for_authentication_context(auth_method:) user_session.delete(:mfa_attempts) - session.delete(:sign_in_recaptcha_assessment_id) + session.delete(:sign_in_recaptcha_assessment_id) if sign_in_recaptcha_annotation_enabled? else handle_invalid_verification_for_authentication_context end end def annotate_recaptcha(reason) - RecaptchaAnnotator.annotate(assessment_id: session[:sign_in_recaptcha_assessment_id], reason:) + if sign_in_recaptcha_annotation_enabled? + RecaptchaAnnotator.annotate(assessment_id: session[:sign_in_recaptcha_assessment_id], reason:) + end end private + def sign_in_recaptcha_annotation_enabled? + IdentityConfig.store.sign_in_recaptcha_annotation_enabled + end + def handle_valid_verification_for_authentication_context(auth_method:) mark_user_session_authenticated(auth_method:, authentication_type: :valid_2fa) disavowal_event, disavowal_token = create_user_event_with_disavowal(:sign_in_after_2fa) diff --git a/app/controllers/idv/welcome_controller.rb b/app/controllers/idv/welcome_controller.rb index 75907f3e93a..d25415fbca5 100644 --- a/app/controllers/idv/welcome_controller.rb +++ b/app/controllers/idv/welcome_controller.rb @@ -7,6 +7,7 @@ class WelcomeController < ApplicationController include StepIndicatorConcern before_action :confirm_not_rate_limited + before_action :cancel_previous_in_person_enrollments, only: :show def show idv_session.proofing_started_at ||= Time.zone.now.iso8601 @@ -23,7 +24,6 @@ def update analytics.idv_doc_auth_welcome_submitted(**analytics_arguments) create_document_capture_session - cancel_previous_in_person_enrollments idv_session.welcome_visited = true @@ -61,7 +61,6 @@ def create_document_capture_session end def cancel_previous_in_person_enrollments - return unless IdentityConfig.store.in_person_proofing_enabled UspsInPersonProofing::EnrollmentHelper.cancel_establishing_and_in_progress_enrollments( current_user, ) diff --git a/app/javascript/packages/document-capture/components/acuant-capture.tsx b/app/javascript/packages/document-capture/components/acuant-capture.tsx index 33c6109b64a..c481d7b0949 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.tsx @@ -136,6 +136,10 @@ interface AcuantCaptureProps { * Determine whether the selfie help text shoule be shown. */ showSelfieHelp: () => void; + /** + * Whether user is on the failed submission review step + */ + isReviewStep?: boolean; } /** @@ -313,6 +317,7 @@ function AcuantCapture( errorMessage, name, showSelfieHelp, + isReviewStep, }: AcuantCaptureProps, ref: Ref, ) { @@ -327,7 +332,7 @@ function AcuantCapture( } = useContext(AcuantContext); const { isMockClient } = useContext(UploadContext); const { trackEvent } = useContext(AnalyticsContext); - const { isSelfieCaptureEnabled, immediatelyBeginCapture } = useContext(SelfieCaptureContext); + const { isSelfieCaptureEnabled } = useContext(SelfieCaptureContext); const fullScreenRef = useRef(null); const inputRef = useRef(null); const isForceUploading = useRef(false); @@ -350,7 +355,7 @@ function AcuantCapture( const isBackOfId = name === 'back'; useLogCameraInfo({ isBackOfId, hasStartedCropping }); const [isCapturingEnvironment, setIsCapturingEnvironment] = useState( - selfieCapture && immediatelyBeginCapture, + selfieCapture && !isReviewStep, ); const { diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx index 5325d1ee36c..4c027f45fa5 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.tsx @@ -100,6 +100,7 @@ function DocumentSideAcuantCapture({ className={className} allowUpload={isUploadAllowed} showSelfieHelp={showSelfieHelp} + isReviewStep={isReviewStep} /> ); } diff --git a/app/javascript/packages/document-capture/context/selfie-capture.tsx b/app/javascript/packages/document-capture/context/selfie-capture.tsx index 6115ccd7f48..76b30704cf0 100644 --- a/app/javascript/packages/document-capture/context/selfie-capture.tsx +++ b/app/javascript/packages/document-capture/context/selfie-capture.tsx @@ -14,17 +14,12 @@ interface SelfieCaptureProps { * the capture component. */ showHelpInitially: boolean; - /** - * Specify whether we should try to capture using Acuant immediately - */ - immediatelyBeginCapture: boolean; } const SelfieCaptureContext = createContext({ isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, showHelpInitially: true, - immediatelyBeginCapture: false, }); SelfieCaptureContext.displayName = 'SelfieCaptureContext'; diff --git a/app/javascript/packages/phone-input/package.json b/app/javascript/packages/phone-input/package.json index 51ad502a648..86e7b043e3a 100644 --- a/app/javascript/packages/phone-input/package.json +++ b/app/javascript/packages/phone-input/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "dependencies": { "intl-tel-input": "^24.5.0", - "libphonenumber-js": "^1.12.4" + "libphonenumber-js": "^1.12.5" }, "sideEffects": [ "./index.ts" diff --git a/app/javascript/packs/document-capture.tsx b/app/javascript/packs/document-capture.tsx index 44a49401423..28e24ecd08f 100644 --- a/app/javascript/packs/document-capture.tsx +++ b/app/javascript/packs/document-capture.tsx @@ -179,7 +179,6 @@ render( isSelfieCaptureEnabled: getSelfieCaptureEnabled(), isSelfieDesktopTestMode: String(docAuthSelfieDesktopTestMode) === 'true', showHelpInitially: true, - immediatelyBeginCapture: false, }} > 'returned_at', 'registration_logs' => 'registered_at', + 'letter_requests_to_usps_ftp_logs' => 'ftp_at', }.freeze def perform(timestamp) diff --git a/app/services/encryption/contextless_kms_client.rb b/app/services/encryption/contextless_kms_client.rb index 392f9d0e34c..1e16eab8159 100644 --- a/app/services/encryption/contextless_kms_client.rb +++ b/app/services/encryption/contextless_kms_client.rb @@ -19,13 +19,23 @@ class ContextlessKmsClient }.freeze def encrypt(plaintext, log_context: nil) - KmsLogger.log(:encrypt, key_id: IdentityConfig.store.aws_kms_key_id, log_context: log_context) + KmsLogger.log( + action: :encrypt, + timestamp: Time.zone.now, + key_id: IdentityConfig.store.aws_kms_key_id, + log_context: log_context, + ) return encrypt_kms(plaintext) if FeatureManagement.use_kms? encrypt_local(plaintext) end def decrypt(ciphertext, log_context: nil) - KmsLogger.log(:decrypt, key_id: IdentityConfig.store.aws_kms_key_id, log_context: log_context) + KmsLogger.log( + action: :decrypt, + timestamp: Time.zone.now, + key_id: IdentityConfig.store.aws_kms_key_id, + log_context: log_context, + ) return decrypt_kms(ciphertext) if use_kms?(ciphertext) decrypt_local(ciphertext) end diff --git a/app/services/encryption/kms_client.rb b/app/services/encryption/kms_client.rb index 55d44d24cd5..1c4189281f6 100644 --- a/app/services/encryption/kms_client.rb +++ b/app/services/encryption/kms_client.rb @@ -32,7 +32,12 @@ def initialize(kms_key_id: IdentityConfig.store.aws_kms_key_id) end def encrypt(plaintext, encryption_context) - KmsLogger.log(:encrypt, context: encryption_context, key_id: kms_key_id) + KmsLogger.log( + action: :encrypt, + timestamp: Time.zone.now, + context: encryption_context, + key_id: kms_key_id, + ) return encrypt_kms(plaintext, encryption_context) if FeatureManagement.use_kms? encrypt_local(plaintext, encryption_context) end @@ -41,7 +46,12 @@ def decrypt(ciphertext, encryption_context) if self.class.looks_like_contextless?(ciphertext) return decrypt_contextless_kms(ciphertext, encryption_context) end - KmsLogger.log(:decrypt, context: encryption_context, key_id: kms_key_id) + KmsLogger.log( + action: :decrypt, + timestamp: Time.zone.now, + context: encryption_context, + key_id: kms_key_id, + ) return decrypt_kms(ciphertext, encryption_context) if use_kms?(ciphertext) decrypt_local(ciphertext, encryption_context) end diff --git a/app/services/encryption/kms_logger.rb b/app/services/encryption/kms_logger.rb index 9824a4ef9ff..c45d0b3c27d 100644 --- a/app/services/encryption/kms_logger.rb +++ b/app/services/encryption/kms_logger.rb @@ -2,9 +2,10 @@ module Encryption class KmsLogger - def self.log(action, key_id:, context: nil, log_context: nil) + def self.log(action:, timestamp:, key_id:, context: nil, log_context: nil) output = { kms: { + timestamp: timestamp, action: action, encryption_context: context, log_context: log_context, diff --git a/app/services/recaptcha_annotator.rb b/app/services/recaptcha_annotator.rb index bdd8466f41c..8b15f55456d 100644 --- a/app/services/recaptcha_annotator.rb +++ b/app/services/recaptcha_annotator.rb @@ -21,8 +21,10 @@ def annotate(assessment_id:, reason: nil, annotation: nil) return if assessment_id.blank? if FeatureManagement.recaptcha_enterprise? - assessment = create_or_update_assessment!(assessment_id:, reason:, annotation:) - RecaptchaAnnotateJob.perform_later(assessment:) + submit_annotation(assessment_id:, reason:, annotation:) + # Future: + # assessment = create_or_update_assessment!(assessment_id:, reason:, annotation:) + # RecaptchaAnnotateJob.perform_later(assessment:) end { assessment_id:, reason:, annotation: } @@ -34,7 +36,6 @@ def submit_assessment(assessment) annotation: assessment.annotation_before_type_cast, reason: assessment.annotation_reason_before_type_cast, ) - assessment.destroy end private diff --git a/app/services/saml_request_validator.rb b/app/services/saml_request_validator.rb index 3baaa584367..6ed756cd567 100644 --- a/app/services/saml_request_validator.rb +++ b/app/services/saml_request_validator.rb @@ -59,6 +59,9 @@ def authorized_service_provider end def authorized_authn_context + # if there is no service provider, an error has already been added + return unless service_provider.present? + if !valid_authn_context? || (identity_proofing_requested? && !service_provider.identity_proofing_allowed?) || (ial_max_requested? && !service_provider.ialmax_allowed?) || @@ -74,7 +77,7 @@ def parsable_vtr end def registered_cert_exists - # if there is no service provider, this error has already been added + # if there is no service provider, an error has already been added return if service_provider.blank? return if service_provider.certs.present? return unless service_provider.encrypt_responses? diff --git a/config/application.yml.default b/config/application.yml.default index f60b1d27667..23e66c2e61f 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -390,6 +390,7 @@ short_term_phone_otp_max_attempt_window_in_seconds: 10 short_term_phone_otp_max_attempts: 2 show_unsupported_passkey_platform_authentication_setup: false show_user_attribute_deprecation_warnings: false +sign_in_recaptcha_annotation_enabled: false sign_in_recaptcha_log_failures_only: false sign_in_recaptcha_percent_tested: 0 sign_in_recaptcha_score_threshold: 0.0 @@ -507,6 +508,7 @@ development: secret_key_base: development_secret_key_base session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 show_unsupported_passkey_platform_authentication_setup: true + sign_in_recaptcha_annotation_enabled: true sign_in_recaptcha_percent_tested: 100 sign_in_recaptcha_score_threshold: 0.3 skip_encryption_allowed_list: '["urn:gov:gsa:SAML:2.0.profiles:sp:sso:localhost"]' @@ -607,6 +609,7 @@ test: secret_key_base: test_secret_key_base session_encryption_key: 27bad3c25711099429c1afdfd1890910f3b59f5a4faec1c85e945cb8b02b02f261ba501d99cfbb4fab394e0102de6fecf8ffe260f322f610db3e96b2a775c120 short_term_phone_otp_max_attempts: 100 + sign_in_recaptcha_annotation_enabled: true skip_encryption_allowed_list: '[]' socure_docv_document_request_endpoint: 'https://sandbox.socure.test/documnt-request' socure_docv_webhook_secret_key: 'secret-key' diff --git a/db/primary_migrate/20250228141538_add_timestamps_to_recaptcha_assessments.rb b/db/primary_migrate/20250228141538_add_timestamps_to_recaptcha_assessments.rb deleted file mode 100644 index fccd79540e5..00000000000 --- a/db/primary_migrate/20250228141538_add_timestamps_to_recaptcha_assessments.rb +++ /dev/null @@ -1,7 +0,0 @@ -class AddTimestampsToRecaptchaAssessments < ActiveRecord::Migration[8.0] - def change - add_timestamps :recaptcha_assessments - change_column_comment :recaptcha_assessments, :created_at, from: nil, to: 'sensitive=false' - change_column_comment :recaptcha_assessments, :updated_at, from: nil, to: 'sensitive=false' - end -end diff --git a/db/schema.rb b/db/schema.rb index f1b6d4e19f8..7838dd11b6a 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[8.0].define(version: 2025_02_28_141538) do +ActiveRecord::Schema[8.0].define(version: 2025_02_24_134110) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -482,8 +482,6 @@ create_table "recaptcha_assessments", id: :string, force: :cascade do |t| t.string "annotation", comment: "sensitive=false" t.string "annotation_reason", comment: "sensitive=false" - t.datetime "created_at", null: false, comment: "sensitive=false" - t.datetime "updated_at", null: false, comment: "sensitive=false" end create_table "registration_logs", force: :cascade do |t| diff --git a/lib/data_pull.rb b/lib/data_pull.rb index 7477d9cf1b9..3745dec774f 100644 --- a/lib/data_pull.rb +++ b/lib/data_pull.rb @@ -300,13 +300,13 @@ def run(args:, config:) ] end elsif config.include_missing? - table << [user.uuid, '[HAS NO PROFILE]', nil, nil, nil, nil, nil, nil] + table << [user.uuid, '[HAS NO PROFILE]', nil, nil, nil, nil, nil, nil, nil] end end if config.include_missing? (uuids - users.map(&:uuid)).each do |missing_uuid| - table << [missing_uuid, '[UUID NOT FOUND]', nil, nil, nil, nil, nil, nil] + table << [missing_uuid, '[UUID NOT FOUND]', nil, nil, nil, nil, nil, nil, nil] end end diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 49e296b8973..81a2a028c04 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -428,6 +428,7 @@ def self.store config.add(:sign_in_user_id_per_ip_attempt_window_in_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_attempt_window_max_minutes, type: :integer) config.add(:sign_in_user_id_per_ip_max_attempts, type: :integer) + config.add(:sign_in_recaptcha_annotation_enabled, type: :boolean) config.add(:sign_in_recaptcha_log_failures_only, type: :boolean) config.add(:sign_in_recaptcha_percent_tested, type: :integer) config.add(:sign_in_recaptcha_score_threshold, type: :float) diff --git a/spec/controllers/concerns/two_factor_authenticatable_methods_spec.rb b/spec/controllers/concerns/two_factor_authenticatable_methods_spec.rb index 523c0930514..69d7810f705 100644 --- a/spec/controllers/concerns/two_factor_authenticatable_methods_spec.rb +++ b/spec/controllers/concerns/two_factor_authenticatable_methods_spec.rb @@ -168,28 +168,53 @@ context 'when there is a sign_in_recaptcha_assessment_id in the session' do let(:assessment_id) { 'projects/project-id/assessments/assessment-id' } - it 'annotates assessment with PASSED_TWO_FACTOR and clears assessment id from session' do - recaptcha_annotation = { - assessment_id:, - reason: RecaptchaAnnotator::AnnotationReasons::PASSED_TWO_FACTOR, - } + context 'when sign_in_recaptcha_annotation_enabled is true' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_annotation_enabled) + .and_return(true) + end - controller.session[:sign_in_recaptcha_assessment_id] = assessment_id + it 'annotates assessment with PASSED_TWO_FACTOR and clears assessment id from session' do + recaptcha_annotation = { + assessment_id:, + reason: RecaptchaAnnotator::AnnotationReasons::PASSED_TWO_FACTOR, + } - expect(RecaptchaAnnotator).to receive(:annotate) - .with(**recaptcha_annotation) - .and_return(recaptcha_annotation) + controller.session[:sign_in_recaptcha_assessment_id] = assessment_id - stub_analytics + expect(RecaptchaAnnotator).to receive(:annotate) + .with(**recaptcha_annotation) + .and_return(recaptcha_annotation) - expect { result } - .to change { controller.session[:sign_in_recaptcha_assessment_id] } - .from(assessment_id).to(nil) + stub_analytics - expect(@analytics).to have_logged_event( - 'Multi-Factor Authentication', - hash_including(recaptcha_annotation:), - ) + expect { result } + .to change { controller.session[:sign_in_recaptcha_assessment_id] } + .from(assessment_id).to(nil) + + expect(@analytics).to have_logged_event( + 'Multi-Factor Authentication', + hash_including(recaptcha_annotation:), + ) + end + end + + context 'when sign_in_recaptcha_annotation_enabled is false' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_annotation_enabled) + .and_return(false) + end + + it 'does not annotate the assessment' do + controller.session[:sign_in_recaptcha_assessment_id] = assessment_id + + expect(RecaptchaAnnotator).not_to receive(:annotate) + + stub_analytics + + expect { result } + .not_to change { controller.session[:sign_in_recaptcha_assessment_id] } + end end end end @@ -228,28 +253,52 @@ context 'when there is a sign_in_recaptcha_assessment_id in the session' do let(:assessment_id) { 'projects/project-id/assessments/assessment-id' } - it 'annotates assessment with FAILED_TWO_FACTOR and clears assessment id from session' do - recaptcha_annotation = { - assessment_id:, - reason: RecaptchaAnnotator::AnnotationReasons::FAILED_TWO_FACTOR, - } + context 'when sign_in_recaptcha_annotation_enabled is true' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_annotation_enabled) + .and_return(true) + end + + it 'annotates assessment with FAILED_TWO_FACTOR and clears assessment id from session' do + recaptcha_annotation = { + assessment_id:, + reason: RecaptchaAnnotator::AnnotationReasons::FAILED_TWO_FACTOR, + } + + controller.session[:sign_in_recaptcha_assessment_id] = assessment_id + + expect(RecaptchaAnnotator).to receive(:annotate) + .with(**recaptcha_annotation) + .and_return(recaptcha_annotation) + + stub_analytics - controller.session[:sign_in_recaptcha_assessment_id] = assessment_id + expect { result } + .not_to change { controller.session[:sign_in_recaptcha_assessment_id] } + .from(assessment_id) - expect(RecaptchaAnnotator).to receive(:annotate) - .with(**recaptcha_annotation) - .and_return(recaptcha_annotation) + expect(@analytics).to have_logged_event( + 'Multi-Factor Authentication', + hash_including(recaptcha_annotation:), + ) + end + end + context 'when sign_in_recaptcha_annotation_enabled is false' do + before do + allow(IdentityConfig.store).to receive(:sign_in_recaptcha_annotation_enabled) + .and_return(false) + end + + it 'does not annotate the assessment' do + controller.session[:sign_in_recaptcha_assessment_id] = assessment_id - stub_analytics + expect(RecaptchaAnnotator).not_to receive(:annotate) - expect { result } - .not_to change { controller.session[:sign_in_recaptcha_assessment_id] } - .from(assessment_id) + stub_analytics - expect(@analytics).to have_logged_event( - 'Multi-Factor Authentication', - hash_including(recaptcha_annotation:), - ) + expect { result } + .not_to change { controller.session[:sign_in_recaptcha_assessment_id] } + end end end end diff --git a/spec/controllers/idv/welcome_controller_spec.rb b/spec/controllers/idv/welcome_controller_spec.rb index e7ac6c6183b..793e1f6092a 100644 --- a/spec/controllers/idv/welcome_controller_spec.rb +++ b/spec/controllers/idv/welcome_controller_spec.rb @@ -28,6 +28,41 @@ :check_for_mail_only_outage, ) end + + it 'includes cancelling previous in person enrollments' do + expect(subject).to have_actions( + :before, + :cancel_previous_in_person_enrollments, + ) + end + + context 'with previous establishing and pending in-person enrollments' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + end + + let!(:establishing_enrollment) { create(:in_person_enrollment, :establishing, user: user) } + let(:password_reset_profile) { create(:profile, :password_reset, user: user) } + let!(:pending_enrollment) do + create(:in_person_enrollment, :pending, user: user, profile: password_reset_profile) + end + let(:fraud_password_reset_profile) { create(:profile, :password_reset, user: user) } + let!(:fraud_review_enrollment) do + create( + :in_person_enrollment, :in_fraud_review, user: user, profile: fraud_password_reset_profile + ) + end + + it 'cancels all previous establishing, pending, and in_fraud_review enrollments' do + put :show + + expect(establishing_enrollment.reload.status).to eq(InPersonEnrollment::STATUS_CANCELLED) + expect(pending_enrollment.reload.status).to eq(InPersonEnrollment::STATUS_CANCELLED) + expect(fraud_review_enrollment.reload.status).to eq(InPersonEnrollment::STATUS_CANCELLED) + expect(user.establishing_in_person_enrollment).to be_blank + expect(user.pending_in_person_enrollment).to be_blank + end + end end describe '#show' do @@ -105,6 +140,22 @@ expect(response).to redirect_to(idv_please_call_url) end + + context 'has pending in-person enrollment' do + before do + allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) + end + + it 'redirects to ready to verify' do + profile = create(:profile, :in_person_verification_pending, user:) + + stub_sign_in(profile.user) + + get :show + + expect(response).to redirect_to(idv_in_person_ready_to_verify_url) + end + end end describe '#update' do @@ -133,33 +184,5 @@ expect { put :update } .to change { subject.idv_session.document_capture_session_uuid }.from(nil) end - - context 'with previous establishing and pending in-person enrollments' do - let!(:establishing_enrollment) { create(:in_person_enrollment, :establishing, user: user) } - let(:password_reset_profile) { create(:profile, :password_reset, user: user) } - let!(:pending_enrollment) do - create(:in_person_enrollment, :pending, user: user, profile: password_reset_profile) - end - let(:fraud_password_reset_profile) { create(:profile, :password_reset, user: user) } - let!(:fraud_review_enrollment) do - create( - :in_person_enrollment, :in_fraud_review, user: user, profile: fraud_password_reset_profile - ) - end - - before do - allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) - end - - it 'cancels all previous establishing, pending, and in_fraud_review enrollments' do - put :update - - expect(establishing_enrollment.reload.status).to eq(InPersonEnrollment::STATUS_CANCELLED) - expect(pending_enrollment.reload.status).to eq(InPersonEnrollment::STATUS_CANCELLED) - expect(fraud_review_enrollment.reload.status).to eq(InPersonEnrollment::STATUS_CANCELLED) - expect(user.establishing_in_person_enrollment).to be_blank - expect(user.pending_in_person_enrollment).to be_blank - end - end end end diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index a64c70fa9ef..901b5ed18bd 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1418,14 +1418,12 @@ def name_id_version(format_urn) expect(controller).to render_template('saml_idp/auth/error') expect(response.status).to eq(400) - expect(response.body).to include(t('errors.messages.unauthorized_authn_context')) expect(response.body).to include(t('errors.messages.unauthorized_service_provider')) expect(@analytics).to have_logged_event( 'SAML Auth', hash_including( success: false, error_details: { - authn_context: { unauthorized_authn_context: true }, service_provider: { unauthorized_service_provider: true }, }, nameid_format: Saml::Idp::Constants::NAME_ID_FORMAT_PERSISTENT, @@ -1440,7 +1438,7 @@ def name_id_version(format_urn) ) expect(@analytics).to have_logged_event( :sp_integration_errors_present, - error_details: ['Unauthorized Service Provider', 'Unauthorized authentication context'], + error_details: ['Unauthorized Service Provider'], error_types: { saml_request_errors: true }, event: :saml_auth_request, integration_exists: false, diff --git a/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx b/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx index 45a2b5acf10..c35317c8f38 100644 --- a/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx +++ b/spec/javascript/packages/document-capture/components/document-side-acuant-capture-spec.tsx @@ -25,7 +25,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, showHelpInitially: false, - immediatelyBeginCapture: false, }} > @@ -50,7 +49,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: true, showHelpInitially: false, - immediatelyBeginCapture: false, }} > @@ -77,7 +75,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, showHelpInitially: false, - immediatelyBeginCapture: false, }} > @@ -100,7 +97,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: true, showHelpInitially: false, - immediatelyBeginCapture: false, }} > @@ -127,7 +123,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: false, showHelpInitially: false, - immediatelyBeginCapture: false, }} > @@ -156,7 +151,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: true, showHelpInitially: false, - immediatelyBeginCapture: false, }} > @@ -193,7 +187,6 @@ describe('DocumentSideAcuantCapture', () => { isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: true, showHelpInitially: false, - immediatelyBeginCapture: false, }} > diff --git a/spec/javascript/packages/document-capture/components/documents-step-spec.tsx b/spec/javascript/packages/document-capture/components/documents-step-spec.tsx index 80bedd1160f..225f9573800 100644 --- a/spec/javascript/packages/document-capture/components/documents-step-spec.tsx +++ b/spec/javascript/packages/document-capture/components/documents-step-spec.tsx @@ -148,7 +148,6 @@ describe('document-capture/components/documents-step', () => { isSelfieCaptureEnabled: true, isSelfieDesktopTestMode: false, showHelpInitially: true, - immediatelyBeginCapture: true, }} > { isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, showHelpInitially: false, - immediatelyBeginCapture: false, }} > { isSelfieCaptureEnabled: false, isSelfieDesktopTestMode: false, showHelpInitially: false, - immediatelyBeginCapture: false, }} > { 'isSelfieCaptureEnabled', 'isSelfieDesktopTestMode', 'showHelpInitially', - 'immediatelyBeginCapture', ]); expect(result.current.isSelfieCaptureEnabled).to.be.a('boolean'); }); diff --git a/spec/lib/data_pull_spec.rb b/spec/lib/data_pull_spec.rb index 98865ef2ef6..cbb3ee6a208 100644 --- a/spec/lib/data_pull_spec.rb +++ b/spec/lib/data_pull_spec.rb @@ -170,14 +170,14 @@ subject(:result) { subtask.run(args:, config:) } it 'looks up the UUIDs for the given email addresses', aggregate_failures: true do - expect(result.table).to eq( - [ - ['email', 'uuid'], - *users.map { |u| [u.email_addresses.first.email, u.uuid] }, - ['missing@example.com', '[NOT FOUND]'], - ], - ) - + expected_table = [ + ['email', 'uuid'], + *users.map { |u| [u.email_addresses.first.email, u.uuid] }, + ['missing@example.com', '[NOT FOUND]'], + ] + + expect(result.table).to eq(expected_table) + expect_consistent_row_length(result.table) expect(result.subtask).to eq('uuid-lookup') expect(result.uuids).to match_array(users.map(&:uuid)) end @@ -231,34 +231,34 @@ end it 'still includes them and marks them as deleted' do - expect(result.table).to eq( - [ - ['partner_uuid', 'source', 'internal_uuid', 'deleted'], - external_uuids.first.then do |external_uuid| - identity = AgencyIdentity.find_by(uuid: external_uuid) - - [ - identity.uuid, - identity.agency.name, - identity.user.uuid, - nil, - ] - end, - external_uuids.last.then do |external_uuid| - identity = ServiceProviderIdentity.find_by(uuid: external_uuid) - - [ - identity.uuid, - identity.agency.name, - DeletedUser.find_by(user_id: identity.user_id).uuid, - true, - ] - end, - ['does-not-exist', '[NOT FOUND]', '[NOT FOUND]', nil], - ], - ) + expected_table = [ + ['partner_uuid', 'source', 'internal_uuid', 'deleted'], + external_uuids.first.then do |external_uuid| + identity = AgencyIdentity.find_by(uuid: external_uuid) + + [ + identity.uuid, + identity.agency.name, + identity.user.uuid, + nil, + ] + end, + external_uuids.last.then do |external_uuid| + identity = ServiceProviderIdentity.find_by(uuid: external_uuid) + + [ + identity.uuid, + identity.agency.name, + DeletedUser.find_by(user_id: identity.user_id).uuid, + true, + ] + end, + ['does-not-exist', '[NOT FOUND]', '[NOT FOUND]', nil], + ] + expect(result.table).to eq(expected_table) expect(result.subtask).to eq('uuid-convert') + expect_consistent_row_length(result.table) expect(result.uuids).to match_array(users.map(&:uuid)) end end @@ -277,17 +277,16 @@ subject(:result) { subtask.run(args:, config:) } it 'loads email addresses for the user', aggregate_failures: true do - expect(result.table).to match( - [ - ['uuid', 'email', 'confirmed_at'], - *user.email_addresses.sort_by(&:id).map do |e| - [e.user.uuid, e.email, kind_of(Time)] - end, - ['does-not-exist', '[NOT FOUND]', nil], - ], - ) - + expected_table = [ + ['uuid', 'email', 'confirmed_at'], + *user.email_addresses.sort_by(&:id).map do |e| + [e.user.uuid, e.email, kind_of(Time)] + end, + ['does-not-exist', '[NOT FOUND]', nil], + ] + expect(result.table).to match(expected_table) expect(result.subtask).to eq('email-lookup') + expect_consistent_row_length(result.table) expect(result.uuids).to eq([user.uuid]) end end @@ -382,29 +381,29 @@ subject(:result) { subtask.run(args:, config:) } it 'loads profile summary for the user', aggregate_failures: true do - expect(result.table).to match_array( - [ - ['uuid', 'profile_id', 'status', 'idv_level', 'activated_timestamp', 'disabled_reason', - 'gpo_verification_pending_timestamp', 'fraud_review_pending_timestamp', - 'fraud_rejection_timestamp'], - *user.profiles.sort_by(&:id).map do |p| - profile_status = p.active ? 'active' : 'inactive' - [ - user.uuid, - p.id, - profile_status, - p.idv_level, - kind_of(Time), - p.deactivation_reason, - nil, - nil, - nil, - ] - end, - [user_without_profile.uuid, '[HAS NO PROFILE]', nil, nil, nil, nil, nil, nil], - ['uuid-does-not-exist', '[UUID NOT FOUND]', nil, nil, nil, nil, nil, nil], - ], - ) + expected_result = [ + ['uuid', 'profile_id', 'status', 'idv_level', 'activated_timestamp', 'disabled_reason', + 'gpo_verification_pending_timestamp', 'fraud_review_pending_timestamp', + 'fraud_rejection_timestamp'], + *user.profiles.sort_by(&:id).map do |p| + profile_status = p.active ? 'active' : 'inactive' + [ + user.uuid, + p.id, + profile_status, + p.idv_level, + kind_of(Time), + p.deactivation_reason, + nil, + nil, + nil, + ] + end, + [user_without_profile.uuid, '[HAS NO PROFILE]', nil, nil, nil, nil, nil, nil, nil], + ['uuid-does-not-exist', '[UUID NOT FOUND]', nil, nil, nil, nil, nil, nil, nil], + ] + expect(result.table).to match_array(expected_result) + expect_consistent_row_length(result.table) expect(result.subtask).to eq('profile-summary') expect(result.uuids).to match_array([user.uuid, user_without_profile.uuid]) @@ -453,6 +452,7 @@ ] expect(result.table).to match_array(expected_table) + expect_consistent_row_length(result.table) expect(result.subtask).to eq('uuid-export') expect(result.uuids).to match_array([user1.uuid, user2.uuid]) end @@ -512,18 +512,24 @@ describe '#run' do it 'loads events for the users' do - expect(result.table).to match_array( - [ - %w[uuid date events_count], - [user.uuid, Date.new(2023, 1, 2), 5], - [user.uuid, Date.new(2023, 1, 1), 1], - ['uuid-does-not-exist', '[UUID NOT FOUND]', nil], - ], - ) - + expected_table = [ + %w[uuid date events_count], + [user.uuid, Date.new(2023, 1, 2), 5], + [user.uuid, Date.new(2023, 1, 1), 1], + ['uuid-does-not-exist', '[UUID NOT FOUND]', nil], + ] + expect(result.table).to match_array(expected_table) expect(result.subtask).to eq('events-summary') + expect_consistent_row_length(result.table) expect(result.uuids).to match_array([user.uuid]) end end end + + # Assert that each row has the same length + def expect_consistent_row_length(table) + first_row_length = table.first.length + + expect(table.all? { |row| row.length == first_row_length }).to eq(true) + end end diff --git a/spec/services/encryption/contextless_kms_client_spec.rb b/spec/services/encryption/contextless_kms_client_spec.rb index 1fe0da64319..d776a2fea63 100644 --- a/spec/services/encryption/contextless_kms_client_spec.rb +++ b/spec/services/encryption/contextless_kms_client_spec.rb @@ -4,6 +4,11 @@ let(:password_pepper) { '1' * 32 } let(:local_plaintext) { 'local plaintext' } let(:local_ciphertext) { 'local ciphertext' } + let(:log_timestamp) { Time.utc(2025, 2, 28, 15, 30, 1) } + + around do |example| + freeze_time { example.run } + end before do stub_const( @@ -149,7 +154,8 @@ it 'logs the encryption' do expect(Encryption::KmsLogger).to receive(:log).with( - :encrypt, + action: :encrypt, + timestamp: Time.zone.now, log_context: { context: 'abc' }, key_id: IdentityConfig.store.aws_kms_key_id, ) @@ -185,7 +191,8 @@ it 'logs the decryption' do expect(Encryption::KmsLogger).to receive(:log).with( - :decrypt, + action: :decrypt, + timestamp: Time.zone.now, log_context: { context: 'abc' }, key_id: IdentityConfig.store.aws_kms_key_id, ) diff --git a/spec/services/encryption/kms_client_spec.rb b/spec/services/encryption/kms_client_spec.rb index 59f70b19c51..4b4a46e94a5 100644 --- a/spec/services/encryption/kms_client_spec.rb +++ b/spec/services/encryption/kms_client_spec.rb @@ -1,6 +1,10 @@ require 'rails_helper' RSpec.describe Encryption::KmsClient do + around do |example| + freeze_time { example.run } + end + before do stub_const( 'Encryption::KmsClient::KMS_CLIENT_POOL', @@ -40,6 +44,7 @@ let(:key_id) { 'key1' } let(:plaintext) { 'a' * 3000 + 'b' * 3000 + 'c' * 3000 } let(:encryption_context) { { 'context' => 'attribute-bundle', 'user_id' => '123-abc-456-def' } } + let(:log_timestamp) { Time.utc(2025, 2, 28, 15, 30, 1) } let(:local_encryption_key) do OpenSSL::HMAC.digest( @@ -112,7 +117,8 @@ it 'logs the context' do expect(Encryption::KmsLogger).to receive(:log).with( - :encrypt, + action: :encrypt, + timestamp: Time.zone.now, context: encryption_context, key_id: subject.kms_key_id, ) @@ -168,7 +174,8 @@ it 'logs the context' do expect(Encryption::KmsLogger).to receive(:log).with( - :decrypt, + action: :decrypt, + timestamp: Time.zone.now, context: encryption_context, key_id: subject.kms_key_id, ) diff --git a/spec/services/encryption/kms_logger_spec.rb b/spec/services/encryption/kms_logger_spec.rb index bf1acaf12f1..51c85549d02 100644 --- a/spec/services/encryption/kms_logger_spec.rb +++ b/spec/services/encryption/kms_logger_spec.rb @@ -2,10 +2,13 @@ RSpec.describe Encryption::KmsLogger do describe '.log' do + let(:log_timestamp) { Time.utc(2025, 2, 28, 15, 30, 1) } + context 'with a context' do it 'logs the context' do log = { kms: { + timestamp: log_timestamp, action: 'encrypt', encryption_context: { context: 'pii-encryption', user_uuid: '1234-abc' }, log_context: 'log_context', @@ -17,10 +20,11 @@ expect(described_class.logger).to receive(:info).with(log) described_class.log( - :encrypt, + action: :encrypt, context: { context: 'pii-encryption', user_uuid: '1234-abc' }, log_context: 'log_context', key_id: 'super-duper-aws-kms-key-id', + timestamp: log_timestamp, ) end end @@ -29,6 +33,7 @@ it 'logs that an encryption happened without a context' do log = { kms: { + timestamp: log_timestamp, action: 'decrypt', encryption_context: nil, log_context: nil, @@ -39,7 +44,11 @@ expect(described_class.logger).to receive(:info).with(log) - described_class.log(:decrypt, key_id: 'super-duper-aws-kms-key-id') + described_class.log( + action: :decrypt, + timestamp: log_timestamp, + key_id: 'super-duper-aws-kms-key-id', + ) end end end diff --git a/spec/services/recaptcha_annotator_spec.rb b/spec/services/recaptcha_annotator_spec.rb index 0cfa5a68793..9d8f1bf0c2b 100644 --- a/spec/services/recaptcha_annotator_spec.rb +++ b/spec/services/recaptcha_annotator_spec.rb @@ -52,18 +52,21 @@ .and_return(recaptcha_enterprise_project_id) allow(IdentityConfig.store).to receive(:recaptcha_enterprise_api_key) .and_return(recaptcha_enterprise_api_key) - allow(RecaptchaAnnotateJob).to receive(:perform_later) + stub_request(:post, annotation_url) + .with do |req| + parsed_body = JSON.parse(req.body) + next if reason && parsed_body['reasons'] != [reason.to_s] + next if !reason && parsed_body.key?('reasons') + next if annotation && parsed_body['annotation'] != annotation.to_s + true + end + .to_return(headers: { 'Content-Type': 'application/json' }, body: '{}') end - it 'schedules the annotation to be submitted' do - expect(RecaptchaAnnotateJob).to receive(:perform_later) do |assessment:| - expect(assessment).to be_kind_of(RecaptchaAssessment) - expect(assessment.annotation_before_type_cast).to eq(annotation) - expect(assessment.annotation_reason_before_type_cast).to eq(reason) - expect(assessment).to be_persisted - end - + it 'submits annotation' do annotate + + expect(WebMock).to have_requested(:post, annotation_url) end it 'logs analytics' do @@ -80,15 +83,11 @@ let(:annotation) { nil } subject(:annotate) { RecaptchaAnnotator.annotate(assessment_id:, reason:) } - it 'schedules the annotation to be with only what is provided' do - expect(RecaptchaAnnotateJob).to receive(:perform_later) do |assessment:| - expect(assessment).to be_kind_of(RecaptchaAssessment) - expect(assessment.annotation_before_type_cast).to be_nil - expect(assessment.annotation_reason_before_type_cast).to eq(reason) - expect(assessment).to be_persisted - end - + it 'submits only what is provided' do annotate + + expect(WebMock).to have_requested(:post, annotation_url) + .with(body: { reasons: [reason] }.to_json) end it 'returns a hash describing annotation' do @@ -99,6 +98,34 @@ ) end end + + context 'with nil assessment id' do + let(:assessment_id) { nil } + + it 'does not submit annotation' do + annotate + + expect(WebMock).not_to have_requested(:post, annotation_url) + end + + it { expect(annotate).to be_nil } + end + + context 'with connection error' do + before do + stub_request(:post, annotation_url).to_timeout + end + + it 'fails gracefully' do + annotate + end + + it 'notices the error to NewRelic' do + expect(NewRelic::Agent).to receive(:notice_error).with(Faraday::Error) + + annotate + end + end end end @@ -126,9 +153,9 @@ .to_return(headers: { 'Content-Type': 'application/json' }, body: '{}') end - it 'submits annotation and destroys the record' do - assessment - expect { result }.to change { RecaptchaAssessment.count }.by(-1) + it 'submits annotation' do + result + expect(WebMock).to have_requested(:post, annotation_url) end diff --git a/spec/services/saml_request_validator_spec.rb b/spec/services/saml_request_validator_spec.rb index 34dfcccef0b..ecfae1008e8 100644 --- a/spec/services/saml_request_validator_spec.rb +++ b/spec/services/saml_request_validator_spec.rb @@ -87,7 +87,7 @@ end context 'valid authn context and invalid sp and authorized nameID format' do - let(:sp) { ServiceProvider.find_by(issuer: 'foo') } + let(:sp) { nil } it 'returns FormResponse with success: false' do expect(response.to_h).to eq( @@ -96,6 +96,42 @@ **extra, ) end + + context 'when identity proofing is requested' do + let(:authn_context) { [Saml::Idp::Constants::IAL_VERIFIED_ACR] } + + it 'returns FormResponse with success: false' do + expect(response.to_h).to eq( + success: false, + error_details: { service_provider: { unauthorized_service_provider: true } }, + **extra, + ) + end + end + + context 'when IALmax is requested' do + let(:authn_context) { [Saml::Idp::Constants::IALMAX_AUTHN_CONTEXT_CLASSREF] } + + it 'returns FormResponse with success: false' do + expect(response.to_h).to eq( + success: false, + error_details: { service_provider: { unauthorized_service_provider: true } }, + **extra, + ) + end + end + + context 'when facial matching is requested' do + let(:authn_context) { [Saml::Idp::Constants::IAL_VERIFIED_FACIAL_MATCH_REQUIRED_ACR] } + + it 'returns FormResponse with success: false' do + expect(response.to_h).to eq( + success: false, + error_details: { service_provider: { unauthorized_service_provider: true } }, + **extra, + ) + end + end end context 'valid authn context and unauthorized nameid format' do @@ -302,7 +338,6 @@ expect(response.to_h).to eq( success: false, error_details: { - authn_context: { unauthorized_authn_context: true }, service_provider: { unauthorized_service_provider: true }, }, **extra, diff --git a/yarn.lock b/yarn.lock index 6b4cb0914e0..e0ddf0be7bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4082,10 +4082,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.12.4: - version "1.12.4" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.4.tgz#15062e61ac29d381305f0a37b0c2aad6ae432931" - integrity sha512-vLmhg7Gan7idyAKfc6pvCtNzvar4/eIzrVVk3hjNFH5+fGqyjD0gQRovdTrDl20wsmZhBtmZpcsR0tOfquwb8g== +libphonenumber-js@^1.12.5: + version "1.12.5" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.5.tgz#8e6043a67112d4beedb8627b359a613f04d88fba" + integrity sha512-DOjiaVjjSmap12ztyb4QgoFmUe/GbgnEXHu+R7iowk0lzDIjScvPAm8cK9RYTEobbRb0OPlwlZUGTTJPJg13Kw== lightningcss-darwin-arm64@1.23.0: version "1.23.0"