diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 7a2e0ce7..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,12 +0,0 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username -open_collective: userfrosting # Replace with a single Open Collective username -ko_fi: lcharette # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 2ab6792f..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,2 +0,0 @@ -🛑 STOP! -Issues should be opened on the main repo : https://github.com/userfrosting/UserFrosting/issues diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index b1c7a3d5..b89df2be 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -22,7 +22,7 @@ jobs: name: PHPUnit Tests - ${{ matrix.php_versions }} - ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -53,7 +53,7 @@ jobs: - name: Upload coverage to Codecov if: github.event_name != 'schedule' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./_meta/coverage.xml diff --git a/.github/workflows/PHPStan.yml b/.github/workflows/PHPStan.yml index 017583ee..e0a0e1d2 100644 --- a/.github/workflows/PHPStan.yml +++ b/.github/workflows/PHPStan.yml @@ -19,7 +19,7 @@ jobs: name: PHPStan - ${{ matrix.php_versions }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -30,4 +30,4 @@ jobs: run: composer install --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: Run PHPStan - run: vendor/bin/phpstan analyse src/ tests/ + run: vendor/bin/phpstan analyse diff --git a/.gitignore b/.gitignore index f4859235..b51de1eb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,8 @@ _meta .phpunit.cache .phpdoc -# OS & Editors +# OS .DS_Store -*.komodoproject -.vscode # Testing artifacts tests/Cache/store diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..de32a0c6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "junstyle.php-cs-fixer", + "xdebug.php-debug", + "neilbrayfield.php-docblocker", + "bmewburn.vscode-intelephense-client", + "sanderronde.phpstan-vscode" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..78a16306 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Listen for XDebug", + "type": "php", + "request": "launch", + "port": 9000 + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e789da75 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "undot" + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..bf4e7d19 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,36 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "PHPUnit", + "type": "shell", + "options": { + "env": { + "XDEBUG_CONFIG": "idekey=VSCODE" + } + }, + "command": "printf '\\33c\\e[3J' && vendor/bin/phpunit --stop-on-failure --stop-on-error", + // "command": "printf '\\33c\\e[3J' && vendor/bin/phpunit --filter FormValidationArrayAdapterTest --stop-on-failure --stop-on-error --display-warnings", + + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "PHP CS Fixer", + "type": "shell", + "command": "vendor/bin/php-cs-fixer fix", + "problemMatcher": [] + }, + { + "label": "PHPStan", + "type": "shell", + "command": "vendor/bin/phpstan analyse", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c5b804..34af8007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [5.2.0](https://github.com/userfrosting/framework/compare/5.0.1...5.2.0) +## [5.1.2](https://github.com/userfrosting/framework/compare/5.1.1...5.1.2) +- Add Slim [type hinting](https://github.com/slimphp/Slim/releases/tag/4.14.0) & bump minimum slim version + +## [5.1.1](https://github.com/userfrosting/framework/compare/5.1.0...5.1.1) +- Fix InputArray in Fortress (See https://github.com/userfrosting/UserFrosting/issues/1251) +- Update PHPStan config +- Add VSCode config + ## [5.1.0](https://github.com/userfrosting/framework/compare/5.0.0...5.1.0) - Removed Assets - Drop PHP 8.1 support, add PHP 8.3 support diff --git a/README.md b/README.md index cbba8253..efd2da5b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,6 @@ See main [UserFrosting Documentation](https://learn.userfrosting.com) and docume ## [License](LICENSE.md) -## [Style Guide](STYLE-GUIDE.md) +## [Style Guide](https://github.com/userfrosting/.github/blob/main/.github/STYLE-GUIDE.md) ## [Testing](RUNNING_TESTS.md) diff --git a/STYLE-GUIDE.md b/STYLE-GUIDE.md deleted file mode 100644 index 76438af6..00000000 --- a/STYLE-GUIDE.md +++ /dev/null @@ -1,50 +0,0 @@ -# Style guide for contributing - -## PHP - -All PHP contributions must adhere to [PSR-1](http://www.php-fig.org/psr/psr-1/) and [PSR-2](http://www.php-fig.org/psr/psr-2/) specifications. - -In addition: - -### Documentation - -- All documentation blocks must adhere to the [PHPDoc](https://phpdoc.org/) format and syntax. -- All PHP files MUST contain the following documentation block immediately after the opening `directory . $this->ds . $folder . (count($parts) > 0 ? implode($this->ds, $parts) . $this->ds : '') . $hash; diff --git a/src/Fortress/Transformer/RequestDataTransformer.php b/src/Fortress/Transformer/RequestDataTransformer.php index 2871e24c..cede6970 100644 --- a/src/Fortress/Transformer/RequestDataTransformer.php +++ b/src/Fortress/Transformer/RequestDataTransformer.php @@ -14,6 +14,7 @@ use HTMLPurifier; use HTMLPurifier_Config; +use Illuminate\Support\Arr; use UserFrosting\Fortress\FortressException; use UserFrosting\Fortress\RequestSchema\RequestSchemaInterface; @@ -50,28 +51,30 @@ public function transform( // Get schema fields $schemaFields = $schema->all(); - // 1. Perform sequence of transformations on each field. - $transformedData = []; - foreach ($data as $name => $value) { - // Handle values not listed in the schema. Pass not found to - // transformField if allow is set, transformField will return the value as is. - if (array_key_exists($name, $schemaFields) || $onUnexpectedVar === 'allow') { - $transformedData[$name] = $this->transformField($schema, $name, $value); - } elseif ($onUnexpectedVar === 'error') { - $e = new FortressException("The field '$name' is not a valid input field."); - - throw $e; + // 1. If we skip or error on unexpected var, purge unwanted fields + if ($onUnexpectedVar === 'skip' || $onUnexpectedVar === 'error') { + $data = $this->purge($schemaFields, $data, $onUnexpectedVar === 'error'); + } + + // 2° Apply each transformation rules. Skip we field as no transformation rules + foreach ($schemaFields as $field => $rules) { + if (!isset($rules['transformations']) || !is_array($rules['transformations'])) { + continue; } + + $data = $this->applyNestedTransformation($rules['transformations'], explode('.', $field), $data); } - // 2. Get default values for any fields missing from $data. Especially useful for checkboxes, etc which are not submitted when they are unchecked + // 3. Get default values for any fields missing from $data. Especially + // useful for checkboxes, etc which are not submitted when they are + // unchecked foreach ($schemaFields as $fieldName => $field) { - if (!isset($transformedData[$fieldName]) && isset($field['default'])) { - $transformedData[$fieldName] = $field['default']; + if (!isset($data[$fieldName]) && isset($field['default'])) { + $data[$fieldName] = $field['default']; } } - return $transformedData; + return $data; } /** @@ -92,29 +95,192 @@ public function transformField(RequestSchemaInterface $schema, string $name, mix return $value; } else { // Field exists in schema, so apply sequence of transformations - $transformedValue = $value; - foreach ($fieldParameters['transformations'] as $transformation) { - $transformedValue = match (strtolower($transformation)) { - 'purify' => $this->purify($transformedValue), - 'escape' => $this->escapeHtmlCharacters($transformedValue), - 'purge' => $this->purgeHtmlCharacters($transformedValue), - 'trim' => $this->trim($transformedValue), - default => $transformedValue, - }; + return $this->applyTransformation($fieldParameters['transformations'], $value); + } + } + + /** + * Apply transformations to a set of nested keys. + * + * @param string[] $rules Rules to apply + * @param string[] $keys Nested keys. Dot notation keys (eg. 'foo.bar') + * represented as an array (eg. array('foo', 'bar')) + * @param mixed $data The data to transform + * + * @return mixed The transformed data + */ + protected function applyNestedTransformation(array $rules, array $keys, mixed $data): mixed + { + $key = array_shift($keys); + + // Parse each element in non-associative array + if ($key === '*' && is_array($data)) { + foreach ($data as $id => $row) { + $data[$id] = $this->applyNestedTransformation($rules, $keys, $row); + } + + return $data; + } + + // Reached the deepest level. Transform the data directly. + if ($key === null) { + return $this->applyTransformation($rules, $data); + } + + // If data don't exist for this key, can't transform what doesn't exist. + if (!isset($data[$key])) { + return $data; + } + + // Reached last key, and data exist, apply transformation to the key. + if (count($keys) === 0) { + $data[$key] = $this->applyTransformation($rules, $data[$key]); + + return $data; + } + + // Dig down another level. + $data[$key] = $this->applyNestedTransformation($rules, $keys, $data[$key]); + + return $data; + } + + /** + * Apply rules to a set of values. + * + * @param string[] $rules The rules to apply + * @param mixed $value The value to transform + * + * @return mixed The transformed value + */ + protected function applyTransformation(array $rules, mixed $value): mixed + { + foreach ($rules as $transformation) { + $value = match (strtolower($transformation)) { + 'purify' => $this->purify($value), + 'escape' => $this->escapeHtmlCharacters($value), + 'purge' => $this->purgeHtmlCharacters($value), + 'trim' => $this->trim($value), + default => $value, + }; + } + + return $value; + } + + /** + * Purge all fields not present the schema fields list. + * + * @param array $schemaFields The fields from the schema to keep. + * @param mixed[] $data The data to purge + * @param bool $throw If true, a FortressException will be thrown if something to purge is found + * + * @throws FortressException If $throw is true and we found something to purge + * + * @return mixed[] The purged data + */ + protected function purge(array $schemaFields, array $data, bool $throw = false): array + { + // N.B.: The '*' wildcard in the schema fields makes it difficult to + // fetch everything we need to keep. Instead, we use double negation : + // It's easier to remove the one we want to keep, then compare this list + // with the original. Plus It will allow to throw exception with the + // extra field. + + // First, we remove all rules, as we don't need them and don't want to + // dot the nested rules. + $fields = array_flip(array_keys($schemaFields)); + + // Then, we need to remove duplicate and overlaps. For example, `Foo.*` + // and `Foo` overlaps. + $fields = Arr::dot(Arr::undot($fields)); + + // Next, find all data that need to be purged. + $toPurge = $data; + foreach ($fields as $field => $rules) { + $toPurge = $this->purgeParts(explode('.', $field), $toPurge); + } + + // Throw exception if we have fields to purge and onUnexpectedVar + // is set to error, continue otherwise (if it's skip) + if ($throw && count($toPurge) > 0) { + $fields = implode(', ', array_keys(Arr::dot($toPurge))); + + throw new FortressException("The fields '$fields' are not a valid input field."); + } + + // Finally we loop again using the same method to apply the purge + // with the converted '*' wildcard to the original data. However, + // this time we use the field to purge instead of the schema data. + $dotToForget = Arr::dot($toPurge); + foreach ($dotToForget as $field => $value) { + $data = $this->purgeParts(explode('.', $field), $data); + } + + return $data; + } + + /** + * Purge a set of nested keys. + * + * @param string[] $keys Nested keys. Dot notation keys (eg. 'foo.bar') + * represented as an array (eg. array('foo', 'bar')) + * @param mixed[] $data The data to purge from + * + * @return mixed[] The purged data + */ + protected function purgeParts(array $keys, array $data): array + { + $key = array_shift($keys); + + // Parse each element in non-associative array + if ($key === '*') { + foreach ($data as $id => $row) { + // Do we need to do another level deeper? + if (is_array($row)) { + $data[$id] = $this->purgeParts($keys, $row); + + // Delete empty array + if (count($data[$id]) === 0) { + unset($data[$id]); + } + } else { + unset($data[$id]); + } } - return $transformedValue; + return $data; + } + + // If data don't exist for this key, can't transform what doesn't exist. + if ($key === null || !isset($data[$key])) { + return $data; + } + + // Reached the last key, and data exist, remove it. + if (count($keys) === 0) { + unset($data[$key]); + + return $data; } + + // Last resort, we dig into another level + $data[$key] = $this->purgeParts($keys, $data[$key]); + if (count($data[$key]) === 0) { + unset($data[$key]); + } + + return $data; } /** * Autodetect if a field is an array or scalar, and filter appropriately. * - * @param string|string[] $value + * @param mixed $value * - * @return string|string[] + * @return mixed */ - protected function escapeHtmlCharacters(string|array $value): string|array + protected function escapeHtmlCharacters(mixed $value): mixed { if (is_array($value)) { return filter_var_array($value, FILTER_SANITIZE_SPECIAL_CHARS); // @phpstan-ignore-line @@ -126,14 +292,19 @@ protected function escapeHtmlCharacters(string|array $value): string|array /** * Autodetect if a field is an array or scalar, and filter appropriately. * - * @param string|string[] $value + * @param mixed $value * - * @return string|string[] + * @return mixed */ - protected function purgeHtmlCharacters(string|array $value): string|array + protected function purgeHtmlCharacters(mixed $value): mixed { if (is_array($value)) { - return array_map('strip_tags', $value); + return $this->arrayMapRecursive('strip_tags', $value); + } + + // Nothing to purge if it's not a string + if (!is_string($value)) { + return $value; } return strip_tags($value); @@ -142,14 +313,19 @@ protected function purgeHtmlCharacters(string|array $value): string|array /** * Autodetect if a field is an array or scalar, and filter appropriately. * - * @param string|string[] $value + * @param mixed $value * - * @return string|string[] + * @return mixed */ - protected function trim(string|array $value): string|array + protected function trim(mixed $value): mixed { if (is_array($value)) { - return array_map('trim', $value); + return $this->arrayMapRecursive('trim', $value); + } + + // Nothing to purge if it's not a string + if (!is_string($value)) { + return $value; } return trim($value); @@ -158,16 +334,41 @@ protected function trim(string|array $value): string|array /** * Autodetect if a field is an array or scalar, and filter appropriately. * - * @param string|string[] $value + * @param mixed $value * - * @return string|string[] + * @return mixed */ - protected function purify(string|array $value): string|array + protected function purify(mixed $value): mixed { if (is_array($value)) { - return array_map([$this->purifier, 'purify'], $value); + return $this->arrayMapRecursive([$this->purifier, 'purify'], $value); + } + + // Nothing to purge if it's not a string + if (!is_string($value)) { + return $value; } return $this->purifier->purify($value); } + + /** + * Applies the callback to the elements of the given arrays recursively. + * Required to apply transformation on multidimensional arrays. + * + * @see https://stackoverflow.com/a/39637749/445757 + * + * @param callable $callback + * @param mixed[] $array + * + * @return mixed[] + */ + protected function arrayMapRecursive(callable $callback, array $array): array + { + $func = function ($item) use (&$func, &$callback) { + return is_array($item) ? array_map($func, $item) : call_user_func($callback, $item); + }; + + return array_map($func, $array); + } } diff --git a/src/Routes/RouteDefinitionInterface.php b/src/Routes/RouteDefinitionInterface.php index 160b726c..3848adaf 100644 --- a/src/Routes/RouteDefinitionInterface.php +++ b/src/Routes/RouteDefinitionInterface.php @@ -16,6 +16,8 @@ interface RouteDefinitionInterface { /** * Register routes to the Slim App. + * + * @param App<\DI\Container> $app */ public function register(App $app): void; } diff --git a/src/ServicesProvider/FrameworkService.php b/src/ServicesProvider/FrameworkService.php index 1e8c276e..728f680c 100644 --- a/src/ServicesProvider/FrameworkService.php +++ b/src/ServicesProvider/FrameworkService.php @@ -77,7 +77,7 @@ public function register(): array /** * Load and register all routes. * - * @param SlimApp $app + * @param SlimApp<\DI\Container> $app * @param SprinkleRoutesRepository $routesRepository */ protected function registerRoutes(SlimApp $app, SprinkleRoutesRepository $routesRepository): void @@ -91,7 +91,7 @@ protected function registerRoutes(SlimApp $app, SprinkleRoutesRepository $routes * Load and register all middlewares. * Last registered middleware is executed first. * - * @param SlimApp $app + * @param SlimApp<\DI\Container> $app * @param SprinkleMiddlewareRepository $middlewareRepository */ protected function registerMiddlewares(SlimApp $app, SprinkleMiddlewareRepository $middlewareRepository): void diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 292e9335..554115cc 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -34,6 +34,8 @@ class TestCase extends BaseTestCase /** * The Slim App Instance. + * + * @var App<\DI\Container> */ protected App $app; diff --git a/src/UniformResourceLocator/StreamWrapper/StreamInterface.php b/src/UniformResourceLocator/StreamWrapper/StreamInterface.php index a43d8c0b..597765dc 100644 --- a/src/UniformResourceLocator/StreamWrapper/StreamInterface.php +++ b/src/UniformResourceLocator/StreamWrapper/StreamInterface.php @@ -163,7 +163,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ * * @see http://php.net/manual/streamwrapper.stream-read.php * - * @param int<0, max> $count How many bytes of data from the current position should be returned. + * @param int<1, max> $count How many bytes of data from the current position should be returned. * * @return string|false If there are less than count bytes available, return as many as are available. If no more data is available, return either false or an empty string. */ diff --git a/src/UserFrosting.php b/src/UserFrosting.php index 7d56f231..283d7861 100644 --- a/src/UserFrosting.php +++ b/src/UserFrosting.php @@ -23,14 +23,14 @@ final class UserFrosting extends Cupcake { /** - * @var App The Slim application instance. + * @var App<\DI\Container> The Slim application instance. */ protected App $app; /** * Return the underlying Slim App instance, if available. * - * @return App + * @return App<\DI\Container> */ public function getApp(): App { @@ -42,7 +42,7 @@ public function getApp(): App */ protected function initiateApp(): void { - /** @var App */ + /** @var App<\DI\Container> */ $app = $this->ci->get(App::class); // Dispatch AppInitiatedEvent diff --git a/tests/Fortress/Transformer/RequestDataTransformerTest.php b/tests/Fortress/Transformer/RequestDataTransformerTest.php index f07a2520..ac9a40be 100644 --- a/tests/Fortress/Transformer/RequestDataTransformerTest.php +++ b/tests/Fortress/Transformer/RequestDataTransformerTest.php @@ -35,12 +35,32 @@ public function setUp(): void $this->transformer = new RequestDataTransformer(); } - public function testTransformFieldForNotInSchema(): void + public function testTransformField(): void { $schema = new RequestSchema([ - 'email' => [], + 'email' => [ + 'transformations' => ['purge', 'trim'], + ], + ]); + + $result = $this->transformer->transformField($schema, 'email', ' foo@bar.com '); + $this->assertSame('foo@bar.com', $result); + } + + public function testTransformFieldButNoRules(): void + { + $schema = new RequestSchema([ + 'email' => [], ]); + $result = $this->transformer->transformField($schema, 'email', ' foo@bar.com '); + $this->assertSame(' foo@bar.com ', $result); + } + + public function testTransformFieldForNotInSchema(): void + { + $schema = new RequestSchema([]); + $result = $this->transformer->transformField($schema, 'foo', 'bar'); $this->assertSame('bar', $result); } @@ -121,7 +141,7 @@ public function testBasicWithOnUnexpectedVarError(): void // Set expectations $this->expectException(Exception::class); - $this->expectExceptionMessage("The field 'admin' is not a valid input field."); + $this->expectExceptionMessage("The fields 'admin' are not a valid input field."); // Act $this->transformer->transform($schema, $rawInput, 'error'); @@ -142,7 +162,7 @@ public function testTrim(): void // Assert $transformedData = [ 'display_name' => 'THE GREATEST', - 'email' => 'david@owlfancy.com', + 'email' => 'david@owlfancy.com', // Default value ]; $this->assertSame($transformedData, $result); @@ -277,6 +297,24 @@ public function testPurifyWithArrayValue(): void $this->assertSame($transformedData, $result); } + public function testPurifyForNonString(): void + { + // Act + $rawInput = [ + 'puppies' => true, + ]; + + $result = $this->transformer->transform($this->schema, $rawInput, 'skip'); + + // Assert + $transformedData = [ + 'puppies' => true, + 'email' => 'david@owlfancy.com', + ]; + + $this->assertSame($transformedData, $result); + } + /** * default transformer. */ @@ -297,4 +335,321 @@ public function testUnsupportedTransformation(): void $this->assertSame($transformedData, $result); } + + public function testInputArray(): void + { + // Data + $rawInput = [ + 'InputArray' => [ + 20, + '>10<
', + ' whitespace ', + ] + ]; + + // Schema + $schema = new RequestSchema($this->basePath . '/InputArray.yaml'); + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations + $transformedData = [ + 'InputArray' => [ + 20, + '>10<', + 'whitespace', + ] + ]; + + $this->assertSame($transformedData, $result); + } + + public function testMultidimensional(): void + { + // Data + $rawInput = [ + 'Settings' => [ + [ + 'name' => ' Foo ', + 'threshold' => ' 20 ', + ], + [ + 'name' => 'Bar
', + 'threshold' => '73
', + ], + ] + ]; + + // Schema + $schema = new RequestSchema($this->basePath . '/Multidimensional.yaml'); + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations + // - threshold has Trim + // - name has purge + $transformedData = [ + 'Settings' => [ + [ + 'name' => ' Foo ', + 'threshold' => '20', // Only threshold is trimmed + ], + [ + 'name' => 'Bar ', // Only name is purged, but since we don't trim, the space will stay ! + 'threshold' => '73
', + ], + ] + ]; + $this->assertSame($transformedData, $result); + } + + /** + * Same as previous test, but the transformation are at the root "Settings". + * Per element rules will be overwritten by the root version. Everything + * will be purged & trimmed. + */ + public function testMultidimensionalRootLevel(): void + { + // Data + $rawInput = [ + 'Settings' => [ + [ + 'name' => ' Foo ', + 'threshold' => ' 20 ', + ], + [ + 'name' => 'Bar
', + 'threshold' => '73
', + ], + ] + ]; + + // Schema + $schema = new RequestSchema([ + 'Settings' => [ + 'transformations' => ['purge', 'trim'], + ], + 'Settings.*.threshold' => [ + 'transformations' => ['trim'], + ], + 'Settings.*.name' => [ + 'transformations' => ['purge'], + ], + ]); + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations + $transformedData = [ + 'Settings' => [ + [ + 'name' => 'Foo', + 'threshold' => '20', + ], + [ + 'name' => 'Bar', + 'threshold' => '73', + ], + ] + ]; + $this->assertSame($transformedData, $result); + } + + public function testMultidimensionalPurge(): void + { + // Schema - Use two multidimensional field : Named has a name only, + // Colored has color only. + $schema = new RequestSchema([ + 'Named' => [], + 'Named.*.name' => [], + 'Colored.*.color' => [], + ]); + + // Data + $rawInput = [ + 'Named' => [ + [ + 'name' => 'Joe', + 'color' => 'blue', + ], + [ + 'name' => 'Kathy', + 'color' => 'pink', + 'description' => 'Something', + ], + ], + 'Colored' => [ + [ + 'name' => 'John', + 'color' => 'red', + ] + ], + ]; + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations - Again, Named has a name only, Colored has color only + $transformedData = [ + 'Named' => [ + ['name' => 'Joe'], + ['name' => 'Kathy'], + ], + 'Colored' => [ + ['color' => 'red'] + ], + ]; + $this->assertSame($transformedData, $result); + } + + /** + * Same as the previous test, but 'Named' doesn't have a root field in the + * schema, to prove both situation are the same + */ + public function testMultidimensionalPurgeNoRoot(): void + { + $schema = new RequestSchema([ + 'Named.*.name' => [], + 'Colored.*.color' => [], + ]); + + // Data + $rawInput = [ + 'Named' => [ + [ + 'name' => 'Joe', + 'color' => 'blue', + ], + [ + 'name' => 'Kathy', + 'color' => 'pink', + 'description' => 'Something', + ], + ], + 'Colored' => [ + [ + 'name' => 'John', + 'color' => 'red', + ] + ], + ]; + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations - Again, Named has a name only, Colored has color only + $transformedData = [ + 'Named' => [ + ['name' => 'Joe'], + ['name' => 'Kathy'], + ], + 'Colored' => [ + ['color' => 'red'] + ], + ]; + $this->assertSame($transformedData, $result); + } + + public function testPurgeNotInSchema(): void + { + // Use empty schema - All input will be purged + $schema = new RequestSchema([]); + + // Data + $rawInput = [ + 'Named' => [ + [ + 'name' => 'Joe', + 'color' => 'blue', + ], + [ + 'name' => 'Kathy', + 'color' => 'pink', + 'description' => 'Something', + ], + ], + 'Colored' => [ + [ + 'name' => 'John', + 'color' => 'red', + ] + ], + 'FooBar' => true, // Will be purged + 'FooBarArray' => [true, false], // Will be purged + ]; + + // Act and assert + $result = $this->transformer->transform($schema, $rawInput); + $this->assertSame([], $result); + } + + public function testInputArrayPurge(): void + { + // Schema + $schema = new RequestSchema([ + 'InputArray.*' => [], + ]); + + // Data + $rawInput = [ + 'InputArray' => [ + '10', + 20, + true, + ], + 'Foo' => true // Will be purged + ]; + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations + $transformedData = [ + 'InputArray' => [ + '10', + 20, + true, + ] + ]; + + $this->assertSame($transformedData, $result); + } + + /** + * Same as previous, but without the '*' wildcard + */ + public function testInputArrayRootPurge(): void + { + // Schema + $schema = new RequestSchema([ + 'InputArray' => [], + ]); + + // Data + $rawInput = [ + 'InputArray' => [ + '10', + 20, + true, + ], + 'Foo' => true // Will be purged + ]; + + // Act + $result = $this->transformer->transform($schema, $rawInput); + + // Set expectations + $transformedData = [ + 'InputArray' => [ + '10', + 20, + true, + ] + ]; + + $this->assertSame($transformedData, $result); + } } diff --git a/tests/Fortress/Validator/ServerSideValidatorTest.php b/tests/Fortress/Validator/ServerSideValidatorTest.php index c9c28e4b..d6c92877 100644 --- a/tests/Fortress/Validator/ServerSideValidatorTest.php +++ b/tests/Fortress/Validator/ServerSideValidatorTest.php @@ -20,11 +20,13 @@ class ServerSideValidatorTest extends TestCase { + protected string $basePath; protected Translator $translator; protected ServerSideValidator $validator; public function setUp(): void { + $this->basePath = __DIR__.'/../data'; $this->translator = new Translator(new DictionaryStub()); $this->validator = new ServerSideValidator($this->translator); } @@ -927,4 +929,118 @@ public function testValidateWithNoValidatorMessage(): void $this->assertNotEmpty($errors); $this->assertSame(['User Name Invalid'], $errors['user_name']); } + + public function testInputArray(): void + { + // Get schema + $schema = new RequestSchema($this->basePath.'/InputArray.yaml'); + + // Test no errors + $errors = $this->validator->validate($schema, [ + 'InputArray' => [20, 73] + ]); + $this->assertEmpty($errors); + + // Test each element must be integer + $errors = $this->validator->validate($schema, [ + 'InputArray' => [20, 'string'] + ]); + $this->assertNotEmpty($errors); + $this->assertSame(['Input elements must be integers.'], $errors['InputArray.*']); + + // Test the input itself is required + $errors = $this->validator->validate($schema, []); + $this->assertNotEmpty($errors); + $this->assertSame(['InputArray is required', 'InputArray must be an array'], $errors['InputArray']); + + // Test the input itself must be an array + $errors = $this->validator->validate($schema, [ + 'InputArray' => 20 + ]); + $this->assertNotEmpty($errors); + $this->assertSame(['InputArray must be an array'], $errors['InputArray']); + } + + public function testMultidimensional(): void + { + // Get schema + $schema = new RequestSchema($this->basePath.'/Multidimensional.yaml'); + + // String threshold + $errors = $this->validator->validate($schema, [ + 'Settings' => [ + [ + 'name' => 'Foo', + 'threshold' => 'eleven', // Bad + ], + [ + 'name' => 'Bar', + 'threshold' => 73, + ], + ] + ]); + $this->assertCount(1, $errors); + $this->assertSame(['Value must be max 100', 'Input elements must be integers'], $errors['Settings.*.threshold']); + + // Max threshold + $errors = $this->validator->validate($schema, [ + 'Settings' => [ + [ + 'name' => 'Foo', + 'threshold' => 11, + ], + [ + 'name' => 'Bar', + 'threshold' => 730, // Bad, to high + ], + ] + ]); + $this->assertCount(1, $errors); + $this->assertSame(['Value must be max 100'], $errors['Settings.*.threshold']); + + // String int are accepted + $errors = $this->validator->validate($schema, [ + 'Settings' => [ + [ + 'name' => 'Foo', + 'threshold' => '11', // Actually good + ], + [ + 'name' => 'Bar', + 'threshold' => '73', // Actually good + ], + ] + ]); + $this->assertCount(0, $errors); + + // Required name, but Threshold is optional + $errors = $this->validator->validate($schema, [ + 'Settings' => [ + [ + 'name' => 'Foo', + 'threshold' => 11, + ], + [ + 'threshold' => 73, // Bad, missing name + ], + [ + 'name' => 'Foo Bar', + ], + ] + ]); + $this->assertCount(1, $errors); + $this->assertSame(['Each settings must have a name'], $errors['Settings.*.name']); + + // Test the Settings input itself is required + $errors = $this->validator->validate($schema, []); + $this->assertNotEmpty($errors); + $this->assertSame(['Settings array is required', 'Settings must be an input array'], $errors['Settings']); + + // Test the input itself must be an array + $errors = $this->validator->validate($schema, [ + 'Settings' => 20 + ]); + $this->assertNotEmpty($errors); + $this->assertSame(['Settings must be an input array'], $errors['Settings']); + } } diff --git a/tests/Fortress/data/InputArray.yaml b/tests/Fortress/data/InputArray.yaml new file mode 100644 index 00000000..0d038d4f --- /dev/null +++ b/tests/Fortress/data/InputArray.yaml @@ -0,0 +1,15 @@ +InputArray: + validators: + required: + message: InputArray is required + array: + message: InputArray must be an array + +InputArray.*: + validators: + integer: + message: Input elements must be integers. + transformations: + - purge + - trim + \ No newline at end of file diff --git a/tests/Fortress/data/Multidimensional.yaml b/tests/Fortress/data/Multidimensional.yaml new file mode 100644 index 00000000..5fae4110 --- /dev/null +++ b/tests/Fortress/data/Multidimensional.yaml @@ -0,0 +1,25 @@ +Settings: + validators: + required: + message: Settings array is required + array: + message: Settings must be an input array + +Settings.*.threshold: + validators: + range: + max: 100 + message: Value must be max 100 + integer: + message: Input elements must be integers + transformations: + - trim + +Settings.*.name: + validators: + required: + message: Each settings must have a name + string: + message: Name elements must be string + transformations: + - purge \ No newline at end of file diff --git a/tests/TestSprinkle/TestRoutesDefinitions.php b/tests/TestSprinkle/TestRoutesDefinitions.php index 20355a02..3675f2b9 100644 --- a/tests/TestSprinkle/TestRoutesDefinitions.php +++ b/tests/TestSprinkle/TestRoutesDefinitions.php @@ -15,6 +15,9 @@ class TestRoutesDefinitions implements RouteDefinitionInterface { + /** + * @param App<\DI\Container> $app + */ public function register(App $app): void { $app->get('/foo', [TestController::class, 'index']); diff --git a/tests/Testing/CustomAssertionsTraitTest.php b/tests/Testing/CustomAssertionsTraitTest.php index 69fcffe5..39e33237 100644 --- a/tests/Testing/CustomAssertionsTraitTest.php +++ b/tests/Testing/CustomAssertionsTraitTest.php @@ -17,6 +17,7 @@ use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use UserFrosting\Testing\CustomAssertionsTrait; /** @@ -34,9 +35,14 @@ class CustomAssertionsTraitTest extends TestCase public function testAssertResponse(): void { + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn('foo bar') + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->once()->andReturn('foo bar') + ->shouldReceive('getBody')->once()->andReturn($stream) ->getMock(); $this->assertResponse('foo bar', $response); @@ -55,9 +61,14 @@ public function testAssertResponseStatus(): void /** @depends testAssertJsonEquals */ public function testAssertJsonResponse(): void { + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn($this->json) + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->times(2)->andReturn($this->json) + ->shouldReceive('getBody')->times(2)->andReturn($stream) ->getMock(); $array = ['result' => ['foo' => true, 'bar' => false, 'list' => ['foo', 'bar']]]; @@ -68,9 +79,14 @@ public function testAssertJsonResponse(): void /** @depends testAssertJsonNotEquals */ public function testAssertNotJsonResponse(): void { + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn($this->json) + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->times(3)->andReturn($this->json) + ->shouldReceive('getBody')->times(3)->andReturn($stream) ->getMock(); $this->assertNotJsonResponse(['foo'], $response); @@ -96,9 +112,14 @@ public function testAssertJsonNotEquals(): void public function testAssertJsonEqualsWithResponse(): void { + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn($this->json) + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->times(3)->andReturn($this->json) + ->shouldReceive('getBody')->times(3)->andReturn($stream) ->getMock(); $array = ['result' => ['foo' => true, 'bar' => false, 'list' => ['foo', 'bar']]]; @@ -116,9 +137,14 @@ public function testAssertJsonStructure(): void public function testAssertJsonStructureWithResponse(): void { + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn($this->json) + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->times(2)->andReturn($this->json) + ->shouldReceive('getBody')->times(2)->andReturn($stream) ->getMock(); $this->assertJsonStructure(['result'], $response); @@ -140,9 +166,14 @@ public function testAssertJsonCount(): void public function testAssertJsonCountWithResponse(): void { + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn($this->json) + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->times(3)->andReturn($this->json) + ->shouldReceive('getBody')->times(3)->andReturn($stream) ->getMock(); $this->assertJsonCount(1, $response); @@ -169,9 +200,14 @@ public function testAssertHtmlTagCountWithResponse(): void { $html = '
One
Two
Not You
Three
'; + /** @var StreamInterface $stream */ + $stream = Mockery::mock(StreamInterface::class) + ->shouldReceive('__toString')->andReturn($html) + ->getMock(); + /** @var ResponseInterface $response */ $response = Mockery::mock(ResponseInterface::class) - ->shouldReceive('getBody')->times(4)->andReturn($html) + ->shouldReceive('getBody')->times(4)->andReturn($stream) ->getMock(); $this->assertHtmlTagCount(3, $response, 'div'); diff --git a/tests/UniformResourceLocator/BuildingLocatorTest.php b/tests/UniformResourceLocator/BuildingLocatorTest.php index 988d50a3..2c72b3d0 100644 --- a/tests/UniformResourceLocator/BuildingLocatorTest.php +++ b/tests/UniformResourceLocator/BuildingLocatorTest.php @@ -441,7 +441,7 @@ public function testStreamWrapperReadFile(): void $filesize = filesize($filename); $this->assertNotFalse($filesize); - $contents = fread($handle, $filesize); + $contents = fread($handle, $filesize); // @phpstan-ignore-line $this->assertNotEquals('', $contents); $this->assertSame('Tesla', json_decode($contents, true)['cars'][1]['make']); // @phpstan-ignore-line