diff --git a/Library/Phalcon/Utils/ArrayUtils.php b/Library/Phalcon/Utils/ArrayUtils.php new file mode 100644 index 000000000..be8f838d5 --- /dev/null +++ b/Library/Phalcon/Utils/ArrayUtils.php @@ -0,0 +1,80 @@ + | + +------------------------------------------------------------------------+ +*/ + +namespace Phalcon\Utils; + +use Traversable; +use InvalidArgumentException; + +/** + * Utility class for manipulation of PHP arrays. + * + * @package Phalcon\Utils + */ +class ArrayUtils +{ + /** + * Convert an iterator to an array. + * + * Converts an iterator to an array. The $recursive flag, on by default, + * hints whether or not you want to do so recursively. + * + * @param array | Traversable $iterator The array or Traversable object to convert + * @param bool $recursive Recursively check all nested structures + * @throws InvalidArgumentException if $iterator is not an array or a Traversable object + * @return array + */ + public function iteratorToArray($iterator, $recursive = true) + { + if (!is_array($iterator) && !$iterator instanceof Traversable) { + throw new InvalidArgumentException(__METHOD__ . ' must be either an array or Traversable'); + } + + if (!$recursive) { + if (is_array($iterator)) { + return $iterator; + } + return iterator_to_array($iterator); + } + + if (method_exists($iterator, 'toArray')) { + return $iterator->toArray(); + } + + $array = []; + foreach ($iterator as $key => $value) { + if (is_scalar($value)) { + $array[$key] = $value; + continue; + } + if ($value instanceof Traversable) { + $array[$key] = $this->iteratorToArray($value, $recursive); + continue; + } + if (is_array($value)) { + $array[$key] = $this->iteratorToArray($value, $recursive); + continue; + } + + $array[$key] = $value; + } + + return $array; + } +} diff --git a/Library/Phalcon/Validation/Validator/Iban.php b/Library/Phalcon/Validation/Validator/Iban.php new file mode 100644 index 000000000..bed6b673d --- /dev/null +++ b/Library/Phalcon/Validation/Validator/Iban.php @@ -0,0 +1,298 @@ + | + +------------------------------------------------------------------------+ +*/ + +namespace Phalcon\Validation\Validator; + +use Phalcon\Validation\Validator; +use Phalcon\Validation; +use Phalcon\Validation\Message; + +/** + * Validates IBAN Numbers (International Bank Account Numbers) + * + * + * use Phalcon\Validation\Validator\Iban; + * + * $validator->add('number', new Iban([ + * 'country_code' => 'AD', // optional + * 'allow_non_sepa' => false, // optional + * 'messageNotSupported' => 'Unknown country within the IBAN', + * 'messageSepaNotSupported' => 'Countries outside the Single Euro Payments Area (SEPA) are not supported', + * 'messageFalseFormat' => 'Field has a false IBAN format', + * 'messageCheckFailed' => 'Field has failed the IBAN check', + * ])); + * + * + * @package Phalcon\Validation\Validator + */ +class Iban extends Validator +{ + /** + * Validation failure message template definitions + * + * @var array + */ + protected $messageTemplates = [ + 'messageNotSupported' => ":field has unknown country within the IBAN", + 'messageSepaNotSupported' => + "Countries outside the Single Euro Payments Area (SEPA) are not supported in :field", + 'messageFalseFormat' => ":field has a false IBAN format", + 'messageCheckFailed' => ":field has failed the IBAN check", + ]; + + /** + * Optional country code by ISO 3166-1 + * + * @var string | null + */ + protected $countryCode; + + /** + * Optionally allow IBAN codes from non-SEPA countries. Default true + * + * @var bool + */ + protected $allowNonSepa = true; + + /** + * The SEPA country codes + * + * @var array + */ + protected $sepaCountries = [ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'FO', 'GL', 'EE', 'FI', 'FR', 'DE', + 'GI', 'GR', 'HU', 'IS', 'IE', 'IT', 'LV', 'LI', 'LT', 'LU', 'MT', 'MC', + 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'CH', 'GB' + ]; + + /** + * IBAN regexes by country code + * + * @var array + */ + protected $ibanRegex = [ + 'AD' => 'AD[0-9]{2}[0-9]{4}[0-9]{4}[A-Z0-9]{12}', + 'AE' => 'AE[0-9]{2}[0-9]{3}[0-9]{16}', + 'AL' => 'AL[0-9]{2}[0-9]{8}[A-Z0-9]{16}', + 'AT' => 'AT[0-9]{2}[0-9]{5}[0-9]{11}', + 'AZ' => 'AZ[0-9]{2}[A-Z]{4}[A-Z0-9]{20}', + 'BA' => 'BA[0-9]{2}[0-9]{3}[0-9]{3}[0-9]{8}[0-9]{2}', + 'BE' => 'BE[0-9]{2}[0-9]{3}[0-9]{7}[0-9]{2}', + 'BG' => 'BG[0-9]{2}[A-Z]{4}[0-9]{4}[0-9]{2}[A-Z0-9]{8}', + 'BH' => 'BH[0-9]{2}[A-Z]{4}[A-Z0-9]{14}', + 'BR' => 'BR[0-9]{2}[0-9]{8}[0-9]{5}[0-9]{10}[A-Z][A-Z0-9]', + 'BY' => 'BY[0-9]{2}[A-Z0-9]{4}[0-9]{4}[A-Z0-9]{16}', + 'CH' => 'CH[0-9]{2}[0-9]{5}[A-Z0-9]{12}', + 'CR' => 'CR[0-9]{2}[0-9]{3}[0-9]{14}', + 'CY' => 'CY[0-9]{2}[0-9]{3}[0-9]{5}[A-Z0-9]{16}', + 'CZ' => 'CZ[0-9]{2}[0-9]{20}', + 'DE' => 'DE[0-9]{2}[0-9]{8}[0-9]{10}', + 'DO' => 'DO[0-9]{2}[A-Z0-9]{4}[0-9]{20}', + 'DK' => 'DK[0-9]{2}[0-9]{14}', + 'EE' => 'EE[0-9]{2}[0-9]{2}[0-9]{2}[0-9]{11}[0-9]{1}', + 'ES' => 'ES[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{1}[0-9]{1}[0-9]{10}', + 'FI' => 'FI[0-9]{2}[0-9]{6}[0-9]{7}[0-9]{1}', + 'FO' => 'FO[0-9]{2}[0-9]{4}[0-9]{9}[0-9]{1}', + 'FR' => 'FR[0-9]{2}[0-9]{5}[0-9]{5}[A-Z0-9]{11}[0-9]{2}', + 'GB' => 'GB[0-9]{2}[A-Z]{4}[0-9]{6}[0-9]{8}', + 'GE' => 'GE[0-9]{2}[A-Z]{2}[0-9]{16}', + 'GI' => 'GI[0-9]{2}[A-Z]{4}[A-Z0-9]{15}', + 'GL' => 'GL[0-9]{2}[0-9]{4}[0-9]{9}[0-9]{1}', + 'GR' => 'GR[0-9]{2}[0-9]{3}[0-9]{4}[A-Z0-9]{16}', + 'GT' => 'GT[0-9]{2}[A-Z0-9]{4}[A-Z0-9]{20}', + 'HR' => 'HR[0-9]{2}[0-9]{7}[0-9]{10}', + 'HU' => 'HU[0-9]{2}[0-9]{3}[0-9]{4}[0-9]{1}[0-9]{15}[0-9]{1}', + 'IE' => 'IE[0-9]{2}[A-Z]{4}[0-9]{6}[0-9]{8}', + 'IL' => 'IL[0-9]{2}[0-9]{3}[0-9]{3}[0-9]{13}', + 'IS' => 'IS[0-9]{2}[0-9]{4}[0-9]{2}[0-9]{6}[0-9]{10}', + 'IT' => 'IT[0-9]{2}[A-Z]{1}[0-9]{5}[0-9]{5}[A-Z0-9]{12}', + 'KW' => 'KW[0-9]{2}[A-Z]{4}[0-9]{22}', + 'KZ' => 'KZ[0-9]{2}[0-9]{3}[A-Z0-9]{13}', + 'LB' => 'LB[0-9]{2}[0-9]{4}[A-Z0-9]{20}', + 'LI' => 'LI[0-9]{2}[0-9]{5}[A-Z0-9]{12}', + 'LT' => 'LT[0-9]{2}[0-9]{5}[0-9]{11}', + 'LU' => 'LU[0-9]{2}[0-9]{3}[A-Z0-9]{13}', + 'LV' => 'LV[0-9]{2}[A-Z]{4}[A-Z0-9]{13}', + 'MC' => 'MC[0-9]{2}[0-9]{5}[0-9]{5}[A-Z0-9]{11}[0-9]{2}', + 'MD' => 'MD[0-9]{2}[A-Z0-9]{20}', + 'ME' => 'ME[0-9]{2}[0-9]{3}[0-9]{13}[0-9]{2}', + 'MK' => 'MK[0-9]{2}[0-9]{3}[A-Z0-9]{10}[0-9]{2}', + 'MR' => 'MR13[0-9]{5}[0-9]{5}[0-9]{11}[0-9]{2}', + 'MT' => 'MT[0-9]{2}[A-Z]{4}[0-9]{5}[A-Z0-9]{18}', + 'MU' => 'MU[0-9]{2}[A-Z]{4}[0-9]{2}[0-9]{2}[0-9]{12}[0-9]{3}[A-Z]{3}', + 'NL' => 'NL[0-9]{2}[A-Z]{4}[0-9]{10}', + 'NO' => 'NO[0-9]{2}[0-9]{4}[0-9]{6}[0-9]{1}', + 'PK' => 'PK[0-9]{2}[A-Z]{4}[A-Z0-9]{16}', + 'PL' => 'PL[0-9]{2}[0-9]{8}[0-9]{16}', + 'PS' => 'PS[0-9]{2}[A-Z]{4}[A-Z0-9]{21}', + 'PT' => 'PT[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{11}[0-9]{2}', + 'RO' => 'RO[0-9]{2}[A-Z]{4}[A-Z0-9]{16}', + 'RS' => 'RS[0-9]{2}[0-9]{3}[0-9]{13}[0-9]{2}', + 'SA' => 'SA[0-9]{2}[0-9]{2}[A-Z0-9]{18}', + 'SE' => 'SE[0-9]{2}[0-9]{3}[0-9]{16}[0-9]{1}', + 'SI' => 'SI[0-9]{2}[0-9]{5}[0-9]{8}[0-9]{2}', + 'SK' => 'SK[0-9]{2}[0-9]{4}[0-9]{6}[0-9]{10}', + 'SM' => 'SM[0-9]{2}[A-Z]{1}[0-9]{5}[0-9]{5}[A-Z0-9]{12}', + 'TN' => 'TN59[0-9]{2}[0-9]{3}[0-9]{13}[0-9]{2}', + 'TR' => 'TR[0-9]{2}[0-9]{5}[A-Z0-9]{1}[A-Z0-9]{16}', + 'VG' => 'VG[0-9]{2}[A-Z]{4}[0-9]{16}', + ]; + + /** + * Sets validator options + * + * @param array $options OPTIONAL + */ + public function __construct(array $options = []) + { + if (isset($options['country_code'])) { + $this->setCountryCode($options['country_code']); + } + + if (isset($options['allow_non_sepa'])) { + $this->allowNonSepa = $options['allow_non_sepa']; + } + + if (!isset($options['messageNotSupported'])) { + $options['messageNotSupported'] = $this->messageTemplates['messageNotSupported']; + } + + if (!isset($options['messageSepaNotSupported'])) { + $options['messageSepaNotSupported'] = $this->messageTemplates['messageSepaNotSupported']; + } + + if (!isset($options['messageFalseFormat'])) { + $options['messageFalseFormat'] = $this->messageTemplates['messageFalseFormat']; + } + + if (!isset($options['messageCheckFailed'])) { + $options['messageCheckFailed'] = $this->messageTemplates['messageCheckFailed']; + } + + parent::__construct($options); + } + + /** + * Sets an optional country code by ISO 3166-1 + * + * @param string | null $countryCode + */ + public function setCountryCode($countryCode = null) + { + if ($countryCode !== null) { + $countryCode = (string) $countryCode; + } + $this->countryCode = $countryCode; + } + + /** + * Sets the optional allow non-sepa countries setting + * + * @param bool $allowNonSepa + */ + public function setAllowNonSepa($allowNonSepa) + { + $this->allowNonSepa = (bool) $allowNonSepa; + } + + /** + * {@inheritdoc} + * + * @param Validation $validation + * @param string $attribute + * + * @return bool + */ + public function validate(Validation $validation, $attribute) + { + $messageCode = $this->getErrorMessageCode($validation, $attribute); + if (!empty($messageCode)) { + $label = $this->prepareLabel($validation, $attribute); + $code = $this->prepareCode($attribute); + $replacePairs = [":field"=> $label]; + + $message = $this->prepareMessage($validation, $attribute, "Iban", $messageCode); + + $validation->appendMessage( + new Message( + strtr($message, $replacePairs), + $attribute, + "Iban", + $code + ) + ); + return false; + } + + return true; + } + + /** + * Validate code and return error message key or empty string + * + * @param Validation $validation + * @param string $attribute + * + * @return string + */ + protected function getErrorMessageCode(Validation $validation, $attribute) + { + $value = $validation->getValue($attribute); + + if ($this->countryCode === null) { + $this->countryCode = substr($value, 0, 2); + } + + if (!array_key_exists($this->countryCode, $this->ibanRegex)) { + return 'messageNotSupported'; + } + + if (!$this->allowNonSepa && !in_array($this->countryCode, $this->sepaCountries)) { + return 'messageSepaNotSupported'; + } + + if (!preg_match('/^' . $this->ibanRegex[$this->countryCode] . '$/', $value)) { + return 'messageFalseFormat'; + } + + $format = substr($value, 4) . substr($value, 0, 4); + $format = str_replace( + ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'], + ['10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', + '23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35'], + $format + ); + + $temp = intval(substr($format, 0, 1)); + $len = strlen($format); + + for ($x = 1; $x < $len; ++$x) { + $temp *= 10; + $temp += intval(substr($format, $x, 1)); + $temp %= 97; + } + + if ($temp != 1) { + return 'messageCheckFailed'; + } + + return ''; + } +} diff --git a/tests/_fixtures/Utils/array_utils.php b/tests/_fixtures/Utils/array_utils.php new file mode 100644 index 000000000..c70127f49 --- /dev/null +++ b/tests/_fixtures/Utils/array_utils.php @@ -0,0 +1,48 @@ + 'four', + 'bar' => 'five', + 'four' + ], + [ + 'foo' => 'four', + 'bar' => 'five', + 'four' + ] + ], + [ + [ + 'foo' => ['six', 'one'], + 'bar' => 'five', + 'four' + ], + [ + 'foo' => ['six', 'one'], + 'bar' => 'five', + 'four' + ] + ], +]; diff --git a/tests/_fixtures/Validation/iban_data.php b/tests/_fixtures/Validation/iban_data.php new file mode 100644 index 000000000..36d86990b --- /dev/null +++ b/tests/_fixtures/Validation/iban_data.php @@ -0,0 +1,92 @@ + [ + [ + 'TR', + 'TR330006100519786457841326', + ], + [ + 'PT', + 'PT50000201231234567890154', + ], + [ + 'AT', + 'AT611904300234573201', + ], + [ + 'SE', + 'SE4550000000058398257466', + ], + [ + 'CH', + 'CH9300762011623852957', + ], + [ + 'GB', + 'GB29NWBK60161331926819', + ], + [ + 'MT', + 'MT84MALT011000012345MTLCAST001S', + ], + [ + 'MD', + 'MD24AG000225100013104168', + ], + [ + 'HU', + 'HU42117730161111101800000000', + ], + [ + 'GR', + 'GR1601101250000000012300695', + ], + [ + 'DE', + 'DE89370400440532013000', + ], + [ + 'EE', + 'EE382200221020145685', + ], + ], + 'iban-error-code' => [ + [ + 'CY', + '', + 'The input has a false IBAN format', + 'messageFalseFormat', + ], + [ + 'CY', + 'CY170020012800000012005', //not enough symbols in code + 'The input has a false IBAN format', + 'messageFalseFormat', + ], + [ + 'DE', + 'TR330006100519786457841326', + 'The input has a false IBAN format', + 'messageFalseFormat', + ], + [ + 'ZZ', + 'TR330006100519786457841326', + 'Unknown country within the IBAN', + 'messageNotSupported', + ], + [ + 'AD', + 'AD1200012030200359100100', + 'Countries outside the Single Euro Payments Area (SEPA) are not supported', + 'messageSepaNotSupported', + ], + [ + 'AT', + 'AT611904300234573205', //changed last symbol. should be - AT611904300234573201 + 'The input has failed the IBAN check', + 'messageCheckFailed', + ], + ], +]; diff --git a/tests/unit/Utils/ArrayUtilsTest.php b/tests/unit/Utils/ArrayUtilsTest.php new file mode 100644 index 000000000..db617d88a --- /dev/null +++ b/tests/unit/Utils/ArrayUtilsTest.php @@ -0,0 +1,78 @@ + | + +------------------------------------------------------------------------+ +*/ + +namespace Phalcon\Test\Utils; + +use Codeception\TestCase\Test; +use UnitTester; +use Phalcon\Utils\ArrayUtils; +use ArrayIterator; + +class ArrayUtilsTest extends Test +{ + /** + * Tests ArrayUtils::iteratorToArray. Testing array. + * + * @dataProvider providerArray + * @param array $array + * @param array $array + * + * @test + * @author Sergii Svyrydenko + * @since 2017-09-26 + */ + public function shouldReturnArrayFromArray($array, $expected) + { + $utils = new ArrayUtils(); + + $this->assertEquals( + $expected, + $utils->iteratorToArray($array), + 'Arrays are different' + ); + } + + /** + * Tests ArrayUtils::iteratorToArray. Testing iterator. + * + * @dataProvider providerArray + * @param array $array + * @param array $array + * + * @test + * @author Sergii Svyrydenko + * @since 2017-09-26 + */ + public function shouldReturnArrayFromIterator($array, $expected) + { + $utils = new ArrayUtils(); + $iterator = new ArrayIterator($array); + + $this->assertEquals( + $expected, + $utils->iteratorToArray($iterator), + 'Arrays are different' + ); + } + + public function providerArray() + { + return require INCUBATOR_FIXTURES . 'Utils/array_utils.php'; + } +} diff --git a/tests/unit/Validation/Validator/IbanTest.php b/tests/unit/Validation/Validator/IbanTest.php new file mode 100644 index 000000000..2c65ac35b --- /dev/null +++ b/tests/unit/Validation/Validator/IbanTest.php @@ -0,0 +1,178 @@ + | + +------------------------------------------------------------------------+ +*/ + +namespace Phalcon\Test\Validation\Validator; + +use UnitTester; +use Codeception\TestCase\Test; +use Phalcon\Validation\Validator\Iban; +use Phalcon\Validation; + +class IbanTest extends Test +{ + /** + * Tests Iban::validate. When country code set after add to validation. + * + * @dataProvider providerValidCode + * @param string $countryCode + * @param string $code + * + * @test + * @issue 809 + * @author Sergii Svyrydenko + * @since 2017-09-26 + */ + public function shouldValidateIbanCodeWithSetCountryCode($countryCode, $code) + { + $validation = new Validation(); + $validation->add( + 'test', + new Iban() + ); + + $validators = $validation->getValidators(); + $validator = $validators[0]; + $validator = $validator[1]; + + $validator->setCountryCode($countryCode); + + $messages = $validation->validate(['test' => $code]); + + $this->assertCount( + 0, + $messages, + 'The Iban number isn\'t valid' + ); + } + + /** + * Tests Iban::validate. When country code didn't set ever. + * + * @dataProvider providerValidCode + * @param string $countryCode + * @param string $code + * + * @test + * @issue 809 + * @author Sergii Svyrydenko + * @since 2017-09-26 + */ + public function shouldValidateIbanCodeWithoutCountryCode($countryCode, $code) + { + $validation = new Validation(); + $iban = new Iban(); + + $validation->add( + 'test', + $iban + ); + + $messages = $validation->validate(['test' => $code]); + + $this->assertCount( + 0, + $messages, + 'The Iban number isn\'t valid' + ); + } + + /** + * Tests Iban::validate. When country code set in construct. + * + * @dataProvider providerValidCode + * @param string $countryCode + * @param string $code + * + * @test + * @issue 809 + * @author Sergii Svyrydenko + * @since 2017-09-26 + */ + public function shouldValidateIbanCodeWithCountryCode($countryCode, $code) + { + $validation = new Validation(); + $validation->add( + 'test', + new Iban([ + 'country_code' => $countryCode, + ]) + ); + + $messages = $validation->validate(['test' => $code]); + + $this->assertCount( + 0, + $messages, + 'The Iban number isn\'t valid' + ); + } + + /** + * Tests Iban::validate. Generate error message. + * + * @dataProvider providerInvalidCode + * @param string $countryCode + * @param string $code + * @param string $message + * @param string $messageType + * + * @test + * @issue 809 + * @author Sergii Svyrydenko + * @since 2017-09-26 + */ + public function shouldCatchErrorMessage($countryCode, $code, $message, $messageType) + { + $validation = new Validation(); + $iban = new Iban([ + 'country_code' => $countryCode, + $messageType => $message, + 'allow_non_sepa' => false, + ]); + + $validation->add( + 'test', + $iban + ); + + $messages = $validation->validate(['test' => $code]); + + foreach ($messages as $messageReturn) { + $this->assertEquals( + $message, + $messageReturn->getMessage(), + 'Method validate() should return error message' + ); + } + } + + public function providerValidCode() + { + $data = require INCUBATOR_FIXTURES . 'Validation/iban_data.php'; + + return $data['iban-codes']; + } + + public function providerInvalidCode() + { + $data = require INCUBATOR_FIXTURES . 'Validation/iban_data.php'; + + return $data['iban-error-code']; + } +}