diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3d79f..a0c7e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,43 @@ CHANGELOG * Custom input validation now rejects integer keys and array or object values with `InvalidInputException` instead of a type error or conversion warning. When validation is disabled, these values pass through unchanged. -* When input validation is disabled, billing, shipping, and credit card - country fields no longer fail in string validators. Billing and shipping - region and phone country code fields, and credit card bank phone country - codes, also pass through unchanged. +* A request value with the wrong type, such as an array where a string is + expected, now causes an `InvalidInputException`, including when input + validation is disabled. This applies to the array arguments of the + `with*()` methods of `MaxMind\MinFraud`, except for custom inputs, and of + `MaxMind\MinFraud\ReportTransaction::report()`. Before, with validation + disabled, such a value caused a type error, a conversion warning, or an + error, or was sent to the web service, depending on the field. A value with + the right type that fails a check is still sent unchanged when validation + is disabled. +* The order `amount`, shopping cart `price` and `quantity`, and device + `session_age` inputs now accept numeric strings, such as `"12.50"`. They are + sent unchanged, except `session_age`, which is converted to a number as + before. A `session_age` string that is not numeric is now always rejected. +* The RFC 3339 check for the event `time` input now accepts any number of + fractional second digits. Before, it rejected values with more than three + digits, such as `2026-10-01T14:30:00.123456Z`. +* The RFC 3339 check for the event `time` input now rejects the values that + the web service rejects. Before, it accepted out-of-range date and time + values, such as `2026-02-30T10:00:00Z`, because PHP rolled them over. It + also accepted time zone names, such as `EST`, offsets without a colon, such + as `+0000`, and a lowercase `z`. * The PHPDoc for the `with()` and `with*()` methods of `MaxMind\MinFraud` now lists the `InvalidInputException` they throw when input validation is enabled. +* Input validation now rejects a trailing newline in fields with a fixed + format, such as country, region, and currency codes, the credit card + `issuer_id_number`, custom input keys, and the minFraud ID of + `MaxMind\MinFraud\ReportTransaction`. Before, it accepted values such as + `"US\n"`. +* Added the `phone_verification_method`, `phone_was_verification_successful`, + and `phone_verification_time` inputs to the `/billing` and `/shipping` + objects. These describe the most recent verification of the billing or + shipping phone number. Use them with `withBilling()` and `withShipping()` as + array keys or as the `phoneVerificationMethod`, + `phoneWasVerificationSuccessful`, and `phoneVerificationTime` named + arguments. Omit `phone_was_verification_successful` if no verification was + attempted. 3.7.0 (2026-07-21) ------------------ diff --git a/CLAUDE.md b/CLAUDE.md index 5f386ca..77d95d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ The library validates input by default (can be disabled with `validateInput: fal ```php private function verifyCountryCode(string $country): void { - if (!preg_match('/^[A-Z]{2}$/', $country)) { + if (!preg_match('/\A[A-Z]{2}\z/', $country)) { $this->maybeThrowInvalidInputException("..."); } } @@ -115,6 +115,13 @@ private function verifyCountryCode(string $country): void - Validation methods throw `InvalidInputException` when enabled - `maybeThrowInvalidInputException()` respects the `validateInput` option +- Use the `\A` and `\z` anchors in a regex. `$` and `\Z` also match before a + trailing newline +- `remove()` always rejects a value with the wrong type, so checks after it + can rely on the type without a `validateInput` guard +- Use `removeString()` for a free-form string field with no other check, or + for a field that already accepted an integer, such as `session_id`. It also + accepts an integer and converts it to a string - Common validations: country codes, region codes, phone codes, email addresses, IP addresses #### 6. **Dual Array/Named Arguments Pattern** diff --git a/README.md b/README.md index 15a1ed8..444ced1 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ validation can be disabled by setting `validateInput` to `false` in the options array for `\MaxMind\MinFraud`, but it is recommended that you keep it on at least through development as it will help ensure that you are sending valid data to the web service. +A value with the wrong type in an array argument, such as an array where a +string is expected, is always rejected, except in custom inputs. After creating the request object, send a Score request by calling `->score()`, an Insights request by calling `->insights()`, or a Factors @@ -182,7 +184,10 @@ $request = $mf->withDevice( country: 'US', postal: '06510', phoneNumber: '123-456-7890', - phoneCountryCode: '1' + phoneCountryCode: '1', + phoneVerificationMethod: 'delivered_code', + phoneWasVerificationSuccessful: true, + phoneVerificationTime: '2026-10-01T14:30:00Z' )->withShipping( firstName: 'ShipFirst', lastName: 'ShipLast', @@ -195,6 +200,9 @@ $request = $mf->withDevice( postal: '73003', phoneNumber: '123-456-0000', phoneCountryCode: '1', + phoneVerificationMethod: 'network', + phoneWasVerificationSuccessful: false, + phoneVerificationTime: '2026-10-01T14:31:00Z', deliverySpeed: 'same_day' )->withPayment( method: 'card', @@ -279,6 +287,8 @@ setting `validateInput` to `false` in the options array for `\MaxMind\MinFraud\ReportTransaction`, but it is recommended that you keep it on at least through development as it will help ensure that you are sending valid data to the web service. +A value with the wrong type in the array argument, such as an array where a +string is expected, is always rejected. If the report is successful, nothing is returned. If the report fails, an exception will be thrown. diff --git a/src/MinFraud.php b/src/MinFraud.php index 7b17b33..2bf4287 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -78,9 +78,10 @@ class MinFraud extends MinFraud\ServiceClient implements \JsonSerializable * - `locales` - An array of locale codes to use for the location name * properties. * - `validateInput` - Default is `true`. Determines whether values passed - * to the `with*()` methods are validated. It is recommended that you - * leave validation on while developing and only (optionally) disable it - * before deployment. + * to the `with*()` methods are validated. A value with the wrong type + * in a `$values` array is always rejected, except in custom inputs. + * It is recommended that you leave validation on while developing and + * only (optionally) disable it before deployment. * * @throws \RuntimeException with older web-service-common releases, if HTTP client setup fails * @throws WebServiceException if HTTP client setup fails @@ -123,8 +124,9 @@ public function jsonSerialize(): array * @param array $values The request as a structured array * * @throws InvalidInputException if a section or shopping cart item is not - * an array, or if validation is enabled and - * a value is invalid or a key is unknown + * an array, if a value has the wrong type, + * or if validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is * a clone of the original with additional data. @@ -220,9 +222,9 @@ public function with(array $values): self * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--device * minFraud device API docs * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone * of the original with additional data. @@ -242,25 +244,19 @@ public function withDevice( 'You may only provide the $values array or named arguments, not both.', ); } - $acceptLanguage = $this->remove($values, 'accept_language'); + $acceptLanguage = $this->removeString($values, 'accept_language'); $ipAddress = $this->remove($values, 'ip_address'); - $v = $this->remove($values, 'session_age', ['double', 'float', 'integer', 'string']); + $v = $this->remove($values, 'session_age', ['double', 'integer', 'numeric string']); if ($v !== null) { - if (!is_numeric($v)) { - $this->maybeThrowInvalidInputException('Expected session_age to be a number'); - } $sessionAge = (float) $v; } - $v = $this->remove($values, 'session_id', ['integer', 'string']); - if ($v !== null) { - $sessionId = (string) $v; - } + $sessionId = $this->removeString($values, 'session_id'); - $trackingToken = $this->remove($values, 'tracking_token'); - $userAgent = $this->remove($values, 'user_agent'); + $trackingToken = $this->removeString($values, 'tracking_token'); + $userAgent = $this->removeString($values, 'user_agent'); $this->verifyEmpty($values); } @@ -351,9 +347,9 @@ public function withDevice( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--event * minFraud event API docs * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone of * the original with additional data. @@ -373,9 +369,9 @@ public function withEvent( ); } $party = $this->remove($values, 'party'); - $shopId = $this->remove($values, 'shop_id'); + $shopId = $this->removeString($values, 'shop_id'); $time = $this->remove($values, 'time'); - $transactionId = $this->remove($values, 'transaction_id'); + $transactionId = $this->removeString($values, 'transaction_id'); $type = $this->remove($values, 'type'); $this->verifyEmpty($values); @@ -393,12 +389,7 @@ public function withEvent( } if ($time !== null) { - if (\DateTime::createFromFormat(\DateTime::RFC3339, $time) === false - && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $time) === false - ) { - $this->maybeThrowInvalidInputException("$time is not a valid RFC 3339 formatted datetime string"); - } - + $this->verifyRfc3339DateTime($time); $values['time'] = $time; } @@ -448,9 +439,9 @@ public function withEvent( * the username or login name associated * with the account * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone * of the original with additional data. @@ -466,7 +457,7 @@ public function withAccount( 'You may only provide the $values array or named arguments, not both.', ); } - $userId = $this->remove($values, 'user_id'); + $userId = $this->removeString($values, 'user_id'); $usernameMd5 = $this->remove($values, 'username_md5'); $this->verifyEmpty($values); @@ -477,7 +468,7 @@ public function withAccount( } if ($usernameMd5 !== null) { - if (!preg_match('/^[a-fA-F0-9]{32}$/', $usernameMd5)) { + if (!preg_match('/\A[a-fA-F0-9]{32}\z/', $usernameMd5)) { $this->maybeThrowInvalidInputException("$usernameMd5 must be an MD5"); } $values['username_md5'] = $usernameMd5; @@ -505,9 +496,9 @@ public function withAccount( * transaction. Do not include the `@` in this * field. * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone * of the original with additional data. @@ -531,7 +522,7 @@ public function withEmail( if ($address !== null) { if (!filter_var($address, \FILTER_VALIDATE_EMAIL) - && !preg_match('/^[a-fA-F0-9]{32}$/', $address)) { + && !preg_match('/\A[a-fA-F0-9]{32}\z/', $address)) { $this->maybeThrowInvalidInputException("$address is an invalid email address or MD5"); } $values['address'] = $address; @@ -561,37 +552,49 @@ public function withEmail( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--billing * minFraud billing API docs * - * @param array $values An array of billing data. The keys are the same as - * the JSON keys. You may use either this or the named - * arguments, but not both. - * @param string|null $address The first line of the user's billing address - * @param string|null $address2 The second line of the user's billing address - * @param string|null $city The city of the user's billing address - * @param string|null $company The company of the end user as provided in - * their billing information - * @param string|null $country The two character ISO 3166-1 alpha-2 country - * code of the user's billing address - * @param string|null $firstName The first name of the end user as provided - * in their billing information - * @param string|null $lastName The last name of the end user as provided - * in their billing information - * @param string|null $phoneCountryCode The country code for phone number - * associated with the user's billing - * address. If you provide this - * information then you must provide - * at least one digit. - * @param string|null $phoneNumber The phone number without the country code - * for the user's billing address. Punctuation - * characters will be stripped. After - * stripping punctuation characters, the - * number must contain only digits. - * @param string|null $postal The postal code of the user's billing address - * @param string|null $region The ISO 3166-2 subdivision code for the user's - * billing address + * @param array $values An array of billing data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $address The first line of the user's billing address + * @param string|null $address2 The second line of the user's billing address + * @param string|null $city The city of the user's billing address + * @param string|null $company The company of the end user as provided in + * their billing information + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code of the user's billing address + * @param string|null $firstName The first name of the end user as provided + * in their billing information + * @param string|null $lastName The last name of the end user as provided + * in their billing information + * @param string|null $phoneCountryCode The country code for phone number + * associated with the user's billing + * address. If you provide this + * information then you must provide + * at least one digit. + * @param string|null $phoneNumber The phone number without the country code + * for the user's billing address. Punctuation + * characters will be stripped. After + * stripping punctuation characters, the + * number must contain only digits. + * @param string|null $postal The postal code of the user's billing address + * @param string|null $region The ISO 3166-2 subdivision code for the user's + * billing address + * @param string|null $phoneVerificationMethod The most recent method used to verify + * the billing phone number: delivered_code, + * network, or other + * @param string|null $phoneVerificationTime The date and time of the most recent + * verification of the billing phone number, + * in RFC 3339 date-time format. The time + * must not be before 1970-01-01T00:00:00Z + * or more than two days in the future. + * @param bool|null $phoneWasVerificationSuccessful Whether the most recent verification + * of the billing phone number succeeded. + * Omit this if no verification was + * attempted. * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone * of the original with additional data. @@ -609,84 +612,34 @@ public function withBilling( ?string $phoneNumber = null, ?string $postal = null, ?string $region = null, + ?string $phoneVerificationMethod = null, + ?string $phoneVerificationTime = null, + ?bool $phoneWasVerificationSuccessful = null, ): self { - if (\count($values) !== 0) { - if (\func_num_args() !== 1) { - throw new \InvalidArgumentException( - 'You may only provide the $values array or named arguments, not both.', - ); - } - - $address = $this->remove($values, 'address'); - $address2 = $this->remove($values, 'address_2'); - $city = $this->remove($values, 'city'); - $company = $this->remove($values, 'company'); - $country = $this->remove($values, 'country'); - $firstName = $this->remove($values, 'first_name'); - $lastName = $this->remove($values, 'last_name'); - $phoneCountryCode = $this->remove($values, 'phone_country_code'); - $phoneNumber = $this->remove($values, 'phone_number'); - $postal = $this->remove($values, 'postal'); - $region = $this->remove($values, 'region'); - - $this->verifyEmpty($values); - } - - if ($address !== null) { - $values['address'] = $address; - } - - if ($address2 !== null) { - $values['address_2'] = $address2; - } - - if ($city !== null) { - $values['city'] = $city; - } - - if ($company !== null) { - $values['company'] = $company; - } - - if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } - $values['country'] = $country; - } - - if ($firstName !== null) { - $values['first_name'] = $firstName; - } - - if ($lastName !== null) { - $values['last_name'] = $lastName; - } - - if ($phoneCountryCode !== null) { - if ($this->validateInput) { - $this->verifyPhoneCountryCode($phoneCountryCode); - } - $values['phone_country_code'] = $phoneCountryCode; - } - - if ($phoneNumber !== null) { - $values['phone_number'] = $phoneNumber; - } - - if ($postal !== null) { - $values['postal'] = $postal; - } - - if ($region !== null) { - if ($this->validateInput) { - $this->verifyRegionCode($region); - } - $values['region'] = $region; + if (\count($values) !== 0 && \func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); } $new = clone $this; - $new->content['billing'] = $values; + $new->content['billing'] = $this->addressValues( + values: $values, + address: $address, + address2: $address2, + city: $city, + company: $company, + country: $country, + firstName: $firstName, + lastName: $lastName, + phoneCountryCode: $phoneCountryCode, + phoneNumber: $phoneNumber, + postal: $postal, + region: $region, + phoneVerificationMethod: $phoneVerificationMethod, + phoneVerificationTime: $phoneVerificationTime, + phoneWasVerificationSuccessful: $phoneWasVerificationSuccessful, + ); return $new; } @@ -698,29 +651,40 @@ public function withBilling( * @link https://dev.maxmind.com/minfraud/api-documentation/requests/?lang=en#schema--request--shipping * minFraud shipping API docs * - * @param array $values An array of shipping data. The keys are the same as - * the JSON keys. You may use either this or the named - * arguments, but not both. - * @param string|null $company The company of the end user as provided in - * their shipping information - * @param string|null $address The first line of the user's shipping address - * @param string|null $city The city of the user's shipping address - * @param string|null $region The ISO 3166-2 subdivision code for the user's - * shipping address - * @param string|null $country The two character ISO 3166-1 alpha-2 country - * code of the user's shipping address - * @param string|null $postal The postal code of the user's shipping address - * @param string|null $address2 the second line of the shipping address - * @param string|null $deliverySpeed the delivery speed: same_day, overnight, - * expedited, or standard - * @param string|null $firstName the recipient's first name - * @param string|null $lastName the recipient's last name - * @param string|null $phoneCountryCode the phone country code, with 1 to 4 digits - * @param string|null $phoneNumber the recipient's phone number + * @param array $values An array of shipping data. The keys are the same as + * the JSON keys. You may use either this or the named + * arguments, but not both. + * @param string|null $company The company of the end user as provided in + * their shipping information + * @param string|null $address The first line of the user's shipping address + * @param string|null $city The city of the user's shipping address + * @param string|null $region The ISO 3166-2 subdivision code for the user's + * shipping address + * @param string|null $country The two character ISO 3166-1 alpha-2 country + * code of the user's shipping address + * @param string|null $postal The postal code of the user's shipping address + * @param string|null $address2 the second line of the shipping address + * @param string|null $deliverySpeed the delivery speed: same_day, overnight, + * expedited, or standard + * @param string|null $firstName the recipient's first name + * @param string|null $lastName the recipient's last name + * @param string|null $phoneCountryCode the phone country code, with 1 to 4 digits + * @param string|null $phoneNumber the recipient's phone number + * @param string|null $phoneVerificationMethod the most recent method used to verify + * the shipping phone number: delivered_code, + * network, or other + * @param string|null $phoneVerificationTime the date and time of the most recent + * verification of the shipping phone number, + * in RFC 3339 date-time format. The time + * must not be before 1970-01-01T00:00:00Z + * or more than two days in the future. + * @param bool|null $phoneWasVerificationSuccessful whether the most recent verification of + * the shipping phone number succeeded. Omit this + * if no verification was attempted. * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is * a clone of the original with additional data. @@ -739,6 +703,9 @@ public function withShipping( ?string $phoneNumber = null, ?string $postal = null, ?string $region = null, + ?string $phoneVerificationMethod = null, + ?string $phoneVerificationTime = null, + ?bool $phoneWasVerificationSuccessful = null, ): self { if (\count($values) !== 0) { if (\func_num_args() !== 1) { @@ -746,86 +713,38 @@ public function withShipping( 'You may only provide the $values array or named arguments, not both.', ); } - - $address = $this->remove($values, 'address'); - $address2 = $this->remove($values, 'address_2'); - $city = $this->remove($values, 'city'); - $company = $this->remove($values, 'company'); - $country = $this->remove($values, 'country'); $deliverySpeed = $this->remove($values, 'delivery_speed'); - $firstName = $this->remove($values, 'first_name'); - $lastName = $this->remove($values, 'last_name'); - $phoneCountryCode = $this->remove($values, 'phone_country_code'); - $phoneNumber = $this->remove($values, 'phone_number'); - $postal = $this->remove($values, 'postal'); - $region = $this->remove($values, 'region'); - - $this->verifyEmpty($values); - } - - if ($address !== null) { - $values['address'] = $address; - } - - if ($address2 !== null) { - $values['address_2'] = $address2; - } - - if ($city !== null) { - $values['city'] = $city; } - if ($company !== null) { - $values['company'] = $company; - } - - if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } - $values['country'] = $country; - } + $shipping = $this->addressValues( + values: $values, + address: $address, + address2: $address2, + city: $city, + company: $company, + country: $country, + firstName: $firstName, + lastName: $lastName, + phoneCountryCode: $phoneCountryCode, + phoneNumber: $phoneNumber, + postal: $postal, + region: $region, + phoneVerificationMethod: $phoneVerificationMethod, + phoneVerificationTime: $phoneVerificationTime, + phoneWasVerificationSuccessful: $phoneWasVerificationSuccessful, + ); + // Check after the address fields, so that type errors and unknown keys + // are reported first. if ($deliverySpeed !== null) { if (!\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true)) { $this->maybeThrowInvalidInputException("$deliverySpeed is not a valid delivery speed"); } - - $values['delivery_speed'] = $deliverySpeed; - } - - if ($firstName !== null) { - $values['first_name'] = $firstName; - } - - if ($lastName !== null) { - $values['last_name'] = $lastName; - } - - if ($phoneCountryCode !== null) { - if ($this->validateInput) { - $this->verifyPhoneCountryCode($phoneCountryCode); - } - $values['phone_country_code'] = $phoneCountryCode; - } - - if ($phoneNumber !== null) { - $values['phone_number'] = $phoneNumber; - } - - if ($postal !== null) { - $values['postal'] = $postal; - } - - if ($region !== null) { - if ($this->validateInput) { - $this->verifyRegionCode($region); - } - $values['region'] = $region; + $shipping['delivery_speed'] = $deliverySpeed; } $new = clone $this; - $new->content['shipping'] = $values; + $new->content['shipping'] = $shipping; return $new; } @@ -860,9 +779,9 @@ public function withShipping( * - `real_time_payment` * - `rewards` * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is * a clone of the original with additional data. @@ -881,7 +800,7 @@ public function withPayment( ); } - $declineCode = $this->remove($values, 'decline_code'); + $declineCode = $this->removeString($values, 'decline_code'); $method = $this->remove($values, 'method'); $processor = $this->remove($values, 'processor'); $wasAuthorized = $this->remove($values, 'was_authorized', ['boolean']); @@ -1129,9 +1048,9 @@ public function withPayment( * @param bool|null $was3dSecureSuccessful Whether the outcome of 3-D Secure * verification was successful * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone of * the original with additional data. @@ -1159,9 +1078,9 @@ public function withCreditCard( $values = Util::cleanCreditCard($values); $avsResult = $this->remove($values, 'avs_result'); - $bankName = $this->remove($values, 'bank_name'); + $bankName = $this->removeString($values, 'bank_name'); $bankPhoneCountryCode = $this->remove($values, 'bank_phone_country_code'); - $bankPhoneNumber = $this->remove($values, 'bank_phone_number'); + $bankPhoneNumber = $this->removeString($values, 'bank_phone_number'); $country = $this->remove($values, 'country'); $cvvResult = $this->remove($values, 'cvv_result'); $issuerIdNumber = $this->remove($values, 'issuer_id_number'); @@ -1184,7 +1103,7 @@ public function withCreditCard( } if ($bankPhoneCountryCode !== null) { - if ($this->validateInput && !preg_match('/^[0-9]{1,4}$/', $bankPhoneCountryCode)) { + if (!preg_match('/\A[0-9]{1,4}\z/', $bankPhoneCountryCode)) { $this->maybeThrowInvalidInputException('Bank phone country code must be a string of 1 to 4 digits.'); } @@ -1196,9 +1115,7 @@ public function withCreditCard( } if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } + $this->verifyCountryCode($country); $values['country'] = $country; } @@ -1210,27 +1127,27 @@ public function withCreditCard( } if ($issuerIdNumber !== null) { - if (!preg_match('/^(?:[0-9]{6}|[0-9]{8})$/', $issuerIdNumber)) { + if (!preg_match('/\A(?:[0-9]{6}|[0-9]{8})\z/', $issuerIdNumber)) { $this->maybeThrowInvalidInputException('Issuer ID number must be a string of 6 or 8 digits.'); } $values['issuer_id_number'] = $issuerIdNumber; } if ($lastDigits !== null) { - if (!preg_match('/^(?:[0-9]{2}|[0-9]{4})$/', $lastDigits)) { + if (!preg_match('/\A(?:[0-9]{2}|[0-9]{4})\z/', $lastDigits)) { $this->maybeThrowInvalidInputException('Last digits must be a string of 2 or 4 digits.'); } $values['last_digits'] = $lastDigits; } if ($token !== null) { - if (!preg_match('/^[\x21-\x7E]{1,255}$/', $token)) { + if (!preg_match('/\A[\x21-\x7E]{1,255}\z/', $token)) { $this->maybeThrowInvalidInputException( 'Credit card token must be a string of 1 to 255 printable ASCII characters.', ); } - if (preg_match('/^[0-9]{1,19}$/', $token)) { + if (preg_match('/\A[0-9]{1,19}\z/', $token)) { $this->maybeThrowInvalidInputException( 'Credit card token cannot look like a card number or part of one.', ); @@ -1289,7 +1206,7 @@ public function withCustomInputs(array $values): self ); } - if (!\is_string($key) || !preg_match('/^[a-z0-9_]{1,25}\Z/', $key)) { + if (!\is_string($key) || !preg_match('/\A[a-z0-9_]{1,25}\z/', $key)) { $this->maybeThrowInvalidInputException( "$key is invalid. Custom input keys must be alphanumeric and have 25 characters or less.", ); @@ -1325,9 +1242,9 @@ public function withCustomInputs(array $values): self * * @see https://support.maxmind.com/knowledge-base/articles/order-and-shopping-cart-inputs-minfraud * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone of the original with additional data. */ @@ -1349,14 +1266,14 @@ public function withOrder( ); } - $affiliateId = $this->remove($values, 'affiliate_id'); - $amount = $this->remove($values, 'amount', ['double', 'float', 'integer']); + $affiliateId = $this->removeString($values, 'affiliate_id'); + $amount = $this->remove($values, 'amount', ['double', 'integer', 'numeric string']); $currency = $this->remove($values, 'currency'); - $discountCode = $this->remove($values, 'discount_code'); + $discountCode = $this->removeString($values, 'discount_code'); $hasGiftMessage = $this->remove($values, 'has_gift_message', ['boolean']); $isGift = $this->remove($values, 'is_gift', ['boolean']); $referrerUri = $this->remove($values, 'referrer_uri'); - $subaffiliateId = $this->remove($values, 'subaffiliate_id'); + $subaffiliateId = $this->removeString($values, 'subaffiliate_id'); $this->verifyEmpty($values); } @@ -1373,7 +1290,7 @@ public function withOrder( } if ($currency !== null) { - if (!preg_match('/^[A-Z]{3}$/', $currency)) { + if (!preg_match('/\A[A-Z]{3}\z/', $currency)) { $this->maybeThrowInvalidInputException("$currency is not a valid currency code"); } $values['currency'] = $currency; @@ -1427,9 +1344,9 @@ public function withOrder( * The value must be a whole number. * @param string|null $itemId the identifier of the item in the shopping cart * - * @throws InvalidInputException if input validation is enabled and a - * value is invalid, a value has the wrong - * type, or a key is unknown + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown * * @return MinFraud A new immutable MinFraud object. This object is a clone * of the original with additional data. @@ -1448,15 +1365,12 @@ public function withShoppingCartItem( ); } - $category = $this->remove($values, 'category'); + $category = $this->removeString($values, 'category'); - $v = $this->remove($values, 'item_id', ['integer', 'string']); - if ($v !== null) { - $itemId = (string) $v; - } + $itemId = $this->removeString($values, 'item_id'); - $price = $this->remove($values, 'price', ['double', 'float', 'integer']); - $quantity = $this->remove($values, 'quantity', ['integer']); + $price = $this->remove($values, 'price', ['double', 'integer', 'numeric string']); + $quantity = $this->remove($values, 'quantity', ['integer', 'numeric string']); $this->verifyEmpty($values); } @@ -1480,6 +1394,9 @@ public function withShoppingCartItem( if ($quantity < 0) { $this->maybeThrowInvalidInputException("$quantity must be greater than or equal to 0"); } + if (\is_string($quantity) && !preg_match('/\A\+?\d+\z/', $quantity)) { + $this->maybeThrowInvalidInputException("$quantity must be a whole number"); + } $values['quantity'] = $quantity; } @@ -1558,6 +1475,127 @@ public function factors(): Factors return $this->post(Factors::class, 'factors'); } + /** + * Builds the address array that withBilling() and withShipping() share. + * + * @param array $values the array argument of the public + * method, without section-specific keys + * + * @throws InvalidInputException if a value has the wrong type, or if + * input validation is enabled and a value is + * invalid or a key is unknown + * + * @return array + */ + private function addressValues( + array $values, + ?string $address, + ?string $address2, + ?string $city, + ?string $company, + ?string $country, + ?string $firstName, + ?string $lastName, + ?string $phoneCountryCode, + ?string $phoneNumber, + ?string $postal, + ?string $region, + ?string $phoneVerificationMethod, + ?string $phoneVerificationTime, + ?bool $phoneWasVerificationSuccessful, + ): array { + if (\count($values) !== 0) { + $address = $this->removeString($values, 'address'); + $address2 = $this->removeString($values, 'address_2'); + $city = $this->removeString($values, 'city'); + $company = $this->removeString($values, 'company'); + $country = $this->remove($values, 'country'); + $firstName = $this->removeString($values, 'first_name'); + $lastName = $this->removeString($values, 'last_name'); + $phoneCountryCode = $this->remove($values, 'phone_country_code'); + $phoneNumber = $this->removeString($values, 'phone_number'); + $phoneVerificationMethod = $this->remove($values, 'phone_verification_method'); + $phoneVerificationTime = $this->remove($values, 'phone_verification_time'); + $phoneWasVerificationSuccessful = $this->remove( + $values, + 'phone_was_verification_successful', + ['boolean'], + ); + $postal = $this->removeString($values, 'postal'); + $region = $this->remove($values, 'region'); + + $this->verifyEmpty($values); + } + + $fields = []; + if ($address !== null) { + $fields['address'] = $address; + } + + if ($address2 !== null) { + $fields['address_2'] = $address2; + } + + if ($city !== null) { + $fields['city'] = $city; + } + + if ($company !== null) { + $fields['company'] = $company; + } + + if ($country !== null) { + $this->verifyCountryCode($country); + $fields['country'] = $country; + } + + if ($firstName !== null) { + $fields['first_name'] = $firstName; + } + + if ($lastName !== null) { + $fields['last_name'] = $lastName; + } + + if ($phoneCountryCode !== null) { + $this->verifyPhoneCountryCode($phoneCountryCode); + $fields['phone_country_code'] = $phoneCountryCode; + } + + if ($phoneNumber !== null) { + $fields['phone_number'] = $phoneNumber; + } + + if ($phoneVerificationMethod !== null) { + if (!\in_array($phoneVerificationMethod, ['delivered_code', 'network', 'other'], true)) { + $this->maybeThrowInvalidInputException( + "$phoneVerificationMethod is not a valid phone verification method", + ); + } + $fields['phone_verification_method'] = $phoneVerificationMethod; + } + + if ($phoneVerificationTime !== null) { + $this->verifyRfc3339DateTime($phoneVerificationTime); + $fields['phone_verification_time'] = $phoneVerificationTime; + } + + if ($phoneWasVerificationSuccessful !== null) { + $fields['phone_was_verification_successful'] = $phoneWasVerificationSuccessful; + } + + if ($postal !== null) { + $fields['postal'] = $postal; + } + + if ($region !== null) { + $this->verifyRegionCode($region); + $fields['region'] = $region; + } + + return $values + $fields; + } + /** * @param string $class the model class name to use * @param string $path the service path suffix to use @@ -1587,13 +1625,31 @@ private function post(string $class, string $path) ); } + /** + * @throws InvalidInputException if the value is invalid and input + * validation is enabled + */ + private function verifyRfc3339DateTime(string $dateTime): void + { + // The pattern is the format that the web service accepts. checkdate() + // rejects dates that do not exist, such as February 30. + $pattern = '/\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/'; + if (!preg_match($pattern, $dateTime, $matches) + || !checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) + ) { + $this->maybeThrowInvalidInputException("$dateTime is not a valid RFC 3339 formatted datetime string"); + } + } + /** * @throws InvalidInputException if the value is invalid and input * validation is enabled */ private function verifyCountryCode(string $country): void { - if (!preg_match('/^[A-Z]{2}$/', $country)) { + if (!preg_match('/\A[A-Z]{2}\z/', $country)) { $this->maybeThrowInvalidInputException("$country is not a valid ISO 3166-1 country code"); } } @@ -1604,7 +1660,7 @@ private function verifyCountryCode(string $country): void */ private function verifyPhoneCountryCode(string $phoneCountryCode): void { - if (!preg_match('/^[0-9]{1,4}$/', $phoneCountryCode)) { + if (!preg_match('/\A[0-9]{1,4}\z/', $phoneCountryCode)) { $this->maybeThrowInvalidInputException('Phone country code must be a string of 1 to 4 digits.'); } } @@ -1615,7 +1671,7 @@ private function verifyPhoneCountryCode(string $phoneCountryCode): void */ private function verifyRegionCode(string $region): void { - if (!preg_match('/^[0-9A-Z]{1,4}$/', $region)) { + if (!preg_match('/\A[0-9A-Z]{1,4}\z/', $region)) { $this->maybeThrowInvalidInputException( "$region is not a valid ISO 3166-2 region code (without country prefix)", ); diff --git a/src/MinFraud/ReportTransaction.php b/src/MinFraud/ReportTransaction.php index fc8e470..bc4f400 100644 --- a/src/MinFraud/ReportTransaction.php +++ b/src/MinFraud/ReportTransaction.php @@ -26,9 +26,10 @@ class ReportTransaction extends ServiceClient * * `proxy` - The HTTP proxy to use. May include a schema, port, * username, and password, e.g., `http://username:password@127.0.0.1:10`. * * `validateInput` - Default is `true`. Determines whether values passed - * to the `with*()` methods are validated. It is recommended that you - * leave validation on while developing and only (optionally) disable it - * before deployment. + * to `report()` are validated. A value with the wrong type in the + * `$values` array is always rejected. It is recommended that you leave + * validation on while developing and only (optionally) disable it before + * deployment. * * @throws \RuntimeException with older web-service-common releases, if HTTP client setup fails * @throws WebServiceException if HTTP client setup fails @@ -113,13 +114,13 @@ public function report( ); } - $chargebackCode = $this->remove($values, 'chargeback_code'); + $chargebackCode = $this->removeString($values, 'chargeback_code'); $ipAddress = $this->remove($values, 'ip_address'); $maxmindId = $this->remove($values, 'maxmind_id'); $minfraudId = $this->remove($values, 'minfraud_id'); - $notes = $this->remove($values, 'notes'); + $notes = $this->removeString($values, 'notes'); $tag = $this->remove($values, 'tag'); - $transactionId = $this->remove($values, 'transaction_id'); + $transactionId = $this->removeString($values, 'transaction_id'); $this->verifyEmpty($values); } @@ -144,7 +145,7 @@ public function report( if ($minfraudId !== null) { if (!preg_match( - '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', + '/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/i', $minfraudId, )) { $this->maybeThrowInvalidInputException("$minfraudId must be a valid minFraud ID"); diff --git a/src/MinFraud/ServiceClient.php b/src/MinFraud/ServiceClient.php index 1699edc..a394ae5 100644 --- a/src/MinFraud/ServiceClient.php +++ b/src/MinFraud/ServiceClient.php @@ -74,23 +74,52 @@ protected function maybeThrowInvalidInputException(string $msg): void } } + /** + * Removes a free-form string. An integer is converted to a string. + * + * @ignore + * + * @param array $array the parent array + * @param string $key the key to remove + * + * @throws InvalidInputException if the value has an unexpected type + */ + protected function removeString(array &$array, string $key): ?string + { + $value = $this->remove($array, $key, ['integer', 'string']); + if (\is_int($value)) { + return (string) $value; + } + + return $value; + } + /** * @ignore * * @param array $array the parent array * @param string $key the key to remove - * @param list $types the expected types + * @param list $types the expected types. `numeric string` + * matches a string that `is_numeric()` + * accepts and that has no surrounding + * whitespace. * - * @throws InvalidInputException if the value has an unexpected type and - * input validation is enabled + * @throws InvalidInputException if the value has an unexpected type */ protected function remove(array &$array, string $key, array $types = ['string']): mixed { if (\array_key_exists($key, $array)) { $value = $array[$key]; $actualType = \gettype($value); + if ($actualType === 'string' + && \in_array('numeric string', $types, true) + && is_numeric($value) + && trim($value) === $value + ) { + $actualType = 'numeric string'; + } if ($value !== null && !\in_array($actualType, $types, true)) { - $this->maybeThrowInvalidInputException( + throw new InvalidInputException( "Expected $key to be in [" . implode(', ', $types) . "] but was $actualType", ); } diff --git a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index 32af1e8..a756cfb 100644 --- a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php +++ b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php @@ -165,7 +165,7 @@ public function testUnknownKey(): void } /** - * @dataProvider notStringTypes + * @dataProvider notStringOrIntegerTypes * * @param mixed $chargebackCode */ @@ -185,7 +185,7 @@ public function testInvalidChargebackCodes($chargebackCode): void } /** - * @dataProvider notStringTypes + * @dataProvider notStringOrIntegerTypes * * @param mixed $notes */ @@ -202,7 +202,7 @@ public function testInvalidNotes($notes): void } /** - * @dataProvider notStringTypes + * @dataProvider notStringOrIntegerTypes * * @param mixed $transactionId */ @@ -224,15 +224,28 @@ public function testInvalidTransactionIds($transactionId): void /** * @return list> */ - public static function notStringTypes(): array + public static function notStringOrIntegerTypes(): array { return [ - [1], [['string']], [new \stdClass()], ]; } + /** + * @testWith ["chargeback_code"] + * ["notes"] + * ["transaction_id"] + */ + public function testIntegerInFreeFormStringField(string $field): void + { + $req = array_merge(Data::minimalRequest(), [$field => 123]); + $expected = array_merge($req, [$field => '123']); + // report() sends the fields in alphabetical order. + ksort($expected); + $this->createReportTransactionRequest($expected)->report($req); + } + /** * @dataProvider invalidIpAddresses */ @@ -322,6 +335,7 @@ public static function invalidMinfraudIds(): array ['12345678-123412341234-12345678901'], ['12345678-1234-1234-1234-1234567890123'], ['12345678-1234-1234-1234-12345678901g'], + ["12345678-1234-1234-1234-123456789012\n"], [''], ]; } @@ -352,6 +366,48 @@ public static function invalidTags(): array ]; } + /** + * @testWith ["chargeback_code"] + * ["ip_address"] + * ["maxmind_id"] + * ["minfraud_id"] + * ["notes"] + * ["tag"] + * ["transaction_id"] + */ + public function testWrongTypeWithoutValidation(string $field): void + { + $values = [true, ['string'], new \stdClass()]; + if (!\in_array($field, ['chargeback_code', 'notes', 'transaction_id'], true)) { + $values[] = 1; + } + + foreach ($values as $value) { + $req = array_merge(Data::minimalRequest(), [$field => $value]); + + try { + $this->createReportTransactionRequest($req, 0, ['validateInput' => false])->report($req); + $this->fail('Expected an InvalidInputException'); + } catch (InvalidInputException $e) { + $this->assertStringStartsWith("Expected $field to be in [", $e->getMessage()); + } + } + } + + /** + * @testWith ["ip_address", "not an IP"] + * ["maxmind_id", "abc"] + * ["minfraud_id", "abc"] + * ["tag", "risky_business"] + */ + public function testInvalidValueWithoutValidation(string $field, string $value): void + { + $req = array_merge(Data::minimalRequest(), [$field => $value]); + // report() sends the fields in alphabetical order. + ksort($req); + $this->createReportTransactionRequest($req, 1, ['validateInput' => false])->report($req); + } + /** * @param array $requestContent * @param array $options diff --git a/tests/MaxMind/Test/MinFraud/ServiceClientTester.php b/tests/MaxMind/Test/MinFraud/ServiceClientTester.php index 745fb56..87e1126 100644 --- a/tests/MaxMind/Test/MinFraud/ServiceClientTester.php +++ b/tests/MaxMind/Test/MinFraud/ServiceClientTester.php @@ -37,7 +37,15 @@ protected function createRequest( $stub->expects($this->exactly($callsToRequest)) ->method('post') - ->with($this->equalTo(json_encode($requestContent))) + ->with($this->callback(function (string $body) use ($requestContent): bool { + // The web service does not use the JSON key order. + $this->assertSame( + self::sortKeys(json_decode(json_encode($requestContent), true)), + self::sortKeys(json_decode($body, true)), + ); + + return true; + })) ->willReturn([$statusCode, $contentType, $responseBody]); $factory = $this->getMockBuilder( @@ -97,4 +105,14 @@ protected function createRequest( $options ); } + + private static function sortKeys(mixed $value): mixed + { + if (!\is_array($value)) { + return $value; + } + ksort($value); + + return array_map(self::sortKeys(...), $value); + } } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 410a90c..5a2d1ae 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -82,32 +82,284 @@ public static function invalidCustomInputs(): array } /** - * @dataProvider unvalidatedAddressFields + * @dataProvider allFields */ - public function testAddressFieldWithoutValidation(string $method, string $section, string $field): void + public function testWrongTypeWithoutValidation(string $method, string $field): void { + $values = [new \stdClass(), ['x']]; + $numbers = ['amount', 'price', 'quantity', 'session_age']; + $strings = array_map(static fn (array $row) => "$row[0] $row[2]", self::freeFormStringFields()); + if (!\in_array($field, $numbers, true) && !\in_array("$method $field", $strings, true)) { + $values[] = 12; + } + $booleans = [ + 'has_gift_message', + 'is_gift', + 'phone_was_verification_successful', + 'was_3d_secure_successful', + 'was_authorized', + ]; + if (\in_array($field, $booleans, true)) { + $values[] = 'true'; + } else { + $values[] = true; + } + if (\in_array($field, $numbers, true)) { + $values[] = 'not a number'; + $values[] = ' 12'; + $values[] = "12\n"; + } + $client = new MinFraud(1, 'key', ['validateInput' => false]); - $result = $client->{$method}([$field => 12]); - $this->assertSame(12, $result->jsonSerialize()['content'][$section][$field]); + foreach ($values as $value) { + try { + $client->{$method}([$field => $value]); + $this->fail('Expected an InvalidInputException'); + } catch (InvalidInputException $e) { + $this->assertStringStartsWith("Expected $field to be in [", $e->getMessage()); + } + } + } + + /** + * @return array + */ + public static function allFields(): array + { + $address = [ + 'address', 'address_2', 'city', 'company', 'country', 'first_name', 'last_name', + 'phone_country_code', 'phone_number', 'phone_verification_method', 'phone_verification_time', + 'phone_was_verification_successful', 'postal', 'region', + ]; + $fields = [ + 'withAccount' => ['user_id', 'username_md5'], + 'withBilling' => $address, + 'withCreditCard' => [ + 'avs_result', 'bank_name', 'bank_phone_country_code', 'bank_phone_number', 'country', + 'cvv_result', 'issuer_id_number', 'last_digits', 'token', 'was_3d_secure_successful', + ], + 'withDevice' => [ + 'accept_language', 'ip_address', 'session_age', 'session_id', 'tracking_token', 'user_agent', + ], + 'withEmail' => ['address', 'domain'], + 'withEvent' => ['party', 'shop_id', 'time', 'transaction_id', 'type'], + 'withOrder' => [ + 'affiliate_id', 'amount', 'currency', 'discount_code', 'has_gift_message', 'is_gift', + 'referrer_uri', 'subaffiliate_id', + ], + 'withPayment' => ['decline_code', 'method', 'processor', 'was_authorized'], + 'withShipping' => [...$address, 'delivery_speed'], + 'withShoppingCartItem' => ['category', 'item_id', 'price', 'quantity'], + ]; + + $rows = []; + foreach ($fields as $method => $keys) { + foreach ($keys as $key) { + $rows["$method $key"] = [$method, $key]; + } + } + + return $rows; } /** - * @return array + * @dataProvider numericStrings */ - public static function unvalidatedAddressFields(): array + public function testNumericString( + string $method, + string $section, + string $field, + string $value, + mixed $expected, + ): void { + $client = new MinFraud(1, 'key'); + $content = $client->{$method}([$field => $value])->jsonSerialize()['content'][$section]; + if ($section === 'shopping_cart') { + $content = $content[0]; + } + $this->assertSame($expected, $content[$field]); + } + + /** + * @return array + */ + public static function numericStrings(): array { return [ - ['withBilling', 'billing', 'country'], - ['withBilling', 'billing', 'region'], - ['withBilling', 'billing', 'phone_country_code'], - ['withShipping', 'shipping', 'country'], - ['withShipping', 'shipping', 'region'], - ['withShipping', 'shipping', 'phone_country_code'], - ['withCreditCard', 'credit_card', 'country'], - ['withCreditCard', 'credit_card', 'bank_phone_country_code'], + 'amount' => ['withOrder', 'order', 'amount', '12.50', '12.50'], + 'price' => ['withShoppingCartItem', 'shopping_cart', 'price', '1.5', '1.5'], + 'quantity' => ['withShoppingCartItem', 'shopping_cart', 'quantity', '3', '3'], + 'quantity with a sign' => ['withShoppingCartItem', 'shopping_cart', 'quantity', '+3', '+3'], + 'session_age' => ['withDevice', 'device', 'session_age', '3600', 3600.0], ]; } + /** + * @testWith ["2.5"] + * ["1e2"] + */ + public function testFractionalQuantityString(string $quantity): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('must be a whole number'); + + $client = new MinFraud(1, 'key'); + $client->withShoppingCartItem(['quantity' => $quantity]); + } + + /** + * @dataProvider freeFormStringFields + */ + public function testIntegerInFreeFormStringField(string $method, string $section, string $field): void + { + $client = new MinFraud(1, 'key'); + $content = $client->{$method}([$field => 12])->jsonSerialize()['content'][$section]; + if ($section === 'shopping_cart') { + $content = $content[0]; + } + $this->assertSame('12', $content[$field]); + } + + /** + * @return array + */ + public static function freeFormStringFields(): array + { + $address = ['address', 'address_2', 'city', 'company', 'first_name', 'last_name', 'phone_number', 'postal']; + $fields = [ + 'withAccount' => ['account', ['user_id']], + 'withBilling' => ['billing', $address], + 'withCreditCard' => ['credit_card', ['bank_name', 'bank_phone_number']], + 'withDevice' => ['device', ['accept_language', 'session_id', 'tracking_token', 'user_agent']], + 'withEvent' => ['event', ['shop_id', 'transaction_id']], + 'withOrder' => ['order', ['affiliate_id', 'discount_code', 'subaffiliate_id']], + 'withPayment' => ['payment', ['decline_code']], + 'withShipping' => ['shipping', $address], + 'withShoppingCartItem' => ['shopping_cart', ['category', 'item_id']], + ]; + + $rows = []; + foreach ($fields as $method => [$section, $keys]) { + foreach ($keys as $key) { + $rows["$method $key"] = [$method, $section, $key]; + } + } + + return $rows; + } + + /** + * @testWith ["withBilling", "billing"] + * ["withShipping", "shipping"] + */ + public function testAddressUnknownKeyWithoutValidation(string $method, string $section): void + { + $client = new MinFraud(1, 'key', ['validateInput' => false]); + $result = $client->{$method}(['unknown' => 'x', 'city' => 'Waltham']); + $this->assertSame(['unknown' => 'x', 'city' => 'Waltham'], $result->jsonSerialize()['content'][$section]); + } + + public function testShippingUnknownKeyBeforeDeliverySpeed(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('Unknown keys in array: typo'); + + $client = new MinFraud(1, 'key'); + $client->withShipping(['delivery_speed' => 'bogus', 'typo' => 5]); + } + + /** + * @dataProvider trailingNewlines + */ + public function testTrailingNewline(string $method, string $field, string $value): void + { + $this->expectException(InvalidInputException::class); + + $client = new MinFraud(1, 'key'); + $client->{$method}([$field => $value]); + } + + /** + * @return array + */ + public static function trailingNewlines(): array + { + return [ + 'username_md5' => ['withAccount', 'username_md5', "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"], + 'billing country' => ['withBilling', 'country', "US\n"], + 'billing phone_country_code' => ['withBilling', 'phone_country_code', "1\n"], + 'billing region' => ['withBilling', 'region', "CT\n"], + 'bank_phone_country_code' => ['withCreditCard', 'bank_phone_country_code', "1\n"], + 'credit card country' => ['withCreditCard', 'country', "US\n"], + 'issuer_id_number' => ['withCreditCard', 'issuer_id_number', "123456\n"], + 'last_digits' => ['withCreditCard', 'last_digits', "1234\n"], + 'token' => ['withCreditCard', 'token', "abc\n"], + 'email address' => ['withEmail', 'address', "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"], + 'currency' => ['withOrder', 'currency', "USD\n"], + 'shipping country' => ['withShipping', 'country', "US\n"], + ]; + } + + /** + * @dataProvider invalidValues + */ + public function testInvalidValueWithoutValidation( + string $method, + string $section, + string $field, + mixed $value, + ): void { + $client = new MinFraud(1, 'key', ['validateInput' => false]); + $content = $client->{$method}([$field => $value])->jsonSerialize()['content'][$section]; + if ($section === 'shopping_cart') { + $content = $content[0]; + } + $this->assertSame($value, $content[$field]); + } + + /** + * @return array + */ + public static function invalidValues(): array + { + $address = [ + 'country' => 'usa', + 'phone_country_code' => '12345', + 'phone_verification_method' => 'other_method', + 'phone_verification_time' => 'not a time', + 'region' => 'invalid', + ]; + $fields = [ + 'withAccount' => ['account', ['username_md5' => 'not an MD5']], + 'withBilling' => ['billing', $address], + 'withCreditCard' => ['credit_card', [ + 'avs_result' => 'YY', + 'bank_phone_country_code' => '12345', + 'country' => 'usa', + 'cvv_result' => 'NN', + 'issuer_id_number' => '12345', + 'last_digits' => '123', + 'token' => '4111111111111111', + ]], + 'withDevice' => ['device', ['ip_address' => 'not an IP', 'session_age' => -1.5, 'session_id' => '']], + 'withEmail' => ['email', ['address' => 'not an email', 'domain' => 'not a domain']], + 'withEvent' => ['event', ['party' => 'other', 'time' => 'not a time', 'type' => 'other']], + 'withOrder' => ['order', ['amount' => -1.5, 'currency' => 'usd', 'referrer_uri' => 'not a URL']], + 'withPayment' => ['payment', ['method' => 'other', 'processor' => 'other']], + 'withShipping' => ['shipping', [...$address, 'delivery_speed' => 'other']], + 'withShoppingCartItem' => ['shopping_cart', ['price' => -1.5, 'quantity' => -1]], + ]; + + $rows = []; + foreach ($fields as $method => [$section, $values]) { + foreach ($values as $key => $value) { + $rows["$method $key"] = [$method, $section, $key, $value]; + } + } + + return $rows; + } + public function testMinFraud(): void { $minFraud = new MinFraud(0, '', ['hashEmail' => true, 'locales' => ['en', 'fr']]); @@ -196,7 +448,10 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser country: 'US', postal: '06510', phoneNumber: '123-456-7890', - phoneCountryCode: '1' + phoneCountryCode: '1', + phoneVerificationMethod: 'delivered_code', + phoneWasVerificationSuccessful: true, + phoneVerificationTime: '2026-10-01T14:30:00Z' ) ->withCreditCard( country: 'US', @@ -263,6 +518,9 @@ public function testFullInsightsRequestUsingNamedArgs(string $class, string $ser postal: '73003', phoneNumber: '123-456-0000', phoneCountryCode: '1', + phoneVerificationMethod: 'network', + phoneWasVerificationSuccessful: false, + phoneVerificationTime: '2026-10-01T14:31:00Z', deliverySpeed: 'same_day' ) ->withShoppingCartItem( @@ -763,6 +1021,193 @@ public function testBadDeliverySpeed(): void )->withShipping(['delivery_speed' => 'slow']); } + /** + * @dataProvider goodPhoneVerificationMethods + */ + public function testGoodBillingPhoneVerificationMethod(string $good): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_method' => $good]); + + $this->assertSame( + $good, + $result->jsonSerialize()['content']['billing']['phone_verification_method'], + ); + } + + /** + * @return array> + */ + public static function goodPhoneVerificationMethods(): array + { + return [ + ['delivered_code'], + ['network'], + ['other'], + ]; + } + + public function testBadBillingPhoneVerificationMethod(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid phone verification method'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_method' => 'sms']); + } + + /** + * @dataProvider goodTimes + */ + public function testGoodBillingPhoneVerificationTimes(string $time): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_time' => $time]); + + $this->assertSame( + $time, + $result->jsonSerialize()['content']['billing']['phone_verification_time'], + ); + } + + /** + * @dataProvider badTimes + */ + public function testBadBillingPhoneVerificationTime(string $time): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_verification_time' => $time]); + } + + public function testBillingPhoneWasVerificationSuccessfulFalse(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(phoneWasVerificationSuccessful: false); + + $this->assertFalse( + $result->jsonSerialize()['content']['billing']['phone_was_verification_successful'], + ); + } + + public function testBadBillingPhoneWasVerificationSuccessful(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage( + 'Expected phone_was_verification_successful to be in [boolean] but was string', + ); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withBilling(['phone_was_verification_successful' => 'true']); + } + + /** + * @dataProvider goodPhoneVerificationMethods + */ + public function testGoodShippingPhoneVerificationMethod(string $good): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_method' => $good]); + + $this->assertSame( + $good, + $result->jsonSerialize()['content']['shipping']['phone_verification_method'], + ); + } + + public function testBadShippingPhoneVerificationMethod(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid phone verification method'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_method' => 'sms']); + } + + /** + * @dataProvider goodTimes + */ + public function testGoodShippingPhoneVerificationTimes(string $time): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_time' => $time]); + + $this->assertSame( + $time, + $result->jsonSerialize()['content']['shipping']['phone_verification_time'], + ); + } + + /** + * @dataProvider badTimes + */ + public function testBadShippingPhoneVerificationTime(string $time): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage('valid RFC 3339'); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_verification_time' => $time]); + } + + public function testShippingPhoneWasVerificationSuccessfulFalse(): void + { + $result = $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(phoneWasVerificationSuccessful: false); + + $this->assertFalse( + $result->jsonSerialize()['content']['shipping']['phone_was_verification_successful'], + ); + } + + public function testPositionalPostalAndRegion(): void + { + $content = $this->createMinFraudRequestWithFullResponse('insights', 0) + ->withBilling([], null, null, null, null, 'US', null, null, null, null, '06510', 'CT') + ->withShipping([], null, null, null, null, 'US', null, null, null, null, null, '73003', 'OK') + ->jsonSerialize()['content']; + + $this->assertSame(['country' => 'US', 'postal' => '06510', 'region' => 'CT'], $content['billing']); + $this->assertSame(['country' => 'US', 'postal' => '73003', 'region' => 'OK'], $content['shipping']); + } + + public function testBadShippingPhoneWasVerificationSuccessful(): void + { + $this->expectException(InvalidInputException::class); + $this->expectExceptionMessage( + 'Expected phone_was_verification_successful to be in [boolean] but was string', + ); + + $this->createMinFraudRequestWithFullResponse( + 'insights', + 0 + )->withShipping(['phone_was_verification_successful' => 'true']); + } + /** * @dataProvider badIins */ @@ -1176,10 +1621,12 @@ public static function goodDomains(): array */ public function testGoodEventTimes(string $time): void { - $this->createMinFraudRequestWithFullResponse( + $result = $this->createMinFraudRequestWithFullResponse( 'insights', 0 )->withEvent(['time' => $time]); + + $this->assertSame($time, $result->jsonSerialize()['content']['event']['time']); } /** @@ -1195,13 +1642,24 @@ public static function goodTimes(): array array_push( $tests, ['2014-04-12T23:20:50.052+01:00'], - ['2014-04-12T23:20:50.052Z'] + ['2014-04-12T23:20:50.052Z'], + ['2014-04-12T23:20:50.5Z'], + ['2014-04-12T23:20:50.052123+01:00'], + ['2014-04-12T23:20:50.052123Z'], + ['2014-04-12T23:20:50.052123456Z'], + ['2026-10-01T14:30:00+23:59'], + ['2024-02-29T00:00:00-05:00'], + ['2024-02-29T00:00:00Z'], + ['2014-04-12T23:20:50.123456789012345678901234567890Z'] ); return $tests; } - public function testBadEventTime(): void + /** + * @dataProvider badTimes + */ + public function testBadEventTime(string $time): void { $this->expectException(InvalidInputException::class); $this->expectExceptionMessage('valid RFC 3339'); @@ -1209,7 +1667,43 @@ public function testBadEventTime(): void $this->createMinFraudRequestWithFullResponse( 'insights', 0 - )->withEvent(['time' => '2014/04/04 19:20']); + )->withEvent(['time' => $time]); + } + + /** + * @return array> + */ + public static function badTimes(): array + { + return [ + ['2014/04/04 19:20'], + ['2014-04-12T23:20:50.Z'], + ['2014-04-12T23:20:50.052123'], + ['2014-02-30T23:20:50Z'], + ['2014-02-30T23:20:50.052123Z'], + ['2026-02-29T00:00:00Z'], + ['2026-04-31T00:00:00Z'], + ['2014-13-12T23:20:50Z'], + ['2014-04-12T24:20:50Z'], + ['2014-04-12T25:20:50Z'], + ['2014-04-12T23:60:50Z'], + ['2026-10-01T14:60:00Z'], + ['2014-04-12T23:20:61Z'], + ['2026-10-01T14:30:60Z'], + ['2026-10-01T24:00:00Z'], + ['2026-10-01t14:30:00Z'], + ['2026-10-01T14:30:00z'], + ['2026-10-01T14:30:00EST'], + ['2026-10-01T14:30:00America/New_York'], + ['2026-10-01T14:30:00GMT+2'], + ['2026-10-01T14:30:00+0000'], + ['2026-10-01T14:30:00+24:00'], + ['2026-10-01T14:30:00+25:00'], + ['2026-10-01T14:30:00+05:60'], + ['2026-1-1T14:30:00Z'], + ['26-10-01T14:30:00Z'], + ['2026-10-01T4:30:00Z'], + ]; } /** @@ -1536,6 +2030,7 @@ public static function badCustomInputs(): array { return [ [['InvalidKey' => 1]], + [["abc\n" => 1]], [['too_long' => str_repeat('x', 256)]], [['has_newline' => "test\n"]], [['too_big' => 1e13]], diff --git a/tests/data/minfraud/full-request.json b/tests/data/minfraud/full-request.json index 848fcc5..837ddad 100644 --- a/tests/data/minfraud/full-request.json +++ b/tests/data/minfraud/full-request.json @@ -25,7 +25,10 @@ "country": "US", "postal": "06510", "phone_number": "123-456-7890", - "phone_country_code": "1" + "phone_country_code": "1", + "phone_verification_method": "delivered_code", + "phone_was_verification_successful": true, + "phone_verification_time": "2026-10-01T14:30:00Z" }, "shipping": { "first_name": "ShipFirst", @@ -39,6 +42,9 @@ "postal": "73003", "phone_number": "123-456-0000", "phone_country_code": "1", + "phone_verification_method": "network", + "phone_was_verification_successful": false, + "phone_verification_time": "2026-10-01T14:31:00Z", "delivery_speed": "same_day" }, "payment": {