From 34f820c95daf7c5ee860d467ba3fdb5e08309616 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Thu, 16 Jan 2025 14:39:56 -0500 Subject: [PATCH 01/16] LG-15168 | ProofingComponents now only use idv_session changelog: Internal, IDV, Update ProofingComponents to only take an idv_session --- app/services/idv/proofing_components.rb | 19 +++---------------- spec/services/idv/proofing_components_spec.rb | 3 --- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/app/services/idv/proofing_components.rb b/app/services/idv/proofing_components.rb index b7432c5c4c1..c549046c6e1 100644 --- a/app/services/idv/proofing_components.rb +++ b/app/services/idv/proofing_components.rb @@ -2,25 +2,12 @@ module Idv class ProofingComponents - def initialize(idv_session:, session:, user:, user_session:) + def initialize(idv_session:) @idv_session = idv_session - @session = session - @user = user - @user_session = user_session end def document_check - if user.establishing_in_person_enrollment || user.pending_in_person_enrollment - Idp::Constants::Vendors::USPS - elsif idv_session.remote_document_capture_complete? - DocAuthRouter.doc_auth_vendor( - request: nil, - service_provider: idv_session.service_provider, - session:, - user_session:, - user:, - ) - end + idv_session.doc_auth_vendor end def document_type @@ -73,6 +60,6 @@ def to_h private - attr_reader :idv_session, :session, :user, :user_session + attr_reader :idv_session end end diff --git a/spec/services/idv/proofing_components_spec.rb b/spec/services/idv/proofing_components_spec.rb index bdea18c079e..c39a72b015b 100644 --- a/spec/services/idv/proofing_components_spec.rb +++ b/spec/services/idv/proofing_components_spec.rb @@ -21,9 +21,6 @@ subject do described_class.new( - session:, - user:, - user_session:, idv_session:, ) end From 269f3eb1f54fe0a0e2b1b2a467d14c322d95ff06 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Fri, 17 Jan 2025 15:11:08 -0500 Subject: [PATCH 02/16] Further cleanup now that LG-15166 is merged --- spec/services/idv/proofing_components_spec.rb | 45 +++---------------- 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/spec/services/idv/proofing_components_spec.rb b/spec/services/idv/proofing_components_spec.rb index c39a72b015b..97645db4bae 100644 --- a/spec/services/idv/proofing_components_spec.rb +++ b/spec/services/idv/proofing_components_spec.rb @@ -37,12 +37,13 @@ idv_session.threatmetrix_review_status = 'pass' idv_session.source_check_vendor = 'aamva' idv_session.resolution_vendor = 'lexis_nexis' + idv_session.doc_auth_vendor = 'feedabee' end it 'returns expected result' do expect(subject.to_h).to eql( { - document_check: 'test_vendor', + document_check: 'feedabee', document_type: 'state_id', source_check: 'aamva', resolution_check: 'lexis_nexis', @@ -55,46 +56,10 @@ end describe '#document_check' do - it 'returns nil by default' do - expect(subject.document_check).to be_nil - end - - context 'in-person proofing' do - context 'establishing' do - let!(:enrollment) { create(:in_person_enrollment, :establishing, user:) } - - it 'returns USPS' do - expect(subject.document_check).to eql(Idp::Constants::Vendors::USPS) - end - end - - context 'pending' do - let!(:enrollment) { create(:in_person_enrollment, :pending, user:) } - - it 'returns USPS' do - expect(subject.document_check).to eql(Idp::Constants::Vendors::USPS) - end - end - end + before { idv_session.doc_auth_vendor = 'feedabee' } - context 'doc auth' do - before do - allow(IdentityConfig.store).to receive(:doc_auth_vendor_default).and_return('test_vendor') - end - - context 'before doc auth complete' do - it 'returns nil' do - expect(subject.document_check).to be_nil - end - end - - context 'after doc auth completed successfully' do - let(:pii_from_doc) { Idp::Constants::MOCK_IDV_APPLICANT } - - it 'returns doc auth vendor' do - expect(subject.document_check).to eql('test_vendor') - end - end + it 'returns doc_auth_vendor' do + expect(subject.document_check).to eql('feedabee') end end From f486fdf0ae68e46941d109a9dfd3fa62f36314bf Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Fri, 17 Jan 2025 15:18:26 -0500 Subject: [PATCH 03/16] LPT: When changing a method definition, consider changing the places that call it to avoid everything breaking in an utterly predicatable manner. --- app/controllers/concerns/idv/verify_info_concern.rb | 7 +------ app/controllers/idv/enter_password_controller.rb | 7 +------ app/services/idv/analytics_events_enhancer.rb | 7 +------ .../idv/in_person/usps_locations_controller_spec.rb | 6 ------ spec/controllers/idv/link_sent_controller_spec.rb | 3 --- spec/services/idv/agent_spec.rb | 7 +------ 6 files changed, 4 insertions(+), 33 deletions(-) diff --git a/app/controllers/concerns/idv/verify_info_concern.rb b/app/controllers/concerns/idv/verify_info_concern.rb index be02dac4b43..f5e0afbdc04 100644 --- a/app/controllers/concerns/idv/verify_info_concern.rb +++ b/app/controllers/concerns/idv/verify_info_concern.rb @@ -40,12 +40,7 @@ def shared_update threatmetrix_session_id: idv_session.threatmetrix_session_id, request_ip: request.remote_ip, ipp_enrollment_in_progress: ipp_enrollment_in_progress?, - proofing_components: ProofingComponents.new( - user: current_user, - idv_session:, - session:, - user_session:, - ), + proofing_components: ProofingComponents.new(idv_session:), ) return true diff --git a/app/controllers/idv/enter_password_controller.rb b/app/controllers/idv/enter_password_controller.rb index dc5bdfaa327..89d2cc04ed4 100644 --- a/app/controllers/idv/enter_password_controller.rb +++ b/app/controllers/idv/enter_password_controller.rb @@ -130,12 +130,7 @@ def init_profile profile = idv_session.create_profile_from_applicant_with_password( password, is_enhanced_ipp: resolved_authn_context_result.enhanced_ipp?, - proofing_components: ProofingComponents.new( - user: current_user, - idv_session:, - session:, - user_session:, - ).to_h, + proofing_components: ProofingComponents.new(idv_session:).to_h, ) if profile.gpo_verification_pending? diff --git a/app/services/idv/analytics_events_enhancer.rb b/app/services/idv/analytics_events_enhancer.rb index 8084338b99a..20a3af1ea08 100644 --- a/app/services/idv/analytics_events_enhancer.rb +++ b/app/services/idv/analytics_events_enhancer.rb @@ -161,12 +161,7 @@ def proofing_components service_provider: sp, ) - proofing_components_hash = ProofingComponents.new( - idv_session:, - session:, - user:, - user_session:, - ).to_h + proofing_components_hash = ProofingComponents.new(idv_session:).to_h proofing_components_hash.empty? ? nil : proofing_components_hash end diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb index a4803ba15a1..d01ee4601ea 100644 --- a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -376,9 +376,6 @@ it 'updates proofing component vendor' do proofing_components = Idv::ProofingComponents.new( idv_session: controller.idv_session, - session: controller.session, - user_session: controller.user_session, - user:, ) expect(proofing_components.document_check).to be_nil @@ -415,9 +412,6 @@ it 'updates proofing component vendor' do proofing_components = Idv::ProofingComponents.new( idv_session: controller.idv_session, - session: controller.session, - user_session: controller.user_session, - user:, ) expect(proofing_components.document_check).to be_nil diff --git a/spec/controllers/idv/link_sent_controller_spec.rb b/spec/controllers/idv/link_sent_controller_spec.rb index d59a440f370..b07206f4d40 100644 --- a/spec/controllers/idv/link_sent_controller_spec.rb +++ b/spec/controllers/idv/link_sent_controller_spec.rb @@ -176,9 +176,6 @@ proofing_components = Idv::ProofingComponents.new( idv_session: subject.idv_session, - session: subject.session, - user_session: subject.user_session, - user:, ) expect(proofing_components.document_check).to eq('mock') expect(proofing_components.document_type).to eq('state_id') diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index bead0c38774..98e2451f43c 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -27,12 +27,7 @@ end end let(:proofing_components) do - Idv::ProofingComponents.new( - idv_session:, - session:, - user:, - user_session:, - ) + Idv::ProofingComponents.new(idv_session:) end subject(:agent) { Idv::Agent.new(applicant) } From 2636f8c04fe28213a1a0947eea6a749532d0e86c Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Mon, 27 Jan 2025 17:19:31 -0500 Subject: [PATCH 04/16] Address some failing tests --- app/controllers/idv/in_person/usps_locations_controller.rb | 2 ++ .../controllers/idv/in_person/usps_locations_controller_spec.rb | 1 + spec/services/idv/agent_spec.rb | 1 - spec/services/idv/analytics_events_enhancer_spec.rb | 1 - 4 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/controllers/idv/in_person/usps_locations_controller.rb b/app/controllers/idv/in_person/usps_locations_controller.rb index 35c8366801e..c6621698eb8 100644 --- a/app/controllers/idv/in_person/usps_locations_controller.rb +++ b/app/controllers/idv/in_person/usps_locations_controller.rb @@ -63,6 +63,8 @@ def update sponsor_id: enrollment_sponsor_id, ) + idv_session.doc_auth_vendor = Idp::Constants::Vendors::USPS + render json: { success: true }, status: :ok end diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb index d01ee4601ea..6c78ad437b9 100644 --- a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -373,6 +373,7 @@ expect(enrollment.service_provider).to eq(sp) end + # MAW: Does this make sense? Is selecting a Post Office really doc auth at all? it 'updates proofing component vendor' do proofing_components = Idv::ProofingComponents.new( idv_session: controller.idv_session, diff --git a/spec/services/idv/agent_spec.rb b/spec/services/idv/agent_spec.rb index 98e2451f43c..cf999e53829 100644 --- a/spec/services/idv/agent_spec.rb +++ b/spec/services/idv/agent_spec.rb @@ -142,7 +142,6 @@ expect(ResolutionProofingJob).to receive(:perform_later).with( hash_including( proofing_components: { - document_check: 'mock', document_type: 'state_id', }, ), diff --git a/spec/services/idv/analytics_events_enhancer_spec.rb b/spec/services/idv/analytics_events_enhancer_spec.rb index 8cd8994461c..1cf65613a41 100644 --- a/spec/services/idv/analytics_events_enhancer_spec.rb +++ b/spec/services/idv/analytics_events_enhancer_spec.rb @@ -100,7 +100,6 @@ def track_event(_event, **kwargs) expect(analytics.called_kwargs).to eql( extra: true, proofing_components: { - document_check: 'mock', document_type: 'state_id', }, ) From d22972b10f5181c4eeeebd72b9176b0b832d42b6 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Tue, 28 Jan 2025 14:25:05 -0500 Subject: [PATCH 05/16] Create enrollment ahead of time It looks like the ProofingComponents class was referencing this causing the let() to fire, maksing that it shoudl have been a let!() from the beginning. --- spec/controllers/idv/cancellations_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/controllers/idv/cancellations_controller_spec.rb b/spec/controllers/idv/cancellations_controller_spec.rb index 12ef3b256c1..883f5f04400 100644 --- a/spec/controllers/idv/cancellations_controller_spec.rb +++ b/spec/controllers/idv/cancellations_controller_spec.rb @@ -222,7 +222,7 @@ context 'with in establishing in-person enrollment' do let(:user) { build(:user, :with_establishing_in_person_enrollment) } - let(:enrollment) { user.establishing_in_person_enrollment } + let!(:enrollment) { user.establishing_in_person_enrollment } before do allow(IdentityConfig.store).to receive(:in_person_proofing_enabled).and_return(true) From 28cdc2e62be57d8b0409785b6d46a37d55adc9c7 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Tue, 28 Jan 2025 15:33:55 -0800 Subject: [PATCH 06/16] Update spec/controllers/idv/in_person/usps_locations_controller_spec.rb Co-authored-by: Matt Wagner --- spec/controllers/idv/in_person/usps_locations_controller_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb index 6c78ad437b9..d01ee4601ea 100644 --- a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -373,7 +373,6 @@ expect(enrollment.service_provider).to eq(sp) end - # MAW: Does this make sense? Is selecting a Post Office really doc auth at all? it 'updates proofing component vendor' do proofing_components = Idv::ProofingComponents.new( idv_session: controller.idv_session, From e2d312650538d4c81298e7656fecffa58448eb67 Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Tue, 28 Jan 2025 15:11:07 -0500 Subject: [PATCH 07/16] LG-15394 Add tooling for an SP proofing events by UUID report (#11787) We have an agency partner who requested a specialized report the involves a table with columns representing proofing events and rows representing users for the partner where the values in each cell correspond to whether that user encountered that event in the given time period. We have been manually servicing this request for a little while. This commit adds tooling to generate this report automatically so it does not require manual processing. [skip changelog] --- .../reports/sp_proofing_events_by_uuid.rb | 62 +++++ config/application.yml.default | 1 + config/initializers/job_configurations.rb | 6 + lib/identity_config.rb | 1 + lib/reporting/sp_proofing_events_by_uuid.rb | 226 ++++++++++++++++++ .../sp_proofing_events_by_uuid_spec.rb | 113 +++++++++ .../sp_proofing_events_by_uuid_spec.rb | 162 +++++++++++++ 7 files changed, 571 insertions(+) create mode 100644 app/jobs/reports/sp_proofing_events_by_uuid.rb create mode 100644 lib/reporting/sp_proofing_events_by_uuid.rb create mode 100644 spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb create mode 100644 spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb diff --git a/app/jobs/reports/sp_proofing_events_by_uuid.rb b/app/jobs/reports/sp_proofing_events_by_uuid.rb new file mode 100644 index 00000000000..03a91bb63cf --- /dev/null +++ b/app/jobs/reports/sp_proofing_events_by_uuid.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'reporting/sp_proofing_events_by_uuid' + +module Reports + class SpProofingEventsByUuid < BaseReport + attr_accessor :report_date + + def perform(report_date) + return unless IdentityConfig.store.s3_reports_enabled + + self.report_date = report_date + + IdentityConfig.store.sp_proofing_events_by_uuid_report_configs.each do |report_config| + send_report(report_config) + end + end + + def send_report(report_config) + return unless IdentityConfig.store.s3_reports_enabled + issuers = report_config['issuers'] + agency_abbreviation = report_config['agency_abbreviation'] + emails = report_config['emails'] + + agency_report_nane = "#{agency_abbreviation.downcase}_proofing_events_by_uuid" + agency_report_title = "#{agency_abbreviation} Proofing Events By UUID" + + report_maker = build_report_maker( + issuers:, + agency_abbreviation:, + time_range: report_date.to_date.weeks_ago(1).all_week(:sunday), + ) + + csv = report_maker.to_csv + + save_report(agency_report_nane, csv, extension: 'csv') + + if emails.blank? + Rails.logger.warn "No email addresses received - #{agency_report_title} NOT SENT" + return false + end + + email_message = <<~HTML.html_safe # rubocop:disable Rails/OutputSafety +

