diff --git a/app/controllers/socure_webhook_controller.rb b/app/controllers/socure_webhook_controller.rb index 0028767330d..5344db65f34 100644 --- a/app/controllers/socure_webhook_controller.rb +++ b/app/controllers/socure_webhook_controller.rb @@ -11,6 +11,7 @@ class SocureWebhookController < ApplicationController def create begin log_webhook_receipt + repeat_webhook process_webhook_event rescue StandardError => e NewRelic::Agent.notice_error(e) @@ -134,4 +135,20 @@ def user def docv_transaction_token @docv_transaction_token ||= event[:docvTransactionToken] || event[:docVTransactionToken] end + + def repeat_webhook + endpoints = IdentityConfig.store.socure_docv_webhook_repeat_endpoints + return if endpoints.blank? + + headers = { + Authorization: request.headers['Authorization'], + 'Content-Type': request.headers['Content-Type'], + } + + body = socure_params.to_h + + endpoints.each do |endpoint| + SocureDocvRepeatWebhookJob.perform_later(body:, headers:, endpoint:) + end + end end diff --git a/app/jobs/socure_docv_repeat_webhook_job.rb b/app/jobs/socure_docv_repeat_webhook_job.rb new file mode 100644 index 00000000000..7e6bc9fbb7a --- /dev/null +++ b/app/jobs/socure_docv_repeat_webhook_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class SocureDocvRepeatWebhookJob < ApplicationJob + queue_as :high_socure_docv + + def perform(body:, headers:, endpoint:) + wr = DocAuth::Socure::WebhookRepeater.new(body:, headers:, endpoint:) + wr.repeat + end +end diff --git a/app/services/doc_auth/socure/webhook_repeater.rb b/app/services/doc_auth/socure/webhook_repeater.rb new file mode 100644 index 00000000000..550389bb1cc --- /dev/null +++ b/app/services/doc_auth/socure/webhook_repeater.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module DocAuth + module Socure + class WebhookRepeater + attr_reader :body, :headers, :endpoint + + def initialize(body:, headers:, endpoint:) + @body = body + @headers = headers + @endpoint = endpoint + end + + def repeat + send_http_post_request(endpoint) + rescue => exception + NewRelic::Agent.notice_error( + exception, + custom_params: { + event: 'Failed to repeat webhook', + endpoint:, + body:, + }, + ) + end + + private + + def send_http_post_request(endpoint) + faraday_connection(endpoint).post do |req| + req.options.context = { service_name: 'socure_webhook_repeater' } + req.body = body.to_json + end + end + + def faraday_connection(endpoint) + retry_options = { + max: 2, + interval: 0.05, + interval_randomness: 0.5, + backoff_factor: 2, + retry_statuses: [404, 500], + retry_block: lambda do |env:, options:, retry_count:, exception:, will_retry_in:| + NewRelic::Agent.notice_error(exception, custom_params: { retry: retry_count }) + end, + } + + timeout = 15 + + Faraday.new(url: url(endpoint).to_s, headers: request_headers) do |conn| + conn.request :retry, retry_options + conn.request :instrumentation, name: 'request_metric.faraday' + conn.adapter :net_http + conn.options.timeout = timeout + conn.options.read_timeout = timeout + conn.options.open_timeout = timeout + conn.options.write_timeout = timeout + end + end + + def url(endpoint) + URI.join(endpoint) + end + + def request_headers(extras = {}) + headers.merge(extras) + end + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index d81f520581e..dcd7ef6c452 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -387,6 +387,7 @@ socure_docv_document_request_endpoint: '' socure_docv_enabled: false socure_docv_verification_data_test_mode: false socure_docv_verification_data_test_mode_tokens: '[]' +socure_docv_webhook_repeat_endpoints: '[]' socure_docv_webhook_secret_key: '' socure_docv_webhook_secret_key_queue: '[]' socure_idplus_api_key: '' diff --git a/lib/identity_config.rb b/lib/identity_config.rb index cd90324d7ed..c8178d27958 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -423,6 +423,7 @@ def self.store config.add(:socure_docv_enabled, type: :boolean) config.add(:socure_docv_verification_data_test_mode, type: :boolean) config.add(:socure_docv_verification_data_test_mode_tokens, type: :json) + config.add(:socure_docv_webhook_repeat_endpoints, type: :json) config.add(:socure_docv_webhook_secret_key_queue, type: :json) config.add(:socure_docv_webhook_secret_key, type: :string) config.add(:socure_idplus_api_key, type: :string) diff --git a/spec/controllers/socure_webhook_controller_spec.rb b/spec/controllers/socure_webhook_controller_spec.rb index 409a18ccaf5..3f9db2cd559 100644 --- a/spec/controllers/socure_webhook_controller_spec.rb +++ b/spec/controllers/socure_webhook_controller_spec.rb @@ -63,6 +63,51 @@ before do request.headers['Authorization'] = socure_secret_key end + + shared_examples 'repeats webhooks' do + let(:headers) do + { + Authorization: socure_secret_key_queue.last, + 'Content-Type': 'application/json', + } + end + let(:endpoints) do + 3.times.map { |i| "https://#{i}.example.test/endpoint" } + end + let(:repeated_body) do + b = {}.merge(webhook_body) + b[:event].delete(:data) + b + end + + before do + allow(IdentityConfig.store) + .to receive(:socure_docv_webhook_repeat_endpoints).and_return(endpoints) + allow(SocureDocvResultsJob).to receive(:perform_now) + allow(SocureDocvRepeatWebhookJob).to receive(:perform_later) + + dcs = create(:document_capture_session, :socure) + webhook_body[:event][:docvTransactionToken] = dcs.socure_docv_transaction_token + + request.headers['Authorization'] = headers[:Authorization] + request.headers['Content-Type'] = headers[:'Content-Type'] + end + + context 'when idv workers are enabled' do + it 'queues SocureDocvRepeatWebhook jobs' do + endpoints.each do |endpoint| + expect(SocureDocvRepeatWebhookJob).to receive(:perform_later) do |*args| + expect(args.first[:body]).to eq(JSON.parse(repeated_body.to_json)) + expect(args.first[:endpoint]).to eq(endpoint) + expect(args.first[:headers]).to eq(headers) + end + end + + post :create, params: webhook_body + end + end + end + it 'returns OK and logs an event with a correct secret key and body' do post :create, params: webhook_body @@ -92,6 +137,8 @@ end end + it_behaves_like 'repeats webhooks' + context 'when document capture session exists' do it 'logs the user\'s uuid' do dcs = create(:document_capture_session, :socure) @@ -113,6 +160,8 @@ context 'when DOCUMENTS_UPLOADED event received' do let(:event_type) { 'DOCUMENTS_UPLOADED' } + it_behaves_like 'repeats webhooks' + it 'returns OK and logs an event with a correct secret key and body' do dcs = create(:document_capture_session, :socure) webhook_body[:event][:docvTransactionToken] = dcs.socure_docv_transaction_token @@ -181,6 +230,8 @@ context 'when SESSION_COMPLETE event received' do let(:event_type) { 'SESSION_COMPLETE' } + it_behaves_like 'repeats webhooks' + it 'does not increment rate limiter of user' do dcs = create(:document_capture_session, :socure) webhook_body[:event][:docvTransactionToken] = dcs.socure_docv_transaction_token @@ -220,6 +271,8 @@ context 'when SESSION_EXPIRED event received' do let(:event_type) { 'SESSION_EXPIRED' } + it_behaves_like 'repeats webhooks' + it 'does not increment rate limiter of user' do dcs = create(:document_capture_session, :socure) webhook_body[:event][:docvTransactionToken] = dcs.socure_docv_transaction_token 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 e8b9e2004e7..480299b7a83 100644 --- a/spec/features/idv/doc_auth/socure_document_capture_spec.rb +++ b/spec/features/idv/doc_auth/socure_document_capture_spec.rb @@ -12,6 +12,7 @@ let(:fake_socure_docv_document_request_endpoint) { 'https://fake-socure.test/document-request' } let(:fake_socure_document_capture_app_url) { 'https://verify.fake-socure.test/something' } let(:socure_docv_verification_data_test_mode) { false } + let(:socure_docv_webhook_repeat_endpoints) { [] } before(:each) do allow(IdentityConfig.store).to receive(:socure_docv_enabled).and_return(true) @@ -22,6 +23,9 @@ .and_return(socure_docv_webhook_secret_key) allow(IdentityConfig.store).to receive(:socure_docv_document_request_endpoint) .and_return(fake_socure_docv_document_request_endpoint) + allow(IdentityConfig.store).to receive(:socure_docv_webhook_repeat_endpoints) + .and_return(socure_docv_webhook_repeat_endpoints) + 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) @docv_transaction_token = stub_docv_document_request @@ -44,13 +48,23 @@ end context 'rate limits calls to backend docauth vendor', allow_browser_log: true do + let(:socure_docv_webhook_repeat_endpoints) do # repeat webhooks + ['https://1.example.test/thepath', 'https://2.example.test/thepath'] + end + before do + expect(SocureDocvRepeatWebhookJob).to receive(:perform_later) + .exactly(6 * max_attempts * socure_docv_webhook_repeat_endpoints.length) + .times.and_call_original (max_attempts - 1).times do socure_docv_upload_documents(docv_transaction_token: @docv_transaction_token) end end it 'redirects to the rate limited error page' do + # recovers when fails to repeat webhook to an endpoint + allow_any_instance_of(DocAuth::Socure::WebhookRepeater) + .to receive(:send_http_post_request).and_raise('doh') expect(page).to have_current_path(fake_socure_document_capture_app_url) visit idv_socure_document_capture_path expect(page).to have_current_path(idv_socure_document_capture_path) @@ -124,6 +138,9 @@ context 'reuses valid capture app urls when appropriate', allow_browser_log: true do context 'successfully erases capture app url when flow is complete' do + before do + expect(DocAuth::Socure::WebhookRepeater).not_to receive(:new) + end it 'proceeds to the next page with valid info' do document_capture_session = DocumentCaptureSession.find_by(user_id: @user.id) expect(document_capture_session.socure_docv_capture_app_url) @@ -230,7 +247,7 @@ expect(DocAuthLog.find_by(user_id: @user.id).state).to be_nil end - xit 'does track state if state tracking is disabled' do + it 'does track state if state tracking is enabled' do allow(IdentityConfig.store).to receive(:state_tracking_enabled).and_return(true) socure_docv_upload_documents( docv_transaction_token: @docv_transaction_token, @@ -294,7 +311,14 @@ end context 'standard mobile flow' do + let(:socure_docv_webhook_repeat_endpoints) do # repeat webhooks + ['https://1.example.test/thepath', 'https://2.example.test/thepath'] + end + it 'proceeds to the next page with valid info' do + expect(SocureDocvRepeatWebhookJob).to receive(:perform_later) + .exactly(6 * socure_docv_webhook_repeat_endpoints.length).times.and_call_original + perform_in_browser(:mobile) do visit_idp_from_oidc_sp_with_ial2 @user = sign_in_and_2fa_user 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 f96d46d215c..6b61c0887a8 100644 --- a/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb +++ b/spec/features/idv/hybrid_mobile/hybrid_socure_mobile_spec.rb @@ -11,6 +11,7 @@ let(:fake_socure_docv_document_request_endpoint) { 'https://fake-socure.test/document-request' } let(:socure_docv_verification_data_test_mode) { false } let(:fake_analytics) { FakeAnalytics.new } + let(:socure_docv_webhook_repeat_endpoints) { [] } before do allow(FeatureManagement).to receive(:doc_capture_polling_enabled?).and_return(true) @@ -21,6 +22,9 @@ allow(IdentityConfig.store).to receive(:ruby_workers_idv_enabled).and_return(false) allow(IdentityConfig.store).to receive(:socure_docv_document_request_endpoint) .and_return(fake_socure_docv_document_request_endpoint) + allow(IdentityConfig.store).to receive(:socure_docv_webhook_repeat_endpoints) + .and_return(socure_docv_webhook_repeat_endpoints) + socure_docv_webhook_repeat_endpoints.each { |endpoint| stub_request(:post, endpoint) } allow(Telephony).to receive(:send_doc_auth_link).and_wrap_original do |impl, config| @sms_link = config[:link] impl.call(**config) @@ -36,6 +40,7 @@ @pass_stub = stub_docv_verification_data_pass(docv_transaction_token: @docv_transaction_token) end it 'proofs and hands off to mobile', js: true do + expect(SocureDocvRepeatWebhookJob).not_to receive(:perform_later) user = nil perform_in_browser(:desktop) do @@ -133,6 +138,7 @@ end it 'shows the waiting screen correctly after cancelling from mobile and restarting', js: true do + expect(SocureDocvRepeatWebhookJob).not_to receive(:perform_later) user = nil perform_in_browser(:desktop) do @@ -165,9 +171,11 @@ context 'user is rate limited on mobile' do let(:max_attempts) { IdentityConfig.store.doc_auth_max_attempts } + let(:socure_docv_webhook_repeat_endpoints) do # repeat webhooks + ['https://1.example.test/thepath', 'https://2.example.test/thepath'] + end before do - allow(IdentityConfig.store).to receive(:doc_auth_max_attempts).and_return(max_attempts) DocAuth::Mock::DocAuthMockClient.mock_response!( method: :post_front_image, response: DocAuth::Response.new( @@ -178,6 +186,10 @@ end it 'shows capture complete on mobile and error page on desktop', js: true do + expect(SocureDocvRepeatWebhookJob).to receive(:perform_later) + .exactly(6 * max_attempts * socure_docv_webhook_repeat_endpoints.length) + .times.and_call_original + user = nil perform_in_browser(:desktop) do @@ -212,49 +224,65 @@ end end - it 'prefills the phone number used on the phone step if the user has no MFA phone', :js do - user = create(:user, :with_authentication_app) - - perform_in_browser(:desktop) do - start_idv_from_sp(facial_match_required: false) - sign_in_and_2fa_user(user) + context 'if the user has no MFA phone' do + let(:socure_docv_webhook_repeat_endpoints) do # repeat webhooks + ['https://1.example.test/thepath', 'https://2.example.test/thepath'] + end - complete_doc_auth_steps_before_hybrid_handoff_step - clear_and_fill_in(:doc_auth_phone, phone_number) - click_send_link + before do + # recovers when fails to repeat webhook to an endpoint + allow_any_instance_of(DocAuth::Socure::WebhookRepeater) + .to receive(:send_http_post_request).and_raise('doh') end - expect(@sms_link).to be_present + it 'prefills the phone number used on the phone step', :js do + expect(SocureDocvRepeatWebhookJob).to receive(:perform_later) + .exactly(6 * socure_docv_webhook_repeat_endpoints.length) + .times.and_call_original - perform_in_browser(:mobile) do - visit @sms_link + user = create(:user, :with_authentication_app) - expect(page).to have_current_path(idv_hybrid_mobile_socure_document_capture_url) - click_idv_continue - expect(page).to have_current_path(fake_socure_document_capture_app_url) - socure_docv_upload_documents(docv_transaction_token: @docv_transaction_token) - visit idv_hybrid_mobile_socure_document_capture_update_url + perform_in_browser(:desktop) do + start_idv_from_sp(facial_match_required: false) + sign_in_and_2fa_user(user) - expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) - expect(page).to have_text(t('doc_auth.instructions.switch_back')) - end + complete_doc_auth_steps_before_hybrid_handoff_step + clear_and_fill_in(:doc_auth_phone, phone_number) + click_send_link + end - perform_in_browser(:desktop) do - expect(page).to have_current_path(idv_ssn_path, wait: 10) + expect(@sms_link).to be_present - fill_out_ssn_form_ok - click_idv_continue + perform_in_browser(:mobile) do + visit @sms_link - expect(page).to have_content(t('headings.verify')) - complete_verify_step + expect(page).to have_current_path(idv_hybrid_mobile_socure_document_capture_url) + click_idv_continue + expect(page).to have_current_path(fake_socure_document_capture_app_url) + socure_docv_upload_documents(docv_transaction_token: @docv_transaction_token) + visit idv_hybrid_mobile_socure_document_capture_update_url - prefilled_phone = page.find(id: 'idv_phone_form_phone').value + expect(page).to have_current_path(idv_hybrid_mobile_capture_complete_url) + expect(page).to have_text(t('doc_auth.instructions.switch_back')) + end - expect( - PhoneFormatter.format(prefilled_phone), - ).to eq( - PhoneFormatter.format(phone_number), - ) + perform_in_browser(:desktop) do + expect(page).to have_current_path(idv_ssn_path, wait: 10) + + fill_out_ssn_form_ok + click_idv_continue + + expect(page).to have_content(t('headings.verify')) + complete_verify_step + + prefilled_phone = page.find(id: 'idv_phone_form_phone').value + + expect( + PhoneFormatter.format(prefilled_phone), + ).to eq( + PhoneFormatter.format(phone_number), + ) + end end end @@ -303,8 +331,20 @@ context 'when an invalid test token is used' do let(:invalid_token) { 'invalid-token' } + let(:socure_docv_webhook_repeat_endpoints) do # repeat webhooks + ['https://1.example.test/thepath', 'https://2.example.test/thepath'] + end + + before do + # recovers when fails to repeat webhook + allow_any_instance_of(DocAuth::Socure::WebhookRepeater) + .to receive(:send_http_post_request).and_raise('doh') + end it 'waits to fetch verificationdata using docv capture session token', js: true do + expect(SocureDocvRepeatWebhookJob).to receive(:perform_later) + .exactly(6 * socure_docv_webhook_repeat_endpoints.length).times.and_call_original + user = nil perform_in_browser(:desktop) do diff --git a/spec/jobs/socure_docv_repeat_webhook_job_spec.rb b/spec/jobs/socure_docv_repeat_webhook_job_spec.rb new file mode 100644 index 00000000000..604b6f5ca4c --- /dev/null +++ b/spec/jobs/socure_docv_repeat_webhook_job_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SocureDocvRepeatWebhookJob do + let(:job) { described_class.new } + let(:headers) do + { + Authorization: 'auhtoriztion-header', + 'Content-Type': 'application/json', + } + end + let(:endpoint) { 'https://example.test/endpoint' } + let(:body) do + { + event: { + created: '2020-01-01T00:00:00Z', + customerUserId: 'customer-user-id', + eventType: 'REPEATED_EVENT', + docvTransactionToken: 'rando-token', + referenceId: 'reference-id', + }, + } + end + + before do + stub_request(:post, endpoint) + .with(body:, headers:) + end + + describe '#perform' do + subject(:perform) do + job.perform(body:, headers:, endpoint:) + end + + it 'repeats the webhook' do + expect(Faraday).to receive(:new).and_call_original + expect(NewRelic::Agent).not_to receive(:notice_error) + + perform + end + + context 'failed endpoint repeat' do + before do + allow_any_instance_of(DocAuth::Socure::WebhookRepeater) + .to receive(:send_http_post_request).with(endpoint).and_raise('uh-oh') + end + + it 'sends message to New Relic' do + expect(NewRelic::Agent).to receive(:notice_error) do |*args| + expect(args.first).to be_a_kind_of(RuntimeError) + expect(args.last).to eq( + { + custom_params: { + event: 'Failed to repeat webhook', + endpoint:, + body:, + }, + }, + ) + end + + perform + end + end + end +end