From: Brad Jorsch Date: Tue, 22 May 2018 23:23:20 +0000 (-0400) Subject: API: Abstract out parameter validation X-Git-Tag: 1.34.0-rc.0~1304^2 X-Git-Url: http://git.cyclocoop.org/?a=commitdiff_plain;h=002f409a0b0ba15264050cd1547ddf56b1a5d963;p=lhc%2Fweb%2Fwiklou.git API: Abstract out parameter validation With the introduction of a REST API into MediaWiki core, we're going to want to share parameter validation logic rather than having similar code in both the Action API and the REST API. This abstracts out parameter validation logic as a library. There will be at least two follow-up patches: * One to add calls in the REST API, plus the interface for the REST API to do body validation. Should be reasonably straightforward. * One to adjust the Action API to use this. That'll be much less straightforward, as the Action API needs some MediaWiki-specific types (which the REST API might use too in the future) and needs to override the defaults on some of the library's checks (to maintain back-compat). Bug: T142080 Bug: T223239 Change-Id: I5c0cc3a8d686ace97596df5832c450a6a50f902c Depends-On: Iea05dc439688871c574c639e617765ae88a75ff7 --- diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 57e434102c..b893bc9e14 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -143,6 +143,7 @@ class AutoLoader { 'MediaWiki\\Sparql\\' => __DIR__ . '/sparql/', 'MediaWiki\\Storage\\' => __DIR__ . '/Storage/', 'MediaWiki\\Tidy\\' => __DIR__ . '/tidy/', + 'Wikimedia\\ParamValidator\\' => __DIR__ . '/libs/ParamValidator/', 'Wikimedia\\Services\\' => __DIR__ . '/libs/services/', ]; } diff --git a/includes/libs/ParamValidator/Callbacks.php b/includes/libs/ParamValidator/Callbacks.php new file mode 100644 index 0000000000..d94a81fbd5 --- /dev/null +++ b/includes/libs/ParamValidator/Callbacks.php @@ -0,0 +1,78 @@ + [ 'class' => TypeDef\BooleanDef::class ], + 'checkbox' => [ 'class' => TypeDef\PresenceBooleanDef::class ], + 'integer' => [ 'class' => TypeDef\IntegerDef::class ], + 'limit' => [ 'class' => TypeDef\LimitDef::class ], + 'float' => [ 'class' => TypeDef\FloatDef::class ], + 'double' => [ 'class' => TypeDef\FloatDef::class ], + 'string' => [ 'class' => TypeDef\StringDef::class ], + 'password' => [ 'class' => TypeDef\PasswordDef::class ], + 'NULL' => [ + 'class' => TypeDef\StringDef::class, + 'args' => [ [ + 'allowEmptyWhenRequired' => true, + ] ], + ], + 'timestamp' => [ 'class' => TypeDef\TimestampDef::class ], + 'upload' => [ 'class' => TypeDef\UploadDef::class ], + 'enum' => [ 'class' => TypeDef\EnumDef::class ], + ]; + + /** @var Callbacks */ + private $callbacks; + + /** @var ObjectFactory */ + private $objectFactory; + + /** @var (TypeDef|array)[] Map parameter type names to TypeDef objects or ObjectFactory specs */ + private $typeDefs = []; + + /** @var int Default values for PARAM_ISMULTI_LIMIT1 */ + private $ismultiLimit1; + + /** @var int Default values for PARAM_ISMULTI_LIMIT2 */ + private $ismultiLimit2; + + /** + * @param Callbacks $callbacks + * @param ObjectFactory $objectFactory To turn specs into TypeDef objects + * @param array $options Associative array of additional settings + * - 'typeDefs': (array) As for addTypeDefs(). If omitted, self::$STANDARD_TYPES will be used. + * Pass an empty array if you want to start with no registered types. + * - 'ismultiLimits': (int[]) Two ints, being the default values for PARAM_ISMULTI_LIMIT1 and + * PARAM_ISMULTI_LIMIT2. If not given, defaults to `[ 50, 500 ]`. + */ + public function __construct( + Callbacks $callbacks, + ObjectFactory $objectFactory, + array $options = [] + ) { + $this->callbacks = $callbacks; + $this->objectFactory = $objectFactory; + + $this->addTypeDefs( $options['typeDefs'] ?? self::$STANDARD_TYPES ); + $this->ismultiLimit1 = $options['ismultiLimits'][0] ?? 50; + $this->ismultiLimit2 = $options['ismultiLimits'][1] ?? 500; + } + + /** + * List known type names + * @return string[] + */ + public function knownTypes() { + return array_keys( $this->typeDefs ); + } + + /** + * Register multiple type handlers + * + * @see addTypeDef() + * @param array $typeDefs Associative array mapping `$name` to `$typeDef`. + */ + public function addTypeDefs( array $typeDefs ) { + foreach ( $typeDefs as $name => $def ) { + $this->addTypeDef( $name, $def ); + } + } + + /** + * Register a type handler + * + * To allow code to omit PARAM_TYPE in settings arrays to derive the type + * from PARAM_DEFAULT, it is strongly recommended that the following types be + * registered: "boolean", "integer", "double", "string", "NULL", and "enum". + * + * When using ObjectFactory specs, the following extra arguments are passed: + * - The Callbacks object for this ParamValidator instance. + * + * @param string $name Type name + * @param TypeDef|array $typeDef Type handler or ObjectFactory spec to create one. + */ + public function addTypeDef( $name, $typeDef ) { + Assert::parameterType( + implode( '|', [ TypeDef::class, 'array' ] ), + $typeDef, + '$typeDef' + ); + + if ( isset( $this->typeDefs[$name] ) ) { + throw new InvalidArgumentException( "Type '$name' is already registered" ); + } + $this->typeDefs[$name] = $typeDef; + } + + /** + * Register a type handler, overriding any existing handler + * @see addTypeDef + * @param string $name Type name + * @param TypeDef|array|null $typeDef As for addTypeDef, or null to unregister a type. + */ + public function overrideTypeDef( $name, $typeDef ) { + Assert::parameterType( + implode( '|', [ TypeDef::class, 'array', 'null' ] ), + $typeDef, + '$typeDef' + ); + + if ( $typeDef === null ) { + unset( $this->typeDefs[$name] ); + } else { + $this->typeDefs[$name] = $typeDef; + } + } + + /** + * Test if a type is registered + * @param string $name Type name + * @return bool + */ + public function hasTypeDef( $name ) { + return isset( $this->typeDefs[$name] ); + } + + /** + * Get the TypeDef for a type + * @param string|array $type Any array is considered equivalent to the string "enum". + * @return TypeDef|null + */ + public function getTypeDef( $type ) { + if ( is_array( $type ) ) { + $type = 'enum'; + } + + if ( !isset( $this->typeDefs[$type] ) ) { + return null; + } + + $def = $this->typeDefs[$type]; + if ( !$def instanceof TypeDef ) { + $def = $this->objectFactory->createObject( $def, [ + 'extraArgs' => [ $this->callbacks ], + 'assertClass' => TypeDef::class, + ] ); + $this->typeDefs[$type] = $def; + } + + return $def; + } + + /** + * Normalize a parameter settings array + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @return array + */ + public function normalizeSettings( $settings ) { + // Shorthand + if ( !is_array( $settings ) ) { + $settings = [ + self::PARAM_DEFAULT => $settings, + ]; + } + + // When type is not given, determine it from the type of the PARAM_DEFAULT + if ( !isset( $settings[self::PARAM_TYPE] ) ) { + $settings[self::PARAM_TYPE] = gettype( $settings[self::PARAM_DEFAULT] ?? null ); + } + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( $typeDef ) { + $settings = $typeDef->normalizeSettings( $settings ); + } + + return $settings; + } + + /** + * Fetch and valiate a parameter value using a settings array + * + * @param string $name Parameter name + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @param array $options Options array, passed through to the TypeDef and Callbacks. + * @return mixed Validated parameter value + * @throws ValidationException if the value is invalid + */ + public function getValue( $name, $settings, array $options = [] ) { + $settings = $this->normalizeSettings( $settings ); + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( !$typeDef ) { + throw new DomainException( + "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}" + ); + } + + $value = $typeDef->getValue( $name, $settings, $options ); + + if ( $value !== null ) { + if ( !empty( $settings[self::PARAM_SENSITIVE] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'param-sensitive', [] ), + $options + ); + } + + // Set a warning if a deprecated parameter has been passed + if ( !empty( $settings[self::PARAM_DEPRECATED] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'param-deprecated', [] ), + $options + ); + } + } elseif ( isset( $settings[self::PARAM_DEFAULT] ) ) { + $value = $settings[self::PARAM_DEFAULT]; + } + + return $this->validateValue( $name, $value, $settings, $options ); + } + + /** + * Valiate a parameter value using a settings array + * + * @param string $name Parameter name + * @param null|mixed $value Parameter value + * @param array|mixed $settings Default value or an array of settings + * using PARAM_* constants. + * @param array $options Options array, passed through to the TypeDef and Callbacks. + * - An additional option, 'values-list', will be set when processing the + * values of a multi-valued parameter. + * @return mixed Validated parameter value(s) + * @throws ValidationException if the value is invalid + */ + public function validateValue( $name, $value, $settings, array $options = [] ) { + $settings = $this->normalizeSettings( $settings ); + + $typeDef = $this->getTypeDef( $settings[self::PARAM_TYPE] ); + if ( !$typeDef ) { + throw new DomainException( + "Param $name's type is unknown - {$settings[self::PARAM_TYPE]}" + ); + } + + if ( $value === null ) { + if ( !empty( $settings[self::PARAM_REQUIRED] ) ) { + throw new ValidationException( $name, $value, $settings, 'missingparam', [] ); + } + return null; + } + + // Non-multi + if ( empty( $settings[self::PARAM_ISMULTI] ) ) { + return $typeDef->validate( $name, $value, $settings, $options ); + } + + // Split the multi-value and validate each parameter + $limit1 = $settings[self::PARAM_ISMULTI_LIMIT1] ?? $this->ismultiLimit1; + $limit2 = $settings[self::PARAM_ISMULTI_LIMIT2] ?? $this->ismultiLimit2; + $valuesList = is_array( $value ) ? $value : self::explodeMultiValue( $value, $limit2 + 1 ); + + // Handle PARAM_ALL + $enumValues = $typeDef->getEnumValues( $name, $settings, $options ); + if ( is_array( $enumValues ) && isset( $settings[self::PARAM_ALL] ) && + count( $valuesList ) === 1 + ) { + $allValue = is_string( $settings[self::PARAM_ALL] ) + ? $settings[self::PARAM_ALL] + : self::ALL_DEFAULT_STRING; + if ( $valuesList[0] === $allValue ) { + return $enumValues; + } + } + + // Avoid checking useHighLimits() unless it's actually necessary + $sizeLimit = count( $valuesList ) > $limit1 && $this->callbacks->useHighLimits( $options ) + ? $limit2 + : $limit1; + if ( count( $valuesList ) > $sizeLimit ) { + throw new ValidationException( $name, $valuesList, $settings, 'toomanyvalues', [ + 'limit' => $sizeLimit + ] ); + } + + $options['values-list'] = $valuesList; + $validValues = []; + $invalidValues = []; + foreach ( $valuesList as $v ) { + try { + $validValues[] = $typeDef->validate( $name, $v, $settings, $options ); + } catch ( ValidationException $ex ) { + if ( empty( $settings[self::PARAM_IGNORE_INVALID_VALUES] ) ) { + throw $ex; + } + $invalidValues[] = $v; + } + } + if ( $invalidValues ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'unrecognizedvalues', [ + 'values' => $invalidValues, + ] ), + $options + ); + } + + // Throw out duplicates if requested + if ( empty( $settings[self::PARAM_ALLOW_DUPLICATES] ) ) { + $validValues = array_values( array_unique( $validValues ) ); + } + + return $validValues; + } + + /** + * Split a multi-valued parameter string, like explode() + * + * Note that, unlike explode(), this will return an empty array when given + * an empty string. + * + * @param string $value + * @param int $limit + * @return string[] + */ + public static function explodeMultiValue( $value, $limit ) { + if ( $value === '' || $value === "\x1f" ) { + return []; + } + + if ( substr( $value, 0, 1 ) === "\x1f" ) { + $sep = "\x1f"; + $value = substr( $value, 1 ); + } else { + $sep = '|'; + } + + return explode( $sep, $value, $limit ); + } + +} diff --git a/includes/libs/ParamValidator/README.md b/includes/libs/ParamValidator/README.md new file mode 100644 index 0000000000..dd992a408a --- /dev/null +++ b/includes/libs/ParamValidator/README.md @@ -0,0 +1,58 @@ +Wikimedia API Parameter Validator +================================= + +This library implements a system for processing and validating parameters to an +API from data like that in PHP's `$_GET`, `$_POST`, and `$_FILES` arrays, based +on a declarative definition of available parameters. + +Usage +----- + +
+use Wikimedia\ParamValidator\ParamValidator;
+use Wikimedia\ParamValidator\TypeDef\IntegerDef;
+use Wikimedia\ParamValidator\SimpleCallbacks as ParamValidatorCallbacks;
+use Wikimedia\ParamValidator\ValidationException;
+
+$validator = new ParamValidator(
+	new ParamValidatorCallbacks( $_POST + $_GET, $_FILES ),
+	$serviceContainer->getObjectFactory()
+);
+
+try {
+	$intValue = $validator->getValue( 'intParam', [
+			ParamValidator::PARAM_TYPE => 'integer',
+			ParamValidator::PARAM_DEFAULT => 0,
+			IntegerDef::PARAM_MIN => 0,
+			IntegerDef::PARAM_MAX => 5,
+	] );
+} catch ( ValidationException $ex ) {
+	$error = lookupI18nMessage( 'param-validator-error-' . $ex->getFailureCode() );
+	echo "Validation error: $error\n";
+}
+
+ +I18n +---- + +This library is designed to generate output in a manner suited to use with an +i18n system. To that end, errors and such are indicated by means of "codes" +consisting of ASCII lowercase letters, digits, and hyphen (and always beginning +with a letter). + +Additional details about each error, such as the allowed range for an integer +value, are similarly returned by means of associative arrays with keys being +similar "code" strings and values being strings, integers, or arrays of strings +that are intended to be formatted as a list (e.g. joined with commas). The +details for any particular "message" will also always have the same keys in the +same order to facilitate use with i18n systems using positional rather than +named parameters. + +For possible codes and their parameters, see the documentation of the relevant +`PARAM_*` constants and TypeDef classes. + +Running tests +------------- + + composer install --prefer-dist + composer test diff --git a/includes/libs/ParamValidator/SimpleCallbacks.php b/includes/libs/ParamValidator/SimpleCallbacks.php new file mode 100644 index 0000000000..77dab92619 --- /dev/null +++ b/includes/libs/ParamValidator/SimpleCallbacks.php @@ -0,0 +1,79 @@ +params = $params; + $this->files = $files; + } + + public function hasParam( $name, array $options ) { + return isset( $this->params[$name] ); + } + + public function getValue( $name, $default, array $options ) { + return $this->params[$name] ?? $default; + } + + public function hasUpload( $name, array $options ) { + return isset( $this->files[$name] ); + } + + public function getUploadedFile( $name, array $options ) { + $file = $this->files[$name] ?? null; + if ( $file && !$file instanceof UploadedFile ) { + $file = new UploadedFile( $file ); + $this->files[$name] = $file; + } + return $file; + } + + public function recordCondition( ValidationException $condition, array $options ) { + $this->conditions[] = $condition; + } + + /** + * Fetch any recorded conditions + * @return array[] + */ + public function getRecordedConditions() { + return $this->conditions; + } + + /** + * Clear any recorded conditions + */ + public function clearRecordedConditions() { + $this->conditions = []; + } + + public function useHighLimits( array $options ) { + return !empty( $options['useHighLimits'] ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef.php b/includes/libs/ParamValidator/TypeDef.php new file mode 100644 index 0000000000..0d54addc58 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef.php @@ -0,0 +1,148 @@ +callbacks = $callbacks; + } + + /** + * Get the value from the request + * + * @note Only override this if you need to use something other than + * $this->callbacks->getValue() to fetch the value. Reformatting from a + * string should typically be done by self::validate(). + * @note Handling of ParamValidator::PARAM_DEFAULT should be left to ParamValidator, + * as should PARAM_REQUIRED and the like. + * + * @param string $name Parameter name being fetched. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return null|mixed Return null if the value wasn't present, otherwise a + * value to be passed to self::validate(). + */ + public function getValue( $name, array $settings, array $options ) { + return $this->callbacks->getValue( $name, null, $options ); + } + + /** + * Validate the value + * + * When ParamValidator is processing a multi-valued parameter, this will be + * called once for each of the supplied values. Which may mean zero calls. + * + * When getValue() returned null, this will not be called. + * + * @param string $name Parameter name being validated. + * @param mixed $value Value to validate, from getValue(). + * @param array $settings Parameter settings array. + * @param array $options Options array. Note the following values that may be set + * by ParamValidator: + * - values-list: (string[]) If defined, values of a multi-valued parameter are being processed + * (and this array holds the full set of values). + * @return mixed Validated value + * @throws ValidationException if the value is invalid + */ + abstract public function validate( $name, $value, array $settings, array $options ); + + /** + * Normalize a settings array + * @param array $settings + * @return array + */ + public function normalizeSettings( array $settings ) { + return $settings; + } + + /** + * Get the values for enum-like parameters + * + * This is primarily intended for documentation and implementation of + * PARAM_ALL; it is the responsibility of the TypeDef to ensure that validate() + * accepts the values returned here. + * + * @param string $name Parameter name being validated. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return array|null All possible enumerated values, or null if this is + * not an enumeration. + */ + public function getEnumValues( $name, array $settings, array $options ) { + return null; + } + + /** + * Convert a value to a string representation. + * + * This is intended as the inverse of getValue() and validate(): this + * should accept anything returned by those methods or expected to be used + * as PARAM_DEFAULT, and if the string from this method is passed in as client + * input or PARAM_DEFAULT it should give equivalent output from validate(). + * + * @param string $name Parameter name being converted. + * @param mixed $value Parameter value being converted. Do not pass null. + * @param array $settings Parameter settings array. + * @param array $options Options array. + * @return string|null Return null if there is no representation of $value + * reasonably satisfying the description given. + */ + public function stringifyValue( $name, $value, array $settings, array $options ) { + return (string)$value; + } + + /** + * "Describe" a settings array + * + * This is intended to format data about a settings array using this type + * in a way that would be useful for automatically generated documentation + * or a machine-readable interface specification. + * + * Keys in the description array should follow the same guidelines as the + * code described for ValidationException. + * + * By default, each value in the description array is a single string, + * integer, or array. When `$options['compact']` is supplied, each value is + * instead an array of such and related values may be combined. For example, + * a non-compact description for an integer type might include + * `[ 'default' => 0, 'min' => 0, 'max' => 5 ]`, while in compact mode it might + * instead report `[ 'default' => [ 'value' => 0 ], 'minmax' => [ 'min' => 0, 'max' => 5 ] ]` + * to facilitate auto-generated documentation turning that 'minmax' into + * "Value must be between 0 and 5" rather than disconnected statements + * "Value must be >= 0" and "Value must be <= 5". + * + * @param string $name Parameter name being described. + * @param array $settings Parameter settings array. + * @param array $options Options array. Defined options for this base class are: + * - 'compact': (bool) Enable compact mode, as described above. + * @return array + */ + public function describeSettings( $name, array $settings, array $options ) { + $compact = !empty( $options['compact'] ); + + $ret = []; + + if ( isset( $settings[ParamValidator::PARAM_DEFAULT] ) ) { + $value = $this->stringifyValue( + $name, $settings[ParamValidator::PARAM_DEFAULT], $settings, $options + ); + $ret['default'] = $compact ? [ 'value' => $value ] : $value; + } + + return $ret; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/BooleanDef.php b/includes/libs/ParamValidator/TypeDef/BooleanDef.php new file mode 100644 index 0000000000..f77c930499 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/BooleanDef.php @@ -0,0 +1,45 @@ + self::$TRUEVALS, + 'falsevals' => array_merge( self::$FALSEVALS, [ 'the empty string' ] ), + ] ); + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + return $value ? self::$TRUEVALS[0] : self::$FALSEVALS[0]; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/EnumDef.php b/includes/libs/ParamValidator/TypeDef/EnumDef.php new file mode 100644 index 0000000000..0f4f6908e5 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/EnumDef.php @@ -0,0 +1,88 @@ +getEnumValues( $name, $settings, $options ); + + if ( in_array( $value, $values, true ) ) { + // Set a warning if a deprecated parameter value has been passed + if ( isset( $settings[self::PARAM_DEPRECATED_VALUES][$value] ) ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'deprecated-value', [ + 'flag' => $settings[self::PARAM_DEPRECATED_VALUES][$value], + ] ), + $options + ); + } + + return $value; + } + + if ( !isset( $options['values-list'] ) && + count( ParamValidator::explodeMultiValue( $value, 2 ) ) > 1 + ) { + throw new ValidationException( $name, $value, $settings, 'notmulti', [] ); + } else { + throw new ValidationException( $name, $value, $settings, 'badvalue', [] ); + } + } + + public function getEnumValues( $name, array $settings, array $options ) { + return $settings[ParamValidator::PARAM_TYPE]; + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + if ( !is_array( $value ) ) { + return parent::stringifyValue( $name, $value, $settings, $options ); + } + + foreach ( $value as $v ) { + if ( strpos( $v, '|' ) !== false ) { + return "\x1f" . implode( "\x1f", $value ); + } + } + return implode( '|', $value ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/FloatDef.php b/includes/libs/ParamValidator/TypeDef/FloatDef.php new file mode 100644 index 0000000000..0a204b3a88 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/FloatDef.php @@ -0,0 +1,72 @@ + '.', + // PHP's number formatting currently uses only the first byte from 'decimal_point'. + // See upstream bug https://bugs.php.net/bug.php?id=78113 + $localeData['decimal_point'][0] => '.', + ] ); + } + return $value; + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + // Ensure sufficient precision for round-tripping. PHP_FLOAT_DIG was added in PHP 7.2. + $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15; + return $this->fixLocaleWeirdness( sprintf( "%.{$digits}g", $value ) ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/IntegerDef.php b/includes/libs/ParamValidator/TypeDef/IntegerDef.php new file mode 100644 index 0000000000..556301b898 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/IntegerDef.php @@ -0,0 +1,171 @@ + $max ) { + if ( $max2 !== null && $this->callbacks->useHighLimits( $options ) ) { + if ( $ret > $max2 ) { + $err = 'abovehighmaximum'; + $ret = $max2; + } + } else { + $err = 'abovemaximum'; + $ret = $max; + } + } + if ( $err !== null ) { + $ex = new ValidationException( $name, $value, $settings, $err, [ + 'min' => $min === null ? '' : $min, + 'max' => $max === null ? '' : $max, + 'max2' => $max2 === null ? '' : $max2, + ] ); + if ( empty( $settings[self::PARAM_IGNORE_RANGE] ) ) { + throw $ex; + } + $this->callbacks->recordCondition( $ex, $options ); + } + + return $ret; + } + + public function normalizeSettings( array $settings ) { + if ( !isset( $settings[self::PARAM_MAX] ) ) { + unset( $settings[self::PARAM_MAX2] ); + } + + if ( isset( $settings[self::PARAM_MAX2] ) && isset( $settings[self::PARAM_MAX] ) && + $settings[self::PARAM_MAX2] < $settings[self::PARAM_MAX] + ) { + $settings[self::PARAM_MAX2] = $settings[self::PARAM_MAX]; + } + + return parent::normalizeSettings( $settings ); + } + + public function describeSettings( $name, array $settings, array $options ) { + $info = parent::describeSettings( $name, $settings, $options ); + + $min = $settings[self::PARAM_MIN] ?? ''; + $max = $settings[self::PARAM_MAX] ?? ''; + $max2 = $settings[self::PARAM_MAX2] ?? ''; + if ( $max === '' || $max2 !== '' && $max2 <= $max ) { + $max2 = ''; + } + + if ( empty( $options['compact'] ) ) { + if ( $min !== '' ) { + $info['min'] = $min; + } + if ( $max !== '' ) { + $info['max'] = $max; + } + if ( $max2 !== '' ) { + $info['max2'] = $max2; + } + } else { + $key = ''; + if ( $min !== '' ) { + $key = 'min'; + } + if ( $max2 !== '' ) { + $key .= 'max2'; + } elseif ( $max !== '' ) { + $key .= 'max'; + } + if ( $key !== '' ) { + $info[$key] = [ 'min' => $min, 'max' => $max, 'max2' => $max2 ]; + } + } + + return $info; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/LimitDef.php b/includes/libs/ParamValidator/TypeDef/LimitDef.php new file mode 100644 index 0000000000..99780c4a0d --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/LimitDef.php @@ -0,0 +1,44 @@ +callbacks->useHighLimits( $options ) + ? $settings[self::PARAM_MAX2] ?? $settings[self::PARAM_MAX] ?? PHP_INT_MAX + : $settings[self::PARAM_MAX] ?? PHP_INT_MAX; + } + return $value; + } + + return parent::validate( $name, $value, $settings, $options ); + } + + public function normalizeSettings( array $settings ) { + $settings += [ + self::PARAM_MIN => 0, + ]; + + return parent::normalizeSettings( $settings ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/PasswordDef.php b/includes/libs/ParamValidator/TypeDef/PasswordDef.php new file mode 100644 index 0000000000..289db54869 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/PasswordDef.php @@ -0,0 +1,22 @@ +callbacks->hasParam( $name, $options ); + } + + public function validate( $name, $value, array $settings, array $options ) { + return (bool)$value; + } + + public function describeSettings( $name, array $settings, array $options ) { + $info = parent::describeSettings( $name, $settings, $options ); + unset( $info['default'] ); + return $info; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/StringDef.php b/includes/libs/ParamValidator/TypeDef/StringDef.php new file mode 100644 index 0000000000..0ed310b50f --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/StringDef.php @@ -0,0 +1,88 @@ +allowEmptyWhenRequired = !empty( $options['allowEmptyWhenRequired'] ); + } + + public function validate( $name, $value, array $settings, array $options ) { + if ( !$this->allowEmptyWhenRequired && $value === '' && + !empty( $settings[ParamValidator::PARAM_REQUIRED] ) + ) { + throw new ValidationException( $name, $value, $settings, 'missingparam', [] ); + } + + if ( isset( $settings[self::PARAM_MAX_BYTES] ) + && strlen( $value ) > $settings[self::PARAM_MAX_BYTES] + ) { + throw new ValidationException( $name, $value, $settings, 'maxbytes', [ + 'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '', + 'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '', + ] ); + } + if ( isset( $settings[self::PARAM_MAX_CHARS] ) + && mb_strlen( $value, 'UTF-8' ) > $settings[self::PARAM_MAX_CHARS] + ) { + throw new ValidationException( $name, $value, $settings, 'maxchars', [ + 'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? '', + 'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? '', + ] ); + } + + return $value; + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/TimestampDef.php b/includes/libs/ParamValidator/TypeDef/TimestampDef.php new file mode 100644 index 0000000000..5d0bf4e951 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/TimestampDef.php @@ -0,0 +1,100 @@ +defaultFormat = $options['defaultFormat'] ?? 'ConvertibleTimestamp'; + $this->stringifyFormat = $options['stringifyFormat'] ?? TS_ISO_8601; + } + + public function validate( $name, $value, array $settings, array $options ) { + // Confusing synonyms for the current time accepted by ConvertibleTimestamp + if ( !$value ) { + $this->callbacks->recordCondition( + new ValidationException( $name, $value, $settings, 'unclearnowtimestamp', [] ), + $options + ); + $value = 'now'; + } + + try { + $timestamp = new ConvertibleTimestamp( $value === 'now' ? false : $value ); + } catch ( TimestampException $ex ) { + throw new ValidationException( $name, $value, $settings, 'badtimestamp', [], $ex ); + } + + $format = $settings[self::PARAM_TIMESTAMP_FORMAT] ?? $this->defaultFormat; + switch ( $format ) { + case 'ConvertibleTimestamp': + return $timestamp; + + case 'DateTime': + // Eew, no getter. + return $timestamp->timestamp; + + default: + return $timestamp->getTimestamp( $format ); + } + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + if ( !$value instanceof ConvertibleTimestamp ) { + $value = new ConvertibleTimestamp( $value ); + } + return $value->getTimestamp( $this->stringifyFormat ); + } + +} diff --git a/includes/libs/ParamValidator/TypeDef/UploadDef.php b/includes/libs/ParamValidator/TypeDef/UploadDef.php new file mode 100644 index 0000000000..b436a6dc54 --- /dev/null +++ b/includes/libs/ParamValidator/TypeDef/UploadDef.php @@ -0,0 +1,116 @@ +callbacks->getUploadedFile( $name, $options ); + + if ( $ret && $ret->getError() === UPLOAD_ERR_NO_FILE && + !$this->callbacks->hasParam( $name, $options ) + ) { + // This seems to be that the client explicitly specified "no file" for the field + // instead of just omitting the field completely. DWTM. + $ret = null; + } elseif ( !$ret && $this->callbacks->hasParam( $name, $options ) ) { + // The client didn't format their upload properly so it came in as an ordinary + // field. Convert it to an error. + $ret = new UploadedFile( [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => -42, // PHP's UPLOAD_ERR_* are all positive numbers. + 'size' => 0, + ] ); + } + + return $ret; + } + + /** + * Fetch the value of PHP's upload_max_filesize ini setting + * + * This method exists so it can be mocked by unit tests that can't + * affect ini_get() directly. + * + * @codeCoverageIgnore + * @return string|false + */ + protected function getIniSize() { + return ini_get( 'upload_max_filesize' ); + } + + public function validate( $name, $value, array $settings, array $options ) { + static $codemap = [ + -42 => 'notupload', // Local from getValue() + UPLOAD_ERR_FORM_SIZE => 'formsize', + UPLOAD_ERR_PARTIAL => 'partial', + UPLOAD_ERR_NO_FILE => 'nofile', + UPLOAD_ERR_NO_TMP_DIR => 'notmpdir', + UPLOAD_ERR_CANT_WRITE => 'cantwrite', + UPLOAD_ERR_EXTENSION => 'phpext', + ]; + + if ( !$value instanceof UploadedFileInterface ) { + // Err? + throw new ValidationException( $name, $value, $settings, 'badupload', [] ); + } + + $err = $value->getError(); + if ( $err === UPLOAD_ERR_OK ) { + return $value; + } elseif ( $err === UPLOAD_ERR_INI_SIZE ) { + static $prefixes = [ + 'g' => 1024 ** 3, + 'm' => 1024 ** 2, + 'k' => 1024 ** 1, + ]; + $size = $this->getIniSize(); + $last = strtolower( substr( $size, -1 ) ); + $size = intval( $size, 10 ) * ( $prefixes[$last] ?? 1 ); + throw new ValidationException( $name, $value, $settings, 'badupload-inisize', [ + 'size' => $size, + ] ); + } elseif ( isset( $codemap[$err] ) ) { + throw new ValidationException( $name, $value, $settings, 'badupload-' . $codemap[$err], [] ); + } else { + throw new ValidationException( $name, $value, $settings, 'badupload-unknown', [ + 'code' => $err, + ] ); + } + } + + public function stringifyValue( $name, $value, array $settings, array $options ) { + // Not going to happen. + return null; + } + +} diff --git a/includes/libs/ParamValidator/Util/UploadedFile.php b/includes/libs/ParamValidator/Util/UploadedFile.php new file mode 100644 index 0000000000..2be9119d25 --- /dev/null +++ b/includes/libs/ParamValidator/Util/UploadedFile.php @@ -0,0 +1,141 @@ +data = $data; + $this->fromUpload = $fromUpload; + } + + /** + * Throw if there was an error + * @throws RuntimeException + */ + private function checkError() { + switch ( $this->data['error'] ) { + case UPLOAD_ERR_OK: + break; + + case UPLOAD_ERR_INI_SIZE: + throw new RuntimeException( 'Upload exceeded maximum size' ); + + case UPLOAD_ERR_FORM_SIZE: + throw new RuntimeException( 'Upload exceeded form-specified maximum size' ); + + case UPLOAD_ERR_PARTIAL: + throw new RuntimeException( 'File was only partially uploaded' ); + + case UPLOAD_ERR_NO_FILE: + throw new RuntimeException( 'No file was uploaded' ); + + case UPLOAD_ERR_NO_TMP_DIR: + throw new RuntimeException( 'PHP has no temporary folder for storing uploaded files' ); + + case UPLOAD_ERR_CANT_WRITE: + throw new RuntimeException( 'PHP was unable to save the uploaded file' ); + + case UPLOAD_ERR_EXTENSION: + throw new RuntimeException( 'A PHP extension stopped the file upload' ); + + default: + throw new RuntimeException( 'Unknown upload error code ' . $this->data['error'] ); + } + + if ( $this->moved ) { + throw new RuntimeException( 'File has already been moved' ); + } + if ( !isset( $this->data['tmp_name'] ) || !file_exists( $this->data['tmp_name'] ) ) { + throw new RuntimeException( 'Uploaded file is missing' ); + } + } + + public function getStream() { + if ( $this->stream ) { + return $this->stream; + } + + $this->checkError(); + $this->stream = new UploadedFileStream( $this->data['tmp_name'] ); + return $this->stream; + } + + public function moveTo( $targetPath ) { + $this->checkError(); + + if ( $this->fromUpload && !is_uploaded_file( $this->data['tmp_name'] ) ) { + throw new RuntimeException( 'Specified file is not an uploaded file' ); + } + + // TODO remove the function_exists check once we drop HHVM support + if ( function_exists( 'error_clear_last' ) ) { + error_clear_last(); + } + $ret = AtEase::quietCall( + $this->fromUpload ? 'move_uploaded_file' : 'rename', + $this->data['tmp_name'], + $targetPath + ); + if ( $ret === false ) { + $err = error_get_last(); + throw new RuntimeException( "Move failed: " . ( $err['message'] ?? 'Unknown error' ) ); + } + + $this->moved = true; + if ( $this->stream ) { + $this->stream->close(); + $this->stream = null; + } + } + + public function getSize() { + return $this->data['size'] ?? null; + } + + public function getError() { + return $this->data['error'] ?? UPLOAD_ERR_NO_FILE; + } + + public function getClientFilename() { + $ret = $this->data['name'] ?? null; + return $ret === '' ? null : $ret; + } + + public function getClientMediaType() { + $ret = $this->data['type'] ?? null; + return $ret === '' ? null : $ret; + } + +} diff --git a/includes/libs/ParamValidator/Util/UploadedFileStream.php b/includes/libs/ParamValidator/Util/UploadedFileStream.php new file mode 100644 index 0000000000..17eaaf4a01 --- /dev/null +++ b/includes/libs/ParamValidator/Util/UploadedFileStream.php @@ -0,0 +1,168 @@ +fp = self::quietCall( 'fopen', [ $filename, 'r' ], false, 'Failed to open file' ); + } + + /** + * Check if the stream is open + * @throws RuntimeException if closed + */ + private function checkOpen() { + if ( !$this->fp ) { + throw new RuntimeException( 'Stream is not open' ); + } + } + + public function __destruct() { + $this->close(); + } + + public function __toString() { + try { + $this->seek( 0 ); + return $this->getContents(); + } catch ( Exception $ex ) { + // Not allowed to throw + return ''; + } catch ( Throwable $ex ) { + // Not allowed to throw + return ''; + } + } + + public function close() { + if ( $this->fp ) { + // Spec doesn't care about close errors. + AtEase::quietCall( 'fclose', $this->fp ); + $this->fp = null; + } + } + + public function detach() { + $ret = $this->fp; + $this->fp = null; + return $ret; + } + + public function getSize() { + if ( $this->size === false ) { + $this->size = null; + + if ( $this->fp ) { + // Spec doesn't care about errors here. + $stat = AtEase::quietCall( 'fstat', $this->fp ); + $this->size = $stat['size'] ?? null; + } + } + + return $this->size; + } + + public function tell() { + $this->checkOpen(); + return self::quietCall( 'ftell', [ $this->fp ], -1, 'Cannot determine stream position' ); + } + + public function eof() { + // Spec doesn't care about errors here. + return !$this->fp || AtEase::quietCall( 'feof', $this->fp ); + } + + public function isSeekable() { + return (bool)$this->fp; + } + + public function seek( $offset, $whence = SEEK_SET ) { + $this->checkOpen(); + self::quietCall( 'fseek', [ $this->fp, $offset, $whence ], -1, 'Seek failed' ); + } + + public function rewind() { + $this->seek( 0 ); + } + + public function isWritable() { + return false; + } + + public function write( $string ) { + $this->checkOpen(); + throw new RuntimeException( 'Stream is read-only' ); + } + + public function isReadable() { + return (bool)$this->fp; + } + + public function read( $length ) { + $this->checkOpen(); + return self::quietCall( 'fread', [ $this->fp, $length ], false, 'Read failed' ); + } + + public function getContents() { + $this->checkOpen(); + return self::quietCall( 'stream_get_contents', [ $this->fp ], false, 'Read failed' ); + } + + public function getMetadata( $key = null ) { + $this->checkOpen(); + $ret = self::quietCall( 'stream_get_meta_data', [ $this->fp ], false, 'Metadata fetch failed' ); + if ( $key !== null ) { + $ret = $ret[$key] ?? null; + } + return $ret; + } + +} diff --git a/includes/libs/ParamValidator/ValidationException.php b/includes/libs/ParamValidator/ValidationException.php new file mode 100644 index 0000000000..c8d995e0b9 --- /dev/null +++ b/includes/libs/ParamValidator/ValidationException.php @@ -0,0 +1,128 @@ +paramName = $name; + $this->paramValue = $value; + $this->settings = $settings; + $this->failureCode = $code; + $this->failureData = $data; + } + + /** + * Make a simple English message for the exception + * @param string $name + * @param string $code + * @param array $data + * @return string + */ + private static function formatMessage( $name, $code, $data ) { + $ret = "Validation of `$name` failed: $code"; + foreach ( $data as $k => $v ) { + if ( is_array( $v ) ) { + $v = implode( ', ', $v ); + } + $ret .= "; $k => $v"; + } + return $ret; + } + + /** + * Fetch the parameter name that failed validation + * @return string + */ + public function getParamName() { + return $this->paramName; + } + + /** + * Fetch the parameter value that failed validation + * @return mixed + */ + public function getParamValue() { + return $this->paramValue; + } + + /** + * Fetch the settings array that failed validation + * @return array + */ + public function getSettings() { + return $this->settings; + } + + /** + * Fetch the validation failure code + * + * A validation failure code is a reasonably short string matching the regex + * `/^[a-z][a-z0-9-]*$/`. + * + * Users are encouraged to use this with a suitable i18n mechanism rather + * than relying on the limited English text returned by getMessage(). + * + * @return string + */ + public function getFailureCode() { + return $this->failureCode; + } + + /** + * Fetch the validation failure data + * + * This returns additional data relevant to the particular failure code. + * + * Keys in the array are short ASCII strings. Values are strings or + * integers, or arrays of strings intended to be displayed as a + * comma-separated list. For any particular code the same keys are always + * returned in the same order, making it safe to use array_values() and + * access them positionally if that is desired. + * + * For example, the data for a hypothetical "integer-out-of-range" code + * might have data `[ 'min' => 0, 'max' => 100 ]` indicating the range of + * allowed values. + * + * @return (string|int|string[])[] + */ + public function getFailureData() { + return $this->failureData; + } + +} diff --git a/tests/common/TestsAutoLoader.php b/tests/common/TestsAutoLoader.php index b60577cd9a..8b6c6d5da3 100644 --- a/tests/common/TestsAutoLoader.php +++ b/tests/common/TestsAutoLoader.php @@ -179,6 +179,7 @@ $wgAutoloadClasses += [ # tests/phpunit/includes/libs 'GenericArrayObjectTest' => "$testDir/phpunit/includes/libs/GenericArrayObjectTest.php", + 'Wikimedia\ParamValidator\TypeDef\TypeDefTestCase' => "$testDir/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php", # tests/phpunit/maintenance 'MediaWiki\Tests\Maintenance\DumpAsserter' => "$testDir/phpunit/maintenance/DumpAsserter.php", diff --git a/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php new file mode 100644 index 0000000000..01b1c02362 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/ParamValidatorTest.php @@ -0,0 +1,506 @@ +getMockForAbstractClass( ContainerInterface::class ) ) + ); + $this->assertSame( array_keys( ParamValidator::$STANDARD_TYPES ), $validator->knownTypes() ); + + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => [], 'bar' => [] ] ] + ); + $validator->addTypeDef( 'baz', [] ); + try { + $validator->addTypeDef( 'baz', [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + } + $validator->overrideTypeDef( 'bar', null ); + $validator->overrideTypeDef( 'baz', [] ); + $this->assertSame( [ 'foo', 'baz' ], $validator->knownTypes() ); + + $this->assertTrue( $validator->hasTypeDef( 'foo' ) ); + $this->assertFalse( $validator->hasTypeDef( 'bar' ) ); + $this->assertTrue( $validator->hasTypeDef( 'baz' ) ); + $this->assertFalse( $validator->hasTypeDef( 'bazz' ) ); + } + + public function testGetTypeDef() { + $callbacks = new SimpleCallbacks( [] ); + $factory = $this->getMockBuilder( ObjectFactory::class ) + ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] ) + ->setMethods( [ 'createObject' ] ) + ->getMock(); + $factory->method( 'createObject' ) + ->willReturnCallback( function ( $spec, $options ) use ( $callbacks ) { + $this->assertInternalType( 'array', $spec ); + $this->assertSame( + [ 'extraArgs' => [ $callbacks ], 'assertClass' => TypeDef::class ], $options + ); + $ret = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + $ret->spec = $spec; + return $ret; + } ); + $validator = new ParamValidator( $callbacks, $factory ); + + $def = $validator->getTypeDef( 'boolean' ); + $this->assertInstanceOf( TypeDef::class, $def ); + $this->assertSame( ParamValidator::$STANDARD_TYPES['boolean'], $def->spec ); + + $def = $validator->getTypeDef( [] ); + $this->assertInstanceOf( TypeDef::class, $def ); + $this->assertSame( ParamValidator::$STANDARD_TYPES['enum'], $def->spec ); + + $def = $validator->getTypeDef( 'missing' ); + $this->assertNull( $def ); + } + + public function testGetTypeDef_caching() { + $callbacks = new SimpleCallbacks( [] ); + + $mb = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ); + $def1 = $mb->getMockForAbstractClass(); + $def2 = $mb->getMockForAbstractClass(); + $this->assertNotSame( $def1, $def2, 'sanity check' ); + + $factory = $this->getMockBuilder( ObjectFactory::class ) + ->setConstructorArgs( [ $this->getMockForAbstractClass( ContainerInterface::class ) ] ) + ->setMethods( [ 'createObject' ] ) + ->getMock(); + $factory->expects( $this->once() )->method( 'createObject' )->willReturn( $def1 ); + + $validator = new ParamValidator( $callbacks, $factory, [ 'typeDefs' => [ + 'foo' => [], + 'bar' => $def2, + ] ] ); + + $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) ); + + // Second call doesn't re-call ObjectFactory + $this->assertSame( $def1, $validator->getTypeDef( 'foo' ) ); + + // When registered a TypeDef directly, doesn't call ObjectFactory + $this->assertSame( $def2, $validator->getTypeDef( 'bar' ) ); + } + + /** + * @expectedException \UnexpectedValueException + * @expectedExceptionMessage Expected instance of Wikimedia\ParamValidator\TypeDef, got stdClass + */ + public function testGetTypeDef_error() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => [ 'class' => \stdClass::class ] ] ] + ); + $validator->getTypeDef( 'foo' ); + } + + /** @dataProvider provideNormalizeSettings */ + public function testNormalizeSettings( $input, $expect ) { + $callbacks = new SimpleCallbacks( [] ); + + $mb = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'normalizeSettings' ] ); + $mock1 = $mb->getMockForAbstractClass(); + $mock1->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) { + $s['foo'] = 'FooBar!'; + return $s; + } ); + $mock2 = $mb->getMockForAbstractClass(); + $mock2->method( 'normalizeSettings' )->willReturnCallback( function ( $s ) { + $s['bar'] = 'FooBar!'; + return $s; + } ); + + $validator = new ParamValidator( + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'foo' => $mock1, 'bar' => $mock2 ] ] + ); + + $this->assertSame( $expect, $validator->normalizeSettings( $input ) ); + } + + public static function provideNormalizeSettings() { + return [ + 'Plain value' => [ + 'ok?', + [ ParamValidator::PARAM_DEFAULT => 'ok?', ParamValidator::PARAM_TYPE => 'string' ], + ], + 'Simple array' => [ + [ 'test' => 'ok?' ], + [ 'test' => 'ok?', ParamValidator::PARAM_TYPE => 'NULL' ], + ], + 'A type with overrides' => [ + [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?' ], + [ ParamValidator::PARAM_TYPE => 'foo', 'test' => 'ok?', 'foo' => 'FooBar!' ], + ], + ]; + } + + /** @dataProvider provideExplodeMultiValue */ + public function testExplodeMultiValue( $value, $limit, $expect ) { + $this->assertSame( $expect, ParamValidator::explodeMultiValue( $value, $limit ) ); + } + + public static function provideExplodeMultiValue() { + return [ + [ 'foobar', 100, [ 'foobar' ] ], + [ 'foo|bar|baz', 100, [ 'foo', 'bar', 'baz' ] ], + [ "\x1Ffoo\x1Fbar\x1Fbaz", 100, [ 'foo', 'bar', 'baz' ] ], + [ 'foo|bar|baz', 2, [ 'foo', 'bar|baz' ] ], + [ "\x1Ffoo\x1Fbar\x1Fbaz", 2, [ 'foo', "bar\x1Fbaz" ] ], + [ '|bar|baz', 100, [ '', 'bar', 'baz' ] ], + [ "\x1F\x1Fbar\x1Fbaz", 100, [ '', 'bar', 'baz' ] ], + [ '', 100, [] ], + [ "\x1F", 100, [] ], + ]; + } + + /** + * @expectedException DomainException + * @expectedExceptionMessage Param foo's type is unknown - string + */ + public function testGetValue_badType() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [] ] + ); + $validator->getValue( 'foo', 'default', [] ); + } + + /** @dataProvider provideGetValue */ + public function testGetValue( + $settings, $parseLimit, $get, $value, $isSensitive, $isDeprecated + ) { + $callbacks = new SimpleCallbacks( $get ); + $dummy = (object)[]; + $options = [ $dummy ]; + + $settings += [ + ParamValidator::PARAM_TYPE => 'xyz', + ParamValidator::PARAM_DEFAULT => null, + ]; + + $mockDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + + // Mock the validateValue method so we can test only getValue + $validator = $this->getMockBuilder( ParamValidator::class ) + ->setConstructorArgs( [ + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [ 'xyz' => $mockDef ] ] + ] ) + ->setMethods( [ 'validateValue' ] ) + ->getMock(); + $validator->expects( $this->once() )->method( 'validateValue' ) + ->with( + $this->identicalTo( 'foobar' ), + $this->identicalTo( $value ), + $this->identicalTo( $settings ), + $this->identicalTo( $options ) + ) + ->willReturn( $dummy ); + + $this->assertSame( $dummy, $validator->getValue( 'foobar', $settings, $options ) ); + + $expectConditions = []; + if ( $isSensitive ) { + $expectConditions[] = new ValidationException( + 'foobar', $value, $settings, 'param-sensitive', [] + ); + } + if ( $isDeprecated ) { + $expectConditions[] = new ValidationException( + 'foobar', $value, $settings, 'param-deprecated', [] + ); + } + $this->assertEquals( $expectConditions, $callbacks->getRecordedConditions() ); + } + + public static function provideGetValue() { + $sen = [ ParamValidator::PARAM_SENSITIVE => true ]; + $dep = [ ParamValidator::PARAM_DEPRECATED => true ]; + $dflt = [ ParamValidator::PARAM_DEFAULT => 'DeFaUlT' ]; + return [ + 'Simple case' => [ [], false, [ 'foobar' => '!!!' ], '!!!', false, false ], + 'Not provided' => [ $sen + $dep, false, [], null, false, false ], + 'Not provided, default' => [ $sen + $dep + $dflt, true, [], 'DeFaUlT', false, false ], + 'Provided' => [ $dflt, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, false ], + 'Provided, sensitive' => [ $sen, false, [ 'foobar' => 'XYZ' ], 'XYZ', true, false ], + 'Provided, deprecated' => [ $dep, false, [ 'foobar' => 'XYZ' ], 'XYZ', false, true ], + 'Provided array' => [ $dflt, false, [ 'foobar' => [ 'XYZ' ] ], [ 'XYZ' ], false, false ], + ]; + } + + /** + * @expectedException DomainException + * @expectedExceptionMessage Param foo's type is unknown - string + */ + public function testValidateValue_badType() { + $validator = new ParamValidator( + new SimpleCallbacks( [] ), + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + [ 'typeDefs' => [] ] + ); + $validator->validateValue( 'foo', null, 'default', [] ); + } + + /** @dataProvider provideValidateValue */ + public function testValidateValue( + $value, $settings, $highLimits, $valuesList, $calls, $expect, $expectConditions = [], + $constructorOptions = [] + ) { + $callbacks = new SimpleCallbacks( [] ); + $settings += [ + ParamValidator::PARAM_TYPE => 'xyz', + ParamValidator::PARAM_DEFAULT => null, + ]; + $dummy = (object)[]; + $options = [ $dummy, 'useHighLimits' => $highLimits ]; + $eOptions = $options; + $eOptions2 = $eOptions; + if ( $valuesList !== null ) { + $eOptions2['values-list'] = $valuesList; + } + + $mockDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'validate', 'getEnumValues' ] ) + ->getMockForAbstractClass(); + $mockDef->method( 'getEnumValues' ) + ->with( + $this->identicalTo( 'foobar' ), $this->identicalTo( $settings ), $this->identicalTo( $eOptions ) + ) + ->willReturn( [ 'a', 'b', 'c', 'd', 'e', 'f' ] ); + $mockDef->expects( $this->exactly( count( $calls ) ) )->method( 'validate' )->willReturnCallback( + function ( $n, $v, $s, $o ) use ( $settings, $eOptions2, $calls ) { + $this->assertSame( 'foobar', $n ); + $this->assertSame( $settings, $s ); + $this->assertSame( $eOptions2, $o ); + + if ( !array_key_exists( $v, $calls ) ) { + $this->fail( "Called with unexpected value '$v'" ); + } + if ( $calls[$v] === null ) { + throw new ValidationException( $n, $v, $s, 'badvalue', [] ); + } + return $calls[$v]; + } + ); + + $validator = new ParamValidator( + $callbacks, + new ObjectFactory( $this->getMockForAbstractClass( ContainerInterface::class ) ), + $constructorOptions + [ 'typeDefs' => [ 'xyz' => $mockDef ] ] + ); + + if ( $expect instanceof ValidationException ) { + try { + $validator->validateValue( 'foobar', $value, $settings, $options ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ValidationException $ex ) { + $this->assertSame( $expect->getFailureCode(), $ex->getFailureCode() ); + $this->assertSame( $expect->getFailureData(), $ex->getFailureData() ); + } + } else { + $this->assertSame( + $expect, $validator->validateValue( 'foobar', $value, $settings, $options ) + ); + + $conditions = []; + foreach ( $callbacks->getRecordedConditions() as $c ) { + $conditions[] = array_merge( [ $c->getFailureCode() ], $c->getFailureData() ); + } + $this->assertSame( $expectConditions, $conditions ); + } + } + + public static function provideValidateValue() { + return [ + 'No value' => [ null, [], false, null, [], null ], + 'No value, required' => [ + null, + [ ParamValidator::PARAM_REQUIRED => true ], + false, + null, + [], + new ValidationException( 'foobar', null, [], 'missingparam', [] ), + ], + 'Non-multi value' => [ 'abc', [], false, null, [ 'abc' => 'def' ], 'def' ], + 'Simple multi value' => [ + 'a|b|c|d', + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Array multi value' => [ + [ 'a', 'b', 'c', 'd' ], + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Multi value with PARAM_ALL' => [ + '*', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => true ], + false, + null, + [], + [ 'a', 'b', 'c', 'd', 'e', 'f' ], + ], + 'Multi value with PARAM_ALL = "x"' => [ + 'x', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ], + false, + null, + [], + [ 'a', 'b', 'c', 'd', 'e', 'f' ], + ], + 'Multi value with PARAM_ALL = "x", passing "*"' => [ + '*', + [ ParamValidator::PARAM_ISMULTI => true, ParamValidator::PARAM_ALL => "x" ], + false, + [ '*' ], + [ '*' => '?' ], + [ '?' ], + ], + + 'Too many values' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + false, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ), + ], + 'Too many values as array' => [ + [ 'a', 'b', 'c', 'd' ], + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + false, + null, + [], + new ValidationException( + 'foobar', [ 'a', 'b', 'c', 'd' ], [], 'toomanyvalues', [ 'limit' => 2 ] + ), + ], + 'Not too many values for highlimits' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + true, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + ], + 'Too many values for highlimits' => [ + 'a|b|c|d|e', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_ISMULTI_LIMIT1 => 2, + ParamValidator::PARAM_ISMULTI_LIMIT2 => 4, + ], + true, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ), + ], + + 'Too many values via default' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + false, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d', [], 'toomanyvalues', [ 'limit' => 2 ] ), + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + 'Not too many values for highlimits via default' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + true, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D' ], + [ 'A', 'B', 'C', 'D' ], + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + 'Too many values for highlimits via default' => [ + 'a|b|c|d|e', + [ + ParamValidator::PARAM_ISMULTI => true, + ], + true, + null, + [], + new ValidationException( 'foobar', 'a|b|c|d|e', [], 'toomanyvalues', [ 'limit' => 4 ] ), + [], + [ 'ismultiLimits' => [ 2, 4 ] ], + ], + + 'Invalid values' => [ + 'a|b|c|d', + [ ParamValidator::PARAM_ISMULTI => true ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => null ], + new ValidationException( 'foobar', 'b', [], 'badvalue', [] ), + ], + 'Ignored invalid values' => [ + 'a|b|c|d', + [ + ParamValidator::PARAM_ISMULTI => true, + ParamValidator::PARAM_IGNORE_INVALID_VALUES => true, + ], + false, + [ 'a', 'b', 'c', 'd' ], + [ 'a' => 'A', 'b' => null, 'c' => null, 'd' => 'D' ], + [ 'A', 'D' ], + [ + [ 'unrecognizedvalues', 'values' => [ 'b', 'c' ] ], + ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php new file mode 100644 index 0000000000..ebe1dcc1d8 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/SimpleCallbacksTest.php @@ -0,0 +1,88 @@ + 'Foo!', 'bar' => null ], + [ + 'file1' => [ + 'name' => 'example.txt', + 'type' => 'text/plain', + 'tmp_name' => '...', + 'error' => UPLOAD_ERR_OK, + 'size' => 123, + ], + 'file2' => [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'size' => 0, + ], + ] + ); + + $this->assertTrue( $callbacks->hasParam( 'foo', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'bar', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'baz', [] ) ); + $this->assertFalse( $callbacks->hasParam( 'file1', [] ) ); + + $this->assertSame( 'Foo!', $callbacks->getValue( 'foo', null, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'bar', null, [] ) ); + $this->assertSame( 123, $callbacks->getValue( 'bar', 123, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'baz', null, [] ) ); + $this->assertSame( null, $callbacks->getValue( 'file1', null, [] ) ); + + $this->assertFalse( $callbacks->hasUpload( 'foo', [] ) ); + $this->assertFalse( $callbacks->hasUpload( 'bar', [] ) ); + $this->assertTrue( $callbacks->hasUpload( 'file1', [] ) ); + $this->assertTrue( $callbacks->hasUpload( 'file2', [] ) ); + $this->assertFalse( $callbacks->hasUpload( 'baz', [] ) ); + + $this->assertNull( $callbacks->getUploadedFile( 'foo', [] ) ); + $this->assertNull( $callbacks->getUploadedFile( 'bar', [] ) ); + $this->assertInstanceOf( + UploadedFileInterface::class, $callbacks->getUploadedFile( 'file1', [] ) + ); + $this->assertInstanceOf( + UploadedFileInterface::class, $callbacks->getUploadedFile( 'file2', [] ) + ); + $this->assertNull( $callbacks->getUploadedFile( 'baz', [] ) ); + + $file = $callbacks->getUploadedFile( 'file1', [] ); + $this->assertSame( 'example.txt', $file->getClientFilename() ); + $file = $callbacks->getUploadedFile( 'file2', [] ); + $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() ); + + $this->assertFalse( $callbacks->useHighLimits( [] ) ); + $this->assertFalse( $callbacks->useHighLimits( [ 'useHighLimits' => false ] ) ); + $this->assertTrue( $callbacks->useHighLimits( [ 'useHighLimits' => true ] ) ); + } + + public function testRecording() { + $callbacks = new SimpleCallbacks( [] ); + + $this->assertSame( [], $callbacks->getRecordedConditions() ); + + $ex1 = new ValidationException( 'foo', 'Foo!', [], 'foo', [] ); + $callbacks->recordCondition( $ex1, [] ); + $ex2 = new ValidationException( 'bar', null, [], 'barbar', [ 'bAr' => 'BaR' ] ); + $callbacks->recordCondition( $ex2, [] ); + $callbacks->recordCondition( $ex2, [] ); + $this->assertSame( [ $ex1, $ex2, $ex2 ], $callbacks->getRecordedConditions() ); + + $callbacks->clearRecordedConditions(); + $this->assertSame( [], $callbacks->getRecordedConditions() ); + $callbacks->recordCondition( $ex1, [] ); + $this->assertSame( [ $ex1 ], $callbacks->getRecordedConditions() ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php new file mode 100644 index 0000000000..75afb33214 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/BooleanDefTest.php @@ -0,0 +1,47 @@ + BooleanDef::$TRUEVALS, + 'falsevals' => array_merge( BooleanDef::$FALSEVALS, [ 'the empty string' ] ), + ] ); + + foreach ( [ + [ BooleanDef::$TRUEVALS, true ], + [ BooleanDef::$FALSEVALS, false ], + [ [ '' ], false ], + [ [ '2', 'foobar' ], $ex ], + ] as list( $vals, $expect ) ) { + foreach ( $vals as $v ) { + yield "Value '$v'" => [ $v, $expect ]; + $v2 = ucfirst( $v ); + if ( $v2 !== $v ) { + yield "Value '$v2'" => [ $v2, $expect ]; + } + $v3 = strtoupper( $v ); + if ( $v3 !== $v2 ) { + yield "Value '$v3'" => [ $v3, $expect ]; + } + } + } + } + + public function provideStringifyValue() { + return [ + [ true, 'true' ], + [ false, 'false' ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php new file mode 100644 index 0000000000..18d0aca29c --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/EnumDefTest.php @@ -0,0 +1,64 @@ + [ 'a', 'b', 'c', 'd' ], + EnumDef::PARAM_DEPRECATED_VALUES => [ + 'b' => [ 'not-to-be' ], + 'c' => true, + ], + ]; + + return [ + 'Basic' => [ 'a', 'a', $settings ], + 'Deprecated' => [ 'c', 'c', $settings, [], [ [ 'deprecated-value', 'flag' => true ] ] ], + 'Deprecated with message' => [ + 'b', 'b', $settings, [], + [ [ 'deprecated-value', 'flag' => [ 'not-to-be' ] ] ], + ], + 'Bad value, non-multi' => [ + 'x', new ValidationException( 'test', 'x', $settings, 'badvalue', [] ), + $settings, + ], + 'Bad value, non-multi but looks like it' => [ + 'x|y', new ValidationException( 'test', 'x|y', $settings, 'notmulti', [] ), + $settings, + ], + 'Bad value, multi' => [ + 'x|y', new ValidationException( 'test', 'x|y', $settings, 'badvalue', [] ), + $settings + [ ParamValidator::PARAM_ISMULTI => true ], + [ 'values-list' => [ 'x|y' ] ], + ], + ]; + } + + public function provideGetEnumValues() { + return [ + 'Basic test' => [ + [ ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ] ], + [ 'a', 'b', 'c', 'd' ], + ], + ]; + } + + public function provideStringifyValue() { + return [ + 'Basic test' => [ 123, '123' ], + 'Array' => [ [ 1, 2, 3 ], '1|2|3' ], + 'Array with pipes' => [ [ 1, 2, '3|4', 5 ], "\x1f1\x1f2\x1f3|4\x1f5" ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php new file mode 100644 index 0000000000..7bd053aa79 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/FloatDefTest.php @@ -0,0 +1,122 @@ + [ '-0', 0 ], + 'Underflow is ok' => [ '1e-9999', 0 ], + + 'Empty decimal part' => [ '1.', new ValidationException( 'test', '1.', [], 'badfloat', [] ) ], + 'Bad sign' => [ ' 1', new ValidationException( 'test', ' 1', [], 'badfloat', [] ) ], + 'Comma as decimal separator or thousands grouping?' + => [ '1,234', new ValidationException( 'test', '1,234', [], 'badfloat', [] ) ], + 'U+2212 minus' => [ '−1', new ValidationException( 'test', '−1', [], 'badfloat', [] ) ], + 'Overflow' => [ '1e9999', new ValidationException( 'test', '1e9999', [], 'notfinite', [] ) ], + 'Overflow, -INF' + => [ '-1e9999', new ValidationException( 'test', '-1e9999', [], 'notfinite', [] ) ], + 'Bogus value' => [ 'foo', new ValidationException( 'test', 'foo', [], 'badfloat', [] ) ], + 'Bogus value (2)' => [ '123f4', new ValidationException( 'test', '123f4', [], 'badfloat', [] ) ], + 'Newline' => [ "123\n", new ValidationException( 'test', "123\n", [], 'badfloat', [] ) ], + ]; + } + + public function provideStringifyValue() { + $digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15; + + return [ + [ 1.2, '1.2' ], + [ 10 / 3, '3.' . str_repeat( '3', $digits - 1 ) ], + [ 1e100, '1.0e+100' ], + [ 6.022e-23, '6.022e-23' ], + ]; + } + + /** @dataProvider provideLocales */ + public function testStringifyValue_localeWeirdness( $locale ) { + static $cats = [ LC_ALL, LC_MONETARY, LC_NUMERIC ]; + + $curLocales = []; + foreach ( $cats as $c ) { + $curLocales[$c] = setlocale( $c, '0' ); + if ( $curLocales[$c] === false ) { + $this->markTestSkipped( 'Locale support is unavailable' ); + } + } + try { + foreach ( $cats as $c ) { + if ( setlocale( $c, $locale ) === false ) { + $this->markTestSkipped( "Locale \"$locale\" is unavailable" ); + } + } + + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), [] ); + $this->assertSame( '123456.789', $typeDef->stringifyValue( 'test', 123456.789, [], [] ) ); + $this->assertSame( '-123456.789', $typeDef->stringifyValue( 'test', -123456.789, [], [] ) ); + $this->assertSame( '1.0e+20', $typeDef->stringifyValue( 'test', 1e20, [], [] ) ); + $this->assertSame( '1.0e-20', $typeDef->stringifyValue( 'test', 1e-20, [], [] ) ); + } finally { + foreach ( $curLocales as $c => $v ) { + setlocale( $c, $v ); + } + } + } + + public function provideLocales() { + return [ + // May as well test these. + [ 'C' ], + [ 'C.UTF-8' ], + + // Some hopefullt-common locales with decimal_point = ',' and thousands_sep = '.' + [ 'de_DE' ], + [ 'de_DE.utf8' ], + [ 'es_ES' ], + [ 'es_ES.utf8' ], + + // This one, on my system at least, has decimal_point as U+066B. + [ 'ps_AF' ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php new file mode 100644 index 0000000000..21fc9878c2 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/IntegerDefTest.php @@ -0,0 +1,159 @@ += 0; $i-- ) { + if ( $v[$i] === '9' ) { + $v[$i] = '0'; + } else { + $v[$i] = $v[$i] + 1; + return $v; + } + } + return '1' . $v; + } + + public function provideValidate() { + $badinteger = new ValidationException( 'test', '...', [], 'badinteger', [] ); + $belowminimum = new ValidationException( + 'test', '...', [], 'belowminimum', [ 'min' => 0, 'max' => 2, 'max2' => '' ] + ); + $abovemaximum = new ValidationException( + 'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => '' ] + ); + $abovemaximum2 = new ValidationException( + 'test', '...', [], 'abovemaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ] + ); + $abovehighmaximum = new ValidationException( + 'test', '...', [], 'abovehighmaximum', [ 'min' => 0, 'max' => 2, 'max2' => 4 ] + ); + $asWarn = function ( ValidationException $ex ) { + return [ $ex->getFailureCode() ] + $ex->getFailureData(); + }; + + $minmax = [ + IntegerDef::PARAM_MIN => 0, + IntegerDef::PARAM_MAX => 2, + ]; + $minmax2 = [ + IntegerDef::PARAM_MIN => 0, + IntegerDef::PARAM_MAX => 2, + IntegerDef::PARAM_MAX2 => 4, + ]; + $ignore = [ + IntegerDef::PARAM_IGNORE_RANGE => true, + ]; + $usehigh = [ 'useHighLimits' => true ]; + + return [ + [ '123', 123 ], + [ '-123', -123 ], + [ '000123', 123 ], + [ '000', 0 ], + [ '-0', 0 ], + [ (string)PHP_INT_MAX, PHP_INT_MAX ], + [ '0000' . PHP_INT_MAX, PHP_INT_MAX ], + [ (string)PHP_INT_MIN, PHP_INT_MIN ], + [ '-0000' . substr( PHP_INT_MIN, 1 ), PHP_INT_MIN ], + + 'Overflow' => [ self::plusOne( (string)PHP_INT_MAX ), $badinteger ], + 'Negative overflow' => [ '-' . self::plusOne( substr( PHP_INT_MIN, 1 ) ), $badinteger ], + + 'Float' => [ '1.5', $badinteger ], + 'Float (e notation)' => [ '1e1', $badinteger ], + 'Bad sign (space)' => [ ' 1', $badinteger ], + 'Bad sign (newline)' => [ "\n1", $badinteger ], + 'Bogus value' => [ 'foo', $badinteger ], + 'Bogus value (2)' => [ '1foo', $badinteger ], + 'Hex value' => [ '0x123', $badinteger ], + 'Newline' => [ "1\n", $badinteger ], + + 'Ok with range' => [ '1', 1, $minmax ], + 'Below minimum' => [ '-1', $belowminimum, $minmax ], + 'Below minimum, ignored' => [ '-1', 0, $minmax + $ignore, [], [ $asWarn( $belowminimum ) ] ], + 'Above maximum' => [ '3', $abovemaximum, $minmax ], + 'Above maximum, ignored' => [ '3', 2, $minmax + $ignore, [], [ $asWarn( $abovemaximum ) ] ], + 'Not above max2 but can\'t use it' => [ '3', $abovemaximum2, $minmax2, [] ], + 'Not above max2 but can\'t use it, ignored' + => [ '3', 2, $minmax2 + $ignore, [], [ $asWarn( $abovemaximum2 ) ] ], + 'Not above max2' => [ '3', 3, $minmax2, $usehigh ], + 'Above max2' => [ '5', $abovehighmaximum, $minmax2, $usehigh ], + 'Above max2, ignored' + => [ '5', 4, $minmax2 + $ignore, $usehigh, [ $asWarn( $abovehighmaximum ) ] ], + ]; + } + + public function provideNormalizeSettings() { + return [ + [ [], [] ], + [ + [ IntegerDef::PARAM_MAX => 2 ], + [ IntegerDef::PARAM_MAX => 2 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MAX2 => 2 ], + [], + ], + ]; + } + + public function provideDescribeSettings() { + return [ + 'Basic' => [ [], [], [] ], + 'Default' => [ + [ ParamValidator::PARAM_DEFAULT => 123 ], + [ 'default' => '123' ], + [ 'default' => [ 'value' => '123' ] ], + ], + 'Min' => [ + [ ParamValidator::PARAM_DEFAULT => 123, IntegerDef::PARAM_MIN => 0 ], + [ 'default' => '123', 'min' => 0 ], + [ 'default' => [ 'value' => '123' ], 'min' => [ 'min' => 0, 'max' => '', 'max2' => '' ] ], + ], + 'Max' => [ + [ IntegerDef::PARAM_MAX => 2 ], + [ 'max' => 2 ], + [ 'max' => [ 'min' => '', 'max' => 2, 'max2' => '' ] ], + ], + 'Max2' => [ + [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ 'max' => 2, 'max2' => 4 ], + [ 'max2' => [ 'min' => '', 'max' => 2, 'max2' => 4 ] ], + ], + 'Minmax' => [ + [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2 ], + [ 'min' => 0, 'max' => 2 ], + [ 'minmax' => [ 'min' => 0, 'max' => 2, 'max2' => '' ] ], + ], + 'Minmax2' => [ + [ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ 'min' => 0, 'max' => 2, 'max2' => 4 ], + [ 'minmax2' => [ 'min' => 0, 'max' => 2, 'max2' => 4 ] ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php new file mode 100644 index 0000000000..2bf25e56e7 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/LimitDefTest.php @@ -0,0 +1,49 @@ + true ]; + $max = [ IntegerDef::PARAM_MAX => 2 ]; + $max2 = [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ]; + + yield 'Max' => [ 'max', 2, $max ]; + yield 'Max, use high' => [ 'max', 2, $max, $useHigh ]; + yield 'Max2' => [ 'max', 2, $max2 ]; + yield 'Max2, use high' => [ 'max', 4, $max2, $useHigh ]; + } + + public function provideNormalizeSettings() { + return [ + [ [], [ IntegerDef::PARAM_MIN => 0 ] ], + [ + [ IntegerDef::PARAM_MAX => 2 ], + [ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MIN => 0 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ], + ], + [ + [ IntegerDef::PARAM_MAX2 => 2 ], + [ IntegerDef::PARAM_MIN => 0 ], + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php new file mode 100644 index 0000000000..dd97903acd --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PasswordDefTest.php @@ -0,0 +1,23 @@ + true ] ], + [ [ ParamValidator::PARAM_SENSITIVE => false ], [ ParamValidator::PARAM_SENSITIVE => true ] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php new file mode 100644 index 0000000000..dd690dee97 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/PresenceBooleanDefTest.php @@ -0,0 +1,31 @@ + 'foo' ], [], [] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php new file mode 100644 index 0000000000..bae2f026ec --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/StringDefTest.php @@ -0,0 +1,71 @@ + true, + ]; + $maxBytes = [ + StringDef::PARAM_MAX_BYTES => 4, + ]; + $maxChars = [ + StringDef::PARAM_MAX_CHARS => 2, + ]; + + return [ + 'Basic' => [ '123', '123' ], + 'Empty' => [ '', '' ], + 'Empty, required' => [ + '', + new ValidationException( 'test', '', [], 'missingparam', [] ), + $req, + ], + 'Empty, required, allowed' => [ '', '', $req, [ 'allowEmptyWhenRequired' => true ] ], + 'Max bytes, ok' => [ 'abcd', 'abcd', $maxBytes ], + 'Max bytes, exceeded' => [ + 'abcde', + new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ), + $maxBytes, + ], + 'Max bytes, ok (2)' => [ '😄', '😄', $maxBytes ], + 'Max bytes, exceeded (2)' => [ + '😭?', + new ValidationException( 'test', '', [], 'maxbytes', [ 'maxbytes' => 4, 'maxchars' => '' ] ), + $maxBytes, + ], + 'Max chars, ok' => [ 'ab', 'ab', $maxChars ], + 'Max chars, exceeded' => [ + 'abc', + new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ), + $maxChars, + ], + 'Max chars, ok (2)' => [ '😄😄', '😄😄', $maxChars ], + 'Max chars, exceeded (2)' => [ + '😭??', + new ValidationException( 'test', '', [], 'maxchars', [ 'maxbytes' => '', 'maxchars' => 2 ] ), + $maxChars, + ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php new file mode 100644 index 0000000000..8adf190d7a --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TimestampDefTest.php @@ -0,0 +1,90 @@ + 'DateTime' ]; + $formatMW = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => TS_MW ]; + + return [ + // We don't try to validate all formats supported by ConvertibleTimestamp, just + // some of the interesting ones. + 'ISO format' => [ '2018-02-03T04:05:06Z', $specific ], + 'ISO format with TZ' => [ '2018-02-03T00:05:06-04:00', $specific ], + 'ISO format without punctuation' => [ '20180203T040506', $specific ], + 'ISO format with ms' => [ '2018-02-03T04:05:06.999000Z', $specificMs ], + 'ISO format with ms without punctuation' => [ '20180203T040506.999', $specificMs ], + 'MW format' => [ '20180203040506', $specific ], + 'Generic format' => [ '2018-02-03 04:05:06', $specific ], + 'Generic format + GMT' => [ '2018-02-03 04:05:06 GMT', $specific ], + 'Generic format + TZ +0100' => [ '2018-02-03 05:05:06+0100', $specific ], + 'Generic format + TZ -01' => [ '2018-02-03 03:05:06-01', $specific ], + 'Seconds-since-epoch format' => [ '1517630706', $specific ], + 'Now' => [ 'now', $now ], + + // Warnings + 'Empty' => [ '', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ], + 'Zero' => [ '0', $now, [], [], [ [ 'unclearnowtimestamp' ] ] ], + + // Error handling + 'Bad value' => [ + 'bogus', + new ValidationException( 'test', 'bogus', [], 'badtimestamp', [] ), + ], + + // Formatting + '=> DateTime' => [ 'now', $now->timestamp, $formatDT ], + '=> TS_MW' => [ 'now', '20190605195042', $formatMW ], + '=> TS_MW as default' => [ 'now', '20190605195042', [], [ 'defaultFormat' => TS_MW ] ], + '=> TS_MW overriding default' + => [ 'now', '20190605195042', $formatMW, [ 'defaultFormat' => TS_ISO_8601 ] ], + ]; + } + + public function provideStringifyValue() { + $specific = new ConvertibleTimestamp( '20180203040506' ); + + return [ + [ '20180203040506', '2018-02-03T04:05:06Z' ], + [ $specific, '2018-02-03T04:05:06Z' ], + [ $specific->timestamp, '2018-02-03T04:05:06Z' ], + [ $specific, '20180203040506', [], [ 'stringifyFormat' => TS_MW ] ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php new file mode 100644 index 0000000000..fa86c79a05 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/TypeDefTestCase.php @@ -0,0 +1,193 @@ + $value ] ); + } + + /** + * Create an instance of the TypeDef subclass being tested + * + * @param SimpleCallbacks $callbacks From $this->getCallbacks() + * @param array $options Options array. + * @return TypeDef + */ + protected function getInstance( SimpleCallbacks $callbacks, array $options ) { + if ( static::$testClass === null ) { + throw new \LogicException( 'Either assign static::$testClass or override ' . __METHOD__ ); + } + + return new static::$testClass( $callbacks ); + } + + /** + * @dataProvider provideValidate + * @param mixed $value Value for getCallbacks() + * @param mixed|ValidationException $expect Expected result from TypeDef::validate(). + * If a ValidationException, it is expected that a ValidationException + * with matching failure code and data will be thrown. Otherwise, the return value must be equal. + * @param array $settings Settings array. + * @param array $options Options array + * @param array[] $expectConds Expected conditions reported. Each array is + * `[ $ex->getFailureCode() ] + $ex->getFailureData()`. + */ + public function testValidate( + $value, $expect, array $settings = [], array $options = [], array $expectConds = [] + ) { + $callbacks = $this->getCallbacks( $value, $options ); + $typeDef = $this->getInstance( $callbacks, $options ); + + if ( $expect instanceof ValidationException ) { + try { + $v = $typeDef->getValue( 'test', $settings, $options ); + $typeDef->validate( 'test', $v, $settings, $options ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ValidationException $ex ) { + $this->assertEquals( $expect->getFailureCode(), $ex->getFailureCode() ); + $this->assertEquals( $expect->getFailureData(), $ex->getFailureData() ); + } + } else { + $v = $typeDef->getValue( 'test', $settings, $options ); + $this->assertEquals( $expect, $typeDef->validate( 'test', $v, $settings, $options ) ); + } + + $conditions = []; + foreach ( $callbacks->getRecordedConditions() as $ex ) { + $conditions[] = array_merge( [ $ex->getFailureCode() ], $ex->getFailureData() ); + } + $this->assertSame( $expectConds, $conditions ); + } + + /** + * @return array|Iterable + */ + abstract public function provideValidate(); + + /** + * @dataProvider provideNormalizeSettings + * @param array $settings + * @param array $expect + * @param array $options Options array + */ + public function testNormalizeSettings( array $settings, array $expect, array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->normalizeSettings( $settings ) ); + } + + /** + * @return array|Iterable + */ + public function provideNormalizeSettings() { + return [ + 'Basic test' => [ [ 'param-foo' => 'bar' ], [ 'param-foo' => 'bar' ] ], + ]; + } + + /** + * @dataProvider provideGetEnumValues + * @param array $settings + * @param array|null $expect + * @param array $options Options array + */ + public function testGetEnumValues( array $settings, $expect, array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->getEnumValues( 'test', $settings, $options ) ); + } + + /** + * @return array|Iterable + */ + public function provideGetEnumValues() { + return [ + 'Basic test' => [ [], null ], + ]; + } + + /** + * @dataProvider provideStringifyValue + * @param mixed $value + * @param string|null $expect + * @param array $settings + * @param array $options Options array + */ + public function testStringifyValue( $value, $expect, array $settings = [], array $options = [] ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( $expect, $typeDef->stringifyValue( 'test', $value, $settings, $options ) ); + } + + /** + * @return array|Iterable + */ + public function provideStringifyValue() { + return [ + 'Basic test' => [ 123, '123' ], + ]; + } + + /** + * @dataProvider provideDescribeSettings + * @param array $settings + * @param array $expectNormal + * @param array $expectCompact + * @param array $options Options array + */ + public function testDescribeSettings( + array $settings, array $expectNormal, array $expectCompact, array $options = [] + ) { + $typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options ); + $this->assertSame( + $expectNormal, + $typeDef->describeSettings( 'test', $settings, $options ), + 'Normal mode' + ); + $this->assertSame( + $expectCompact, + $typeDef->describeSettings( 'test', $settings, [ 'compact' => true ] + $options ), + 'Compact mode' + ); + } + + /** + * @return array|Iterable + */ + public function provideDescribeSettings() { + yield 'Basic test' => [ [], [], [] ]; + + foreach ( $this->provideStringifyValue() as $i => $v ) { + yield "Default value (from provideStringifyValue data set \"$i\")" => [ + [ ParamValidator::PARAM_DEFAULT => $v[0] ] + ( $v[2] ?? [] ), + [ 'default' => $v[1] ], + [ 'default' => [ 'value' => $v[1] ] ], + $v[3] ?? [], + ]; + } + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php new file mode 100644 index 0000000000..c81647ca09 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDef/UploadDefTest.php @@ -0,0 +1,113 @@ + $value ] ); + } else { + return new SimpleCallbacks( [ 'test' => $value ] ); + } + } + + protected function getInstance( SimpleCallbacks $callbacks, array $options ) { + $ret = $this->getMockBuilder( UploadDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->setMethods( [ 'getIniSize' ] ) + ->getMock(); + $ret->method( 'getIniSize' )->willReturn( $options['inisize'] ?? 2 * 1024 * 1024 ); + return $ret; + } + + private function makeUpload( $err = UPLOAD_ERR_OK ) { + return new UploadedFile( [ + 'name' => 'example.txt', + 'type' => 'text/plain', + 'size' => 0, + 'tmp_name' => '...', + 'error' => $err, + ] ); + } + + public function testGetNoFile() { + $typeDef = $this->getInstance( + $this->getCallbacks( $this->makeUpload( UPLOAD_ERR_NO_FILE ), [] ), + [] + ); + + $this->assertNull( $typeDef->getValue( 'test', [], [] ) ); + $this->assertNull( $typeDef->getValue( 'nothing', [], [] ) ); + } + + public function provideValidate() { + $okFile = $this->makeUpload(); + $iniFile = $this->makeUpload( UPLOAD_ERR_INI_SIZE ); + $exIni = new ValidationException( + 'test', '', [], 'badupload-inisize', [ 'size' => 2 * 1024 * 1024 * 1024 ] + ); + + return [ + 'Valid upload' => [ $okFile, $okFile ], + 'Not an upload' => [ + 'bar', + new ValidationException( 'test', 'bar', [], 'badupload-notupload', [] ), + ], + + 'Too big (bytes)' => [ $iniFile, $exIni, [], [ 'inisize' => 2 * 1024 * 1024 * 1024 ] ], + 'Too big (k)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'k' ] ], + 'Too big (K)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'K' ] ], + 'Too big (m)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'm' ] ], + 'Too big (M)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'M' ] ], + 'Too big (g)' => [ $iniFile, $exIni, [], [ 'inisize' => '2g' ] ], + 'Too big (G)' => [ $iniFile, $exIni, [], [ 'inisize' => '2G' ] ], + + 'Form size' => [ + $this->makeUpload( UPLOAD_ERR_FORM_SIZE ), + new ValidationException( 'test', '', [], 'badupload-formsize', [] ), + ], + 'Partial' => [ + $this->makeUpload( UPLOAD_ERR_PARTIAL ), + new ValidationException( 'test', '', [], 'badupload-partial', [] ), + ], + 'No tmp' => [ + $this->makeUpload( UPLOAD_ERR_NO_TMP_DIR ), + new ValidationException( 'test', '', [], 'badupload-notmpdir', [] ), + ], + 'Can\'t write' => [ + $this->makeUpload( UPLOAD_ERR_CANT_WRITE ), + new ValidationException( 'test', '', [], 'badupload-cantwrite', [] ), + ], + 'Ext abort' => [ + $this->makeUpload( UPLOAD_ERR_EXTENSION ), + new ValidationException( 'test', '', [], 'badupload-phpext', [] ), + ], + 'Unknown' => [ + $this->makeUpload( -43 ), // Should be safe from ever being an UPLOAD_ERR_* constant + new ValidationException( 'test', '', [], 'badupload-unknown', [ 'code' => -43 ] ), + ], + + 'Validating null' => [ + null, + new ValidationException( 'test', '', [], 'badupload', [] ), + ], + ]; + } + + public function provideStringifyValue() { + return [ + 'Yeah, right' => [ $this->makeUpload(), null ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php new file mode 100644 index 0000000000..7675a8cc3f --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/TypeDefTest.php @@ -0,0 +1,79 @@ +getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] ) + ->getMockForAbstractClass(); + + $this->assertSame( [ 'foobar' ], $typeDef->normalizeSettings( [ 'foobar' ] ) ); + $this->assertNull( $typeDef->getEnumValues( 'foobar', [], [] ) ); + $this->assertSame( '123', $typeDef->stringifyValue( 'foobar', 123, [], [] ) ); + } + + public function testGetValue() { + $options = [ (object)[] ]; + + $callbacks = $this->getMockBuilder( Callbacks::class )->getMockForAbstractClass(); + $callbacks->expects( $this->once() )->method( 'getValue' ) + ->with( + $this->identicalTo( 'foobar' ), + $this->identicalTo( null ), + $this->identicalTo( $options ) + ) + ->willReturn( 'zyx' ); + + $typeDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ $callbacks ] ) + ->getMockForAbstractClass(); + + $this->assertSame( + 'zyx', + $typeDef->getValue( 'foobar', [ ParamValidator::PARAM_DEFAULT => 'foo' ], $options ) + ); + } + + public function testDescribeSettings() { + $typeDef = $this->getMockBuilder( TypeDef::class ) + ->setConstructorArgs( [ new SimpleCallbacks( [] ) ] ) + ->getMockForAbstractClass(); + + $this->assertSame( + [], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_TYPE => 'xxx' ], + [] + ) + ); + + $this->assertSame( + [ + 'default' => '123', + ], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_DEFAULT => 123 ], + [] + ) + ); + + $this->assertSame( + [ + 'default' => [ 'value' => '123' ], + ], + $typeDef->describeSettings( + 'foobar', + [ ParamValidator::PARAM_DEFAULT => 123 ], + [ 'compact' => true ] + ) + ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php new file mode 100644 index 0000000000..9eaddf6882 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileStreamTest.php @@ -0,0 +1,294 @@ +makeTemp( __FUNCTION__ ); + unlink( $filename ); + + $this->assertFileNotExists( $filename, 'sanity check' ); + $stream = new UploadedFileStream( $filename ); + } + + /** + * @expectedException RuntimeException + * @expectedExceptionMessage Failed to open file: + */ + public function testConstruct_notReadable() { + $filename = $this->makeTemp( __FUNCTION__ ); + + chmod( $filename, 0000 ); + $stream = new UploadedFileStream( $filename ); + } + + public function testCloseOnDestruct() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + $fp = TestingAccessWrapper::newFromObject( $stream )->fp; + $this->assertSame( 'f', fread( $fp, 1 ), 'sanity check' ); + unset( $stream ); + $this->assertFalse( AtEase::quietCall( 'fread', $fp, 1 ) ); + } + + public function testToString() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + // Always starts at the start of the stream + $stream->seek( 3 ); + $this->assertSame( 'foobar', (string)$stream ); + + // No exception when closed + $stream->close(); + $this->assertSame( '', (string)$stream ); + } + + public function testToString_Error() { + if ( !class_exists( \Error::class ) ) { + $this->markTestSkipped( 'No PHP Error class' ); + } + + // ... Yeah + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = $this->getMockBuilder( UploadedFileStream::class ) + ->setConstructorArgs( [ $filename ] ) + ->setMethods( [ 'getContents' ] ) + ->getMock(); + $stream->method( 'getContents' )->willReturnCallback( function () { + throw new \Error( 'Bogus' ); + } ); + $this->assertSame( '', (string)$stream ); + } + + public function testClose() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->close(); + + // Second call doesn't error + $stream->close(); + } + + public function testDetach() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + // We got the file descriptor + $fp = $stream->detach(); + $this->assertNotNull( $fp ); + $this->assertSame( 'f', fread( $fp, 1 ) ); + + // Stream operations now fail. + try { + $stream->seek( 0 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + + // Stream close doesn't affect the file descriptor + $stream->close(); + $this->assertSame( 'o', fread( $fp, 1 ) ); + + // Stream destruction doesn't affect the file descriptor + unset( $stream ); + $this->assertSame( 'o', fread( $fp, 1 ) ); + + // On a closed stream, we don't get a file descriptor + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertNull( $stream->detach() ); + } + + public function testGetSize() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + file_put_contents( $filename, 'foobarbaz' ); + $this->assertSame( 9, $stream->getSize() ); + + // Cached + file_put_contents( $filename, 'baz' ); + clearstatcache(); + $this->assertSame( 3, stat( $filename )['size'], 'sanity check' ); + $this->assertSame( 9, $stream->getSize() ); + + // No error if closed + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertSame( null, $stream->getSize() ); + + // No error even if the fd goes bad + $stream = new UploadedFileStream( $filename ); + fclose( TestingAccessWrapper::newFromObject( $stream )->fp ); + $this->assertSame( null, $stream->getSize() ); + } + + public function testSeekTell() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->seek( 2 ); + $this->assertSame( 2, $stream->tell() ); + $stream->seek( 2, SEEK_CUR ); + $this->assertSame( 4, $stream->tell() ); + $stream->seek( -5, SEEK_END ); + $this->assertSame( 1, $stream->tell() ); + $stream->read( 2 ); + $this->assertSame( 3, $stream->tell() ); + + $stream->close(); + try { + $stream->seek( 0 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + try { + $stream->tell(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testEof() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertFalse( $stream->eof() ); + $stream->getContents(); + $this->assertTrue( $stream->eof() ); + $stream->seek( -1, SEEK_END ); + $this->assertFalse( $stream->eof() ); + + // No error if closed + $stream = new UploadedFileStream( $filename ); + $stream->close(); + $this->assertTrue( $stream->eof() ); + + // No error even if the fd goes bad + $stream = new UploadedFileStream( $filename ); + fclose( TestingAccessWrapper::newFromObject( $stream )->fp ); + $this->assertInternalType( 'boolean', $stream->eof() ); + } + + public function testIsFuncs() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + $this->assertTrue( $stream->isSeekable() ); + $this->assertTrue( $stream->isReadable() ); + $this->assertFalse( $stream->isWritable() ); + + $stream->close(); + $this->assertFalse( $stream->isSeekable() ); + $this->assertFalse( $stream->isReadable() ); + $this->assertFalse( $stream->isWritable() ); + } + + public function testRewind() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $stream->seek( 2 ); + $this->assertSame( 2, $stream->tell() ); + $stream->rewind(); + $this->assertSame( 0, $stream->tell() ); + + $stream->close(); + try { + $stream->rewind(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testWrite() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + try { + $stream->write( 'foo' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testRead() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertSame( 'foo', $stream->read( 3 ) ); + $this->assertSame( 'bar', $stream->read( 10 ) ); + $this->assertSame( '', $stream->read( 10 ) ); + $stream->rewind(); + $this->assertSame( 'foobar', $stream->read( 10 ) ); + + $stream->close(); + try { + $stream->read( 1 ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testGetContents() { + $filename = $this->makeTemp( __FUNCTION__ ); + $stream = new UploadedFileStream( $filename ); + + $this->assertSame( 'foobar', $stream->getContents() ); + $this->assertSame( '', $stream->getContents() ); + $stream->seek( 3 ); + $this->assertSame( 'bar', $stream->getContents() ); + + $stream->close(); + try { + $stream->getContents(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + public function testGetMetadata() { + // Whatever + $filename = $this->makeTemp( __FUNCTION__ ); + $fp = fopen( $filename, 'r' ); + $expect = stream_get_meta_data( $fp ); + fclose( $fp ); + + $stream = new UploadedFileStream( $filename ); + $this->assertSame( $expect, $stream->getMetadata() ); + foreach ( $expect as $k => $v ) { + $this->assertSame( $v, $stream->getMetadata( $k ) ); + } + $this->assertNull( $stream->getMetadata( 'bogus' ) ); + + $stream->close(); + try { + $stream->getMetadata(); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php new file mode 100644 index 0000000000..80a74e7e74 --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTest.php @@ -0,0 +1,215 @@ +makeTemp( __FUNCTION__ ); + + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + + // getStream() fails for non-OK uploads + foreach ( [ + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + -42 + ] as $code ) { + $file2 = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false ); + try { + $file2->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } + + // getStream() works + $stream = $file->getStream(); + $this->assertInstanceOf( StreamInterface::class, $stream ); + $stream->seek( 0 ); + $this->assertSame( 'foobar', $stream->getContents() ); + + // Second call also works + $this->assertInstanceOf( StreamInterface::class, $file->getStream() ); + + // getStream() throws after move, and the stream is invalidated too + $file->moveTo( $filename . '.xxx' ); + try { + try { + $file->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'File has already been moved', $ex->getMessage() ); + } + try { + $stream->seek( 0 ); + $stream->getContents(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + } finally { + unlink( $filename . '.xxx' ); // Clean up + } + + // getStream() fails if the file is missing + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], true ); + try { + $file->getStream(); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Uploaded file is missing', $ex->getMessage() ); + } + } + + public function testMoveTo() { + // Successful move + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + $file->moveTo( $filename . '.xxx' ); + $this->assertFileNotExists( $filename ); + $this->assertFileExists( "$filename.xxx" ); + + // Fails on a second move attempt + $this->assertFileNotExists( "$filename.yyy", 'sanity check' ); + try { + $file->moveTo( $filename . '.yyy' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'File has already been moved', $ex->getMessage() ); + } + $this->assertFileNotExists( $filename ); + $this->assertFileExists( "$filename.xxx" ); + $this->assertFileNotExists( "$filename.yyy" ); + + // Fails if the file is missing + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => "$filename.aaa" ], false ); + $this->assertFileNotExists( "$filename.aaa", 'sanity check' ); + $this->assertFileNotExists( "$filename.bbb", 'sanity check' ); + try { + $file->moveTo( $filename . '.bbb' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Uploaded file is missing', $ex->getMessage() ); + } + $this->assertFileNotExists( "$filename.aaa" ); + $this->assertFileNotExists( "$filename.bbb" ); + + // Fails for non-upload file (when not flagged to ignore that) + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ] ); + try { + $file->moveTo( $filename . '.xxx' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Specified file is not an uploaded file', $ex->getMessage() ); + } + $this->assertFileExists( $filename ); + $this->assertFileNotExists( "$filename.xxx" ); + + // Fails for error uploads + $filename = $this->makeTemp( __FUNCTION__ ); + $this->assertFileExists( $filename, 'sanity check' ); + $this->assertFileNotExists( "$filename.xxx", 'sanity check' ); + foreach ( [ + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + -42 + ] as $code ) { + $file = new UploadedFile( [ 'error' => $code, 'tmp_name' => $filename ], false ); + try { + $file->moveTo( $filename . '.xxx' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + $this->assertFileExists( $filename ); + $this->assertFileNotExists( "$filename.xxx" ); + } + + // Move failure triggers exception + $filename = $this->makeTemp( __FUNCTION__, 'file1' ); + $filename2 = $this->makeTemp( __FUNCTION__, 'file2' ); + $this->assertFileExists( $filename, 'sanity check' ); + $file = new UploadedFile( [ 'error' => UPLOAD_ERR_OK, 'tmp_name' => $filename ], false ); + try { + $file->moveTo( $filename2 . DIRECTORY_SEPARATOR . 'foobar' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \PHPUnit\Framework\AssertionFailedError $ex ) { + throw $ex; + } catch ( RuntimeException $ex ) { + } + $this->assertFileExists( $filename ); + } + + public function testInfoMethods() { + $filename = $this->makeTemp( __FUNCTION__ ); + $file = new UploadedFile( [ + 'name' => 'C:\\example.txt', + 'type' => 'text/plain', + 'size' => 1025, + 'error' => UPLOAD_ERR_OK, + 'tmp_name' => $filename, + ], false ); + $this->assertSame( 1025, $file->getSize() ); + $this->assertSame( UPLOAD_ERR_OK, $file->getError() ); + $this->assertSame( 'C:\\example.txt', $file->getClientFilename() ); + $this->assertSame( 'text/plain', $file->getClientMediaType() ); + + // None of these are allowed to error + $file = new UploadedFile( [], false ); + $this->assertSame( null, $file->getSize() ); + $this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() ); + $this->assertSame( null, $file->getClientFilename() ); + $this->assertSame( null, $file->getClientMediaType() ); + + // "if none was provided" behavior, given that $_FILES often contains + // the empty string. + $file = new UploadedFile( [ + 'name' => '', + 'type' => '', + 'size' => 100, + 'error' => UPLOAD_ERR_NO_FILE, + 'tmp_name' => $filename, + ], false ); + $this->assertSame( null, $file->getClientFilename() ); + $this->assertSame( null, $file->getClientMediaType() ); + } + +} diff --git a/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php new file mode 100644 index 0000000000..6e1bd6adbb --- /dev/null +++ b/tests/phpunit/includes/libs/ParamValidator/Util/UploadedFileTestBase.php @@ -0,0 +1,82 @@ +isDir() ) { + rmdir( $file->getRealPath() ); + } else { + unlink( $file->getRealPath() ); + } + } + rmdir( self::$tmpdir ); + self::$tmpdir = null; + } + } + + protected static function assertTmpdir() { + if ( self::$tmpdir === null || !is_dir( self::$tmpdir ) ) { + self::fail( 'No temporary directory for ' . static::class ); + } + } + + /** + * @param string $prefix For tempnam() + * @param string $content Contents of the file + * @return string Filename + */ + protected function makeTemp( $prefix, $content = 'foobar' ) { + self::assertTmpdir(); + + $filename = tempnam( self::$tmpdir, $prefix ); + if ( $filename === false ) { + self::fail( 'Failed to create temporary file' ); + } + + self::assertSame( + strlen( $content ), + file_put_contents( $filename, $content ), + 'Writing test temporary file' + ); + + return $filename; + } + +}