diff --git a/app/services/proofing/resolution/result.rb b/app/services/proofing/resolution/result.rb index 8a46a99bb92..f8d07ea6398 100644 --- a/app/services/proofing/resolution/result.rb +++ b/app/services/proofing/resolution/result.rb @@ -22,7 +22,8 @@ def initialize( reference: '', failed_result_can_pass_with_additional_verification: false, attributes_requiring_additional_verification: [], - vendor_workflow: nil + vendor_workflow: nil, + verified_attributes: nil ) @success = success @errors = errors @@ -35,6 +36,7 @@ def initialize( @attributes_requiring_additional_verification = attributes_requiring_additional_verification @vendor_workflow = vendor_workflow + @verified_attributes = verified_attributes end def success? diff --git a/app/services/proofing/socure/id_plus/config.rb b/app/services/proofing/socure/id_plus/config.rb new file mode 100644 index 00000000000..1bfaa21ac90 --- /dev/null +++ b/app/services/proofing/socure/id_plus/config.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module IdPlus + Config = RedactedStruct.new( + :api_key, + :base_url, + :timeout, + keyword_init: true, + allowed_members: [ + :base_url, + :timeout, + ], + ).freeze + end +end +end diff --git a/app/services/proofing/socure/id_plus/input.rb b/app/services/proofing/socure/id_plus/input.rb new file mode 100644 index 00000000000..28070fc51f2 --- /dev/null +++ b/app/services/proofing/socure/id_plus/input.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module IdPlus + Input = RedactedStruct.new( + :address1, + :address2, + :city, + :dob, + :first_name, + :last_name, + :middle_name, + :state, + :zipcode, + :phone, + :email, + :ssn, + keyword_init: true, + ).freeze + end + end +end diff --git a/app/services/proofing/socure/id_plus/proofer.rb b/app/services/proofing/socure/id_plus/proofer.rb new file mode 100644 index 00000000000..91edb4c4300 --- /dev/null +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module IdPlus + class Proofer + attr_reader :config + + VENDOR_NAME = 'socure_kyc' + + VERIFIED_ATTRIBUTE_MAP = { + address: %i[streetAddress city state zip].freeze, + first_name: :firstName, + last_name: :surName, + phone: :mobileNumber, + ssn: :ssn, + dob: :dob, + }.freeze + + REQUIRED_ATTRIBUTES = %i[ + first_name + last_name + address + dob + ssn + ].to_set.freeze + + # @param [Proofing::Socure::IdPlus::Config] config + def initialize(config) + @config = config + end + + # @param [Hash] applicant + # @return [Proofing::Resolution::Result] + def proof(applicant) + input = Input.new(applicant) + + request = Request.new(config:, input:) + + response = request.send_request + + build_result_from_response(response) + rescue Proofing::TimeoutError, RequestError => err + build_result_from_error(err) + end + + private + + # @param [Proofing::Socure::IdPlus::Response] response + def all_required_attributes_verified?(response) + (REQUIRED_ATTRIBUTES - verified_attributes(response)).empty? + end + + def build_result_from_error(err) + Proofing::Resolution::Result.new( + success: false, + errors: {}, + exception: err, + vendor_name: VENDOR_NAME, + transaction_id: err.respond_to?(:reference_id) ? err.reference_id : nil, + ) + end + + # @param [Proofing::Socure::IdPlus::Response] response + # @return [Proofing::Resolution::Result] + def build_result_from_response(response) + Proofing::Resolution::Result.new( + success: all_required_attributes_verified?(response), + errors: reason_codes_as_errors(response), + exception: nil, + vendor_name: VENDOR_NAME, + verified_attributes: verified_attributes(response), + transaction_id: response.reference_id, + ) + end + + # @param [Proofing::Socure::IdPlus::Response] response + # @return [Hash] + def reason_codes_as_errors(response) + { + reason_codes: response.kyc_reason_codes.sort, + } + end + + # @param [Proofing::Socure::IdPlus::Response] response + def verified_attributes(response) + VERIFIED_ATTRIBUTE_MAP.each_with_object([]) do |(attr_name, field_names), result| + if Array(field_names).all? { |f| response.kyc_field_validations[f] } + result << attr_name + end + end.to_set + end + end + end + end +end diff --git a/app/services/proofing/socure/id_plus/request.rb b/app/services/proofing/socure/id_plus/request.rb new file mode 100644 index 00000000000..d15591eb2aa --- /dev/null +++ b/app/services/proofing/socure/id_plus/request.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module IdPlus + class RequestError < StandardError + def initialize(wrapped) + @wrapped = wrapped + super(build_message) + end + + def reference_id + return @reference_id if defined?(@reference_id) + @reference_id = response_body.is_a?(Hash) ? + response_body['referenceId'] : + nil + end + + def response_body + return @response_body if defined?(@response_body) + @response_body = wrapped.try(:response_body) + end + + def response_status + return @response_status if defined?(@response_status) + @response_status = wrapped.try(:response_status) + end + + private + + attr_reader :wrapped + + def build_message + message = response_body.is_a?(Hash) ? response_body['msg'] : nil + message ||= wrapped.message + status = response_status ? " (#{response_status})" : '' + [message, status].join('') + end + end + + class Request + attr_reader :config, :input + + SERVICE_NAME = 'socure_id_plus' + + # @param [Proofing::Socure::IdPlus::Config] config + # @param [Proofing::Socure::IdPlus::Input] input + def initialize(config:, input:) + @config = config + @input = input + end + + def send_request + conn = Faraday.new do |f| + f.request :instrumentation, name: 'request_metric.faraday' + f.response :raise_error + f.response :json + f.options.timeout = config.timeout + f.options.read_timeout = config.timeout + f.options.open_timeout = config.timeout + f.options.write_timeout = config.timeout + end + + Response.new( + conn.post(url, body, headers) do |req| + req.options.context = { service_name: SERVICE_NAME } + end, + ) + rescue Faraday::BadRequestError, + Faraday::ConnectionFailed, + Faraday::ServerError, + Faraday::SSLError, + Faraday::TimeoutError, + Faraday::UnauthorizedError => e + + if timeout_error?(e) + raise ::Proofing::TimeoutError, + 'Timed out waiting for verification response' + end + + raise RequestError, e + end + + def body + @body ||= { + modules: ['kyc'], + firstName: input.first_name, + surName: input.last_name, + country: 'US', + + physicalAddress: input.address1, + physicalAddress2: input.address2, + city: input.city, + state: input.state, + zip: input.zipcode, + + nationalId: input.ssn, + dob: input.dob&.to_date&.to_s, + + userConsent: true, + consentTimestamp: 5.minutes.ago.iso8601, + + email: input.email, + mobileNumber: input.phone, + + # > The country or jurisdiction from where the transaction originates, + # > specified in ISO-2 country codes format + countryOfOrigin: 'US', + }.to_json + end + + def headers + @headers ||= { + 'Content-Type' => 'application/json', + 'Authorization' => "SocureApiKey #{config.api_key}", + } + end + + def url + @url ||= URI.join( + config.base_url, + '/api/3.0/EmailAuthScore', + ).to_s + end + + private + + # @param [Faraday::Error] err + def faraday_error_message(err) + message = begin + err.response[:body].dig('msg') + rescue + 'HTTP request failed' + end + + status = begin + err.response[:status] + rescue + 'unknown status' + end + + "#{message} (#{status})" + end + + def timeout_error?(err) + err.is_a?(Faraday::TimeoutError) || + (err.is_a?(Faraday::ConnectionFailed) && err.wrapped_exception.is_a?(Net::OpenTimeout)) + end + end + end + end +end diff --git a/app/services/proofing/socure/id_plus/response.rb b/app/services/proofing/socure/id_plus/response.rb new file mode 100644 index 00000000000..085e21c58dd --- /dev/null +++ b/app/services/proofing/socure/id_plus/response.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Proofing + module Socure + module IdPlus + class Response + # @param [Faraday::Response] http_response + def initialize(http_response) + @http_response = http_response + end + + # @return [Hash] + def kyc_field_validations + @kyc_field_validations ||= kyc('fieldValidations'). + each_with_object({}) do |(field, valid), obj| + obj[field.to_sym] = valid.round == 1 + end.freeze + end + + # @return [Set] + def kyc_reason_codes + @kyc_reason_codes ||= kyc('reasonCodes').to_set.freeze + end + + def reference_id + http_response.body['referenceId'] + end + + private + + attr_reader :http_response + + def kyc(*fields) + kyc_object = http_response.body['kyc'] + raise 'No kyc section on response' unless kyc_object + kyc_object.dig(*fields) + end + end + end + end +end diff --git a/spec/services/proofing/socure/id_plus/input_spec.rb b/spec/services/proofing/socure/id_plus/input_spec.rb new file mode 100644 index 00000000000..ba709a98f5b --- /dev/null +++ b/spec/services/proofing/socure/id_plus/input_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe Proofing::Socure::IdPlus::Input do + let(:user) { build(:user) } + + let(:state_id) do + Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE + end + + subject do + described_class.new( + **state_id.to_h.slice(*described_class.members), + email: user.email, + ) + end + + it 'creates an appropriate instance' do + expect(subject.to_h).to eql( + { + address1: '1 FAKE RD', + address2: nil, + city: 'GREAT FALLS', + state: 'MT', + zipcode: '59010-1234', + + first_name: 'FAKEY', + last_name: 'MCFAKERSON', + middle_name: nil, + + dob: '1938-10-06', + + phone: '12025551212', + ssn: '900-66-1234', + email: user.email, + }, + ) + end +end diff --git a/spec/services/proofing/socure/id_plus/proofer_spec.rb b/spec/services/proofing/socure/id_plus/proofer_spec.rb new file mode 100644 index 00000000000..97e3d3a1bd6 --- /dev/null +++ b/spec/services/proofing/socure/id_plus/proofer_spec.rb @@ -0,0 +1,287 @@ +require 'rails_helper' + +RSpec.describe Proofing::Socure::IdPlus::Proofer do + let(:config) do + end + + let(:proofer) do + described_class.new(config) + end + + let(:applicant) do + {} + end + + let(:api_key) { 'super-$ecret' } + + let(:base_url) { 'https://example.org/' } + + let(:config) do + Proofing::Socure::IdPlus::Config.new( + api_key:, + base_url:, + ) + end + + let(:result) do + proofer.proof(applicant) + end + + let(:response_status) { 200 } + + let(:field_validation_overrides) { {} } + + let(:response_body) do + { + 'referenceId' => 'a-really-unique-id', + 'kyc' => { + 'reasonCodes' => [ + 'I919', + 'I914', + 'I905', + ], + 'fieldValidations' => { + 'firstName' => 0.99, + 'surName' => 0.99, + 'streetAddress' => 0.99, + 'city' => 0.99, + 'state' => 0.99, + 'zip' => 0.99, + 'mobileNumber' => 0.99, + 'dob' => 0.99, + 'ssn' => 0.99, + }.merge(field_validation_overrides), + }, + } + end + + before do + using_json = !response_body.is_a?(String) + + stub_request(:post, URI.join(base_url, '/api/3.0/EmailAuthScore').to_s). + to_return( + status: response_status, + headers: { + 'Content-Type' => using_json ? + 'application/json' : + 'text/html', + }, + body: using_json ? JSON.generate(response_body) : response_body, + ) + end + + it 'reports reason codes as errors' do + expect(result.errors).to eql( + { + reason_codes: [ + 'I905', + 'I914', + 'I919', + ], + }, + ) + end + + context 'when user is 100% matched' do + it 'returns a resolution result' do + expect(result).to be_an_instance_of(Proofing::Resolution::Result) + end + + describe 'the result' do + it 'is successful' do + expect(result.success).to eql(true) + end + + it 'has a vendor name' do + expect(result.vendor_name).to eql('socure_kyc') + end + + it 'has a transaction id' do + expect(result.transaction_id).to eql('a-really-unique-id') + end + + it('has verified attributes') do + expect(result.verified_attributes).to eql( + %i[ + first_name + last_name + address + phone + dob + ssn + ].to_set, + ) + end + end + end + + context 'when parts of address do not match' do + context '(streetAddress)' do + let(:field_validation_overrides) { { 'streetAddress' => 0.01 } } + it 'is not successful' do + expect(result.success).to eql(false) + end + it 'address is not verified' do + expect(result.verified_attributes).not_to include(:address) + end + end + context '(city)' do + let(:field_validation_overrides) { { 'city' => 0.01 } } + it 'is not successful' do + expect(result.success).to eql(false) + end + it 'address is not verified' do + expect(result.verified_attributes).not_to include(:address) + end + end + context '(state)' do + let(:field_validation_overrides) { { 'state' => 0.01 } } + it 'is not successful' do + expect(result.success).to eql(false) + end + it 'address is not verified' do + expect(result.verified_attributes).not_to include(:address) + end + end + context '(zip)' do + let(:field_validation_overrides) { { 'zip' => 0.01 } } + it 'is not successful' do + expect(result.success).to eql(false) + end + it 'address is not verified' do + expect(result.verified_attributes).not_to include(:address) + end + end + end + + context 'when dob does not match' do + let(:field_validation_overrides) { { 'dob' => 0.01 } } + it 'is not successful' do + expect(result.success).to eql(false) + end + it 'address is not verified' do + expect(result.verified_attributes).not_to include(:dob) + end + end + + context 'when ssn does not match' do + let(:field_validation_overrides) { { 'ssn' => 0.01 } } + it 'is not successful' do + expect(result.success).to eql(false) + end + it 'address is not verified' do + expect(result.verified_attributes).not_to include(:ssn) + end + end + + context 'when request times out' do + before do + stub_request(:post, URI.join(base_url, '/api/3.0/EmailAuthScore').to_s). + to_timeout + end + + describe 'the result' do + it 'is not successful' do + expect(result.success).to eql(false) + end + + it 'has a vendor name' do + expect(result.vendor_name).to eql('socure_kyc') + end + + it 'does not have transaction id' do + expect(result.transaction_id).to be_nil + end + + it 'includes exception details' do + expect(result.exception).to be_an_instance_of(Proofing::TimeoutError) + end + end + end + + context 'when request returns HTTP 400' do + let(:response_status) { 400 } + let(:response_body) do + { + status: 'Error', + referenceId: 'a-big-unique-reference-id', + data: { + parameters: ['firstName'], + }, + msg: 'Request-specific error message goes here', + } + end + + describe 'the result' do + it 'is not successful' do + expect(result.success).to eql(false) + end + + it 'has a vendor name' do + expect(result.vendor_name).to eql('socure_kyc') + end + + it 'has a transaction id' do + expect(result.transaction_id).to eql('a-big-unique-reference-id') + end + + it 'includes exception details' do + expect(result.exception).to be_an_instance_of(Proofing::Socure::IdPlus::RequestError) + end + end + end + + context 'when request returns HTTP 401' do + let(:response_status) { 401 } + let(:response_body) do + { + status: 'Error', + referenceId: 'a-big-unique-reference-id', + msg: 'Request-specific error message goes here', + } + end + + describe 'the result' do + it 'is not successful' do + expect(result.success).to eql(false) + end + + it 'has a vendor name' do + expect(result.vendor_name).to eql('socure_kyc') + end + + it 'has a transaction id' do + expect(result.transaction_id).to eql('a-big-unique-reference-id') + end + + it 'includes exception details' do + expect(result.exception).to be_an_instance_of(Proofing::Socure::IdPlus::RequestError) + end + end + end + + context 'when request returns a weird non-JSON HTTP 500' do + let(:response_status) { 500 } + let(:response_body) do + 'It works!' + end + + describe 'the result' do + it 'is not successful' do + expect(result.success).to eql(false) + end + + it 'has a vendor name' do + expect(result.vendor_name).to eql('socure_kyc') + end + + it 'does not have a transaction id' do + expect(result.transaction_id).to be_nil + end + + it 'includes exception details' do + expect(result.exception).to be_an_instance_of(Proofing::Socure::IdPlus::RequestError) + end + end + end +end diff --git a/spec/services/proofing/socure/id_plus/request_spec.rb b/spec/services/proofing/socure/id_plus/request_spec.rb new file mode 100644 index 00000000000..4c0fd4ec867 --- /dev/null +++ b/spec/services/proofing/socure/id_plus/request_spec.rb @@ -0,0 +1,242 @@ +require 'rails_helper' + +RSpec.describe Proofing::Socure::IdPlus::Request do + let(:config) do + Proofing::Socure::IdPlus::Config.new( + api_key:, + base_url:, + timeout:, + ) + end + let(:api_key) { 'super-$ecret' } + let(:base_url) { 'https://example.org/' } + let(:timeout) { 5 } + let(:user) { build(:user) } + let(:input) do + Proofing::Socure::IdPlus::Input.new( + email: user.email, + **Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE.slice( + *Proofing::Socure::IdPlus::Input.members, + ), + ) + end + + subject(:request) do + described_class.new(config:, input:) + end + + describe '#body' do + it 'contains all expected values' do + freeze_time do + expect(JSON.parse(request.body, symbolize_names: true)).to eql( + { + modules: [ + 'kyc', + ], + firstName: 'FAKEY', + surName: 'MCFAKERSON', + dob: '1938-10-06', + physicalAddress: '1 FAKE RD', + physicalAddress2: nil, + city: 'GREAT FALLS', + state: 'MT', + zip: '59010-1234', + country: 'US', + nationalId: Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE[:ssn], + countryOfOrigin: 'US', + + email: user.email, + mobileNumber: Idp::Constants::MOCK_IDV_APPLICANT_WITH_PHONE[:phone], + + userConsent: true, + + # XXX: This should be set to the time the user submitted agreement, + # which we are not currently tracking. The "5.minutes.ago" is + # because Socure will reject times "in the future", so we avoid + # our clocks being out of sync with theirs. + consentTimestamp: 5.minutes.ago.iso8601, + }, + ) + end + end + end + + describe '#headers' do + it 'includes appropriate Content-Type header' do + expect(request.headers).to include('Content-Type' => 'application/json') + end + + it 'includes appropriate Authorization header' do + expect(request.headers).to include('Authorization' => "SocureApiKey #{api_key}") + end + end + + describe '#send_request' do + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_return( + headers: { + 'Content-Type' => 'application/json', + }, + body: JSON.generate( + { + referenceId: 'a-big-unique-reference-id', + kyc: { + reasonCodes: [ + 'I100', + 'R200', + ], + fieldValidations: { + firstName: 0.99, + surName: 0.99, + streetAddress: 0.99, + city: 0.99, + state: 0.99, + zip: 0.99, + mobileNumber: 0.99, + dob: 0.99, + ssn: 0.99, + }, + }, + }, + ), + ) + end + + it 'includes API key' do + request.send_request + + expect(WebMock).to have_requested( + :post, 'https://example.org/api/3.0/EmailAuthScore' + ).with(headers: { 'Authorization' => "SocureApiKey #{api_key}" }) + end + + it 'includes JSON serialized body' do + request.send_request + + expect(WebMock).to have_requested( + :post, 'https://example.org/api/3.0/EmailAuthScore' + ).with(body: request.body) + end + + context 'when service returns HTTP 200 response' do + it 'method returns a Proofing::Socure::IdPlus::Response' do + res = request.send_request + expect(res).to be_a(Proofing::Socure::IdPlus::Response) + end + + it 'response has kyc data' do + res = request.send_request + expect(res.kyc_field_validations).to be + expect(res.kyc_reason_codes).to be + end + end + + context 'when service returns an HTTP 400 response' do + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_return( + status: 400, + headers: { + 'Content-Type' => 'application/json', + }, + body: JSON.generate( + { + status: 'Error', + referenceId: 'a-big-unique-reference-id', + data: { + parameters: ['firstName'], + }, + msg: 'Request-specific error message goes here', + }, + ), + ) + end + + it 'raises RequestError' do + expect do + request.send_request + end.to raise_error( + Proofing::Socure::IdPlus::RequestError, + 'Request-specific error message goes here (400)', + ) + end + + it 'includes reference_id on RequestError' do + expect do + request.send_request + end.to raise_error( + Proofing::Socure::IdPlus::RequestError, + ) do |err| + expect(err.reference_id).to eql('a-big-unique-reference-id') + end + end + end + + context 'when service returns an HTTP 401 reponse' do + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_return( + status: 401, + headers: { + 'Content-Type' => 'application/json', + }, + body: JSON.generate( + { + status: 'Error', + referenceId: 'a-big-unique-reference-id', + msg: 'Request-specific error message goes here', + }, + ), + ) + end + + it 'raises RequestError' do + expect do + request.send_request + end.to raise_error( + Proofing::Socure::IdPlus::RequestError, + 'Request-specific error message goes here (401)', + ) + end + end + + context 'when service returns weird HTTP 500 response' do + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_return( + status: 500, + body: 'It works!', + ) + end + + it 'raises RequestError' do + expect do + request.send_request + end.to raise_error(Proofing::Socure::IdPlus::RequestError) + end + end + + context 'when request times out' do + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_timeout + end + + it 'raises a ProofingTimeoutError' do + expect { request.send_request }.to raise_error Proofing::TimeoutError + end + end + + context 'when connection is reset' do + before do + stub_request(:post, 'https://example.org/api/3.0/EmailAuthScore'). + to_raise(Errno::ECONNRESET) + end + + it 'raises a RequestError' do + expect { request.send_request }.to raise_error Proofing::Socure::IdPlus::RequestError + end + end + end +end diff --git a/spec/services/proofing/socure/id_plus/response_spec.rb b/spec/services/proofing/socure/id_plus/response_spec.rb new file mode 100644 index 00000000000..c41fd1d3037 --- /dev/null +++ b/spec/services/proofing/socure/id_plus/response_spec.rb @@ -0,0 +1,95 @@ +require 'rails_helper' + +RSpec.describe Proofing::Socure::IdPlus::Response do + let(:response_body) do + { + 'referenceId' => 'a1234b56-e789-0123-4fga-56b7c890d123', + 'kyc' => { + 'reasonCodes' => [ + 'I919', + 'I914', + 'I905', + ], + 'fieldValidations' => { + 'firstName' => 0.99, + 'surName' => 0.99, + 'streetAddress' => 0.99, + 'city' => 0.01, + 'state' => 0.01, + 'zip' => 0.01, + 'mobileNumber' => 0.99, + 'dob' => 0.99, + 'ssn' => 0.99, + }, + }, + } + end + + let(:http_response) do + instance_double(Faraday::Response).tap do |r| + allow(r).to receive(:body).and_return(response_body) + end + end + + subject do + described_class.new(http_response) + end + + describe '#reference_id' do + it 'returns referenceId' do + expect(subject.reference_id).to eql('a1234b56-e789-0123-4fga-56b7c890d123') + end + end + + describe '#kyc_reason_codes' do + it 'returns the correct reason codes' do + expect(subject.kyc_reason_codes).to contain_exactly( + 'I919', + 'I914', + 'I905', + ) + end + + context 'no kyc section on response' do + let(:response_body) do + {} + end + + it 'raises an error' do + expect do + subject.kyc_reason_codes + end.to raise_error(RuntimeError) + end + end + end + + describe '#kyc_field_validations' do + it 'returns an object with actual booleans' do + expect(subject.kyc_field_validations).to eql( + { + firstName: true, + surName: true, + streetAddress: true, + city: false, + state: false, + zip: false, + mobileNumber: true, + dob: true, + ssn: true, + }, + ) + end + + context 'no kyc section on response' do + let(:response_body) do + {} + end + + it 'raises an error' do + expect do + subject.kyc_field_validations + end.to raise_error(RuntimeError) + end + end + end +end