#{agency_report_title}

+ HTML + + emails.each do |email| + ReportMailer.tables_report( + email: email, + subject: "#{agency_report_title} - #{report_date.to_date}", + reports: report_maker.as_emailable_reports, + message: email_message, + attachment_format: :csv, + ).deliver_now + end + end + + def build_report_maker(issuers:, agency_abbreviation:, time_range:) + Reporting::SpProofingEventsByUuid.new(issuers:, agency_abbreviation:, time_range:) + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index 743581627b4..fc77926b7ba 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -403,6 +403,7 @@ socure_reason_code_base_url: '' socure_reason_code_timeout_in_seconds: 5 sp_handoff_bounce_max_seconds: 2 sp_issuer_user_counts_report_configs: '[]' +sp_proofing_events_by_uuid_report_configs: '[]' state_tracking_enabled: true team_ada_email: '' team_all_login_emails: '[]' diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index 572c450ce86..d8f87610a8b 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -119,6 +119,12 @@ cron: cron_24h, args: -> { [Time.zone.yesterday] }, }, + # Send the SP IdV Weekly Dropoff Report + sp_idv_weekly_dropoff: { + class: 'Reports::SpProofingEventsByUuid', + cron: cron_every_monday_2am, + args: -> { [Time.zone.today] }, + }, # Sync opted out phone numbers from AWS phone_number_opt_out_sync_job: { class: 'PhoneNumberOptOutSyncJob', diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 4a6557cac45..b09a7ff326a 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -439,6 +439,7 @@ def self.store config.add(:socure_reason_code_timeout_in_seconds, type: :integer) config.add(:sp_handoff_bounce_max_seconds, type: :integer) config.add(:sp_issuer_user_counts_report_configs, type: :json) + config.add(:sp_proofing_events_by_uuid_report_configs, type: :json) config.add(:state_tracking_enabled, type: :boolean) config.add(:team_ada_email, type: :string) config.add(:team_all_login_emails, type: :json) diff --git a/lib/reporting/sp_proofing_events_by_uuid.rb b/lib/reporting/sp_proofing_events_by_uuid.rb new file mode 100644 index 00000000000..d9cc5eca312 --- /dev/null +++ b/lib/reporting/sp_proofing_events_by_uuid.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require 'reporting/cloudwatch_client' +require 'reporting/cloudwatch_query_quoting' + +module Reporting + class SpProofingEventsByUuid + attr_reader :issuers, :agency_abbreviation, :time_range + + def initialize( + issuers:, + agency_abbreviation:, + time_range:, + verbose: false, + progress: false, + cloudwatch_client: nil + ) + @issuers = issuers + @agency_abbreviation = agency_abbreviation + @time_range = time_range + @verbose = verbose + @progress = progress + @cloudwatch_client = cloudwatch_client + end + + def verbose? + @verbose + end + + def progress? + @progress + end + + def query(after_row:) + base_query = <<~QUERY + filter properties.service_provider in #{issuers.inspect} or + (name = "IdV: enter verify by mail code submitted" and properties.event_properties.initiating_service_provider in #{issuers.inspect}) + | filter name in [ + "IdV: doc auth welcome visited", + "IdV: doc auth document_capture visited", + "Frontend: IdV: front image added", + "Frontend: IdV: back image added", + "idv_selfie_image_added", + "IdV: doc auth image upload vendor submitted", + "IdV: doc auth ssn submitted", + "IdV: doc auth verify proofing results", + "IdV: phone confirmation form", + "IdV: phone confirmation vendor", + "IdV: final resolution", + "IdV: enter verify by mail code submitted", + "Fraud: Profile review passed", + "Fraud: Profile review rejected", + "User registration: agency handoff visited", + "SP redirect initiated" + ] + + | fields coalesce(name = "Fraud: Profile review passed" and properties.event_properties.success, 0) * properties.event_properties.profile_age_in_seconds as fraud_review_profile_age_in_seconds, + coalesce(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed, 0) * properties.event_properties.profile_age_in_seconds as gpo_profile_age_in_seconds, + fraud_review_profile_age_in_seconds + gpo_profile_age_in_seconds as profile_age_in_seconds + + | stats sum(name = "IdV: doc auth welcome visited") > 0 as workflow_started, + sum(name = "IdV: doc auth document_capture visited") > 0 as doc_auth_started, + sum(name = "Frontend: IdV: front image added") > 0 and sum(name = "Frontend: IdV: back image added") > 0 as document_captured, + sum(name = "idv_selfie_image_added") > 0 as selfie_captured, + sum(name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.success) > 0 as doc_auth_passed, + sum(name = "IdV: doc auth ssn submitted") > 0 as ssn_submitted, + sum(name = "IdV: doc auth verify proofing results") > 0 as personal_info_submitted, + sum(name = "IdV: doc auth verify proofing results" and properties.event_properties.success) > 0 as personal_info_verified, + sum(name = "IdV: phone confirmation form") > 0 as phone_submitted, + sum(name = "IdV: phone confirmation vendor" and properties.event_properties.success) > 0 as phone_verified, + sum(name = "IdV: final resolution") > 0 as online_workflow_completed, + sum(name = "IdV: final resolution" and !properties.event_properties.gpo_verification_pending and !properties.event_properties.in_person_verification_pending and !coalesce(properties.event_properties.fraud_pending_reason, 0)) > 0 as verified_in_band, + sum(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed) > 0 as verified_by_mail, + sum(name = "Fraud: Profile review passed" and properties.event_properties.success) > 0 as verified_fraud_review, + max(profile_age_in_seconds) as out_of_band_verification_pending_seconds, + sum(name = "User registration: agency handoff visited" and properties.event_properties.ial2) > 0 as agency_handoff, + sum(name = "SP redirect initiated" and properties.event_properties.ial == 2) > 0 as sp_redirect, + toMillis(min(@timestamp)) as first_event + by properties.user_id as login_uuid + | filter workflow_started > 0 or verified_by_mail > 0 or verified_fraud_review > 0 + | limit 10000 + | sort first_event asc + QUERY + return base_query if after_row.nil? + + base_query + " | filter first_event > #{after_row['first_event']}" + end + + def as_csv + csv = [] + csv << ['Date Range', "#{time_range.begin.to_date} - #{time_range.end.to_date}"] + csv << csv_header + data.each do |result_row| + csv << result_row + end + csv.compact + end + + def to_csv + CSV.generate do |csv| + as_csv.each do |row| + csv << row + end + end + end + + def as_emailable_reports + [ + EmailableReport.new( + title: "#{agency_abbreviation} Proofing Events By UUID", + table: as_csv, + filename: "#{agency_abbreviation.downcase}_proofing_events_by_uuid", + ), + ] + end + + def csv_header + [ + 'UUID', + 'Workflow Started', + 'Documnet Capture Started', + 'Document Captured', + 'Selfie Captured', + 'Document Authentication Passed', + 'SSN Submitted', + 'Personal Information Submitted', + 'Personal Information Verified', + 'Phone Submitted', + 'Phone Verified', + 'Verification Workflow Complete', + 'Identity Verified for In-Band Users', + 'Identity Verified for Verify-By-Mail Users', + 'Identity Verified for Fraud Review Users', + 'Out-of-Band Verification Pending Seconds', + 'Agency Handoff Visited', + 'Agency Handoff Submitted', + ] + end + + def data + return @data if defined? @data + + login_uuid_data ||= fetch_results.map do |result_row| + process_result_row(result_row) + end + login_uuid_to_agency_uuid_map = build_uuid_map(login_uuid_data.map(&:first)) + + @data = login_uuid_data.map do |row| + login_uuid, *row_data = row + agency_uuid = login_uuid_to_agency_uuid_map[login_uuid] + next if agency_uuid.nil? + [agency_uuid, *row_data] + end.compact + end + + def process_result_row(result_row) + [ + result_row['login_uuid'], + result_row['workflow_started'] == '1', + result_row['doc_auth_started'] == '1', + result_row['document_captured'] == '1', + result_row['selfie_captured'] == '1', + result_row['doc_auth_passed'] == '1', + result_row['ssn_submitted'] == '1', + result_row['personal_info_submitted'] == '1', + result_row['personal_info_verified'] == '1', + result_row['phone_submitted'] == '1', + result_row['phone_verified'] == '1', + result_row['online_workflow_completed'] == '1', + result_row['verified_in_band'] == '1', + result_row['verified_by_mail'] == '1', + result_row['verified_fraud_review'] == '1', + result_row['out_of_band_verification_pending_seconds'].to_i, + result_row['agency_handoff'] == '1', + result_row['sp_redirect'] == '1', + ] + end + + # rubocop:disable Rails/FindEach + # Use of `find` instead of `find_each` here is safe since we are already batching the UUIDs + # that go into the query + def build_uuid_map(uuids) + uuid_map = Hash.new + + uuids.each_slice(1000) do |uuid_slice| + AgencyIdentity.joins(:user).where( + agency:, + users: { uuid: uuid_slice }, + ).each do |agency_identity| + uuid_map[agency_identity.user.uuid] = agency_identity.uuid + end + end + + uuid_map + end + # rubocop:enable Rails/FindEach + + def agency + @agency ||= begin + record = Agency.find_by(abbreviation: agency_abbreviation) + raise "Unable to find agency with abbreviation: #{agency_abbreviation}" if record.nil? + record + end + end + + def fetch_results(after_row: nil) + results = cloudwatch_client.fetch( + query: query(after_row:), + from: time_range.begin.beginning_of_day, + to: time_range.end.end_of_day, + ) + return results if results.count < 10000 + results + fetch_results(after_row: results.last) + end + + def cloudwatch_client + @cloudwatch_client ||= Reporting::CloudwatchClient.new( + num_threads: 1, + ensure_complete_logs: false, + slice_interval: 100.years, + progress: progress?, + logger: verbose? ? Logger.new(STDERR) : nil, + ) + end + end +end diff --git a/spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb b/spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb new file mode 100644 index 00000000000..a2fe2bff220 --- /dev/null +++ b/spec/jobs/reports/sp_proofing_events_by_uuid_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe Reports::SpProofingEventsByUuid do + let(:report_date) { Date.new(2024, 12, 9) } + let(:agency_abbreviation) { 'ABC' } + let(:report_emails) { ['test@example.com'] } + let(:issuers) { ['super:cool:test:issuer'] } + let(:sp_proofing_events_by_uuid_report_configs) do + [ + { + 'issuers' => issuers, + 'agency_abbreviation' => 'ABC', + 'emails' => report_emails, + }, + ] + end + + before do + allow(IdentityConfig.store).to receive(:s3_reports_enabled).and_return(true) + allow(IdentityConfig.store).to receive( + :sp_proofing_events_by_uuid_report_configs, + ).and_return( + sp_proofing_events_by_uuid_report_configs, + ) + end + + describe '#perform' do + it 'gets a CSV from the report maker, saves it to S3, and sends email to team' do + report = [ + ['UUID', 'Welcome Visited', 'Welcome Submitted'], + ['123abc', true, true], + ['456def', true, false], + ] + csv_report = CSV.generate do |csv| + report.each { |row| csv << row } + end + emailable_reports = [ + Reporting::EmailableReport.new( + title: 'DOL Proofing Events By UUID - 2024-12-01', + table: report, + filename: 'dol_proofing_events_by_uuid', + ), + ] + + report_maker = double( + Reporting::SpProofingEventsByUuid, + to_csv: csv_report, + as_emailable_reports: emailable_reports, + ) + + allow(subject).to receive(:build_report_maker).with( + issuers: issuers, + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 7), + ).and_return(report_maker) + expect(subject).to receive(:save_report).with( + 'abc_proofing_events_by_uuid', + csv_report, + extension: 'csv', + ) + + expect(ReportMailer).to receive(:tables_report).once.with( + email: 'test@example.com', + subject: 'ABC Proofing Events By UUID - 2024-12-09', + reports: emailable_reports, + message: anything, + attachment_format: :csv, + ).and_call_original + + subject.perform(report_date) + end + + context 'with no emails configured' do + let(:report_emails) { [] } + + it 'does not send the report in email' do + report_maker = double( + Reporting::SpProofingEventsByUuid, + to_csv: 'I am a CSV, see', + identity_verification_emailable_report: 'I am a report', + ) + allow(subject).to receive(:build_report_maker).with( + issuers: issuers, + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 7), + ).and_return(report_maker) + expect(subject).to receive(:save_report).with( + 'abc_proofing_events_by_uuid', + 'I am a CSV, see', + extension: 'csv', + ) + + expect(ReportMailer).to_not receive(:tables_report) + + subject.perform(report_date) + end + end + end + + describe '#build_report_maker' do + it 'is a identity verification report maker with the correct attributes' do + report_maker = subject.build_report_maker( + issuers: ['super:cool:test:issuer'], + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 7), + ) + + expect(report_maker.issuers).to eq(['super:cool:test:issuer']) + expect(report_maker.agency_abbreviation).to eq('ABC') + expect(report_maker.time_range).to eq(Date.new(2024, 12, 1)..Date.new(2024, 12, 7)) + end + end +end diff --git a/spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb b/spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb new file mode 100644 index 00000000000..674a8f83dc6 --- /dev/null +++ b/spec/lib/reporting/sp_proofing_events_by_uuid_spec.rb @@ -0,0 +1,162 @@ +require 'rails_helper' +require 'reporting/sp_proofing_events_by_uuid' + +RSpec.describe Reporting::SpProofingEventsByUuid do + let(:issuer) { 'super:cool:test:issuer' } + let(:agency_abbreviation) { 'DOL' } + let(:agency) { Agency.find_by(abbreviation: agency_abbreviation) } + + let(:time_range) { Date.new(2024, 12, 1).all_week(:sunday) } + + let(:deleted_user_uuid) { 'deleted_user_test' } + let(:non_agency_user_uuid) { 'non_agency_user_test' } + let(:agency_user_login_uuid) { 'agency_user_login_uuid_test' } + let(:agency_user_agency_uuid) { 'agency_user_agency_uuid_test' } + + let(:cloudwatch_logs) do + [ + { + 'login_uuid' => deleted_user_uuid, + 'workflow_started' => '1', + 'first_event' => '1.735275676123E12', + }, + { + 'login_uuid' => non_agency_user_uuid, + 'workflow_started' => '1', + 'first_event' => '1.735275676456E12', + }, + { + 'login_uuid' => agency_user_login_uuid, + 'workflow_started' => '1', + 'first_event' => '1.735275676789E12', + }, + ] + end + + let(:expect_csv_result) do + [ + ['Date Range', '2024-12-01 - 2024-12-07'], + [ + 'UUID', + 'Workflow Started', + 'Documnet Capture Started', + 'Document Captured', + 'Selfie Captured', + 'Document Authentication Passed', + 'SSN Submitted', + 'Personal Information Submitted', + 'Personal Information Verified', + 'Phone Submitted', + 'Phone Verified', + 'Verification Workflow Complete', + 'Identity Verified for In-Band Users', + 'Identity Verified for Verify-By-Mail Users', + 'Identity Verified for Fraud Review Users', + 'Out-of-Band Verification Pending Seconds', + 'Agency Handoff Visited', + 'Agency Handoff Submitted', + ], + [ + agency_user_agency_uuid, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + 0, + false, + false, + ], + ] + end + + before do + create(:user, uuid: non_agency_user_uuid) + agency_user = create(:user, uuid: agency_user_login_uuid) + create(:agency_identity, user: agency_user, agency:, uuid: agency_user_agency_uuid) + + stub_cloudwatch_logs(cloudwatch_logs) + end + + subject(:report) do + Reporting::SpProofingEventsByUuid.new( + issuers: Array(issuer), agency_abbreviation:, time_range:, + ) + end + + describe '#as_csv' do + it 'renders a CSV report with converted UUIDs' do + aggregate_failures do + expect_csv_result.zip(report.as_csv).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe '#to_csv' do + it 'generates a csv' do + csv = CSV.parse(report.to_csv, headers: false) + + stringified_csv = expect_csv_result.map { |row| row.map(&:to_s) } + + aggregate_failures do + csv.map(&:to_a).zip(stringified_csv).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe '#as_emailable_reports' do + it 'returns an array with an emailable report' do + expect(report.as_emailable_reports).to eq( + [ + Reporting::EmailableReport.new( + title: 'DOL Proofing Events By UUID', + table: expect_csv_result, + filename: 'dol_proofing_events_by_uuid', + ), + ], + ) + end + end + + describe '#data' do + it 'fetches additional results if 10k results are returned' do + cloudwatch_client = double(Reporting::CloudwatchClient) + expect(cloudwatch_client).to receive(:fetch).ordered do |args| + expect(args[:query]).to_not include('| filter first_event') + [ + { + 'login_uuid' => agency_user_login_uuid, + 'workflow_started' => '1', + 'first_event' => '1.123456E12', + }, + ] * 10000 + end + expect(cloudwatch_client).to receive(:fetch).ordered do |args| + expect(args[:query]).to include('| filter first_event > 1.123456E12') + [ + { + 'login_uuid' => agency_user_login_uuid, + 'workflow_started' => '1', + 'first_event' => '1.123456E12', + }, + ] + end + allow(report).to receive(:cloudwatch_client).and_return(cloudwatch_client) + + expect(report.data.count).to eq(10_001) + end + end +end From 59bb94131f52f96bd1de1ecf86a481a2ed68d909 Mon Sep 17 00:00:00 2001 From: Jessica Dembe Date: Tue, 28 Jan 2025 15:56:06 -0500 Subject: [PATCH 08/16] LG-15248: Account Management no change available (#11701) * add requested attribute to accounts_show_presenter * add logic for decorated_sp_session * check for requested_attributes and ial2_requested * add tests for requested attribes and ial2 requested * changelog: User-Facing Improvements, account management, no change available if partner shares all emails * add logic to show/hide change button * clean up controller and presenter * clean up requested_attributes in the rest of controllers and specs * change logic for all emails requested as well as `nil` to `false` in specs * remove `ial2_requested` * lintfix * fix logic to show desired behavior when is false * remove debugger * fix logic * add supporting test for view * match `all_emails_requested` to be a boolean value * add logic for if the partner does not have `email` as a consented attribute * logic change to `all_emails_requested?` * refine check for no email (WIP), refine method name * fix all broken tests * change service provider -> partner * more logic correction * rename and fix logic * Update app/controllers/accounts/connected_accounts_controller.rb Co-authored-by: Andrew Duthie <1779930+aduth@users.noreply.github.com> * change `all_emails requested` -> `change_email_available?` * remove `change_email_available` * move logic around * logic in presenter and view * change to `change_option_available`, fix test * edit test block * change name to be more intuitive * add tests to view * fix account show presenter tests * method name cleanup * more test fixes * more changes * fix eager loading problem Co-authored-by: Andrew Duthie * lintfix * add test * test 1 for connected apps * check against `identities` and pass value * place logic in model, remove logic from presenter and controller, add check to presenter * no longer doing logic in controller, so return connected apps to previous state * remove `requested_attributes` * fix logic in model and view * method name change * remove unneeded keyword * fix tests * Repurpose ServiceProviderIdentity#hide_change_email? as verified_single_email_attribute? * Revert changes to ApplicationController, AccountShowPresenter spec * Handle nil verified_attributes * Update tests for connected accounts view * Exempt eager loading error validation for connected accounts * Update stubbed identity in connected accounts feature spec to include email --------- Co-authored-by: Andrew Duthie <1779930+aduth@users.noreply.github.com> Co-authored-by: Andrew Duthie --- app/models/service_provider_identity.rb | 6 +++ app/views/accounts/_connected_app.html.erb | 30 +++++++------ config/environments/test.rb | 10 +++++ spec/features/account_connected_apps_spec.rb | 2 + spec/models/service_provider_identity_spec.rb | 42 +++++++++++++++++++ .../connected_accounts/show.html.erb_spec.rb | 37 +++++++++++++++- 6 files changed, 113 insertions(+), 14 deletions(-) diff --git a/app/models/service_provider_identity.rb b/app/models/service_provider_identity.rb index 5ad3eaf6ede..3da98d4eb9f 100644 --- a/app/models/service_provider_identity.rb +++ b/app/models/service_provider_identity.rb @@ -65,6 +65,12 @@ def happened_at last_authenticated_at.in_time_zone('UTC') end + def verified_single_email_attribute? + verified_attributes.present? && + verified_attributes.include?('email') && + !verified_attributes.include?('all_emails') + end + def email_address_for_sharing if IdentityConfig.store.feature_select_email_to_share_enabled && email_address return email_address diff --git a/app/views/accounts/_connected_app.html.erb b/app/views/accounts/_connected_app.html.erb index 63b229fea39..615fbae0409 100644 --- a/app/views/accounts/_connected_app.html.erb +++ b/app/views/accounts/_connected_app.html.erb @@ -12,18 +12,24 @@ <% if IdentityConfig.store.feature_select_email_to_share_enabled %> - <%= t( - 'account.connected_apps.associated_attributes_html', - timestamp_html: render(TimeComponent.new(time: identity.created_at)), - ) %> -
- - <%= identity.email_address&.email || t('account.connected_apps.email_not_selected') %> - - <%= link_to( - t('help_text.requested_attributes.change_email_link'), - edit_connected_account_selected_email_path(identity_id: identity.id), - ) %> + <% if identity.verified_single_email_attribute? %> + <%= t( + 'account.connected_apps.associated_attributes_html', + timestamp_html: render(TimeComponent.new(time: identity.created_at)), + ) %> + + <%= identity.email_address&.email || t('account.connected_apps.email_not_selected') %> + + <%= link_to( + t('help_text.requested_attributes.change_email_link'), + edit_connected_account_selected_email_path(identity_id: identity.id), + ) %> + <% else %> + <%= t( + 'account.connected_apps.associated_html', + timestamp_html: render(TimeComponent.new(time: identity.created_at)), + ) %> + <% end %> <% else %> <%= t( 'account.connected_apps.associated_html', diff --git a/config/environments/test.rb b/config/environments/test.rb index 2abaa4644e9..b85d3bc7c1d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -52,6 +52,16 @@ ].each do |association| Bullet.add_safelist(type: :n_plus_one_query, class_name: 'User', association: association) end + + # Eager loading of email addresses is used on the Connected Accounts page, since most accounts + # will share an email address that can be changed by the user. An unoptimized query error is + # raised by bullet if the email address is not used, but it can't be known at the time of the + # query whether the email addresses will be used for all connected accounts. + Bullet.add_safelist( + type: :unused_eager_loading, + class_name: 'ServiceProviderIdentity', + association: :email_address, + ) end config.active_support.test_order = :random diff --git a/spec/features/account_connected_apps_spec.rb b/spec/features/account_connected_apps_spec.rb index 9ed731123a6..c37ad9cd25f 100644 --- a/spec/features/account_connected_apps_spec.rb +++ b/spec/features/account_connected_apps_spec.rb @@ -18,6 +18,7 @@ user: user, created_at: Time.zone.now - 80.days, service_provider: 'http://localhost:3000', + verified_attributes: ['email'], ) end let(:identity_without_link) do @@ -27,6 +28,7 @@ user: user, created_at: Time.zone.now - 50.days, service_provider: 'https://rp2.serviceprovider.com/auth/saml/metadata', + verified_attributes: ['email'], ) end let(:identity_timestamp) do diff --git a/spec/models/service_provider_identity_spec.rb b/spec/models/service_provider_identity_spec.rb index 54015505d9d..f188c84368f 100644 --- a/spec/models/service_provider_identity_spec.rb +++ b/spec/models/service_provider_identity_spec.rb @@ -2,10 +2,12 @@ RSpec.describe ServiceProviderIdentity do let(:user) { create(:user, :fully_registered) } + let(:verified_attributes) { [] } let(:identity) do ServiceProviderIdentity.create( user_id: user.id, service_provider: 'externalapp', + verified_attributes:, ) end subject { identity } @@ -182,6 +184,46 @@ end end + describe '#verified_single_email_attribute?' do + subject(:verified_single_email_attribute?) { identity.verified_single_email_attribute? } + + context 'with attributes nil' do + let(:verified_attributes) { nil } + + it { is_expected.to be false } + end + + context 'with no attributes verified' do + let(:verified_attributes) { [] } + + it { is_expected.to be false } + end + + context 'with a non-email attribute verified' do + let(:verified_attributes) { ['openid'] } + + it { is_expected.to be false } + end + + context 'with all_emails attribute verified' do + let(:verified_attributes) { ['all_emails'] } + + it { is_expected.to be false } + end + + context 'with email attribute verified' do + let(:verified_attributes) { ['email'] } + + it { is_expected.to be true } + + context 'with all_emails attribute verified' do + let(:verified_attributes) { ['email', 'all_emails'] } + + it { is_expected.to be false } + end + end + end + describe '#email_address_for_sharing' do let!(:last_login_email_address) do create( diff --git a/spec/views/accounts/connected_accounts/show.html.erb_spec.rb b/spec/views/accounts/connected_accounts/show.html.erb_spec.rb index 9a18c8cbb00..eed3b16bb81 100644 --- a/spec/views/accounts/connected_accounts/show.html.erb_spec.rb +++ b/spec/views/accounts/connected_accounts/show.html.erb_spec.rb @@ -31,7 +31,7 @@ end context 'with a connected app' do - let!(:identity) { create(:service_provider_identity, user:) } + let!(:identity) { create(:service_provider_identity, user:, verified_attributes: ['email']) } it 'lists applications with link to revoke' do render @@ -68,10 +68,43 @@ end end + context 'when the partner requests all_emails' do + before { identity.update(verified_attributes: ['all_emails']) } + + it 'does not show the change link' do + render + + expect(rendered).not_to have_content(t('account.connected_apps.email_not_selected')) + expect(rendered).not_to have_link( + t('help_text.requested_attributes.change_email_link'), + href: edit_connected_account_selected_email_path(identity_id: identity.id), + ) + end + end + + context 'when the partner does not request email' do + before { identity.update(verified_attributes: ['ssn']) } + + it 'hides the change link' do + render + + expect(rendered).not_to have_content(t('account.connected_apps.email_not_selected')) + expect(rendered).to_not have_link( + t('help_text.requested_attributes.change_email_link'), + href: edit_connected_account_selected_email_path(identity_id: identity.id), + ) + end + end + context 'with connected app having linked email' do let(:email_address) { user.confirmed_email_addresses.take } let!(:identity) do - create(:service_provider_identity, user:, email_address_id: email_address.id) + create( + :service_provider_identity, + user:, + email_address_id: email_address.id, + verified_attributes: ['email'], + ) end it 'renders associated email with option to change' do From d4dd239811e24cd5bcff9564ffe124d139d9b10c Mon Sep 17 00:00:00 2001 From: Andrew Duthie <1779930+aduth@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:58:26 -0500 Subject: [PATCH 09/16] Extract mixin for common MFA deletion behaviors (#11796) changelog: Internal, Code Quality, Extract mixin for common MFA deletion behaviors --- .../two_factor_authentication/auth_app_controller.rb | 6 ++---- .../two_factor_authentication/piv_cac_controller.rb | 10 ++-------- .../two_factor_authentication/webauthn_controller.rb | 6 ++---- app/controllers/concerns/mfa_deletion_concern.rb | 2 +- app/controllers/users/auth_app_controller.rb | 6 ++---- app/controllers/users/backup_code_setup_controller.rb | 5 ++--- app/controllers/users/edit_phone_controller.rb | 6 ++---- app/controllers/users/piv_cac_controller.rb | 10 ++-------- app/controllers/users/webauthn_controller.rb | 6 ++---- spec/controllers/concerns/mfa_deletion_concern_spec.rb | 10 ++++++++++ 10 files changed, 27 insertions(+), 40 deletions(-) diff --git a/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb index 4fb8f491c94..0d71428bc8e 100644 --- a/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb +++ b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb @@ -6,6 +6,7 @@ module TwoFactorAuthentication class AuthAppController < ApplicationController include CsrfTokenConcern include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :render_unauthorized, unless: :recently_authenticated_2fa? @@ -37,10 +38,7 @@ def destroy analytics.auth_app_delete_submitted(**result) if result.success? - create_user_event(:authenticator_disabled) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :authenticator_disabled) render json: { success: true } else render json: { success: false, error: result.first_error_message }, status: :bad_request diff --git a/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb b/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb index ca0425b1cf7..1079576606a 100644 --- a/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb +++ b/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb @@ -7,6 +7,7 @@ class PivCacController < ApplicationController include CsrfTokenConcern include ReauthenticationRequiredConcern include PivCacConcern + include MfaDeletionConcern before_action :render_unauthorized, unless: :recently_authenticated_2fa? @@ -38,9 +39,7 @@ def destroy analytics.piv_cac_delete_submitted(**result) if result.success? - create_user_event(:piv_cac_disabled) - revoke_remember_device(current_user) - deliver_push_notification + handle_successful_mfa_deletion(event_type: :piv_cac_disabled) clear_piv_cac_information render json: { success: true } else @@ -50,11 +49,6 @@ def destroy private - def deliver_push_notification - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - end - def render_unauthorized render json: { error: 'Unauthorized' }, status: :unauthorized end diff --git a/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb b/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb index d34aef733e6..b5a291d949a 100644 --- a/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb +++ b/app/controllers/api/internal/two_factor_authentication/webauthn_controller.rb @@ -6,6 +6,7 @@ module TwoFactorAuthentication class WebauthnController < ApplicationController include CsrfTokenConcern include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :render_unauthorized, unless: :recently_authenticated_2fa? @@ -37,10 +38,7 @@ def destroy analytics.webauthn_delete_submitted(**result) if result.success? - create_user_event(:webauthn_key_removed) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :webauthn_key_removed) render json: { success: true } else render json: { success: false, error: result.first_error_message }, status: :bad_request diff --git a/app/controllers/concerns/mfa_deletion_concern.rb b/app/controllers/concerns/mfa_deletion_concern.rb index 0f4c647aa2e..0b533882da1 100644 --- a/app/controllers/concerns/mfa_deletion_concern.rb +++ b/app/controllers/concerns/mfa_deletion_concern.rb @@ -4,7 +4,7 @@ module MfaDeletionConcern include RememberDeviceConcern def handle_successful_mfa_deletion(event_type:) - create_user_event(event_type) + create_user_event(event_type) if event_type revoke_remember_device(current_user) event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) PushNotification::HttpPush.deliver(event) diff --git a/app/controllers/users/auth_app_controller.rb b/app/controllers/users/auth_app_controller.rb index 34b07987fe5..32a6058dfd3 100644 --- a/app/controllers/users/auth_app_controller.rb +++ b/app/controllers/users/auth_app_controller.rb @@ -3,6 +3,7 @@ module Users class AuthAppController < ApplicationController include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -32,10 +33,7 @@ def destroy if result.success? flash[:success] = t('two_factor_authentication.auth_app.deleted') - create_user_event(:authenticator_disabled) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :authenticator_disabled) redirect_to account_path else flash[:error] = result.first_error_message diff --git a/app/controllers/users/backup_code_setup_controller.rb b/app/controllers/users/backup_code_setup_controller.rb index dfc9619b8de..4d9ece3644a 100644 --- a/app/controllers/users/backup_code_setup_controller.rb +++ b/app/controllers/users/backup_code_setup_controller.rb @@ -4,6 +4,7 @@ module Users class BackupCodeSetupController < ApplicationController include TwoFactorAuthenticatableMethods include MfaSetupConcern + include MfaDeletionConcern include SecureHeadersConcern include ReauthenticationRequiredConcern @@ -58,10 +59,8 @@ def refreshed def delete current_user.backup_code_configurations.destroy_all - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: nil) flash[:success] = t('notices.backup_codes_deleted') - revoke_remember_device(current_user) if in_multi_mfa_selection_flow? redirect_to authentication_methods_setup_path else diff --git a/app/controllers/users/edit_phone_controller.rb b/app/controllers/users/edit_phone_controller.rb index 9642cece9e4..9f829207829 100644 --- a/app/controllers/users/edit_phone_controller.rb +++ b/app/controllers/users/edit_phone_controller.rb @@ -4,6 +4,7 @@ module Users class EditPhoneController < ApplicationController include RememberDeviceConcern include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_user_can_edit_phone @@ -29,9 +30,7 @@ def update def destroy track_deletion_analytics_event phone_configuration.destroy! - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - revoke_remember_device(current_user) + handle_successful_mfa_deletion(event_type: :phone_removed) flash[:success] = t('two_factor_authentication.phone.delete.success') redirect_to account_url end @@ -55,7 +54,6 @@ def track_deletion_analytics_event success: true, phone_configuration_id: phone_configuration.id, ) - create_user_event(:phone_removed) end def phone_configuration diff --git a/app/controllers/users/piv_cac_controller.rb b/app/controllers/users/piv_cac_controller.rb index ec87ff792ad..ff157c17e7c 100644 --- a/app/controllers/users/piv_cac_controller.rb +++ b/app/controllers/users/piv_cac_controller.rb @@ -4,6 +4,7 @@ module Users class PivCacController < ApplicationController include ReauthenticationRequiredConcern include PivCacConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -33,9 +34,7 @@ def destroy analytics.piv_cac_delete_submitted(**result) if result.success? - create_user_event(:piv_cac_disabled) - revoke_remember_device(current_user) - deliver_push_notification + handle_successful_mfa_deletion(event_type: :piv_cac_disabled) clear_piv_cac_information flash[:success] = presenter.delete_success_alert_text @@ -48,11 +47,6 @@ def destroy private - def deliver_push_notification - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) - end - def form @form ||= form_class.new(user: current_user, configuration_id: params[:id]) end diff --git a/app/controllers/users/webauthn_controller.rb b/app/controllers/users/webauthn_controller.rb index c7dee0cf2a8..60cb69f8e47 100644 --- a/app/controllers/users/webauthn_controller.rb +++ b/app/controllers/users/webauthn_controller.rb @@ -3,6 +3,7 @@ module Users class WebauthnController < ApplicationController include ReauthenticationRequiredConcern + include MfaDeletionConcern before_action :confirm_two_factor_authenticated before_action :confirm_recently_authenticated_2fa @@ -33,10 +34,7 @@ def destroy if result.success? flash[:success] = presenter.delete_success_alert_text - create_user_event(:webauthn_key_removed) - revoke_remember_device(current_user) - event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) - PushNotification::HttpPush.deliver(event) + handle_successful_mfa_deletion(event_type: :webauthn_key_removed) redirect_to account_path else flash[:error] = result.first_error_message diff --git a/spec/controllers/concerns/mfa_deletion_concern_spec.rb b/spec/controllers/concerns/mfa_deletion_concern_spec.rb index 0193cbffaae..45bd0ccf3f5 100644 --- a/spec/controllers/concerns/mfa_deletion_concern_spec.rb +++ b/spec/controllers/concerns/mfa_deletion_concern_spec.rb @@ -38,5 +38,15 @@ result end + + context 'with nil event_type argument' do + let(:event_type) { nil } + + it 'does not create user event' do + expect(controller).not_to receive(:create_user_event) + + result + end + end end end From 0fd5a07b5ec7c98b4dca38595efd57999184c5d4 Mon Sep 17 00:00:00 2001 From: John Maxwell Date: Tue, 28 Jan 2025 19:10:56 -0500 Subject: [PATCH 10/16] LG-15309 simple log pii validation to analytics (#11813) Log Pii validation event to analytics in the Socure flow changelog: Upcoming Features, Socure, Log Pii validation --------- Co-authored-by: Amir Reavis-Bey --- app/jobs/socure_docv_results_job.rb | 34 +++++++++++++++++-- app/models/document_capture_session.rb | 5 ++- .../socure/responses/docv_result_response.rb | 10 +----- .../doc_auth/socure_document_capture_spec.rb | 25 ++++++++++++++ .../hybrid_socure_mobile_spec.rb | 8 +++++ spec/jobs/socure_docv_results_job_spec.rb | 29 +++++++++++++++- .../responses/docv_result_response_spec.rb | 28 --------------- 7 files changed, 97 insertions(+), 42 deletions(-) delete mode 100644 spec/services/doc_auth/socure/responses/docv_result_response_spec.rb diff --git a/app/jobs/socure_docv_results_job.rb b/app/jobs/socure_docv_results_job.rb index 38c79c0ec05..21a46b73dfc 100644 --- a/app/jobs/socure_docv_results_job.rb +++ b/app/jobs/socure_docv_results_job.rb @@ -15,14 +15,31 @@ def perform(document_capture_session_uuid:, async: true, docv_transaction_token_ document_capture_session timer = JobHelpers::Timer.new - response = timer.time('vendor_request') do + docv_result_response = timer.time('vendor_request') do socure_document_verification_result end log_verification_request( - docv_result_response: response, + docv_result_response:, vendor_request_time_in_ms: timer.results['vendor_request'], ) - document_capture_session.store_result_from_response(response) + + if docv_result_response.success? + doc_pii_response = Idv::DocPiiForm.new(pii: docv_result_response.pii_from_doc.to_h).submit + log_pii_validation(doc_pii_response:) + + unless doc_pii_response&.success? + document_capture_session.store_failed_auth_data( + doc_auth_success: true, + selfie_status: docv_result_response.selfie_status, + errors: { pii_validation: 'failed' }, + front_image_fingerprint: nil, + back_image_fingerprint: nil, + selfie_image_fingerprint: nil, + ) + return + end + end + document_capture_session.store_result_from_response(docv_result_response) end private @@ -50,6 +67,17 @@ def log_verification_request(docv_result_response:, vendor_request_time_in_ms:) ) end + def log_pii_validation(doc_pii_response:) + analytics.idv_doc_auth_submitted_pii_validation( + **doc_pii_response.to_h.merge( + submit_attempts: rate_limiter&.attempts, + remaining_submit_attempts: rate_limiter&.remaining_count, + flow_path: nil, + liveness_checking_required: nil, + ), + ) + end + def socure_document_verification_result DocAuth::Socure::Requests::DocvResultRequest.new( document_capture_session_uuid:, diff --git a/app/models/document_capture_session.rb b/app/models/document_capture_session.rb index 47b618a6bb6..2ce93dea6ca 100644 --- a/app/models/document_capture_session.rb +++ b/app/models/document_capture_session.rb @@ -33,7 +33,8 @@ def store_result_from_response(doc_auth_response) end def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, - selfie_image_fingerprint:, doc_auth_success:, selfie_status:) + selfie_image_fingerprint:, doc_auth_success:, selfie_status:, + errors: nil) session_result = load_result || DocumentCaptureSessionResult.new( id: generate_result_id, ) @@ -46,6 +47,8 @@ def store_failed_auth_data(front_image_fingerprint:, back_image_fingerprint:, session_result.add_failed_back_image!(back_image_fingerprint) session_result.add_failed_selfie_image!(selfie_image_fingerprint) if selfie_status == :fail + session_result.errors = errors + EncryptedRedisStructStorage.store( session_result, expires_in: IdentityConfig.store.doc_capture_request_valid_for_minutes.minutes.in_seconds, diff --git a/app/services/doc_auth/socure/responses/docv_result_response.rb b/app/services/doc_auth/socure/responses/docv_result_response.rb index bc89bab0d22..017b65f0b89 100644 --- a/app/services/doc_auth/socure/responses/docv_result_response.rb +++ b/app/services/doc_auth/socure/responses/docv_result_response.rb @@ -42,7 +42,7 @@ def initialize(http_response:, @pii_from_doc = read_pii super( - success: successful_result? && pii_valid?, + success: successful_result?, errors: error_messages, pii_from_doc:, extra: extra_attributes, @@ -98,8 +98,6 @@ def successful_result? def error_messages if !successful_result? { socure: { reason_codes: get_data(DATA_PATHS[:reason_codes]) } } - elsif !pii_valid? - { pii_validation: 'failed' } else {} end @@ -178,12 +176,6 @@ def parse_date(date_string) Rails.logger.info(message) nil end - - def pii_valid? - return @pii_valid if !@pii_valid.nil? - - @pii_valid = Idv::DocPiiForm.new(pii: pii_from_doc.to_h).submit.success? - end end end end diff --git a/spec/features/idv/doc_auth/socure_document_capture_spec.rb b/spec/features/idv/doc_auth/socure_document_capture_spec.rb index 98e623fa94c..e591091fa0a 100644 --- a/spec/features/idv/doc_auth/socure_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/socure_document_capture_spec.rb @@ -29,6 +29,7 @@ socure_docv_webhook_repeat_endpoints.each { |endpoint| stub_request(:post, endpoint) } allow(IdentityConfig.store).to receive(:ruby_workers_idv_enabled).and_return(false) allow_any_instance_of(ApplicationController).to receive(:analytics).and_return(fake_analytics) + allow_any_instance_of(SocureDocvResultsJob).to receive(:analytics).and_return(fake_analytics) @docv_transaction_token = stub_docv_document_request allow(IdentityConfig.store).to receive(:socure_docv_verification_data_test_mode) .and_return(socure_docv_verification_data_test_mode) @@ -153,6 +154,9 @@ visit idv_socure_document_capture_path expect(page).to have_current_path(idv_session_errors_rate_limited_path) + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) end end end @@ -263,6 +267,9 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) end end @@ -285,6 +292,9 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) end end end @@ -333,6 +343,9 @@ expect(page).to have_current_path(idv_ssn_url) expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) fill_out_ssn_form_ok click_idv_continue @@ -357,6 +370,9 @@ expect(page).to have_current_path(idv_ssn_url) expect(DocAuthLog.find_by(user_id: @user.id).state).to eq('NY') + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) fill_out_ssn_form_ok click_idv_continue @@ -394,6 +410,12 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).to have_logged_event( + :idv_socure_verification_data_requested, + ) + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) fill_out_ssn_form_ok click_idv_continue @@ -427,6 +449,9 @@ expect(fake_analytics).to have_logged_event( :idv_socure_document_request_submitted, ) + expect(fake_analytics).not_to have_logged_event( + :idv_doc_auth_submitted_pii_validation, + ) end end diff --git a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb index f5729f69374..866794851cb 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb @@ -36,6 +36,7 @@ .and_return(socure_docv_verification_data_test_mode) @docv_transaction_token = stub_docv_document_request stub_analytics + allow_any_instance_of(SocureDocvResultsJob).to receive(:analytics).and_return(@analytics) end context 'happy path', allow_browser_log: true do @@ -112,6 +113,10 @@ expect(page).to_not have_content(t('doc_auth.headings.text_message'), wait: 10) expect(page).to have_current_path(idv_ssn_path) expect(@analytics).to have_logged_event(:idv_socure_document_request_submitted) + expect(@analytics).to have_logged_event(:idv_socure_verification_data_requested) + expect(@analytics).to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) fill_out_ssn_form_ok click_idv_continue @@ -549,6 +554,9 @@ expect(page).to have_text(t('doc_auth.headers.general.network_error')) expect(page).to have_text(t('doc_auth.errors.general.new_network_error')) expect(@analytics).to have_logged_event(:idv_socure_document_request_submitted) + expect(@analytics).not_to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + ) end perform_in_browser(:desktop) do diff --git a/spec/jobs/socure_docv_results_job_spec.rb b/spec/jobs/socure_docv_results_job_spec.rb index 99aadd2ffdc..a50cf8421b8 100644 --- a/spec/jobs/socure_docv_results_job_spec.rb +++ b/spec/jobs/socure_docv_results_job_spec.rb @@ -112,7 +112,34 @@ expect(document_capture_session_result.selfie_status).to eq(:not_processed) end - it 'expect fake analytics to have logged idv_socure_verification_data_requested' do + context 'Pii validation fails' do + before do + allow_any_instance_of(Idv::DocPiiForm).to receive(:zipcode).and_return(:invalid_junk) + end + + it 'stores a failed result' do + perform + + document_capture_session.reload + document_capture_session_result = document_capture_session.load_result + expect(document_capture_session_result.success).to eq(false) + expect(document_capture_session_result.doc_auth_success).to eq(true) + expect(document_capture_session_result.errors).to eq({ pii_validation: 'failed' }) + end + end + + it 'logs an idv_doc_auth_submitted_pii_validation event' do + perform + expect(fake_analytics).to have_logged_event( + 'IdV: doc auth image upload vendor pii validation', + hash_including( + :submit_attempts, + :remaining_submit_attempts, + ), + ) + end + + it 'logs an idv_socure_verification_data_requested event' do perform expect(fake_analytics).to have_logged_event( :idv_socure_verification_data_requested, diff --git a/spec/services/doc_auth/socure/responses/docv_result_response_spec.rb b/spec/services/doc_auth/socure/responses/docv_result_response_spec.rb deleted file mode 100644 index dc8525994d4..00000000000 --- a/spec/services/doc_auth/socure/responses/docv_result_response_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'rails_helper' - -RSpec.describe DocAuth::Socure::Responses::DocvResultResponse do - subject(:docv_response) do - http_response = Struct.new(:body).new(SocureDocvFixtures.pass_json) - described_class.new(http_response:) - end - - context 'Socure says OK and the PII is valid' do - it 'succeeds' do - expect(docv_response.success?).to be(true) - end - end - - context 'Socure says OK but the PII is invalid' do - before do - allow_any_instance_of(Idv::DocPiiForm).to receive(:zipcode).and_return(:invalid_junk) - end - - it 'fails' do - expect(docv_response.success?).to be(false) - end - - it 'with a pii failure error' do - expect(docv_response.errors).to eq({ pii_validation: 'failed' }) - end - end -end From f9c2f0a7162b9939bf46ff6311fc27e7ce1d9f5a Mon Sep 17 00:00:00 2001 From: Mitchell Henke Date: Wed, 29 Jan 2025 07:59:34 -0600 Subject: [PATCH 11/16] Create MFA Report script (#11740) changelog: Internal, Reporting, Create MFA Report script --- lib/data_pull.rb | 41 +++++++++++++++++++ .../create_mfa_configurations_report.rb | 1 + spec/lib/data_pull_spec.rb | 27 ++++++++++++ .../create_mfa_configurations_report_spec.rb | 3 ++ 4 files changed, 72 insertions(+) diff --git a/lib/data_pull.rb b/lib/data_pull.rb index 1a2749dfda6..17da72ee2cc 100644 --- a/lib/data_pull.rb +++ b/lib/data_pull.rb @@ -39,6 +39,8 @@ def banner * #{basename} ig-request uuid1 uuid2 --requesting-issuer=ABC:DEF:GHI + * #{basename} mfa-report uuid1 uuid2 + * #{basename} profile-summary uuid1 uuid2 * #{basename} uuid-convert partner-uuid1 partner-uuid2 @@ -59,6 +61,7 @@ def subtask(name) 'email-lookup' => EmailLookup, 'events-summary' => EventsSummary, 'ig-request' => InspectorGeneralRequest, + 'mfa-report' => MfaReport, 'profile-summary' => ProfileSummary, 'uuid-convert' => UuidConvert, 'uuid-export' => UuidExport, @@ -156,6 +159,44 @@ def run(args:, config:) end end + class MfaReport + def run(args:, config:) + require 'data_requests/deployed' + uuids = args + + users, missing_uuids = uuids.map do |uuid| + DataRequests::Deployed::LookupUserByUuid.new(uuid).call || uuid + end.partition { |u| u.is_a?(User) } + + output = users.map do |user| + output = DataRequests::Deployed::CreateMfaConfigurationsReport.new(user).call + output[:uuid] = user.uuid + + output + end + + if config.include_missing? + output += missing_uuids.map do |uuid| + { + uuid: uuid, + phone_configurations: [], + auth_app_configurations: [], + webauthn_configurations: [], + piv_cac_configurations: [], + backup_code_configurations: [], + not_found: true, + } + end + end + + ScriptBase::Result.new( + subtask: 'mfa-report', + uuids: uuids, + json: output, + ) + end + end + class InspectorGeneralRequest def run(args:, config:) require 'data_requests/deployed' diff --git a/lib/data_requests/deployed/create_mfa_configurations_report.rb b/lib/data_requests/deployed/create_mfa_configurations_report.rb index 3a52f2e743c..d5c827d40ca 100644 --- a/lib/data_requests/deployed/create_mfa_configurations_report.rb +++ b/lib/data_requests/deployed/create_mfa_configurations_report.rb @@ -63,6 +63,7 @@ def webauthn_configurations_report user.webauthn_configurations.map do |webauthn_configuration| { name: webauthn_configuration.name, + platform_authenticator: webauthn_configuration.platform_authenticator, created_at: webauthn_configuration.created_at, } end diff --git a/spec/lib/data_pull_spec.rb b/spec/lib/data_pull_spec.rb index d772f9abfaf..fcbea784da4 100644 --- a/spec/lib/data_pull_spec.rb +++ b/spec/lib/data_pull_spec.rb @@ -293,6 +293,33 @@ end end + describe DataPull::MfaReport do + subject(:subtask) { DataPull::MfaReport.new } + + describe '#run' do + let(:user) { create(:user) } + let(:args) { [user.uuid] } + let(:config) { ScriptBase::Config.new } + + subject(:result) { subtask.run(args:, config:) } + + it 'runs the MFA report, has a JSON-only response', aggregate_failures: true do + expect(result.table).to be_nil + expect(result.json.first.keys).to contain_exactly( + :uuid, + :phone_configurations, + :auth_app_configurations, + :webauthn_configurations, + :piv_cac_configurations, + :backup_code_configurations, + ) + + expect(result.subtask).to eq('mfa-report') + expect(result.uuids).to eq([user.uuid]) + end + end + end + describe DataPull::InspectorGeneralRequest do subject(:subtask) { DataPull::InspectorGeneralRequest.new } diff --git a/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb b/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb index 6477d692196..77b69927273 100644 --- a/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb +++ b/spec/lib/data_requests/deployed/create_mfa_configurations_report_spec.rb @@ -41,6 +41,9 @@ webauthn_data = result[:webauthn_configurations] expect(webauthn_data.first[:name]).to eq(webauthn_configuration.name) + expect(webauthn_data.first[:platform_authenticator]).to eq( + webauthn_configuration.platform_authenticator, + ) expect(webauthn_data.first[:created_at]).to be_within(1.second).of( webauthn_configuration.created_at, ) From 9c9816ef5855333ec09e65ca987d097c738866cc Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 29 Jan 2025 09:15:39 -0500 Subject: [PATCH 12/16] LG-15395 Add specialized SP IdV Dropoff Report (#11803) We have a partner who requested a specialized IdV drop-off report for their service providers. This report needs to run every Monday and collect weekly data from a specified date up through the previous week. We have been running this report manually for a few weeks now. This commit moves the code the generates this report into the IdP so it can be run automatically. [skip changelog] --- .../reports/sp_idv_weekly_dropoff_report.rb | 61 +++ config/application.yml.default | 1 + config/initializers/job_configurations.rb | 7 +- lib/identity_config.rb | 1 + lib/reporting/sp_idv_weekly_dropoff_report.rb | 478 ++++++++++++++++++ .../sp_idv_weekly_dropoff_report_spec.rb | 96 ++++ .../sp_idv_weekly_dropoff_report_spec.rb | 218 ++++++++ 7 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 app/jobs/reports/sp_idv_weekly_dropoff_report.rb create mode 100644 lib/reporting/sp_idv_weekly_dropoff_report.rb create mode 100644 spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb create mode 100644 spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb diff --git a/app/jobs/reports/sp_idv_weekly_dropoff_report.rb b/app/jobs/reports/sp_idv_weekly_dropoff_report.rb new file mode 100644 index 00000000000..018883799f2 --- /dev/null +++ b/app/jobs/reports/sp_idv_weekly_dropoff_report.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'reporting/sp_idv_weekly_dropoff_report' + +module Reports + class SpIdvWeeklyDropoffReport < BaseReport + attr_accessor :report_date + + def perform(report_date) + return unless IdentityConfig.store.s3_reports_enabled + + self.report_date = report_date + + IdentityConfig.store.sp_idv_weekly_dropoff_report_configs.each do |report_config| + send_report(report_config) + end + end + + def send_report(report_config) + report_start_date = Date.parse(report_config['report_start_date']) + report_end_date = report_date.end_of_week(:sunday).weeks_ago(1).to_date + issuers = report_config['issuers'] + agency_abbreviation = report_config['agency_abbreviation'] + emails = report_config['emails'] + + agency_report_name = "#{agency_abbreviation.downcase}_idv_dropoff_report" + agency_report_title = "#{agency_abbreviation} IdV Dropoff Report" + + report_maker = build_report_maker( + issuers:, + agency_abbreviation:, + time_range: report_start_date..report_end_date, + ) + + save_report(agency_report_name, report_maker.to_csv, extension: 'csv') + + if emails.blank? + Rails.logger.warn "No email addresses received - #{agency_report_title} NOT SENT" + return false + end + + message = <<~HTML.html_safe # rubocop:disable Rails/OutputSafety, +

