From e53fb4cbab087315f582f34c00cecf69d29a8603 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:47:10 +0000 Subject: [PATCH 01/10] Extract the RFC 3339 date-time check into a helper Move the event time check from withEvent into a private helper so other inputs can use the same check. Behavior does not change. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/MinFraud.php b/src/MinFraud.php index 7b17b33..c70443b 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -393,12 +393,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; } @@ -1587,6 +1582,19 @@ 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 + { + if (\DateTime::createFromFormat(\DateTime::RFC3339, $dateTime) === false + && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $dateTime) === false + ) { + $this->maybeThrowInvalidInputException("$dateTime is not a valid RFC 3339 formatted datetime string"); + } + } + /** * @throws InvalidInputException if the value is invalid and input * validation is enabled From dc0bd39564405bef07a476fede56f8f304a33d4a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 19:23:36 +0000 Subject: [PATCH 02/10] Make the RFC 3339 check match the web service The check used DateTime::createFromFormat(), which caused three problems: * RFC3339_EXTENDED parses at most three fractional second digits, so the check rejected valid values such as 2026-10-01T14:30:00.123456Z. * createFromFormat() rolls over out-of-range values, such as February 30 or hour 25, and reports them only as warnings, so the check accepted them. * createFromFormat() accepts forms that are not RFC 3339, such as time zone names, offsets without a colon, and a lowercase "z". The check now matches the pattern that the web service uses, which also checks the hour, minute, and second ranges. checkdate() rejects dates that do not exist. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ src/MinFraud.php | 9 ++++- tests/MaxMind/Test/MinFraudTest.php | 57 +++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3d79f..8947717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ CHANGELOG 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. +* 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. diff --git a/src/MinFraud.php b/src/MinFraud.php index c70443b..5e5684b 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -1588,8 +1588,13 @@ private function post(string $class, string $path) */ private function verifyRfc3339DateTime(string $dateTime): void { - if (\DateTime::createFromFormat(\DateTime::RFC3339, $dateTime) === false - && \DateTime::createFromFormat(\DateTime::RFC3339_EXTENDED, $dateTime) === false + // 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"); } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 410a90c..6c09873 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -1176,10 +1176,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 +1197,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 +1222,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'], + ]; } /** From 44e59b82640283ddf6bf1f0721c8e1e51ef96413 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 16 Oct 2026 15:41:59 +0000 Subject: [PATCH 03/10] Always reject request values with the wrong type With input validation disabled, remove() let a value of the wrong type through. A later check then failed with a type error, a conversion warning, or an error, or the value went to the web service, depending on the field. The validateInput guards on the address fields fixed this for a few fields by sending these values unchanged. remove() now always throws InvalidInputException for a value of the wrong type. The named arguments already have type declarations. validateInput now controls only the value checks, so a value with the right type that fails a check is still sent unchanged. Every check can rely on the type, so the guards are no longer needed. The web service accepts more types than remove() did, and callers often have these values from a database. The number fields now also accept a numeric string without surrounding whitespace, which the web service does not trim. It is sent unchanged, except session_age, which is still converted. A quantity string must be a whole number when validation is on. The new removeString() accepts an integer in a free-form string field and sends it as a string, as session_id and item_id already did. The documentation still describes these fields as strings. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +- CLAUDE.md | 5 + README.md | 4 + src/MinFraud.php | 184 +++++++-------- src/MinFraud/ReportTransaction.php | 13 +- src/MinFraud/ServiceClient.php | 37 ++- .../ReportTransactionTest.php | 65 +++++- tests/MaxMind/Test/MinFraudTest.php | 215 ++++++++++++++++-- 8 files changed, 406 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8947717..5d8dca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,19 @@ 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`. diff --git a/CLAUDE.md b/CLAUDE.md index 5f386ca..c6e20eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,11 @@ private function verifyCountryCode(string $country): void - Validation methods throw `InvalidInputException` when enabled - `maybeThrowInvalidInputException()` respects the `validateInput` option +- `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..e0d06c1 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 @@ -279,6 +281,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 5e5684b..88023df 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); @@ -443,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. @@ -461,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); @@ -500,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. @@ -584,9 +580,9 @@ public function withEmail( * @param string|null $region The ISO 3166-2 subdivision code for the user's * billing address * - * @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. @@ -612,16 +608,16 @@ public function withBilling( ); } - $address = $this->remove($values, 'address'); - $address2 = $this->remove($values, 'address_2'); - $city = $this->remove($values, 'city'); - $company = $this->remove($values, 'company'); + $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->remove($values, 'first_name'); - $lastName = $this->remove($values, 'last_name'); + $firstName = $this->removeString($values, 'first_name'); + $lastName = $this->removeString($values, 'last_name'); $phoneCountryCode = $this->remove($values, 'phone_country_code'); - $phoneNumber = $this->remove($values, 'phone_number'); - $postal = $this->remove($values, 'postal'); + $phoneNumber = $this->removeString($values, 'phone_number'); + $postal = $this->removeString($values, 'postal'); $region = $this->remove($values, 'region'); $this->verifyEmpty($values); @@ -644,9 +640,7 @@ public function withBilling( } if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } + $this->verifyCountryCode($country); $values['country'] = $country; } @@ -659,9 +653,7 @@ public function withBilling( } if ($phoneCountryCode !== null) { - if ($this->validateInput) { - $this->verifyPhoneCountryCode($phoneCountryCode); - } + $this->verifyPhoneCountryCode($phoneCountryCode); $values['phone_country_code'] = $phoneCountryCode; } @@ -674,9 +666,7 @@ public function withBilling( } if ($region !== null) { - if ($this->validateInput) { - $this->verifyRegionCode($region); - } + $this->verifyRegionCode($region); $values['region'] = $region; } @@ -713,9 +703,9 @@ public function withBilling( * @param string|null $phoneCountryCode the phone country code, with 1 to 4 digits * @param string|null $phoneNumber the recipient's phone number * - * @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. @@ -742,17 +732,17 @@ public function withShipping( ); } - $address = $this->remove($values, 'address'); - $address2 = $this->remove($values, 'address_2'); - $city = $this->remove($values, 'city'); - $company = $this->remove($values, 'company'); + $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'); $deliverySpeed = $this->remove($values, 'delivery_speed'); - $firstName = $this->remove($values, 'first_name'); - $lastName = $this->remove($values, 'last_name'); + $firstName = $this->removeString($values, 'first_name'); + $lastName = $this->removeString($values, 'last_name'); $phoneCountryCode = $this->remove($values, 'phone_country_code'); - $phoneNumber = $this->remove($values, 'phone_number'); - $postal = $this->remove($values, 'postal'); + $phoneNumber = $this->removeString($values, 'phone_number'); + $postal = $this->removeString($values, 'postal'); $region = $this->remove($values, 'region'); $this->verifyEmpty($values); @@ -775,9 +765,7 @@ public function withShipping( } if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } + $this->verifyCountryCode($country); $values['country'] = $country; } @@ -798,9 +786,7 @@ public function withShipping( } if ($phoneCountryCode !== null) { - if ($this->validateInput) { - $this->verifyPhoneCountryCode($phoneCountryCode); - } + $this->verifyPhoneCountryCode($phoneCountryCode); $values['phone_country_code'] = $phoneCountryCode; } @@ -813,9 +799,7 @@ public function withShipping( } if ($region !== null) { - if ($this->validateInput) { - $this->verifyRegionCode($region); - } + $this->verifyRegionCode($region); $values['region'] = $region; } @@ -855,9 +839,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. @@ -876,7 +860,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']); @@ -1124,9 +1108,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. @@ -1154,9 +1138,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'); @@ -1179,7 +1163,7 @@ public function withCreditCard( } if ($bankPhoneCountryCode !== null) { - if ($this->validateInput && !preg_match('/^[0-9]{1,4}$/', $bankPhoneCountryCode)) { + if (!preg_match('/^[0-9]{1,4}$/', $bankPhoneCountryCode)) { $this->maybeThrowInvalidInputException('Bank phone country code must be a string of 1 to 4 digits.'); } @@ -1191,9 +1175,7 @@ public function withCreditCard( } if ($country !== null) { - if ($this->validateInput) { - $this->verifyCountryCode($country); - } + $this->verifyCountryCode($country); $values['country'] = $country; } @@ -1320,9 +1302,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. */ @@ -1344,14 +1326,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); } @@ -1422,9 +1404,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. @@ -1443,15 +1425,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); } @@ -1475,6 +1454,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; } diff --git a/src/MinFraud/ReportTransaction.php b/src/MinFraud/ReportTransaction.php index fc8e470..768f87b 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); } diff --git a/src/MinFraud/ServiceClient.php b/src/MinFraud/ServiceClient.php index 1699edc..82e0bd6 100644 --- a/src/MinFraud/ServiceClient.php +++ b/src/MinFraud/ServiceClient.php @@ -79,18 +79,27 @@ protected function maybeThrowInvalidInputException(string $msg): void * * @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", ); } @@ -102,6 +111,26 @@ protected function remove(array &$array, string $key, array $types = ['string']) return null; } + /** + * 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; + } + /** * @param array $values * diff --git a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index 32af1e8..08e6730 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 */ @@ -352,6 +365,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/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 6c09873..fa5e684 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -82,30 +82,217 @@ 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', '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 + * @return array */ - public static function unvalidatedAddressFields(): array + public static function allFields(): array + { + $address = [ + 'address', 'address_2', 'city', 'company', 'country', 'first_name', 'last_name', + 'phone_country_code', 'phone_number', '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; + } + + /** + * @dataProvider numericStrings + */ + 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; + } + + /** + * @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', '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 From 0e0c95e259ccdc731c033295445dc106078211d2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 12 Oct 2026 19:34:56 +0000 Subject: [PATCH 04/10] Share billing and shipping address validation withBilling() and withShipping() repeated the same array handling, validation, and array building for the eleven address fields. Move it into one private helper. withShipping() keeps only the delivery_speed handling, and checks the delivery_speed value after the helper, so type errors and unknown keys are still reported first. The public signatures and the data sent do not change. If a request has more than one error, the error that is reported first can differ. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud.php | 288 ++++++++++++++-------------- tests/MaxMind/Test/MinFraudTest.php | 20 ++ 2 files changed, 169 insertions(+), 139 deletions(-) diff --git a/src/MinFraud.php b/src/MinFraud.php index 88023df..8117bc0 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -601,77 +601,28 @@ public function withBilling( ?string $postal = null, ?string $region = 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->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'); - $postal = $this->removeString($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) { - $this->verifyCountryCode($country); - $values['country'] = $country; - } - - if ($firstName !== null) { - $values['first_name'] = $firstName; - } - - if ($lastName !== null) { - $values['last_name'] = $lastName; - } - - if ($phoneCountryCode !== null) { - $this->verifyPhoneCountryCode($phoneCountryCode); - $values['phone_country_code'] = $phoneCountryCode; - } - - if ($phoneNumber !== null) { - $values['phone_number'] = $phoneNumber; - } - - if ($postal !== null) { - $values['postal'] = $postal; - } - - if ($region !== null) { - $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, + fields: [], + address: $address, + address2: $address2, + city: $city, + company: $company, + country: $country, + firstName: $firstName, + lastName: $lastName, + phoneCountryCode: $phoneCountryCode, + phoneNumber: $phoneNumber, + postal: $postal, + region: $region, + ); return $new; } @@ -725,86 +676,46 @@ public function withShipping( ?string $postal = null, ?string $region = null, ): self { + if (\count($values) !== 0 && \func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } 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->removeString($values, 'address'); - $address2 = $this->removeString($values, 'address_2'); - $city = $this->removeString($values, 'city'); - $company = $this->removeString($values, 'company'); - $country = $this->remove($values, 'country'); $deliverySpeed = $this->remove($values, 'delivery_speed'); - $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'); - $postal = $this->removeString($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) { - $this->verifyCountryCode($country); - $values['country'] = $country; } + $fields = []; 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) { - $this->verifyPhoneCountryCode($phoneCountryCode); - $values['phone_country_code'] = $phoneCountryCode; - } - - if ($phoneNumber !== null) { - $values['phone_number'] = $phoneNumber; - } - - if ($postal !== null) { - $values['postal'] = $postal; - } + $fields['delivery_speed'] = $deliverySpeed; + } + + $shipping = $this->addressValues( + values: $values, + fields: $fields, + address: $address, + address2: $address2, + city: $city, + company: $company, + country: $country, + firstName: $firstName, + lastName: $lastName, + phoneCountryCode: $phoneCountryCode, + phoneNumber: $phoneNumber, + postal: $postal, + region: $region, + ); - if ($region !== null) { - $this->verifyRegionCode($region); - $values['region'] = $region; + // Check after the address fields, so that type errors and unknown keys + // are reported first. + if ($deliverySpeed !== null + && !\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true) + ) { + $this->maybeThrowInvalidInputException("$deliverySpeed is not a valid delivery speed"); } $new = clone $this; - $new->content['shipping'] = $values; + $new->content['shipping'] = $shipping; return $new; } @@ -1535,6 +1446,105 @@ 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 + * @param array $fields section-specific fields to add. + * The caller checks their values. + * + * @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, + array $fields, + ?string $address, + ?string $address2, + ?string $city, + ?string $company, + ?string $country, + ?string $firstName, + ?string $lastName, + ?string $phoneCountryCode, + ?string $phoneNumber, + ?string $postal, + ?string $region, + ): 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'); + $postal = $this->removeString($values, 'postal'); + $region = $this->remove($values, 'region'); + + $this->verifyEmpty($values); + } + + 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 ($postal !== null) { + $fields['postal'] = $postal; + } + + if ($region !== null) { + $this->verifyRegionCode($region); + $fields['region'] = $region; + } + + // Keep the keys in alphabetical order, with any unknown keys that + // pass through when validation is off first. + ksort($fields); + + return $values + $fields; + } + /** * @param string $class the model class name to use * @param string $path the service path suffix to use diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index fa5e684..2b1dc88 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -241,6 +241,26 @@ public static function freeFormStringFields(): array 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 invalidValues */ From 51af909eea1a88db7f207fbe2dff4980d9121954 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 9 Oct 2026 20:48:40 +0000 Subject: [PATCH 05/10] Add phone verification inputs Add the phone_verification_method, phone_was_verification_successful, and phone_verification_time inputs to both the /billing and /shipping objects. They describe the most recent verification of the billing or shipping phone number. Add them to withBilling() and withShipping() as array keys and as named arguments. The new arguments go after the existing ones, so positional calls keep working. The client always checks the value types. With input validation on, it also checks the method value and the RFC 3339 time format. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + README.md | 8 +- src/MinFraud.php | 155 +++++++++++++------ tests/MaxMind/Test/MinFraudTest.php | 214 +++++++++++++++++++++++++- tests/data/minfraud/full-request.json | 8 +- 5 files changed, 341 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8dca1..e08621f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ CHANGELOG * The PHPDoc for the `with()` and `with*()` methods of `MaxMind\MinFraud` now lists the `InvalidInputException` they throw when input validation is enabled. +* 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/README.md b/README.md index e0d06c1..444ced1 100644 --- a/README.md +++ b/README.md @@ -184,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', @@ -197,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', diff --git a/src/MinFraud.php b/src/MinFraud.php index 8117bc0..85a36cf 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -552,33 +552,45 @@ 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 a value has the wrong type, or if * input validation is enabled and a value is @@ -600,6 +612,9 @@ 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 && \func_num_args() !== 1) { throw new \InvalidArgumentException( @@ -622,6 +637,9 @@ public function withBilling( phoneNumber: $phoneNumber, postal: $postal, region: $region, + phoneVerificationMethod: $phoneVerificationMethod, + phoneVerificationTime: $phoneVerificationTime, + phoneWasVerificationSuccessful: $phoneWasVerificationSuccessful, ); return $new; @@ -634,25 +652,36 @@ 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 a value has the wrong type, or if * input validation is enabled and a value is @@ -675,6 +704,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 && \func_num_args() !== 1) { throw new \InvalidArgumentException( @@ -704,6 +736,9 @@ public function withShipping( phoneNumber: $phoneNumber, postal: $postal, region: $region, + phoneVerificationMethod: $phoneVerificationMethod, + phoneVerificationTime: $phoneVerificationTime, + phoneWasVerificationSuccessful: $phoneWasVerificationSuccessful, ); // Check after the address fields, so that type errors and unknown keys @@ -1474,6 +1509,9 @@ private function addressValues( ?string $phoneNumber, ?string $postal, ?string $region, + ?string $phoneVerificationMethod, + ?string $phoneVerificationTime, + ?bool $phoneWasVerificationSuccessful, ): array { if (\count($values) !== 0) { $address = $this->removeString($values, 'address'); @@ -1485,6 +1523,13 @@ private function addressValues( $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'); @@ -1529,6 +1574,24 @@ private function addressValues( $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; } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 2b1dc88..0a75b1b 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -92,7 +92,13 @@ public function testWrongTypeWithoutValidation(string $method, string $field): v if (!\in_array($field, $numbers, true) && !\in_array("$method $field", $strings, true)) { $values[] = 12; } - $booleans = ['has_gift_message', 'is_gift', 'was_3d_secure_successful', 'was_authorized']; + $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 { @@ -122,7 +128,8 @@ public static function allFields(): array { $address = [ 'address', 'address_2', 'city', 'company', 'country', 'first_name', 'last_name', - 'phone_country_code', 'phone_number', 'postal', 'region', + 'phone_country_code', 'phone_number', 'phone_verification_method', 'phone_verification_time', + 'phone_was_verification_successful', 'postal', 'region', ]; $fields = [ 'withAccount' => ['user_id', 'username_md5'], @@ -283,7 +290,13 @@ public function testInvalidValueWithoutValidation( */ public static function invalidValues(): array { - $address = ['country' => 'usa', 'phone_country_code' => '12345', 'region' => 'invalid']; + $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], @@ -403,7 +416,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', @@ -470,6 +486,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( @@ -970,6 +989,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 */ 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": { From d277a2145f7474690b30db01e2fb48117bf6e673 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 16 Oct 2026 18:53:50 +0000 Subject: [PATCH 06/10] Reject a trailing newline in fixed-format inputs The format checks for country, region, and phone country codes, the currency code, the credit card fields, the MD5 values, and the ReportTransaction minFraud ID anchored their patterns with $. In PHP, $ also matches before a trailing newline, so validation accepted values such as "US\n", which the web service rejects. Anchor these patterns with \A and \z. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ src/MinFraud.php | 22 ++++++------- src/MinFraud/ReportTransaction.php | 2 +- .../ReportTransactionTest.php | 1 + tests/MaxMind/Test/MinFraudTest.php | 32 +++++++++++++++++++ 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08621f..0e32a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ CHANGELOG * 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`, 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 diff --git a/src/MinFraud.php b/src/MinFraud.php index 85a36cf..2fb31ec 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -468,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; @@ -522,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; @@ -1109,7 +1109,7 @@ public function withCreditCard( } if ($bankPhoneCountryCode !== null) { - if (!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.'); } @@ -1133,27 +1133,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.', ); @@ -1296,7 +1296,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; @@ -1661,7 +1661,7 @@ private function verifyRfc3339DateTime(string $dateTime): void */ 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"); } } @@ -1672,7 +1672,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.'); } } @@ -1683,7 +1683,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 768f87b..bc4f400 100644 --- a/src/MinFraud/ReportTransaction.php +++ b/src/MinFraud/ReportTransaction.php @@ -145,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/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php index 08e6730..a756cfb 100644 --- a/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php +++ b/tests/MaxMind/Test/MinFraud/ReportTransaction/ReportTransactionTest.php @@ -335,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"], [''], ]; } diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index 0a75b1b..e5b48ab 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -268,6 +268,38 @@ public function testShippingUnknownKeyBeforeDeliverySpeed(): void $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 */ From c83443245de59ad6c558879315e89559cfb720a3 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:44:56 +0000 Subject: [PATCH 07/10] Reject a trailing newline in custom input keys The web service requires custom input keys to match \A[a-z0-9_]{1,25}\z. The old check used \Z, so it accepted a key such as "abc\n". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/MinFraud.php | 2 +- tests/MaxMind/Test/MinFraudTest.php | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e32a0f..a0c7e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ CHANGELOG 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`, and the minFraud ID of + `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`, diff --git a/src/MinFraud.php b/src/MinFraud.php index 2fb31ec..6f29e68 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -1212,7 +1212,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.", ); diff --git a/tests/MaxMind/Test/MinFraudTest.php b/tests/MaxMind/Test/MinFraudTest.php index e5b48ab..5a2d1ae 100644 --- a/tests/MaxMind/Test/MinFraudTest.php +++ b/tests/MaxMind/Test/MinFraudTest.php @@ -2030,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]], From 791430df7d47aab8e51c45f8b0673f197c44f54c Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:45:10 +0000 Subject: [PATCH 08/10] Use \A and \z anchors in the CLAUDE.md regex example The `$` and `\Z` anchors also match before a trailing newline. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c6e20eb..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,8 @@ 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 From 262418d0134151b556a4d347a35563b079428b7d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:47:28 +0000 Subject: [PATCH 09/10] Simplify withShipping() and addressValues() withShipping() now tests for the $values array once, like the other with*() methods, and sets delivery_speed after addressValues(). This removes the $fields parameter and the ksort() from addressValues(). The web service does not use the JSON key order. The request mock in the tests now compares the decoded JSON with sorted keys, because delivery_speed is now the last shipping key. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud.php | 34 ++++++------------- .../Test/MinFraud/ServiceClientTester.php | 20 ++++++++++- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/MinFraud.php b/src/MinFraud.php index 6f29e68..2bf4287 100644 --- a/src/MinFraud.php +++ b/src/MinFraud.php @@ -625,7 +625,6 @@ public function withBilling( $new = clone $this; $new->content['billing'] = $this->addressValues( values: $values, - fields: [], address: $address, address2: $address2, city: $city, @@ -708,23 +707,17 @@ public function withShipping( ?string $phoneVerificationTime = null, ?bool $phoneWasVerificationSuccessful = null, ): self { - if (\count($values) !== 0 && \func_num_args() !== 1) { - throw new \InvalidArgumentException( - 'You may only provide the $values array or named arguments, not both.', - ); - } if (\count($values) !== 0) { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'You may only provide the $values array or named arguments, not both.', + ); + } $deliverySpeed = $this->remove($values, 'delivery_speed'); } - $fields = []; - if ($deliverySpeed !== null) { - $fields['delivery_speed'] = $deliverySpeed; - } - $shipping = $this->addressValues( values: $values, - fields: $fields, address: $address, address2: $address2, city: $city, @@ -743,10 +736,11 @@ public function withShipping( // Check after the address fields, so that type errors and unknown keys // are reported first. - if ($deliverySpeed !== null - && !\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true) - ) { - $this->maybeThrowInvalidInputException("$deliverySpeed is not a valid delivery speed"); + if ($deliverySpeed !== null) { + if (!\in_array($deliverySpeed, ['same_day', 'overnight', 'expedited', 'standard'], true)) { + $this->maybeThrowInvalidInputException("$deliverySpeed is not a valid delivery speed"); + } + $shipping['delivery_speed'] = $deliverySpeed; } $new = clone $this; @@ -1486,8 +1480,6 @@ public function factors(): Factors * * @param array $values the array argument of the public * method, without section-specific keys - * @param array $fields section-specific fields to add. - * The caller checks their values. * * @throws InvalidInputException if a value has the wrong type, or if * input validation is enabled and a value is @@ -1497,7 +1489,6 @@ public function factors(): Factors */ private function addressValues( array $values, - array $fields, ?string $address, ?string $address2, ?string $city, @@ -1536,6 +1527,7 @@ private function addressValues( $this->verifyEmpty($values); } + $fields = []; if ($address !== null) { $fields['address'] = $address; } @@ -1601,10 +1593,6 @@ private function addressValues( $fields['region'] = $region; } - // Keep the keys in alphabetical order, with any unknown keys that - // pass through when validation is off first. - ksort($fields); - return $values + $fields; } 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); + } } From 48b5d200c7b9d8b479a0ea48d084e066e9eeedbb Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 19 Oct 2026 20:47:47 +0000 Subject: [PATCH 10/10] Move removeString() above remove() removeString() calls remove(), so it comes first. Co-Authored-By: Claude Opus 5.5 --- src/MinFraud/ServiceClient.php | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/MinFraud/ServiceClient.php b/src/MinFraud/ServiceClient.php index 82e0bd6..a394ae5 100644 --- a/src/MinFraud/ServiceClient.php +++ b/src/MinFraud/ServiceClient.php @@ -74,6 +74,26 @@ 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 * @@ -111,26 +131,6 @@ protected function remove(array &$array, string $key, array $types = ['string']) return null; } - /** - * 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; - } - /** * @param array $values *