From f5619636514fbba0ea24de77134321e4a6b3f4c9 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Wed, 14 Aug 2024 15:53:58 -0700 Subject: [PATCH 1/9] Socure KYC Proofer - Basic Proofer implementation for Socure KYC. - Request / Response classes, error handling, etc. changelog: Upcoming Features, Identity verification, Implement proofer for Socure KYC --- app/services/proofing/resolution/result.rb | 4 +- .../proofing/socure/id_plus/config.rb | 18 ++ app/services/proofing/socure/id_plus/input.rb | 21 ++ .../proofing/socure/id_plus/proofer.rb | 94 ++++++ .../proofing/socure/id_plus/request.rb | 158 ++++++++++ .../proofing/socure/id_plus/response.rb | 41 +++ .../proofing/socure/id_plus/input_spec.rb | 38 +++ .../proofing/socure/id_plus/proofer_spec.rb | 287 ++++++++++++++++++ .../proofing/socure/id_plus/request_spec.rb | 243 +++++++++++++++ .../proofing/socure/id_plus/response_spec.rb | 95 ++++++ 10 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 app/services/proofing/socure/id_plus/config.rb create mode 100644 app/services/proofing/socure/id_plus/input.rb create mode 100644 app/services/proofing/socure/id_plus/proofer.rb create mode 100644 app/services/proofing/socure/id_plus/request.rb create mode 100644 app/services/proofing/socure/id_plus/response.rb create mode 100644 spec/services/proofing/socure/id_plus/input_spec.rb create mode 100644 spec/services/proofing/socure/id_plus/proofer_spec.rb create mode 100644 spec/services/proofing/socure/id_plus/request_spec.rb create mode 100644 spec/services/proofing/socure/id_plus/response_spec.rb 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..023990a8aac --- /dev/null +++ b/app/services/proofing/socure/id_plus/input.rb @@ -0,0 +1,21 @@ +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..884b7236955 --- /dev/null +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -0,0 +1,94 @@ +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 [] applicant + # @returns [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 + # @returns [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 + # @returns [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.wrap(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..cdce2c0a8f0 --- /dev/null +++ b/app/services/proofing/socure/id_plus/request.rb @@ -0,0 +1,158 @@ +# 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 = if wrapped.respond_to?(:response_body) + wrapped.response_body + end + end + + def response_status + return @response_status if defined?(@response_status) + @response_status = if wrapped.respond_to?(:response_status) + wrapped.response_status + end + 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 ||= begin + { + 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: Time.zone.now.to_date.to_s, + + 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 + 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..efd4a072dc1 --- /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 + @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..90ead3b39f8 --- /dev/null +++ b/spec/services/proofing/socure/id_plus/request_spec.rb @@ -0,0 +1,243 @@ +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 do + described_class.new(config:, input:) + end + + describe '#body' do + it 'looks right' do + freeze_time do + expect(JSON.parse(subject.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 + consentTimestamp: Time.zone.now.to_date.to_s, + }, + ) + end + end + end + + describe '#headers' do + it 'look right' do + expect(subject.headers).to eql( + 'Content-Type' => 'application/json', + '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 + subject.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 + subject.send_request + + expect(WebMock).to have_requested( + :post, 'https://example.org/api/3.0/EmailAuthScore' + ).with(body: subject.body) + end + + context 'when service returns HTTP 200 response' do + it 'method returns a Proofing::Socure::IdPlus::Response' do + res = subject.send_request + expect(res).to be_a(Proofing::Socure::IdPlus::Response) + end + + it 'response has kyc data' do + res = subject.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 + subject.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 + subject.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 + subject.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 + subject.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 { subject.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 { subject.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 From 44fd9142b017e284c601a2c8133e351b430ccccd Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:11:04 -0700 Subject: [PATCH 2/9] Updates after manual integration test - Lint fixes - Move temporary consent timestamp back a little bit --- app/services/proofing/socure/id_plus/input.rb | 2 ++ app/services/proofing/socure/id_plus/proofer.rb | 4 +++- app/services/proofing/socure/id_plus/request.rb | 2 +- app/services/proofing/socure/id_plus/response.rb | 2 +- spec/services/proofing/socure/id_plus/request_spec.rb | 7 +++++-- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/services/proofing/socure/id_plus/input.rb b/app/services/proofing/socure/id_plus/input.rb index 023990a8aac..28070fc51f2 100644 --- a/app/services/proofing/socure/id_plus/input.rb +++ b/app/services/proofing/socure/id_plus/input.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Proofing module Socure module IdPlus diff --git a/app/services/proofing/socure/id_plus/proofer.rb b/app/services/proofing/socure/id_plus/proofer.rb index 884b7236955..11468f61e92 100644 --- a/app/services/proofing/socure/id_plus/proofer.rb +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Proofing module Socure module IdPlus @@ -28,7 +30,7 @@ def initialize(config) @config = config end - # @param [] applicant + # @param [Hash] applicant # @returns [Proofing::Resolution::Result] def proof(applicant) input = Input.new(applicant) diff --git a/app/services/proofing/socure/id_plus/request.rb b/app/services/proofing/socure/id_plus/request.rb index cdce2c0a8f0..d43e29fb344 100644 --- a/app/services/proofing/socure/id_plus/request.rb +++ b/app/services/proofing/socure/id_plus/request.rb @@ -103,7 +103,7 @@ def body dob: input.dob&.to_date&.to_s, userConsent: true, - consentTimestamp: Time.zone.now.to_date.to_s, + consentTimestamp: 5.minutes.ago.iso8601, email: input.email, mobileNumber: input.phone, diff --git a/app/services/proofing/socure/id_plus/response.rb b/app/services/proofing/socure/id_plus/response.rb index efd4a072dc1..085e21c58dd 100644 --- a/app/services/proofing/socure/id_plus/response.rb +++ b/app/services/proofing/socure/id_plus/response.rb @@ -11,7 +11,7 @@ def initialize(http_response) # @return [Hash] def kyc_field_validations - @field_validations ||= kyc('fieldValidations'). + @kyc_field_validations ||= kyc('fieldValidations'). each_with_object({}) do |(field, valid), obj| obj[field.to_sym] = valid.round == 1 end.freeze diff --git a/spec/services/proofing/socure/id_plus/request_spec.rb b/spec/services/proofing/socure/id_plus/request_spec.rb index 90ead3b39f8..221e446f11b 100644 --- a/spec/services/proofing/socure/id_plus/request_spec.rb +++ b/spec/services/proofing/socure/id_plus/request_spec.rb @@ -55,8 +55,11 @@ userConsent: true, - # XXX: This should be set to the time the user submitted agreement - consentTimestamp: Time.zone.now.to_date.to_s, + # 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 From bd9e8b49d134401e497d8ee3156dba8da65305ca Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:13:00 -0700 Subject: [PATCH 3/9] In ruby we just say @return --- app/services/proofing/socure/id_plus/proofer.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/proofing/socure/id_plus/proofer.rb b/app/services/proofing/socure/id_plus/proofer.rb index 11468f61e92..7f0527f4ac9 100644 --- a/app/services/proofing/socure/id_plus/proofer.rb +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -31,7 +31,7 @@ def initialize(config) end # @param [Hash] applicant - # @returns [Proofing::Resolution::Result] + # @return [Proofing::Resolution::Result] def proof(applicant) input = Input.new(applicant) @@ -62,7 +62,7 @@ def build_result_from_error(err) end # @param [Proofing::Socure::IdPlus::Response] response - # @returns [Proofing::Resolution::Result] + # @return [Proofing::Resolution::Result] def build_result_from_response(response) Proofing::Resolution::Result.new( success: all_required_attributes_verified?(response), @@ -75,7 +75,7 @@ def build_result_from_response(response) end # @param [Proofing::Socure::IdPlus::Response] response - # @returns [Hash] + # @return [Hash] def reason_codes_as_errors(response) { reason_codes: response.kyc_reason_codes.sort, From dce1b7ca7bb07f696a06843aa137dabf2b0535ca Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:14:33 -0700 Subject: [PATCH 4/9] Array.wrap -> Array --- app/services/proofing/socure/id_plus/proofer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/proofing/socure/id_plus/proofer.rb b/app/services/proofing/socure/id_plus/proofer.rb index 7f0527f4ac9..91edb4c4300 100644 --- a/app/services/proofing/socure/id_plus/proofer.rb +++ b/app/services/proofing/socure/id_plus/proofer.rb @@ -85,7 +85,7 @@ def reason_codes_as_errors(response) # @param [Proofing::Socure::IdPlus::Response] response def verified_attributes(response) VERIFIED_ATTRIBUTE_MAP.each_with_object([]) do |(attr_name, field_names), result| - if Array.wrap(field_names).all? { |f| response.kyc_field_validations[f] } + if Array(field_names).all? { |f| response.kyc_field_validations[f] } result << attr_name end end.to_set From 61315d3f7062a3d81121495cfe27e1c84e7bc55e Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:16:37 -0700 Subject: [PATCH 5/9] Clean up accessors with .try --- app/services/proofing/socure/id_plus/request.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/services/proofing/socure/id_plus/request.rb b/app/services/proofing/socure/id_plus/request.rb index d43e29fb344..fd08147eddf 100644 --- a/app/services/proofing/socure/id_plus/request.rb +++ b/app/services/proofing/socure/id_plus/request.rb @@ -18,16 +18,12 @@ def reference_id def response_body return @response_body if defined?(@response_body) - @response_body = if wrapped.respond_to?(:response_body) - wrapped.response_body - end + @response_body = wrapped.try(:response_body) end def response_status return @response_status if defined?(@response_status) - @response_status = if wrapped.respond_to?(:response_status) - wrapped.response_status - end + @response_status = wrapped.try(:response_status) end private From 332d62670025e0824323121f8387ed9ae47bc25e Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:17:32 -0700 Subject: [PATCH 6/9] Remove pointless begin / end --- .../proofing/socure/id_plus/request.rb | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/app/services/proofing/socure/id_plus/request.rb b/app/services/proofing/socure/id_plus/request.rb index fd08147eddf..d15591eb2aa 100644 --- a/app/services/proofing/socure/id_plus/request.rb +++ b/app/services/proofing/socure/id_plus/request.rb @@ -82,33 +82,31 @@ def send_request end def body - @body ||= begin - { - 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 + @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 From 15d27c418d9be754f146b2ae75c1972fce4549f8 Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:20:06 -0700 Subject: [PATCH 7/9] Use named subject in request spec --- .../proofing/socure/id_plus/request_spec.rb | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/spec/services/proofing/socure/id_plus/request_spec.rb b/spec/services/proofing/socure/id_plus/request_spec.rb index 221e446f11b..43d3f07803e 100644 --- a/spec/services/proofing/socure/id_plus/request_spec.rb +++ b/spec/services/proofing/socure/id_plus/request_spec.rb @@ -26,14 +26,14 @@ ) end - subject do + subject(:request) do described_class.new(config:, input:) end describe '#body' do it 'looks right' do freeze_time do - expect(JSON.parse(subject.body, symbolize_names: true)).to eql( + expect(JSON.parse(request.body, symbolize_names: true)).to eql( { modules: [ 'kyc', @@ -68,7 +68,7 @@ describe '#headers' do it 'look right' do - expect(subject.headers).to eql( + expect(request.headers).to eql( 'Content-Type' => 'application/json', 'Authorization' => "SocureApiKey #{api_key}", ) @@ -108,7 +108,7 @@ end it 'includes API key' do - subject.send_request + request.send_request expect(WebMock).to have_requested( :post, 'https://example.org/api/3.0/EmailAuthScore' @@ -116,21 +116,21 @@ end it 'includes JSON serialized body' do - subject.send_request + request.send_request expect(WebMock).to have_requested( :post, 'https://example.org/api/3.0/EmailAuthScore' - ).with(body: subject.body) + ).with(body: request.body) end context 'when service returns HTTP 200 response' do it 'method returns a Proofing::Socure::IdPlus::Response' do - res = subject.send_request + res = request.send_request expect(res).to be_a(Proofing::Socure::IdPlus::Response) end it 'response has kyc data' do - res = subject.send_request + res = request.send_request expect(res.kyc_field_validations).to be expect(res.kyc_reason_codes).to be end @@ -159,7 +159,7 @@ it 'raises RequestError' do expect do - subject.send_request + request.send_request end.to raise_error( Proofing::Socure::IdPlus::RequestError, 'Request-specific error message goes here (400)', @@ -168,7 +168,7 @@ it 'includes reference_id on RequestError' do expect do - subject.send_request + request.send_request end.to raise_error( Proofing::Socure::IdPlus::RequestError, ) do |err| @@ -197,7 +197,7 @@ it 'raises RequestError' do expect do - subject.send_request + request.send_request end.to raise_error( Proofing::Socure::IdPlus::RequestError, 'Request-specific error message goes here (401)', @@ -216,7 +216,7 @@ it 'raises RequestError' do expect do - subject.send_request + request.send_request end.to raise_error(Proofing::Socure::IdPlus::RequestError) end end @@ -228,7 +228,7 @@ end it 'raises a ProofingTimeoutError' do - expect { subject.send_request }.to raise_error Proofing::TimeoutError + expect { request.send_request }.to raise_error Proofing::TimeoutError end end @@ -239,7 +239,7 @@ end it 'raises a RequestError' do - expect { subject.send_request }.to raise_error Proofing::Socure::IdPlus::RequestError + expect { request.send_request }.to raise_error Proofing::Socure::IdPlus::RequestError end end end From 7d2ee0f823b63857dc3bb1e84bb36f544cee0fab Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Thu, 15 Aug 2024 17:20:43 -0700 Subject: [PATCH 8/9] Remove empty lines --- spec/services/proofing/socure/id_plus/request_spec.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/spec/services/proofing/socure/id_plus/request_spec.rb b/spec/services/proofing/socure/id_plus/request_spec.rb index 43d3f07803e..ec4e276e144 100644 --- a/spec/services/proofing/socure/id_plus/request_spec.rb +++ b/spec/services/proofing/socure/id_plus/request_spec.rb @@ -8,15 +8,10 @@ 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, From 06f4bf6546c1b93613951904a207a610978145bc Mon Sep 17 00:00:00 2001 From: Matt Hinz Date: Tue, 20 Aug 2024 10:15:12 -0700 Subject: [PATCH 9/9] Improve test names "it does the thing" is not good enough --- .../proofing/socure/id_plus/request_spec.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/spec/services/proofing/socure/id_plus/request_spec.rb b/spec/services/proofing/socure/id_plus/request_spec.rb index ec4e276e144..4c0fd4ec867 100644 --- a/spec/services/proofing/socure/id_plus/request_spec.rb +++ b/spec/services/proofing/socure/id_plus/request_spec.rb @@ -26,7 +26,7 @@ end describe '#body' do - it 'looks right' do + it 'contains all expected values' do freeze_time do expect(JSON.parse(request.body, symbolize_names: true)).to eql( { @@ -62,11 +62,12 @@ end describe '#headers' do - it 'look right' do - expect(request.headers).to eql( - 'Content-Type' => 'application/json', - 'Authorization' => "SocureApiKey #{api_key}", - ) + 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