From 65e00aa05c856cd2fe57486e23eca61a2efcd273 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:45:36 +0000 Subject: [PATCH 1/9] Send boolean inputs as JSON booleans Request components dropped every false value and sent most booleans as strings. Only was_authorized, is_gift, and has_gift_message kept true as a JSON boolean, so was_3d_secure_successful and boolean custom inputs went out as "true". Keep any true value as a JSON boolean, and send false for boolean inputs and custom inputs. Other inputs still omit false. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 ++++++++++ lib/minfraud/components/base.rb | 21 +++++------ lib/minfraud/validates.rb | 38 +++++++++++--------- spec/components/base_spec.rb | 62 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb709cd..4883d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v2.11.0 + +* Boolean inputs are now always sent as JSON booleans, and `false` is no + longer dropped. Previously, every input set to `false` was omitted. + Now only `nil` inputs are omitted. In particular: + * `true` for `/credit_card/was_3d_secure_successful` was sent as the + string `"true"`, which the web service rejected and dropped. It is + now sent as a JSON boolean, so it reaches the web service. + * Custom input values of `true` and `false` are now sent as JSON + booleans, and `false` is no longer dropped. + * **Warning:** If a custom input is a string type on your account, a + Ruby `true` or `false` for it is now sent as a JSON boolean. The web + service rejects a boolean for a string custom input with an + `INPUT_INVALID` warning. Pass a string, such as `'true'`, instead. + * With validation on, `true` or `false` for a non-boolean input now + raises `InvalidInputError`. + * `/order/has_gift_message`, `/order/is_gift`, and + `/payment/was_authorized` already sent `true` as a JSON boolean. + ## v2.10.0 (2026-07-21) * Added the `residential` attribute to the `anonymizer` object on diff --git a/lib/minfraud/components/base.rb b/lib/minfraud/components/base.rb index 06ccc4f..0556ef7 100644 --- a/lib/minfraud/components/base.rb +++ b/lib/minfraud/components/base.rb @@ -26,27 +26,22 @@ def to_json(*_args) # # @return [Hash] def populate!(hash, v_sym) - return hash unless (value = instance_variable_get(v_sym)) + value = instance_variable_get(v_sym) + key = v_sym.to_s.gsub(/@/, '') + return hash if value.nil? - key = v_sym.to_s.gsub(/@/, '') - hash.merge!(key => represent(key, value)) + hash.merge!(key => represent(value)) end - # Return the value according to the request format. - # - # @param key [Symbol] An instance variable symbol. + # Return the value according to the request format. Booleans stay + # booleans. Other values become strings. # # @param value [Object] An instance variable value. # # @return [Object] - def represent(key, value) - BOOLS.include?(key) ? value : value.to_s + def represent(value) + [true, false].include?(value) ? value : value.to_s end - - # Keys that have to remain boolean - BOOLS = %w[was_authorized is_gift has_gift_message].freeze - - private_constant :BOOLS end end end diff --git a/lib/minfraud/validates.rb b/lib/minfraud/validates.rb index 4748766..a7a5161 100644 --- a/lib/minfraud/validates.rb +++ b/lib/minfraud/validates.rb @@ -22,7 +22,11 @@ module Validates # # @return [nil] def validate_string(field, length, value) - return if !value + return if value.nil? + + if [true, false].include?(value) + raise InvalidInputError, "The #{field} value is not valid. It must not be a boolean." + end if value.to_s.length > length raise InvalidInputError, "The #{field} value is not valid. The maximum length is #{length}." @@ -38,7 +42,7 @@ def validate_string(field, length, value) # # @return [nil] def validate_md5(field, value) - return if !value + return if value.nil? if !value.to_s.match(/\A[a-fA-F0-9]{32}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an MD5 hash as a string." @@ -54,7 +58,7 @@ def validate_md5(field, value) # # @return [nil] def validate_uuid(field, value) - return if !value + return if value.nil? stripped_value = value.to_s.delete('-') @@ -75,7 +79,7 @@ def validate_uuid(field, value) # # @return [nil] def validate_subdivision_code(field, value) - return if !value + return if value.nil? if !value.to_s.match(/\A[0-9A-Z]{1,4}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an ISO 3166-2 subdivision code." @@ -91,7 +95,7 @@ def validate_subdivision_code(field, value) # # @return [nil] def validate_country_code(field, value) - return if !value + return if value.nil? if !value.to_s.match(/\A[A-Z]{2}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an ISO 3166-1 alpha-2 country code." @@ -108,7 +112,7 @@ def validate_country_code(field, value) # # @return [nil] def validate_telephone_country_code(field, value) - return if !value + return if value.nil? if !value.to_s.match(/\A[0-9]{1,4}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be at most 4 digits." @@ -125,7 +129,7 @@ def validate_telephone_country_code(field, value) # # @return [nil] def validate_regex(field, regex, value) - return if !value + return if value.nil? if !regex.match(value.to_s) raise InvalidInputError, "The #{field} value is not valid. It must match the pattern #{regex}." @@ -141,7 +145,7 @@ def validate_regex(field, regex, value) # # @return [nil] def validate_credit_card_token(field, value) - return if !value + return if value.nil? s = value.to_s @@ -163,7 +167,7 @@ def validate_credit_card_token(field, value) # # @return [nil] def validate_custom_input_value(field, value) - return if !value + return if value.nil? if [true, false].include?(value) return @@ -189,7 +193,7 @@ def validate_custom_input_value(field, value) # # @return [nil] def validate_ip(field, value) - return if !value + return if value.nil? s = value.to_s if s.include? '/' @@ -216,7 +220,7 @@ def validate_ip(field, value) # # @return [nil] def validate_nonnegative_number(field, value) - return if !value + return if value.nil? if !value.is_a? Numeric raise InvalidInputError, "The #{field} value is not valid. It must be numeric." @@ -237,7 +241,7 @@ def validate_nonnegative_number(field, value) # # @return [nil] def validate_nonnegative_integer(field, value) - return if !value + return if value.nil? if !value.is_a? Integer raise InvalidInputError, "The #{field} is not valid. It must be an integer." @@ -257,9 +261,9 @@ def validate_nonnegative_integer(field, value) # # @return [nil] def validate_email(field, value) - return if !value + return if value.nil? - if /.@./.match?(value) + if /.@./.match?(value.to_s) validate_string(field, 255, value) return end @@ -276,7 +280,7 @@ def validate_email(field, value) # # @return [nil] def validate_rfc3339(field, value) - return if !value + return if value.nil? # rubocop:disable-next Style/RescueStandardError begin @@ -297,7 +301,7 @@ def validate_rfc3339(field, value) # # @return [nil] def validate_boolean(field, value) - return if !value + return if value.nil? if ![false, true].include? value raise InvalidInputError, "The #{field} value is not valid. It must be boolean." @@ -313,7 +317,7 @@ def validate_boolean(field, value) # # @return [nil] def validate_uri(field, value) - return if !value + return if value.nil? if value.to_s.length > 1_024 raise InvalidInputError, "The #{field} value is not valid. It must not exceed 1024 characters." diff --git a/spec/components/base_spec.rb b/spec/components/base_spec.rb index dac2448..afc5411 100644 --- a/spec/components/base_spec.rb +++ b/spec/components/base_spec.rb @@ -29,6 +29,68 @@ base_component.instance_variable_set(:@null, nil) expect(base_component.to_json).to eq(expected) end + + it 'returns json with true kept as a boolean' do + base_component.instance_variable_set(:@yes, true) + expect(base_component.to_json).to eq(expected.merge('yes' => true)) + end + + it 'returns json with false kept as a boolean' do + base_component.instance_variable_set(:@no, false) + expect(base_component.to_json).to eq(expected.merge('no' => false)) + end + end + end + + describe 'boolean inputs' do + [ + [Minfraud::Components::CreditCard, :was_3d_secure_successful], + [Minfraud::Components::Order, :has_gift_message], + [Minfraud::Components::Order, :is_gift], + [Minfraud::Components::Payment, :was_authorized], + [Minfraud::Components::CustomInputs, :boolean_input], + ].product([true, false]).each do |(component, field), value| + it "sends #{component}##{field} #{value} as a JSON boolean" do + expect(component.new(field => value).to_json).to eq(field.to_s => value) + end + end + end + + describe 'false with validation' do + before do + Minfraud.configure { |c| c.enable_validation = 1 } + end + + after do + Minfraud.configure { |c| c.enable_validation = false } + end + + [ + [Minfraud::Components::Billing, :company], + [Minfraud::Components::Email, :address], + [Minfraud::Components::Device, :ip_address], + [Minfraud::Components::Event, :time], + ].each do |component, field| + it "raises an exception for #{component}##{field}" do + expect do + component.new(field => false) + end.to raise_exception(Minfraud::InvalidInputError) + end + end + + it 'raises an exception for true in a string field' do + expect do + Minfraud::Components::Billing.new(company: true) + end.to raise_exception(Minfraud::InvalidInputError) + end + + [ + [Minfraud::Components::Order, :is_gift], + [Minfraud::Components::CustomInputs, :boolean_input], + ].each do |component, field| + it "accepts false for #{component}##{field}" do + expect(component.new(field => false).to_json).to eq(field.to_s => false) + end end end end From 0c6bdc974d4cd59020c7977476afcbd928b8ab25 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 14:57:34 +0000 Subject: [PATCH 2/9] Support enums declared on a parent class A subclass of a class that calls enum_accessor raised NoMethodError when it set the attribute. The setter looked up the permitted values on the receiving class, whose mapping does not hold them. The setter and the *_values method now use the values captured at declaration, and mapping includes the attributes declared on parent classes. The *_values method is now a singleton method of the declaring class, which subclasses inherit. It was an instance method of Class, so every class responded to it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ lib/minfraud/enum.rb | 30 ++++++++++++++++++++---------- spec/enum_spec.rb | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4883d4e..3ea80f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ raises `InvalidInputError`. * `/order/has_gift_message`, `/order/is_gift`, and `/payment/was_authorized` already sent `true` as a JSON boolean. +* The `*_values` class methods of enum attributes, such as + `Minfraud::Components::Event.type_values`, now exist only on the class + that declares the enum and its subclasses. Previously, every class + responded to them. `mapping` on a subclass now includes the enums of + its parent class. ## v2.10.0 (2026-07-21) diff --git a/lib/minfraud/enum.rb b/lib/minfraud/enum.rb index 3613ca2..a49d77e 100644 --- a/lib/minfraud/enum.rb +++ b/lib/minfraud/enum.rb @@ -17,14 +17,16 @@ def self.included(base) # types. module ClassMethods # Returns a hash in the following format: attribute_name => - # permitted_values + # permitted_values. It includes the attributes declared on parent + # classes. # # @return [Hash] def mapping - # rubocop:disable ThreadSafety/ClassInstanceVariable - # This is a false positive - this is set during class definition and then only read - @mapping ||= {} - # rubocop:enable ThreadSafety/ClassInstanceVariable + if superclass.respond_to?(:mapping) + superclass.mapping.merge(declared_mapping) + else + declared_mapping.dup + end end # Create a set of methods for enum-like behavior of the attribute. @@ -35,16 +37,15 @@ def mapping # # @return [nil] def enum_accessor(attribute, assignable_values) - mapping[attribute] = assignable_values.map(&:intern) + values = assignable_values.map(&:intern) + declared_mapping[attribute] = values - self.class.instance_eval do - define_method("#{attribute}_values") { mapping[attribute] } - end + define_singleton_method("#{attribute}_values") { values } class_eval do define_method(attribute.to_s) { instance_variable_get("@#{attribute}") } define_method "#{attribute}=" do |value| - raise NotEnumValueError, 'Value is not permitted' if value && !self.class.mapping[attribute].include?(value.intern) + raise NotEnumValueError, 'Value is not permitted' if value && !values.include?(value.intern) instance_variable_set("@#{attribute}", value ? value.intern : nil) end @@ -52,6 +53,15 @@ def enum_accessor(attribute, assignable_values) nil end + + private + + def declared_mapping + # rubocop:disable ThreadSafety/ClassInstanceVariable + # This is a false positive - this is set during class definition and then only read + @declared_mapping ||= {} + # rubocop:enable ThreadSafety/ClassInstanceVariable + end end end end diff --git a/spec/enum_spec.rb b/spec/enum_spec.rb index f2facc9..f4c5243 100644 --- a/spec/enum_spec.rb +++ b/spec/enum_spec.rb @@ -28,6 +28,21 @@ expect(klass).to respond_to :test_values end + it 'defines #[attribute_name]_values only on the declaring class' do + klass + expect(String).not_to respond_to :test_values + end + + it 'keeps #[attribute_name]_values when another class declares the same attribute' do + klass + Class.new do + include Minfraud::Enum + + enum_accessor :test, %i[baz] + end + expect(klass.test_values).to eq %i[foo bar] + end + it 'defines attribute reader' do expect(instance).to respond_to :test end @@ -49,5 +64,24 @@ instance.test = :foo expect(instance.test).to eq :foo end + + it 'assigns a permitted value in a subclass' do + subinstance = Class.new(klass).new + subinstance.test = :foo + expect(subinstance.test).to eq :foo + end + + it 'raises NotEnumValueError for unpermitted values in a subclass' do + expect { Class.new(klass).new.test = :unpermitted_value }.to raise_error Minfraud::NotEnumValueError + end + + it 'returns the parent values from #[attribute_name]_values in a subclass' do + expect(Class.new(klass).test_values).to eq %i[foo bar] + end + + it 'includes parent attributes in the subclass mapping' do + subclass = Class.new(klass) { enum_accessor :other, %i[baz] } + expect(subclass.mapping).to eq(test: %i[foo bar], other: %i[baz]) + end end end From 31ae626dd1c8ff3abaf1468b09726c99d5db50aa Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 14:57:50 +0000 Subject: [PATCH 3/9] Add phone verification inputs Add the phone_verification_method, phone_was_verification_successful, and phone_verification_time attributes to Billing and Shipping. They describe the most recent verification of the billing or shipping phone number. Both components share them through Addressable. The method must be :delivered_code, :network, or :other. With validation on, the result must be a boolean and the time must be an RFC 3339 date-time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ README.md | 52 ++++++++++++--------- lib/minfraud/components/addressable.rb | 56 +++++++++++++++++----- lib/minfraud/components/shipping.rb | 2 - spec/components/billing_spec.rb | 2 + spec/components/shipping_spec.rb | 7 +++ spec/spec_helper.rb | 1 + spec/support/addressable_examples.rb | 64 ++++++++++++++++++++++++++ 8 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 spec/components/shipping_spec.rb create mode 100644 spec/support/addressable_examples.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea80f0..57f35e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## v2.11.0 +* Added the `phone_verification_method`, + `phone_was_verification_successful`, and `phone_verification_time` + attributes to `Minfraud::Components::Billing` and + `Minfraud::Components::Shipping`. They describe the most recent + verification of the billing or shipping phone number. * Boolean inputs are now always sent as JSON booleans, and `false` is no longer dropped. Previously, every input set to `false` was omitted. Now only `nil` inputs are omitted. In particular: diff --git a/README.md b/README.md index 605b912..90c2cd8 100644 --- a/README.md +++ b/README.md @@ -102,31 +102,37 @@ assessment = Minfraud::Assessments.new( domain: 'maxmind.com', }, billing: { - first_name: 'First', - last_name: 'Last', - company: 'Company', - address: '101 Address Rd.', - address_2: 'Unit 5', - city: 'New Haven', - region: 'CT', - country: 'US', - postal: '06510', - phone_number: '123-456-7890', - phone_country_code: '1', + first_name: 'First', + last_name: 'Last', + company: 'Company', + address: '101 Address Rd.', + address_2: 'Unit 5', + city: 'New Haven', + region: 'CT', + country: 'US', + postal: '06510', + phone_number: '123-456-7890', + phone_country_code: '1', + phone_verification_method: :delivered_code, + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: true, }, shipping: { - first_name: 'ShipFirst', - last_name: 'ShipLast', - company: 'ShipCo', - address: '322 Ship Addr. Ln.', - address_2: 'St. 43', - city: 'Nowhere', - region: 'OK', - country: 'US', - postal: '73003', - phone_number: '123-456-0000', - phone_country_code: '1', - delivery_speed: :same_day, + first_name: 'ShipFirst', + last_name: 'ShipLast', + company: 'ShipCo', + address: '322 Ship Addr. Ln.', + address_2: 'St. 43', + city: 'Nowhere', + region: 'OK', + country: 'US', + postal: '73003', + phone_number: '123-456-0000', + phone_country_code: '1', + delivery_speed: :same_day, + phone_verification_method: :delivered_code, + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: true, }, payment: { method: :card, diff --git a/lib/minfraud/components/addressable.rb b/lib/minfraud/components/addressable.rb index bb8a2a0..7abf2bf 100644 --- a/lib/minfraud/components/addressable.rb +++ b/lib/minfraud/components/addressable.rb @@ -5,6 +5,7 @@ module Components # This is a parent class for the Billing and Shipping components. class Addressable < Base include Minfraud::Validates + include ::Minfraud::Enum # The first name of the end user as provided in their billing / shipping # information. @@ -74,20 +75,51 @@ class Addressable < Base # @return [String, nil] attr_accessor :phone_country_code + # The most recent method used to verify the phone number. This must be + # one of +:delivered_code+, +:network+, or +:other+. + # +:delivered_code+ is a code delivered to the phone, such as by SMS, + # voice call, or messaging app. +:network+ is verification through the + # mobile network operator, such as silent network authentication. + # + # @!attribute phone_verification_method + # + # @return [Symbol, nil] + enum_accessor :phone_verification_method, %i[delivered_code network other] + + # Whether the most recent verification of the phone number + # succeeded. Do not include this field if no verification was + # attempted. + # + # @return [Boolean, nil] + attr_accessor :phone_was_verification_successful + + # The date and time of the most recent verification of the phone + # number. The string must be in the RFC 3339 date-time format, e.g., + # "2012-04-12T23:20:50.52Z". The time must not be before + # 1970-01-01T00:00:00Z or more than two days in the future. + # + # @see https://datatracker.ietf.org/doc/html/rfc3339 + # + # @return [String, nil] + attr_accessor :phone_verification_time + # @param params [Hash] Hash of parameters. Each key/value should # correspond to one of the available attributes. def initialize(params = {}) - @first_name = params[:first_name] - @last_name = params[:last_name] - @company = params[:company] - @address = params[:address] - @address_2 = params[:address_2] - @city = params[:city] - @region = params[:region] - @country = params[:country] - @postal = params[:postal] - @phone_number = params[:phone_number] - @phone_country_code = params[:phone_country_code] + @first_name = params[:first_name] + @last_name = params[:last_name] + @company = params[:company] + @address = params[:address] + @address_2 = params[:address_2] + @city = params[:city] + @region = params[:region] + @country = params[:country] + @postal = params[:postal] + @phone_number = params[:phone_number] + @phone_country_code = params[:phone_country_code] + self.phone_verification_method = params[:phone_verification_method] + @phone_was_verification_successful = params[:phone_was_verification_successful] + @phone_verification_time = params[:phone_verification_time] validate end @@ -108,6 +140,8 @@ def validate validate_string('postal', 255, @postal) validate_string('phone_number', 255, @phone_number) validate_telephone_country_code('phone_country_code', @phone_country_code) + validate_boolean('phone_was_verification_successful', @phone_was_verification_successful) + validate_rfc3339('phone_verification_time', @phone_verification_time) end end end diff --git a/lib/minfraud/components/shipping.rb b/lib/minfraud/components/shipping.rb index 3248b1e..37f632d 100644 --- a/lib/minfraud/components/shipping.rb +++ b/lib/minfraud/components/shipping.rb @@ -6,8 +6,6 @@ module Components # # @see https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--shipping class Shipping < Addressable - include ::Minfraud::Enum - # The shipping delivery speed for the order. Must be one of +:same_day+, # +:overnight+, +:expedited+, or +:standard+. # diff --git a/spec/components/billing_spec.rb b/spec/components/billing_spec.rb index 2ddee4c..e990f88 100644 --- a/spec/components/billing_spec.rb +++ b/spec/components/billing_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Minfraud::Components::Billing do + it_behaves_like 'an addressable component' + describe 'validation' do before do Minfraud.configure { |c| c.enable_validation = 1 } diff --git a/spec/components/shipping_spec.rb b/spec/components/shipping_spec.rb new file mode 100644 index 0000000..bc91802 --- /dev/null +++ b/spec/components/shipping_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Minfraud::Components::Shipping do + it_behaves_like 'an addressable component' +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0b1c732..04720c0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,3 +2,4 @@ $LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'minfraud' +require_relative 'support/addressable_examples' diff --git a/spec/support/addressable_examples.rb b/spec/support/addressable_examples.rb new file mode 100644 index 0000000..1d2890f --- /dev/null +++ b/spec/support/addressable_examples.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'an addressable component' do + it 'includes phone_verification_method in the mapping' do + expect(described_class.mapping).to include(phone_verification_method: %i[delivered_code network other]) + end + + it 'accepts each phone_verification_method value' do + %i[delivered_code network other].each do |method| + expect(described_class.new(phone_verification_method: method).phone_verification_method).to eq(method) + end + end + + it 'raises an exception for an invalid phone_verification_method' do + expect do + described_class.new(phone_verification_method: :sms) + end.to raise_exception(Minfraud::NotEnumValueError) + end + + [true, false].each do |successful| + it "sends phone_was_verification_successful #{successful} as a JSON boolean" do + component = described_class.new( + phone_verification_method: 'network', + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: successful, + ) + expect(component.to_json).to eq( + 'phone_verification_method' => 'network', + 'phone_verification_time' => '2026-10-01T14:30:00Z', + 'phone_was_verification_successful' => successful, + ) + end + end + + context 'with validation' do + before do + Minfraud.configure { |c| c.enable_validation = 1 } + end + + it 'raises an exception for an invalid phone_was_verification_successful' do + expect do + described_class.new( + phone_was_verification_successful: 'true', + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'raises an exception for an invalid phone_verification_time' do + expect do + described_class.new( + phone_verification_time: '2026-10-01 14:30:00', + ) + end.to raise_exception(Minfraud::InvalidInputError) + end + + it 'does not raise an exception for valid phone verification values' do + described_class.new( + phone_verification_method: :delivered_code, + phone_verification_time: '2026-10-01T14:30:00Z', + phone_was_verification_successful: false, + ) + end + end +end From bb0ec16a55a0fb7c2356d1b2d1edbe59b86cd7f5 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Thu, 15 Oct 2026 21:53:14 +0000 Subject: [PATCH 4/9] Match the web service's RFC 3339 validation validate_rfc3339 used only DateTime.rfc3339. DateTime accepts hour 24, second 60, UTC offsets of 24 hours or more, and a lowercase t or z. The web service rejects them. DateTime also rejects strings longer than 128 characters, such as a long fraction of a second that the web service accepts. Before 1582, it uses the Julian calendar, so it accepts 1500-02-29. JRuby ignores the limit: nil option of DateTime.rfc3339. The validator now matches the format the web service requires, then uses Date.valid_date? with the Gregorian calendar to reject dates that do not exist. This applies to Event#time and the new phone_verification_time attributes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ lib/minfraud/validates.rb | 17 +++++++++++++---- spec/components/event_spec.rb | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f35e4..ab3d298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ that declares the enum and its subclasses. Previously, every class responded to them. `mapping` on a subclass now includes the enums of its parent class. +* With validation on, `Minfraud::Components::Event` now rejects a `time` + that the web service rejects. Previously, the client accepted hour 24, + second 60, a UTC offset of 24 hours or more, and a lowercase `t` or + `z`. ## v2.10.0 (2026-07-21) diff --git a/lib/minfraud/validates.rb b/lib/minfraud/validates.rb index a7a5161..af78090 100644 --- a/lib/minfraud/validates.rb +++ b/lib/minfraud/validates.rb @@ -12,6 +12,13 @@ module Minfraud # # rubocop:disable Metrics/ModuleLength module Validates + RFC3339_REGEX = / + \A(\d{4})-(\d{2})-(\d{2}) + T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)? + (?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\z + /x + private_constant :RFC3339_REGEX + # Validates that a string value does not exceed a maximum length. # # @param field [String] The field name for error messages. @@ -282,10 +289,12 @@ def validate_email(field, value) def validate_rfc3339(field, value) return if value.nil? - # rubocop:disable-next Style/RescueStandardError - begin - DateTime.rfc3339(value) - rescue + # The regex checks the format, time, and offset that the web service + # requires. valid_date? rejects dates that do not exist, such as + # February 30. The web service uses the Gregorian calendar for all + # years. + match = RFC3339_REGEX.match(value.to_s) + if !match || !Date.valid_date?(match[1].to_i, match[2].to_i, match[3].to_i, Date::GREGORIAN) raise InvalidInputError, "The #{field} value is not valid. It must be in the RFC 3339 date-time format." end diff --git a/spec/components/event_spec.rb b/spec/components/event_spec.rb index d6b8bed..a018a8c 100644 --- a/spec/components/event_spec.rb +++ b/spec/components/event_spec.rb @@ -34,6 +34,41 @@ end.to raise_exception(Minfraud::InvalidInputError) end + %w[ + 2026-10-01T24:00:00Z + 2026-10-01T14:60:00Z + 2026-10-01T14:30:60Z + 2026-10-01T14:30:00+24:00 + 2026-10-01T14:30:00+25:00 + 2026-10-01T14:30:00+05:60 + 2026-13-01T10:00:00Z + 2026-02-30T10:00:00Z + 2026-02-29T00:00:00Z + 1500-02-29T00:00:00Z + 2026-10-01t14:30:00Z + 2026-10-01T14:30:00z + 2026-1-01T14:30:00Z + ].each do |time| + it "raises an exception for time #{time}" do + expect do + described_class.new(time:) + end.to raise_exception(Minfraud::InvalidInputError) + end + end + + %W[ + 2026-10-01T14:30:00Z + 2026-10-01T14:30:00.123456789Z + 2026-10-01T14:30:00.#{'9' * 120}Z + 2026-10-01T14:30:00+23:59 + 2024-02-29T00:00:00Z + 2024-02-29T00:00:00-05:00 + ].each do |time| + it "accepts time #{time}" do + expect(described_class.new(time:).time).to eq(time) + end + end + it 'does not raise an exception for valid values' do described_class.new( time: '2020-08-28T14:00:00Z', From 7aef50f26f04fa3a9a6e151f91271b399d859e34 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:45:06 +0000 Subject: [PATCH 5/9] Reject booleans in every non-boolean input With validation on, some inputs still accepted true and false. validate_credit_card_token converted a boolean to a string and accepted it. Device#tracking_token and the chargeback_code, notes, and transaction_id fields of Report::Transaction had no validation. Report::Transaction raised NoMethodError for transaction_id: false. Add validate_not_boolean and the boolean? helper. Every non-boolean validator now calls validate_not_boolean, and the fields above use it directly. The server documents no length limit for those fields, so the check is for type only. represent, validate_boolean, and validate_custom_input_value now use boolean?. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++- lib/minfraud/components/base.rb | 2 +- lib/minfraud/components/device.rb | 1 + lib/minfraud/components/report/transaction.rb | 3 + lib/minfraud/validates.rb | 58 ++++++++++++++++--- spec/components/base_spec.rb | 32 ++++++---- 6 files changed, 82 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3d298..b3286cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,12 @@ service rejects a boolean for a string custom input with an `INPUT_INVALID` warning. Pass a string, such as `'true'`, instead. * With validation on, `true` or `false` for a non-boolean input now - raises `InvalidInputError`. + raises `InvalidInputError`. This includes `token` on + `Minfraud::Components::CreditCard`, `tracking_token` on + `Minfraud::Components::Device`, and `chargeback_code`, `notes`, and + `transaction_id` on `Minfraud::Components::Report::Transaction`. + Previously, these accepted a boolean, and `transaction_id: false` + raised `NoMethodError`. * `/order/has_gift_message`, `/order/is_gift`, and `/payment/was_authorized` already sent `true` as a JSON boolean. * The `*_values` class methods of enum attributes, such as diff --git a/lib/minfraud/components/base.rb b/lib/minfraud/components/base.rb index 0556ef7..37a3b21 100644 --- a/lib/minfraud/components/base.rb +++ b/lib/minfraud/components/base.rb @@ -40,7 +40,7 @@ def populate!(hash, v_sym) # # @return [Object] def represent(value) - [true, false].include?(value) ? value : value.to_s + Minfraud::Validates.boolean?(value) ? value : value.to_s end end end diff --git a/lib/minfraud/components/device.rb b/lib/minfraud/components/device.rb index bab10df..a043ed7 100644 --- a/lib/minfraud/components/device.rb +++ b/lib/minfraud/components/device.rb @@ -69,6 +69,7 @@ def validate validate_string('accept_language', 255, @accept_language) validate_nonnegative_number('session_age', @session_age) validate_string('session_id', 255, @session_id) + validate_not_boolean('tracking_token', @tracking_token) end end end diff --git a/lib/minfraud/components/report/transaction.rb b/lib/minfraud/components/report/transaction.rb index 59dba0e..7695a94 100644 --- a/lib/minfraud/components/report/transaction.rb +++ b/lib/minfraud/components/report/transaction.rb @@ -94,6 +94,9 @@ def validate validate_ip('ip_address', @ip_address) validate_string('maxmind_id', 8, @maxmind_id) validate_uuid('minfraud_id', @minfraud_id) + validate_not_boolean('chargeback_code', @chargeback_code) + validate_not_boolean('notes', @notes) + validate_not_boolean('transaction_id', @transaction_id) if ip_address.nil? && (minfraud_id.nil? || empty_uuid?(minfraud_id)) && diff --git a/lib/minfraud/validates.rb b/lib/minfraud/validates.rb index af78090..6d1f096 100644 --- a/lib/minfraud/validates.rb +++ b/lib/minfraud/validates.rb @@ -31,9 +31,7 @@ module Validates def validate_string(field, length, value) return if value.nil? - if [true, false].include?(value) - raise InvalidInputError, "The #{field} value is not valid. It must not be a boolean." - end + validate_not_boolean(field, value) if value.to_s.length > length raise InvalidInputError, "The #{field} value is not valid. The maximum length is #{length}." @@ -51,6 +49,8 @@ def validate_string(field, length, value) def validate_md5(field, value) return if value.nil? + validate_not_boolean(field, value) + if !value.to_s.match(/\A[a-fA-F0-9]{32}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an MD5 hash as a string." end @@ -67,6 +67,8 @@ def validate_md5(field, value) def validate_uuid(field, value) return if value.nil? + validate_not_boolean(field, value) + stripped_value = value.to_s.delete('-') # Define a regex pattern for a valid UUID without dashes @@ -88,6 +90,8 @@ def validate_uuid(field, value) def validate_subdivision_code(field, value) return if value.nil? + validate_not_boolean(field, value) + if !value.to_s.match(/\A[0-9A-Z]{1,4}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an ISO 3166-2 subdivision code." end @@ -104,6 +108,8 @@ def validate_subdivision_code(field, value) def validate_country_code(field, value) return if value.nil? + validate_not_boolean(field, value) + if !value.to_s.match(/\A[A-Z]{2}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be an ISO 3166-1 alpha-2 country code." end @@ -121,6 +127,8 @@ def validate_country_code(field, value) def validate_telephone_country_code(field, value) return if value.nil? + validate_not_boolean(field, value) + if !value.to_s.match(/\A[0-9]{1,4}\z/) raise InvalidInputError, "The #{field} value is not valid. It must be at most 4 digits." end @@ -138,6 +146,8 @@ def validate_telephone_country_code(field, value) def validate_regex(field, regex, value) return if value.nil? + validate_not_boolean(field, value) + if !regex.match(value.to_s) raise InvalidInputError, "The #{field} value is not valid. It must match the pattern #{regex}." end @@ -154,6 +164,8 @@ def validate_regex(field, regex, value) def validate_credit_card_token(field, value) return if value.nil? + validate_not_boolean(field, value) + s = value.to_s if !/\A[\x21-\x7E]{1,255}\z/.match(s) @@ -176,9 +188,7 @@ def validate_credit_card_token(field, value) def validate_custom_input_value(field, value) return if value.nil? - if [true, false].include?(value) - return - end + return if boolean?(value) if value.is_a? Numeric if value < -1e13 + 1 || value > 1e13 - 1 @@ -202,6 +212,8 @@ def validate_custom_input_value(field, value) def validate_ip(field, value) return if value.nil? + validate_not_boolean(field, value) + s = value.to_s if s.include? '/' raise InvalidInputError, "The #{field} value is not valid. It must be an individual IP address." @@ -229,6 +241,8 @@ def validate_ip(field, value) def validate_nonnegative_number(field, value) return if value.nil? + validate_not_boolean(field, value) + if !value.is_a? Numeric raise InvalidInputError, "The #{field} value is not valid. It must be numeric." end @@ -250,6 +264,8 @@ def validate_nonnegative_number(field, value) def validate_nonnegative_integer(field, value) return if value.nil? + validate_not_boolean(field, value) + if !value.is_a? Integer raise InvalidInputError, "The #{field} is not valid. It must be an integer." end @@ -289,6 +305,8 @@ def validate_email(field, value) def validate_rfc3339(field, value) return if value.nil? + validate_not_boolean(field, value) + # The regex checks the format, time, and offset that the web service # requires. valid_date? rejects dates that do not exist, such as # February 30. The web service uses the Gregorian calendar for all @@ -312,7 +330,7 @@ def validate_rfc3339(field, value) def validate_boolean(field, value) return if value.nil? - if ![false, true].include? value + if !boolean?(value) raise InvalidInputError, "The #{field} value is not valid. It must be boolean." end end @@ -328,6 +346,8 @@ def validate_boolean(field, value) def validate_uri(field, value) return if value.nil? + validate_not_boolean(field, value) + if value.to_s.length > 1_024 raise InvalidInputError, "The #{field} value is not valid. It must not exceed 1024 characters." end @@ -342,6 +362,30 @@ def validate_uri(field, value) raise InvalidInputError, "The #{field} value is not valid. It must be an absolute URI." end end + + # Validates that a value is not a boolean. + # + # @param field [String] The field name for error messages. + # @param value [Object] The value to validate. + # + # @raise [InvalidInputError] If the value is true or false. + # + # @return [nil] + def validate_not_boolean(field, value) + if boolean?(value) + raise InvalidInputError, "The #{field} value is not valid. It must not be a boolean." + end + end + + # Returns true if the value is true or false. + # + # @param value [Object] The value to check. + # + # @return [Boolean] + def boolean?(value) + [true, false].include?(value) + end + module_function :boolean? # rubocop:enable Metrics/ModuleLength end end diff --git a/spec/components/base_spec.rb b/spec/components/base_spec.rb index afc5411..2b1395e 100644 --- a/spec/components/base_spec.rb +++ b/spec/components/base_spec.rb @@ -56,7 +56,7 @@ end end - describe 'false with validation' do + describe 'booleans with validation' do before do Minfraud.configure { |c| c.enable_validation = 1 } end @@ -67,23 +67,31 @@ [ [Minfraud::Components::Billing, :company], - [Minfraud::Components::Email, :address], + [Minfraud::Components::Billing, :country], + [Minfraud::Components::Billing, :phone_country_code], + [Minfraud::Components::Billing, :region], + [Minfraud::Components::CreditCard, :issuer_id_number], + [Minfraud::Components::CreditCard, :token], [Minfraud::Components::Device, :ip_address], + [Minfraud::Components::Device, :tracking_token], + [Minfraud::Components::Email, :address], [Minfraud::Components::Event, :time], - ].each do |component, field| - it "raises an exception for #{component}##{field}" do + [Minfraud::Components::Order, :amount], + [Minfraud::Components::Order, :currency], + [Minfraud::Components::Order, :referrer_uri], + [Minfraud::Components::Report::Transaction, :chargeback_code], + [Minfraud::Components::Report::Transaction, :minfraud_id], + [Minfraud::Components::Report::Transaction, :notes], + [Minfraud::Components::Report::Transaction, :transaction_id], + [Minfraud::Components::ShoppingCartItem, :quantity], + ].product([true, false]).each do |(component, field), value| + it "raises an exception for #{value} in #{component}##{field}" do expect do - component.new(field => false) - end.to raise_exception(Minfraud::InvalidInputError) + component.new(field => value) + end.to raise_exception(Minfraud::InvalidInputError, /must not be a boolean/) end end - it 'raises an exception for true in a string field' do - expect do - Minfraud::Components::Billing.new(company: true) - end.to raise_exception(Minfraud::InvalidInputError) - end - [ [Minfraud::Components::Order, :is_gift], [Minfraud::Components::CustomInputs, :boolean_input], From e51b3154d862df9bb1c6bed37233645f26b8bc94 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:45:34 +0000 Subject: [PATCH 6/9] Reject non-String, non-Symbol enum values The enum setter used truthiness. It ignored false, so phone_verification_method: false sent nothing. It raised NoMethodError for true and other values without intern. The setter now treats only nil as unset and raises NotEnumValueError for any other value that is not a permitted String or Symbol. Freeze the permitted values so that *_values << :x cannot change what the setter accepts. mapping now reads @declared_mapping and does not set it at run time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ lib/minfraud/enum.rb | 20 ++++++++++++++------ spec/enum_spec.rb | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3286cd..ada6a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ that declares the enum and its subclasses. Previously, every class responded to them. `mapping` on a subclass now includes the enums of its parent class. +* Enum setters, such as `Minfraud::Components::Event#type=`, now raise + `NotEnumValueError` for a value that is not a String, a Symbol, or + `nil`. Previously, `false` cleared the attribute, and `true` raised + `NoMethodError`. The `*_values` class methods now return a frozen + array. * With validation on, `Minfraud::Components::Event` now rejects a `time` that the web service rejects. Previously, the client accepted hour 24, second 60, a UTC offset of 24 hours or more, and a lowercase `t` or diff --git a/lib/minfraud/enum.rb b/lib/minfraud/enum.rb index a49d77e..39905e4 100644 --- a/lib/minfraud/enum.rb +++ b/lib/minfraud/enum.rb @@ -22,10 +22,16 @@ module ClassMethods # # @return [Hash] def mapping + # rubocop:disable ThreadSafety/ClassInstanceVariable + # This is a false positive - this only reads the mapping that + # enum_accessor sets during class definition + declared = @declared_mapping || {} + # rubocop:enable ThreadSafety/ClassInstanceVariable + if superclass.respond_to?(:mapping) - superclass.mapping.merge(declared_mapping) + superclass.mapping.merge(declared) else - declared_mapping.dup + declared.dup end end @@ -37,7 +43,7 @@ def mapping # # @return [nil] def enum_accessor(attribute, assignable_values) - values = assignable_values.map(&:intern) + values = assignable_values.map(&:intern).freeze declared_mapping[attribute] = values define_singleton_method("#{attribute}_values") { values } @@ -45,9 +51,10 @@ def enum_accessor(attribute, assignable_values) class_eval do define_method(attribute.to_s) { instance_variable_get("@#{attribute}") } define_method "#{attribute}=" do |value| - raise NotEnumValueError, 'Value is not permitted' if value && !values.include?(value.intern) + value = value.intern if value.is_a?(String) + raise NotEnumValueError, 'Value is not permitted' if !value.nil? && !values.include?(value) - instance_variable_set("@#{attribute}", value ? value.intern : nil) + instance_variable_set("@#{attribute}", value) end end @@ -58,7 +65,8 @@ def enum_accessor(attribute, assignable_values) def declared_mapping # rubocop:disable ThreadSafety/ClassInstanceVariable - # This is a false positive - this is set during class definition and then only read + # This is a false positive - only enum_accessor calls this, during class + # definition @declared_mapping ||= {} # rubocop:enable ThreadSafety/ClassInstanceVariable end diff --git a/spec/enum_spec.rb b/spec/enum_spec.rb index f4c5243..2d0d0a8 100644 --- a/spec/enum_spec.rb +++ b/spec/enum_spec.rb @@ -55,6 +55,22 @@ expect { instance.test = :unpermitted_value }.to raise_error Minfraud::NotEnumValueError end + [true, false, 1].each do |value| + it "raises NotEnumValueError for #{value.inspect}" do + expect { instance.test = value }.to raise_error Minfraud::NotEnumValueError + end + end + + it 'clears the value for nil' do + instance.test = :foo + instance.test = nil + expect(instance.test).to be_nil + end + + it 'returns frozen #[attribute_name]_values' do + expect { klass.test_values << :baz }.to raise_error FrozenError + end + it 'converts string value to symbol' do instance.test = 'foo' expect(instance.test).to eq :foo @@ -79,6 +95,12 @@ expect(Class.new(klass).test_values).to eq %i[foo bar] end + it 'does not change a subclass when it reads the mapping' do + subclass = Class.new(klass) + subclass.mapping + expect(subclass.instance_variable_defined?(:@declared_mapping)).to be false + end + it 'includes parent attributes in the subclass mapping' do subclass = Class.new(klass) { enum_accessor :other, %i[baz] } expect(subclass.mapping).to eq(test: %i[foo bar], other: %i[baz]) From 016038b58129c8a29640bdf1b166b8759cce8158 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:45:45 +0000 Subject: [PATCH 7/9] Compute the JSON key only for a non-nil value Base#populate! built the key before it skipped a nil value. Build it after the nil check, and use delete_prefix('@') to remove only the leading @. Co-Authored-By: Claude Opus 5.5 --- lib/minfraud/components/base.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/minfraud/components/base.rb b/lib/minfraud/components/base.rb index 37a3b21..8cf8e20 100644 --- a/lib/minfraud/components/base.rb +++ b/lib/minfraud/components/base.rb @@ -27,9 +27,9 @@ def to_json(*_args) # @return [Hash] def populate!(hash, v_sym) value = instance_variable_get(v_sym) - key = v_sym.to_s.gsub(/@/, '') return hash if value.nil? + key = v_sym.to_s.delete_prefix('@') hash.merge!(key => represent(value)) end From ac0048370f27c3b74429b88b82a9a009d85cf8fe Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:46:11 +0000 Subject: [PATCH 8/9] Restore the validation setting after addressable specs The shared examples turned on Minfraud.enable_validation and did not turn it off. Later specs then ran with validation on. An around hook now restores the previous value after each example. Co-Authored-By: Claude Opus 5.5 --- spec/support/addressable_examples.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/support/addressable_examples.rb b/spec/support/addressable_examples.rb index 1d2890f..eab45c2 100644 --- a/spec/support/addressable_examples.rb +++ b/spec/support/addressable_examples.rb @@ -33,8 +33,13 @@ end context 'with validation' do - before do + around do |example| + previous = Minfraud.enable_validation + Minfraud.configure { |c| c.enable_validation = 1 } + example.run + ensure + Minfraud.configure { |c| c.enable_validation = previous } end it 'raises an exception for an invalid phone_was_verification_successful' do From e7a2ab44b2a19c7b69b65a5ff81989252c29de59 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:46:18 +0000 Subject: [PATCH 9/9] Document how the web service handles an out-of-range time The web service ignores a phone_verification_time outside the documented range and returns a warning. The client does not check the range. Co-Authored-By: Claude Opus 5.5 --- lib/minfraud/components/addressable.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/minfraud/components/addressable.rb b/lib/minfraud/components/addressable.rb index 7abf2bf..e38f9c9 100644 --- a/lib/minfraud/components/addressable.rb +++ b/lib/minfraud/components/addressable.rb @@ -96,7 +96,8 @@ class Addressable < Base # The date and time of the most recent verification of the phone # number. The string must be in the RFC 3339 date-time format, e.g., # "2012-04-12T23:20:50.52Z". The time must not be before - # 1970-01-01T00:00:00Z or more than two days in the future. + # 1970-01-01T00:00:00Z or more than two days in the future. The web + # service ignores a time outside this range and returns a warning. # # @see https://datatracker.ietf.org/doc/html/rfc3339 #