#{agency_report_title}

+ HTML + + emails.each do |email| + ReportMailer.tables_report( + email: email, + subject: "#{agency_report_title} - #{report_date.to_date}", + reports: report_maker.as_emailable_reports, + message: message, + attachment_format: :csv, + ).deliver_now + end + end + + def build_report_maker(issuers:, agency_abbreviation:, time_range:) + Reporting::SpProofingEventsByUuid.new(issuers:, agency_abbreviation:, time_range:) + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index fc77926b7ba..23a5a22d3bc 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -402,6 +402,7 @@ socure_reason_code_api_key: '' socure_reason_code_base_url: '' socure_reason_code_timeout_in_seconds: 5 sp_handoff_bounce_max_seconds: 2 +sp_idv_weekly_dropoff_report_configs: '[]' sp_issuer_user_counts_report_configs: '[]' sp_proofing_events_by_uuid_report_configs: '[]' state_tracking_enabled: true diff --git a/config/initializers/job_configurations.rb b/config/initializers/job_configurations.rb index d8f87610a8b..0f3bb7adf49 100644 --- a/config/initializers/job_configurations.rb +++ b/config/initializers/job_configurations.rb @@ -120,7 +120,12 @@ args: -> { [Time.zone.yesterday] }, }, # Send the SP IdV Weekly Dropoff Report - sp_idv_weekly_dropoff: { + sp_idv_weekly_dropoff_report: { + class: 'Reports::SpIdvWeeklyDropoffReport', + cron: cron_every_monday_2am, + args: -> { [Time.zone.today] }, + }, + sp_proofing_events_by_uuid_report: { class: 'Reports::SpProofingEventsByUuid', cron: cron_every_monday_2am, args: -> { [Time.zone.today] }, diff --git a/lib/identity_config.rb b/lib/identity_config.rb index b09a7ff326a..bd7f8b65868 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -438,6 +438,7 @@ def self.store config.add(:socure_reason_code_base_url, type: :string) config.add(:socure_reason_code_timeout_in_seconds, type: :integer) config.add(:sp_handoff_bounce_max_seconds, type: :integer) + config.add(:sp_idv_weekly_dropoff_report_configs, type: :json) config.add(:sp_issuer_user_counts_report_configs, type: :json) config.add(:sp_proofing_events_by_uuid_report_configs, type: :json) config.add(:state_tracking_enabled, type: :boolean) diff --git a/lib/reporting/sp_idv_weekly_dropoff_report.rb b/lib/reporting/sp_idv_weekly_dropoff_report.rb new file mode 100644 index 00000000000..cc7dcadb4bf --- /dev/null +++ b/lib/reporting/sp_idv_weekly_dropoff_report.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +require 'csv' +begin + require 'reporting/cloudwatch_client' + require 'reporting/cloudwatch_query_quoting' + require 'reporting/command_line_options' +rescue LoadError => e + warn 'could not load paths, try running with "bundle exec rails runner"' + raise e +end + +module Reporting + class SpIdvWeeklyDropoffReport + attr_reader :issuers, :agency_abbreviation, :time_range + + WeeklyDropoffValues = Struct.new( + :start_date, + :end_date, + :ial2_verified_user_count, + :non_ial2_verified_user_count, + :document_authentication_failure_pct, + :selfie_check_failure_pct, + :aamva_check_failure_pct, + :fraud_review_rejected_user_count, + :gpo_passed_count, + :fraud_review_passed_count, + :ipp_passed_count, + :ial2_getting_started_dropoff, + :non_ial2_getting_started_dropoff, + :ial2_document_capture_started_dropoff, + :non_ial2_document_capture_started_dropoff, + :ial2_document_captured_dropoff, + :non_ial2_document_captured_dropoff, + :ial2_selfie_captured_dropoff, + :non_ial2_selfie_captured_dropoff, + :ial2_document_authentication_passed_dropoff, + :non_ial2_document_authentication_passed_dropoff, + :ial2_ssn_dropoff, + :non_ial2_ssn_dropoff, + :ial2_verify_info_submitted_dropoff, + :non_ial2_verify_info_submitted_dropoff, + :ial2_verify_info_passed_dropoff, + :non_ial2_verify_info_passed_dropoff, + :ial2_phone_submitted_dropoff, + :non_ial2_phone_submitted_dropoff, + :ial2_phone_passed_dropoff, + :non_ial2_phone_passed_dropoff, + :ial2_enter_password_dropoff, + :non_ial2_enter_password_dropoff, + :ial2_inline_dropoff, + :non_ial2_inline_dropoff, + :ial2_verify_by_mail_dropoff, + :non_ial2_verify_by_mail_dropoff, + :ial2_fraud_review_dropoff, + :non_ial2_fraud_review_dropoff, + :ial2_personal_key_dropoff, + :non_ial2_personal_key_dropoff, + :ial2_agency_handoff_dropoff, + :non_ial2_agency_handoff_dropoff, + keyword_init: true, + ) do + def formatted_date_range + "#{start_date.to_date} - #{end_date.to_date}" + end + end + + def initialize( + issuers:, + agency_abbreviation:, + time_range:, + verbose: false, + progress: false, + cloudwatch_client: nil + ) + @issuers = issuers + @agency_abbreviation = agency_abbreviation + @time_range = time_range + @verbose = verbose + @progress = progress + @cloudwatch_client = cloudwatch_client + end + + def verbose? + @verbose + end + + def progress? + @progress + end + + # rubocop:disable Layout/LineLength + def as_csv + [ + ['', *data.map(&:formatted_date_range)], + ['Overview'], + ['# of verified users'], + [' - IAL2', *data.map(&:ial2_verified_user_count)], + [' - Non-IAL2', *data.map(&:non_ial2_verified_user_count)], + ['# of contact center cases'], + ['Fraud Checks'], + ['% of users that failed document authentication check', *data.map(&:document_authentication_failure_pct)], + ['% of users that failed facial match check (Only for IAL2)', *data.map(&:selfie_check_failure_pct)], + ['% of users that failed AAMVA attribute match check', *data.map(&:aamva_check_failure_pct)], + ['# of users that failed LG-99 fraud review', *data.map(&:fraud_review_rejected_user_count)], + ['User Experience'], + ['# of verified users via verify-by-mail process (Only for non-IAL2)', *data.map(&:gpo_passed_count)], + ['# of verified users via fraud redress process', *data.map(&:fraud_review_passed_count)], + ['# of verified users via in-person proofing (Not currently enabled)', *data.map(&:ipp_passed_count)], + ['Funnel Analysis'], + ['% drop-off at Workflow Started'], + [' - IAL2', *data.map(&:ial2_getting_started_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_getting_started_dropoff)], + ['% drop-off at Document Capture Started'], + [' - IAL2', *data.map(&:ial2_document_capture_started_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_document_capture_started_dropoff)], + ['% drop-off at Document Captured'], + [' - IAL2', *data.map(&:ial2_document_captured_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_document_captured_dropoff)], + ['% drop-off at Selfie Captured'], + [' - IAL2', *data.map(&:ial2_selfie_captured_dropoff)], + ['% drop-off at Document Authentication Passed'], + [' - IAL2 (with Facial Match)', *data.map(&:ial2_document_authentication_passed_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_document_authentication_passed_dropoff)], + ['% drop-off at SSN Submitted'], + [' - IAL2', *data.map(&:ial2_ssn_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_ssn_dropoff)], + ['% drop-off at Personal Information Submitted'], + [' - IAL2', *data.map(&:ial2_verify_info_submitted_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_verify_info_submitted_dropoff)], + ['% drop-off at Personal Information Verified'], + [' - IAL2', *data.map(&:ial2_verify_info_passed_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_verify_info_passed_dropoff)], + ['% drop-off at Phone Submitted'], + [' - IAL2', *data.map(&:ial2_phone_submitted_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_phone_submitted_dropoff)], + ['% drop-off at Phone Verified'], + [' - IAL2', *data.map(&:ial2_phone_passed_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_phone_passed_dropoff)], + ['% drop-off at Online Wofklow Completed'], + [' - IAL2', *data.map(&:ial2_enter_password_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_enter_password_dropoff)], + ['% drop-off at Verified for In-Band Users'], + [' - IAL2', *data.map(&:ial2_inline_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_inline_dropoff)], + ['% drop-off at Verified for Verify-by-mail Users'], + [' - Non-IAL2', *data.map(&:non_ial2_verify_by_mail_dropoff)], + ['% drop-off at Verified for Fraud Review Users'], + [' - IAL2', *data.map(&:ial2_fraud_review_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_fraud_review_dropoff)], + ['% drop-off at Personal Key Saved'], + [' - IAL2', *data.map(&:ial2_personal_key_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_personal_key_dropoff)], + ['% drop-off at Agency Handoff Submitted'], + [' - IAL2', *data.map(&:ial2_agency_handoff_dropoff)], + [' - Non-IAL2', *data.map(&:non_ial2_agency_handoff_dropoff)], + ] + end + # rubocop:enable Layout/LineLength + + def to_csv + CSV.generate do |csv| + as_csv.each do |row| + csv << row + end + end + end + + def as_emailable_reports + [ + EmailableReport.new( + title: "#{agency_abbreviation} IdV Dropoff Report", + table: as_csv, + filename: "#{agency_abbreviation.downcase}_idv_dropoff_report", + ), + ] + end + + def out_of_band_query(inline_event_end_date) + inline_event_end_date_ms = inline_event_end_date.to_i * 1000 + <<~QUERY + filter (name = "IdV: final resolution" and properties.service_provider in #{issuers.inspect}) or + name = "IdV: enter verify by mail code submitted" or + name = "GetUspsProofingResultsJob: Enrollment status updated" or + name = "Fraud: Profile review passed" + + | filter (name = "IdV: final resolution" and @timestamp < #{inline_event_end_date_ms} or name != "IdV: final resolution" + + | fields name = "IdV: final resolution" and ( + !properties.event_properties.gpo_verification_pending and + !properties.event_properties.in_person_verification_pending and + !ispresent(properties.event_properties.fraud_pending_reason) + ) + as @verified_inline + | fields name = "IdV: final resolution" and ( + properties.event_properties.gpo_verification_pending and + !properties.event_properties.in_person_verification_pending and + !ispresent(properties.event_properties.fraud_pending_reason) + ) + as @gpo_pending + | fields name = "IdV: final resolution" and ( + properties.event_properties.in_person_verification_pending and + !ispresent(properties.event_properties.fraud_pending_reason) + ) + as @ipp_pending + | fields name = "IdV: final resolution" and ( + ispresent(properties.event_properties.fraud_pending_reason) + ) + as @fraud_pending + + | fields coalesce(name = "IdV: final resolution" and properties.sp_request.facial_match, 0) as is_ial2 + + | stats sum(@verified_inline) > 0 as verified_inline, + sum(@gpo_pending) > 0 and !verified_inline as gpo_pending, + sum(@ipp_pending) > 0 and !gpo_pending and !verified_inline as ipp_pending, + sum(@fraud_pending) > 0 and !ipp_pending and !gpo_pending and !verified_inline as fraud_pending, + sum(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed) > 0 as gpo_passed, + sum(name = "GetUspsProofingResultsJob: Enrollment status updated" and properties.event_properties.passed and properties.event_properties.tmx_status not in ["threatmetrix_review", "threatmetrix_reject"]) > 0 as ipp_passed, + sum(name = "Fraud: Profile review passed") > 0 as fraud_review_passed, + max(is_ial2) + 1 as ial + by properties.user_id + + | filter verified_inline or gpo_pending or ipp_pending or fraud_pending + + | stats 1 - sum(gpo_passed and gpo_pending) / sum(gpo_pending) as verify_by_mail_dropoff, + 1 - sum(ipp_passed and ipp_pending) / sum(ipp_pending) as in_person_dropoff, + 1 - sum(fraud_review_passed and fraud_pending) / sum(fraud_pending) as fraud_review_dropoff + by ial + QUERY + end + + def sp_session_events_query + <<~QUERY + filter (name in [ + "IdV: doc auth welcome visited", + "IdV: doc auth welcome submitted", + "IdV: doc auth document_capture visited", + "Frontend: IdV: front image clicked", + "Frontend: IdV: back image clicked", + "Frontend: IdV: front image added", + "Frontend: IdV: back image added", + "idv_selfie_image_added", + "IdV: doc auth image upload vendor submitted", + "IdV: doc auth ssn visited", + "IdV: doc auth ssn submitted", + "IdV: doc auth verify visited", + "IdV: doc auth verify proofing results", + "IdV: phone of record visited", + "IdV: phone confirmation vendor", + "idv_enter_password_visited", + "IdV: personal key visited", + "IdV: personal key submitted", + "IdV: final resolution", + "User registration: agency handoff visited", + "User registration: complete", + "Fraud: Profile review passed", + "Fraud: Profile review rejected" + ] and properties.service_provider in #{issuers.inspect}) or + (name = "IdV: enter verify by mail code submitted" and properties.event_properties.initiating_service_provider in #{issuers.inspect}) or + (name = "GetUspsProofingResultsJob: Enrollment status updated" and properties.event_properties.issuer in #{issuers.inspect}) + + | fields properties.event_properties.selfie_check_required as selfie_check_required, + name in ["Frontend: IdV: front image clicked", "Frontend: IdV: back image clicked"] as @document_capture_clicked, + name in ["Frontend: IdV: front image added", "Frontend: IdV: back image added"] as @document_captured, + name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.success as @document_authentication_passed + + | fields coalesce(name = "IdV: doc auth welcome visited" and properties.sp_request.facial_match, 0) as is_ial2 + + | stats sum(name = "IdV: doc auth welcome visited") > 0 as getting_started_visited, + sum(name = "IdV: doc auth welcome submitted") > 0 as getting_started_submitted, + sum(name = "IdV: doc auth document_capture visited") > 0 as document_capture_visited, + sum(@document_capture_clicked) > 0 as document_capture_clicked, + sum(@document_captured) > 0 as document_captured, + sum(name = "idv_selfie_image_added") > 0 or sum(selfie_check_required) == 0 as selfie_captured_or_not_required, + sum(name = "IdV: doc auth image upload vendor submitted") > 0 as document_authentication_submitted, + sum(@document_authentication_passed) > 0 as document_authentication_passed, + sum(name = "IdV: doc auth image upload vendor submitted" and ispresent(properties.event_properties.doc_auth_success) and !properties.event_properties.doc_auth_success) > 0 as doc_auth_failure, + sum(name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.liveness_checking_required) > 0 as doc_auth_selfie_check_required, + sum(name = "IdV: doc auth image upload vendor submitted" and properties.event_properties.selfie_status == "fail") > 0 as doc_auth_selfie_check_failure, + sum(name = "IdV: doc auth ssn visited") > 0 as ssn_visited, + sum(name = "IdV: doc auth ssn submitted") > 0 as ssn_submitted, + sum(name = "IdV: doc auth verify visited") > 0 as verify_info_visited, + sum(name = "IdV: doc auth verify proofing results") > 0 as verify_info_submitted, + sum(name = "IdV: doc auth verify proofing results" and properties.event_properties.success) > 0 as verify_info_passed, + sum(name = "IdV: doc auth verify proofing results" and ispresent(properties.event_properties.proofing_results.context.stages.state_id.success) and !properties.event_properties.proofing_results.context.stages.state_id.success and !ispresent(properties.event_properties.proofing_results.context.stages.state_id.exception)) as aamva_failure, + sum(name = "IdV: phone of record visited") > 0 as phone_visited, + sum(name = "IdV: phone confirmation vendor") > 0 as phone_submitted, + sum(name = "IdV: phone confirmation vendor" and properties.event_properties.success) > 0 as phone_passed, + sum(name = "idv_enter_password_visited") > 0 as enter_password_visited, + sum(name = "IdV: final resolution") > 0 as enter_password_submitted, + sum(name = "IdV: personal key visited") > 0 as personal_key_visited, + sum(name = "IdV: personal key submitted") > 0 as personal_key_submitted, + sum(name = "User registration: agency handoff visited" and properties.event_properties.ial2) > 0 as agnecy_handoff_visited, + sum(name = "User registration: complete" and properties.event_properties.ial2) > 0 as agency_handoff_submitted, + sum(name = "IdV: final resolution" and !properties.event_properties.gpo_verification_pending and !properties.event_properties.in_person_verification_pending and !ispresent(properties.event_properties.fraud_pending_reason)) > 0 as verified_inline, + sum(name = "Fraud: Profile review passed") > 0 as fraud_review_passed, + sum(name = "Fraud: Profile review rejected") > 0 as fraud_review_rejected, + sum(name = "IdV: enter verify by mail code submitted" and properties.event_properties.success and !properties.event_properties.pending_in_person_enrollment and !properties.event_properties.fraud_check_failed) > 0 as gpo_passed, + sum(name = "GetUspsProofingResultsJob: Enrollment status updated" and properties.event_properties.passed and properties.event_properties.tmx_status not in ["threatmetrix_review", "threatmetrix_reject"]) > 0 as ipp_passed, + max(is_ial2) + 1 as ial + by properties.user_id + + | stats 1 - sum(getting_started_submitted) / sum(getting_started_visited) as getting_started_dropoff, + 1 - sum(document_capture_clicked) / sum(document_capture_visited) as document_capture_started_dropoff, + 1 - sum(document_captured) / sum(document_capture_visited) as document_captured_dropoff, + 1 - sum(selfie_captured_or_not_required) / sum(document_capture_visited) as selfie_captured_dropoff, + 1 - sum(document_authentication_passed) / sum(document_capture_visited) as document_authentication_passed_dropoff, + 1 - sum(ssn_submitted) / sum(ssn_visited) as ssn_dropoff, + 1 - sum(verify_info_submitted) / sum(verify_info_visited) as verify_info_submitted_dropoff, + 1 - sum(verify_info_passed) / sum(verify_info_visited) as verify_info_passed_dropoff, + 1 - sum(phone_submitted) / sum(phone_visited) as phone_submitted_dropoff, + 1 - sum(phone_passed) / sum(phone_visited) as phone_passed_dropoff, + 1 - sum(enter_password_submitted) / sum(enter_password_visited) as enter_password_dropoff, + 1 - sum(personal_key_submitted) / sum(personal_key_visited) as personal_key_dropoff, + 1 - sum(agency_handoff_submitted) / sum(agnecy_handoff_visited) as agency_handoff_dropoff, + sum(doc_auth_failure and !ssn_submitted) as document_authentication_failure_numerator, + sum(document_authentication_submitted) as document_authentication_failure_denominator, + sum(doc_auth_selfie_check_failure and !doc_auth_failure and !ssn_submitted) as selfie_check_failure_numerator, + sum(doc_auth_selfie_check_required) as selfie_check_failure_denominator, + sum(aamva_failure and !verify_info_passed) as aamva_check_failure_numerator, + sum(verify_info_submitted) as aamva_check_failure_denominator, + sum(verified_inline) as verified_inline_count, + sum(fraud_review_passed) as fraud_review_passed_count, + sum(fraud_review_rejected) as fraud_review_rejected_count, + sum(gpo_passed) as gpo_passed_count, + sum(ipp_passed) as ipp_passed_count + by ial + QUERY + end + + def data + @data ||= time_range_weekly_ranges.map do |week_time_range| + get_results_for_week(week_time_range) + end + end + + def get_results_for_week(week_time_range) + sp_session_events_result_by_ial = fetch_results( + query: sp_session_events_query, + query_time_range: week_time_range, + ).index_by { |result_row| result_row['ial'] } + + out_of_band_query_start = week_time_range.begin + out_of_band_query_end = [week_time_range.end + 4.weeks, Time.zone.now.to_date].min + out_of_band_inline_end_date = week_time_range.end.end_of_day + out_of_band_results_by_ial = fetch_results( + query: out_of_band_query(out_of_band_inline_end_date), + query_time_range: (out_of_band_query_start..out_of_band_query_end), + ).index_by { |result_row| result_row['ial'] } + + compute_weekly_dropoff_values( + sp_session_events_result_by_ial, + out_of_band_results_by_ial, + week_time_range, + ) + end + + # rubocop:disable Layout/LineLength + def compute_weekly_dropoff_values( + sp_session_events_result_by_ial, out_of_band_results_by_ial, week_time_range + ) + WeeklyDropoffValues.new( + start_date: week_time_range.begin.to_s, + end_date: week_time_range.end.to_s, + ial2_verified_user_count: [ + sp_session_events_result_by_ial.dig('2', 'verified_inline_count').to_i, + sp_session_events_result_by_ial.dig('2', 'fraud_review_passed_count').to_i, + sp_session_events_result_by_ial.dig('2', 'gpo_passed_count').to_i, + sp_session_events_result_by_ial.dig('2', 'ipp_passed_count').to_i, + ].sum.to_s, + non_ial2_verified_user_count: [ + sp_session_events_result_by_ial.dig('1', 'verified_inline_count').to_i, + sp_session_events_result_by_ial.dig('1', 'fraud_review_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'gpo_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'ipp_passed_count').to_i, + ].sum.to_s, + document_authentication_failure_pct: compute_percentage( + sp_session_events_result_by_ial.dig('2', 'document_authentication_failure_numerator').to_i + sp_session_events_result_by_ial.dig('1', 'document_authentication_failure_numerator').to_i, + sp_session_events_result_by_ial.dig('2', 'document_authentication_failure_denominator').to_i + sp_session_events_result_by_ial.dig('1', 'document_authentication_failure_denominator').to_i, + ), + selfie_check_failure_pct: compute_percentage( + sp_session_events_result_by_ial.dig('2', 'selfie_check_failure_numerator').to_i + sp_session_events_result_by_ial.dig('1', 'selfie_check_failure_numerator').to_i, + sp_session_events_result_by_ial.dig('2', 'selfie_check_failure_denominator').to_i + sp_session_events_result_by_ial.dig('1', 'selfie_check_failure_denominator').to_i, + ), + aamva_check_failure_pct: compute_percentage( + sp_session_events_result_by_ial.dig('2', 'aamva_check_failure_numerator').to_i + sp_session_events_result_by_ial.dig('1', 'aamva_check_failure_numerator').to_i, + sp_session_events_result_by_ial.dig('2', 'aamva_check_failure_denominator').to_i + sp_session_events_result_by_ial.dig('1', 'aamva_check_failure_denominator').to_i, + ), + fraud_review_rejected_user_count: [ + sp_session_events_result_by_ial.dig('2', 'fraud_review_rejected_count').to_i, + sp_session_events_result_by_ial.dig('1', 'fraud_review_rejected_count').to_i, + ].sum.to_s, + gpo_passed_count: [ + sp_session_events_result_by_ial.dig('2', 'gpo_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'gpo_passed_count').to_i, + ].sum.to_s, + fraud_review_passed_count: [ + sp_session_events_result_by_ial.dig('2', 'fraud_review_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'fraud_review_passed_count').to_i, + ].sum.to_s, + ipp_passed_count: [ + sp_session_events_result_by_ial.dig('2', 'ipp_passed_count').to_i, + sp_session_events_result_by_ial.dig('1', 'ipp_passed_count').to_i, + ].sum.to_s, + ial2_getting_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'getting_started_dropoff').to_f), + non_ial2_getting_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'getting_started_dropoff').to_f), + ial2_document_capture_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'document_capture_started_dropoff').to_f), + non_ial2_document_capture_started_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'document_capture_started_dropoff').to_f), + ial2_document_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'document_captured_dropoff').to_f), + non_ial2_document_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'document_captured_dropoff').to_f), + ial2_selfie_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'selfie_captured_dropoff').to_f), + non_ial2_selfie_captured_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'selfie_captured_dropoff').to_f), + ial2_document_authentication_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'document_authentication_passed_dropoff').to_f), + non_ial2_document_authentication_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'document_authentication_passed_dropoff').to_f), + ial2_ssn_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'ssn_dropoff').to_f), + non_ial2_ssn_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'ssn_dropoff').to_f), + ial2_verify_info_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'verify_info_submitted_dropoff').to_f), + non_ial2_verify_info_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'verify_info_submitted_dropoff').to_f), + ial2_verify_info_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'verify_info_passed_dropoff').to_f), + non_ial2_verify_info_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'verify_info_passed_dropoff').to_f), + ial2_phone_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'phone_submitted_dropoff').to_f), + non_ial2_phone_submitted_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'phone_submitted_dropoff').to_f), + ial2_phone_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'phone_passed_dropoff').to_f), + non_ial2_phone_passed_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'phone_passed_dropoff').to_f), + ial2_enter_password_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'enter_password_dropoff').to_f), + non_ial2_enter_password_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'enter_password_dropoff').to_f), + ial2_inline_dropoff: format_percentage(0.0), + non_ial2_inline_dropoff: format_percentage(0.0), + ial2_verify_by_mail_dropoff: format_percentage(out_of_band_results_by_ial.dig('2', 'verify_by_mail_dropoff').to_f), + non_ial2_verify_by_mail_dropoff: format_percentage(out_of_band_results_by_ial.dig('1', 'verify_by_mail_dropoff').to_f), + ial2_fraud_review_dropoff: format_percentage(out_of_band_results_by_ial.dig('2', 'fraud_review_dropoff').to_f), + non_ial2_fraud_review_dropoff: format_percentage(out_of_band_results_by_ial.dig('1', 'fraud_review_dropoff').to_f), + ial2_personal_key_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'personal_key_dropoff').to_f), + non_ial2_personal_key_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'personal_key_dropoff').to_f), + ial2_agency_handoff_dropoff: format_percentage(sp_session_events_result_by_ial.dig('2', 'agency_handoff_dropoff').to_f), + non_ial2_agency_handoff_dropoff: format_percentage(sp_session_events_result_by_ial.dig('1', 'agency_handoff_dropoff').to_f), + ) + end + # rubocop:enable Layout/LineLength + + def compute_percentage(numerator, denominator) + return format_percentage(0.0) if denominator == 0 + + format_percentage(numerator.to_f / denominator.to_f) + end + + def format_percentage(value) + return '0.0%' if value.blank? + (value * 100).round(2).to_s + '%' + end + + def time_range_weekly_ranges + start_date = time_range.begin.beginning_of_week(:sunday) + end_date = time_range.end.end_of_week(:sunday) + (start_date..end_date).step(7).map do |week_start| + week_start.all_week(:sunday) + end + end + + def fetch_results(query:, query_time_range:) + cloudwatch_client.fetch( + query:, + from: query_time_range.begin.beginning_of_day, + to: query_time_range.end.end_of_day, + ) + end + + def cloudwatch_client + @cloudwatch_client ||= Reporting::CloudwatchClient.new( + num_threads: 1, + ensure_complete_logs: false, + slice_interval: 100.years, + progress: progress?, + logger: verbose? ? Logger.new(STDERR) : nil, + ) + end + end +end diff --git a/spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb b/spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb new file mode 100644 index 00000000000..a6bf58587e1 --- /dev/null +++ b/spec/jobs/reports/sp_idv_weekly_dropoff_report_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Reports::SpIdvWeeklyDropoffReport do + let(:report_date) { Date.new(2024, 12, 16).in_time_zone('UTC') } + let(:agency_abbreviation) { 'ABC' } + let(:report_emails) { ['test@example.com'] } + let(:sp_idv_weekly_dropoff_report_configs) do + [ + { + 'issuers' => ['super:cool:test:issuer'], + 'report_start_date' => '2024-12-01', + 'agency_abbreviation' => 'ABC', + 'emails' => report_emails, + }, + ] + end + + before do + allow(IdentityConfig.store).to receive(:s3_reports_enabled).and_return(true) + allow(IdentityConfig.store).to receive(:sp_idv_weekly_dropoff_report_configs).and_return( + sp_idv_weekly_dropoff_report_configs, + ) + end + + describe '#perform' do + it 'gets a CSV from the report maker, saves it to S3, and sends email to team' do + allow(IdentityConfig.store).to receive(:team_ada_email).and_return('ada@example.com') + + report = [ + ['Label'], + ['Useful dropoff info', '80%', '90%'], + ['Other dropoff info', '70%', '60%'], + ] + csv_report = CSV.generate do |csv| + report.each { |row| csv << row } + end + emailable_reports = [ + Reporting::EmailableReport.new( + title: 'ABC IdV Dropoff Report - 2024-12-16', + table: report, + filename: 'abc_idv_dropoff_report', + ), + ] + + report_maker = double( + Reporting::SpIdvWeeklyDropoffReport, + to_csv: csv_report, + as_emailable_reports: emailable_reports, + ) + + allow(subject).to receive(:build_report_maker).with( + issuers: ['super:cool:test:issuer'], + agency_abbreviation: 'ABC', + time_range: Date.new(2024, 12, 1)..Date.new(2024, 12, 14), + ).and_return(report_maker) + + expect(subject).to receive(:save_report).with( + 'abc_idv_dropoff_report', + csv_report, + extension: 'csv', + ) + + expect(ReportMailer).to receive(:tables_report).once.with( + email: 'test@example.com', + subject: 'ABC IdV Dropoff Report - 2024-12-16', + reports: emailable_reports, + message: anything, + attachment_format: :csv, + ).and_call_original + + subject.perform(report_date) + end + + context 'with no emails configured' do + let(:report_emails) { [] } + + it 'does not send the report in email' do + report_maker = double( + Reporting::SpIdvWeeklyDropoffReport, + to_csv: 'I am a CSV, see', + identity_verification_emailable_report: 'I am a report', + ) + allow(subject).to receive(:build_report_maker).and_return(report_maker) + expect(subject).to receive(:save_report).with( + 'abc_idv_dropoff_report', + 'I am a CSV, see', + extension: 'csv', + ) + + expect(ReportMailer).to_not receive(:tables_report) + + subject.perform(report_date) + end + end + end +end diff --git a/spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb b/spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb new file mode 100644 index 00000000000..945ab7f4e45 --- /dev/null +++ b/spec/lib/reporting/sp_idv_weekly_dropoff_report_spec.rb @@ -0,0 +1,218 @@ +require 'rails_helper' +require 'reporting/sp_idv_weekly_dropoff_report' + +RSpec.describe Reporting::SpIdvWeeklyDropoffReport do + let(:issuers) { ['super:cool:test:issuer'] } + let(:agency_abbreviation) { 'ABC' } + let(:time_range) { Date.new(2024, 12, 1)..Date.new(2024, 12, 14) } + + let(:cloudwatch_results) do + [ + [ + { + 'ial' => '1', + 'getting_started_dropoff' => '0.01', + 'document_capture_started_dropoff' => '0.02', + 'document_captured_dropoff' => '0.03', + 'selfie_captured_dropoff' => '0', + 'document_authentication_passed_dropoff' => '0.04', + 'ssn_dropoff' => '0.05', + 'verify_info_submitted_dropoff' => '0.06', + 'verify_info_passed_dropoff' => '0.07', + 'phone_submitted_dropoff' => '0.08', + 'phone_passed_dropoff' => '0.09', + 'enter_password_dropoff' => '0.10', + 'personal_key_dropoff' => '0.11', + 'agency_handoff_dropoff' => '0.12', + 'document_authentication_failure_numerator' => '100', + 'document_authentication_failure_denominator' => '200', + 'selfie_check_failure_numerator' => '0', + 'selfie_check_failure_denominator' => '0', + 'aamva_check_failure_numerator' => '300', + 'aamva_check_failure_denominator' => '400', + 'verified_inline_count' => '500', + 'fraud_review_passed_count' => '600', + 'fraud_review_rejected_count' => '700', + }, + ], + [ + { + 'ial' => '1', + 'verify_by_mail_dropoff' => '0.01', + 'fraud_review_dropoff' => '0.02', + }, + ], + [ + { + 'ial' => '1', + 'getting_started_dropoff' => '0.13', + 'document_capture_started_dropoff' => '0.14', + 'document_captured_dropoff' => '0.15', + 'selfie_captured_dropoff' => '0', + 'document_authentication_passed_dropoff' => '0.16', + 'ssn_dropoff' => '0.17', + 'verify_info_submitted_dropoff' => '0.18', + 'verify_info_passed_dropoff' => '0.19', + 'phone_submitted_dropoff' => '0.20', + 'phone_passed_dropoff' => '0.21', + 'enter_password_dropoff' => '0.22', + 'personal_key_dropoff' => '0.23', + 'agency_handoff_dropoff' => '0.24', + 'document_authentication_failure_numerator' => '800', + 'document_authentication_failure_denominator' => '900', + 'selfie_check_failure_numerator' => '0', + 'selfie_check_failure_denominator' => '0', + 'aamva_check_failure_numerator' => '1000', + 'aamva_check_failure_denominator' => '1100', + 'verified_inline_count' => '1200', + 'fraud_review_passed_count' => '1300', + 'fraud_review_rejected_count' => '1400', + 'gpo_passed_count' => '1500', + }, + { + 'ial' => '2', + 'getting_started_dropoff' => '0.25', + 'document_capture_started_dropoff' => '0.26', + 'document_captured_dropoff' => '0.27', + 'selfie_captured_dropoff' => '0.29', + 'document_authentication_passed_dropoff' => '0.30', + 'ssn_dropoff' => '0.31', + 'verify_info_submitted_dropoff' => '0.32', + 'verify_info_passed_dropoff' => '0.33', + 'phone_submitted_dropoff' => '0.34', + 'phone_passed_dropoff' => '0.35', + 'enter_password_dropoff' => '0.36', + 'personal_key_dropoff' => '0.37', + 'agency_handoff_dropoff' => '0.38', + 'document_authentication_failure_numerator' => '1600', + 'document_authentication_failure_denominator' => '1700', + 'selfie_check_failure_numerator' => '1800', + 'selfie_check_failure_denominator' => '1900', + 'aamva_check_failure_numerator' => '2000', + 'aamva_check_failure_denominator' => '2100', + 'verified_inline_count' => '2200', + 'fraud_review_passed_count' => '2300', + 'fraud_review_rejected_count' => '2400', + 'gpo_passed_count' => '0', + }, + ], + [ + { + 'ial' => '1', + 'verify_by_mail_dropoff' => '0.03', + 'fraud_review_dropoff' => '0.04', + }, + { + 'ial' => '2', + 'verify_by_mail_dropoff' => '0', + 'fraud_review_dropoff' => '0.5', + }, + ], + ] + end + + let(:expected_result) do + [ + ['', '2024-12-01 - 2024-12-07', '2024-12-08 - 2024-12-14'], + ['Overview'], + ['# of verified users'], + [' - IAL2', '0', '4500'], + [' - Non-IAL2', '1100', '4000'], + ['# of contact center cases'], + ['Fraud Checks'], + ['% of users that failed document authentication check', '50.0%', '92.31%'], + ['% of users that failed facial match check (Only for IAL2)', '0.0%', '94.74%'], + ['% of users that failed AAMVA attribute match check', '75.0%', '93.75%'], + ['# of users that failed LG-99 fraud review', '700', '3800'], + ['User Experience'], + ['# of verified users via verify-by-mail process (Only for non-IAL2)', '0', '1500'], + ['# of verified users via fraud redress process', '600', '3600'], + ['# of verified users via in-person proofing (Not currently enabled)', '0', '0'], + ['Funnel Analysis'], + ['% drop-off at Workflow Started'], + [' - IAL2', '0.0%', '25.0%'], + [' - Non-IAL2', '1.0%', '13.0%'], + ['% drop-off at Document Capture Started'], + [' - IAL2', '0.0%', '26.0%'], + [' - Non-IAL2', '2.0%', '14.0%'], + ['% drop-off at Document Captured'], + [' - IAL2', '0.0%', '27.0%'], + [' - Non-IAL2', '3.0%', '15.0%'], + ['% drop-off at Selfie Captured'], + [' - IAL2', '0.0%', '29.0%'], + ['% drop-off at Document Authentication Passed'], + [' - IAL2 (with Facial Match)', '0.0%', '30.0%'], + [' - Non-IAL2', '4.0%', '16.0%'], + ['% drop-off at SSN Submitted'], + [' - IAL2', '0.0%', '31.0%'], + [' - Non-IAL2', '5.0%', '17.0%'], + ['% drop-off at Personal Information Submitted'], + [' - IAL2', '0.0%', '32.0%'], + [' - Non-IAL2', '6.0%', '18.0%'], + ['% drop-off at Personal Information Verified'], + [' - IAL2', '0.0%', '33.0%'], + [' - Non-IAL2', '7.0%', '19.0%'], + ['% drop-off at Phone Submitted'], + [' - IAL2', '0.0%', '34.0%'], + [' - Non-IAL2', '8.0%', '20.0%'], + ['% drop-off at Phone Verified'], + [' - IAL2', '0.0%', '35.0%'], + [' - Non-IAL2', '9.0%', '21.0%'], + ['% drop-off at Online Wofklow Completed'], + [' - IAL2', '0.0%', '36.0%'], + [' - Non-IAL2', '10.0%', '22.0%'], + ['% drop-off at Verified for In-Band Users'], + [' - IAL2', '0.0%', '0.0%'], + [' - Non-IAL2', '0.0%', '0.0%'], + ['% drop-off at Verified for Verify-by-mail Users'], + [' - Non-IAL2', '1.0%', '3.0%'], + ['% drop-off at Verified for Fraud Review Users'], + [' - IAL2', '0.0%', '50.0%'], + [' - Non-IAL2', '2.0%', '4.0%'], + ['% drop-off at Personal Key Saved'], + [' - IAL2', '0.0%', '37.0%'], + [' - Non-IAL2', '11.0%', '23.0%'], + ['% drop-off at Agency Handoff Submitted'], + [' - IAL2', '0.0%', '38.0%'], + [' - Non-IAL2', '12.0%', '24.0%'], + ] + end + + before do + stub_multiple_cloudwatch_logs(*cloudwatch_results) + end + + subject(:report) { described_class.new(issuers:, agency_abbreviation:, time_range:) } + + describe '#as_csv' do + it 'queries cloudwatch and formats a report' do + expect(report.as_csv).to eq(expected_result) + end + end + + describe '#to_csv' do + it 'returns a CSV report' do + csv = CSV.parse(report.to_csv, headers: false) + + aggregate_failures do + csv.map(&:to_a).zip(expected_result).each do |actual, expected| + expect(actual).to eq(expected) + end + end + end + end + + describe 'as_emailable_report' do + it 'returns an array with an emailable report' do + expect(report.as_emailable_reports).to eq( + [ + Reporting::EmailableReport.new( + title: 'ABC IdV Dropoff Report', + table: expected_result, + filename: 'abc_idv_dropoff_report', + ), + ], + ) + end + end +end From a140ffb2ea2b52d4bd4ad608384617ca6d89b59a Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Wed, 29 Jan 2025 11:26:47 -0500 Subject: [PATCH 13/16] LG-15179 Timed out hybrid user should be able to retry docv (#11792) * set socure_docv_wait_polling timestamp to nil in show on hybrid mobile * add changelog changelog: Internal, IdV Doc Auth, Allow timed out user to retry docv * have spec ensure socure_docv_wait_polling_started_at is nil --- .../idv/hybrid_mobile/socure/document_capture_controller.rb | 2 ++ .../hybrid_mobile/socure/document_capture_controller_spec.rb | 4 ++++ .../idv/socure/document_capture_controller_spec.rb | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb index f9fd639c4db..d3311aceac7 100644 --- a/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb +++ b/app/controllers/idv/hybrid_mobile/socure/document_capture_controller.rb @@ -19,6 +19,8 @@ class DocumentCaptureController < ApplicationController before_action :fetch_test_verification_data, only: [:update] def show + session[:socure_docv_wait_polling_started_at] = nil + Funnel::DocAuth::RegisterStep.new(document_capture_user.id, sp_session[:issuer]) .call('hybrid_mobile_socure_document_capture', :view, true) diff --git a/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb b/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb index 8b629416441..faa294e55fd 100644 --- a/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb +++ b/spec/controllers/idv/hybrid_mobile/socure/document_capture_controller_spec.rb @@ -148,6 +148,10 @@ ) end + it 'sets any docv timeouts to nil' do + expect(session[:socure_docv_wait_polling_started_at]).to eq nil + end + it 'logs correct info' do expect(@analytics).to have_logged_event( :idv_socure_document_request_submitted, diff --git a/spec/controllers/idv/socure/document_capture_controller_spec.rb b/spec/controllers/idv/socure/document_capture_controller_spec.rb index 453c7a7c3ed..042a53d75f2 100644 --- a/spec/controllers/idv/socure/document_capture_controller_spec.rb +++ b/spec/controllers/idv/socure/document_capture_controller_spec.rb @@ -180,6 +180,10 @@ ) end + it 'sets any docv timeouts to nil' do + expect(subject.idv_session.socure_docv_wait_polling_started_at).to eq nil + end + it 'logs correct info' do expect(@analytics).to have_logged_event( :idv_socure_document_request_submitted, From 277b0690a4ee9bc3f2cbc56dd1dfb2985f51762e Mon Sep 17 00:00:00 2001 From: Jonathan Hooper Date: Wed, 29 Jan 2025 15:45:05 -0500 Subject: [PATCH 14/16] LG-15457 Read the sex attribute from the TrueID authentication result (#11819) We have received clarification on the following from our vendor: 1. The `Fields_Sex` attribute is no longer available in the `ID_AUTH_FIELDS` group 2. The `Sex` attribute available in the `AUTHENTICATION_RESULT` group can be used to reliably determine the sex displayed on the document 3. The `Sex` attribute in the `AUTHENTICATION_RESULT` group takes on the values of `Male` or `Female`. This commit updates the `DocPiiReader` and the TrueID response fixtures to reflect this. [skip changelog] --- app/services/doc_auth/lexis_nexis/doc_pii_reader.rb | 9 +++++---- .../true_id/true_id_response_attention_barcode.json | 5 ----- ..._response_attention_barcode_with_face_match_fail.json | 5 ----- .../true_id/true_id_response_failed_to_ocr_dob.json | 7 +------ .../true_id/true_id_response_failure_no_liveness.json | 5 ----- .../true_id_response_failure_no_liveness_low_dpi.json | 5 ----- .../true_id_response_failure_with_all_failures.json | 5 ----- .../true_id_response_failure_with_face_match_fail.json | 7 +------ .../true_id_response_failure_with_face_match_pass.json | 5 ----- .../true_id/true_id_response_failure_with_liveness.json | 5 ----- .../lexis_nexis/true_id/true_id_response_success.json | 9 --------- .../lexis_nexis/responses/true_id_response_spec.rb | 2 +- 12 files changed, 8 insertions(+), 61 deletions(-) diff --git a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb index 79a46b11d64..f9360503542 100644 --- a/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb +++ b/app/services/doc_auth/lexis_nexis/doc_pii_reader.rb @@ -22,6 +22,7 @@ module DocPiiReader # @return [Pii::StateId, nil] def read_pii(true_id_product) id_auth_field_data = true_id_product&.dig(:IDAUTH_FIELD_DATA) + authentication_result_field_data = true_id_product&.dig(:AUTHENTICATION_RESULT) return nil unless id_auth_field_data.present? state_id_type_slug = id_auth_field_data['Fields_DocumentClassName'] @@ -42,7 +43,7 @@ def read_pii(true_id_product) month: id_auth_field_data['Fields_DOB_Month'], day: id_auth_field_data['Fields_DOB_Day'], ), - sex: parse_sex_value(id_auth_field_data['Fields_Sex']), + sex: parse_sex_value(authentication_result_field_data&.[]('Sex')), height: parse_height_value(id_auth_field_data['Fields_Height']), weight: nil, eye_color: nil, @@ -87,10 +88,10 @@ def parse_sex_value(sex_attribute) # This code will return `nil` for those cases with the intent that they will not be verified # against the DLDV where they will not be recognized # - case sex_attribute&.upcase - when 'M' + case sex_attribute + when 'Male' 'male' - when 'F' + when 'Female' 'female' end end diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json index b65a8b12672..4ec00edb41f 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode.json @@ -694,11 +694,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "12345"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_Height", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json index b2a6d6421d2..fc51458f48b 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_attention_barcode_with_face_match_fail.json @@ -714,11 +714,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "12345"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_Height", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json index 2e151deb30e..fedcb07d700 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failed_to_ocr_dob.json @@ -409,12 +409,7 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "12345"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, - { + { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", "Values": [{"Value": "6820051160"}] diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json index d47c7b52a28..8ef023d6c7c 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness.json @@ -674,11 +674,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json index 040084be529..b86089bc0d7 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_no_liveness_low_dpi.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json index 3d3282e16f2..2caf549db4e 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_all_failures.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json index af9221a5f71..6da199b0bf9 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_fail.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", @@ -737,4 +732,4 @@ } } ] -} \ No newline at end of file +} diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json index d82cd53bb5a..9c32330a468 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_face_match_pass.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json index 9ae6ac15f2c..13aeae5800a 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_failure_with_liveness.json @@ -669,11 +669,6 @@ "Name": "Fields_PostalCode", "Values": [{"Value": "02809-2366"}] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [{"Value": "M"}] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json index 6b171049751..1439c76561e 100644 --- a/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json +++ b/spec/fixtures/proofing/lexis_nexis/true_id/true_id_response_success.json @@ -697,15 +697,6 @@ } ] }, - { - "Group": "IDAUTH_FIELD_DATA", - "Name": "Fields_Sex", - "Values": [ - { - "Value": "M" - } - ] - }, { "Group": "IDAUTH_FIELD_DATA", "Name": "Fields_ControlNumber", diff --git a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb index 4a2e5bb4dec..9c64557a9c1 100644 --- a/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb +++ b/spec/services/doc_auth/lexis_nexis/responses/true_id_response_spec.rb @@ -133,7 +133,7 @@ city: 'ANYTOWN', state: 'MD', dob: '1986-07-01', - sex: nil, + sex: 'male', height: 69, weight: nil, eye_color: nil, From 664cf931b471e593ba35de03b96d86cb6684d816 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Fri, 31 Jan 2025 16:26:32 -0500 Subject: [PATCH 15/16] Removes unnecessary dead code --- .../in_person/usps_locations_controller.rb | 2 -- .../usps_locations_controller_spec.rb | 28 ------------------- 2 files changed, 30 deletions(-) diff --git a/app/controllers/idv/in_person/usps_locations_controller.rb b/app/controllers/idv/in_person/usps_locations_controller.rb index c6621698eb8..35c8366801e 100644 --- a/app/controllers/idv/in_person/usps_locations_controller.rb +++ b/app/controllers/idv/in_person/usps_locations_controller.rb @@ -63,8 +63,6 @@ def update sponsor_id: enrollment_sponsor_id, ) - idv_session.doc_auth_vendor = Idp::Constants::Vendors::USPS - render json: { success: true }, status: :ok end diff --git a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb index d01ee4601ea..30d461dc70b 100644 --- a/spec/controllers/idv/in_person/usps_locations_controller_spec.rb +++ b/spec/controllers/idv/in_person/usps_locations_controller_spec.rb @@ -372,20 +372,6 @@ ) expect(enrollment.service_provider).to eq(sp) end - - it 'updates proofing component vendor' do - proofing_components = Idv::ProofingComponents.new( - idv_session: controller.idv_session, - ) - - expect(proofing_components.document_check).to be_nil - - response - - user.reload - - expect(proofing_components.document_check).to eq Idp::Constants::Vendors::USPS - end end context 'when the user is going through EIPP' do @@ -408,20 +394,6 @@ ) expect(enrollment.service_provider).to eq(sp) end - - it 'updates proofing component vendor' do - proofing_components = Idv::ProofingComponents.new( - idv_session: controller.idv_session, - ) - - expect(proofing_components.document_check).to be_nil - - response - - user.reload - - expect(proofing_components.document_check).to eq Idp::Constants::Vendors::USPS - end end context 'when unauthenticated' do From 04b395f7c0159a753e28f4d6aa579c9d7a35fe04 Mon Sep 17 00:00:00 2001 From: Matt Wagner Date: Mon, 3 Feb 2025 17:14:20 -0500 Subject: [PATCH 16/16] Fix IPP analytics logging --- app/controllers/idv/in_person/state_id_controller.rb | 8 +++++--- spec/features/idv/analytics_spec.rb | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/idv/in_person/state_id_controller.rb b/app/controllers/idv/in_person/state_id_controller.rb index 80d8bde6146..1e50d58dd0d 100644 --- a/app/controllers/idv/in_person/state_id_controller.rb +++ b/app/controllers/idv/in_person/state_id_controller.rb @@ -30,9 +30,6 @@ def update pii_from_user[attr] = flow_params[attr] end - analytics.idv_in_person_proofing_state_id_submitted( - **analytics_arguments.merge(**form_result), - ) # Accept Date of Birth from both memorable date and input date components formatted_dob = MemorableDateComponent.extract_date_param flow_params&.[](:dob) pii_from_user[:dob] = formatted_dob if formatted_dob @@ -57,6 +54,11 @@ def update end idv_session.doc_auth_vendor = Idp::Constants::Vendors::USPS + + analytics.idv_in_person_proofing_state_id_submitted( + **analytics_arguments.merge(**form_result), + ) + redirect_to redirect_url else render :show, locals: extra_view_variables diff --git a/spec/features/idv/analytics_spec.rb b/spec/features/idv/analytics_spec.rb index 859d53fad37..0e5cc356992 100644 --- a/spec/features/idv/analytics_spec.rb +++ b/spec/features/idv/analytics_spec.rb @@ -584,7 +584,7 @@ flow_path: 'standard', opted_in_to_in_person_proofing: false }, 'IdV: in person proofing state_id visited' => { - step: 'state_id', flow_path: 'standard', analytics_id: 'In Person Proofing', proofing_components: { document_check: 'usps' } + step: 'state_id', flow_path: 'standard', analytics_id: 'In Person Proofing' }, 'IdV: in person proofing state_id submitted' => { success: true, flow_path: 'standard', step: 'state_id', analytics_id: 'In Person Proofing', birth_year: '1938', document_zip_code: '12345', proofing_components: { document_check: 'usps' }