diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index 16c2dcb60ec..401176cdb42 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Get Cakebot App Token id: app-token - uses: getsentry/action-github-app-token@v2 + uses: getsentry/action-github-app-token@v3 with: app_id: ${{ secrets.CAKEBOT_APP_ID }} private_key: ${{ secrets.CAKEBOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27e6f0b93cd..07e8f576d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: fail-fast: false matrix: php-version: ['7.4', '8.0'] - db-type: [sqlite, mysql, pgsql] + db-type: [sqlite, pgsql] prefer-lowest: [''] exclude: - php-version: '7.4' @@ -54,7 +54,9 @@ jobs: steps: - name: Setup MySQL latest if: matrix.db-type == 'mysql' && matrix.php-version == '7.4' - run: docker run --rm --name=mysqld -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=cakephp -p 3306:3306 -d mysql --default-authentication-plugin=mysql_native_password --disable-log-bin + run: | + sudo service mysql start + mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp;' - name: Setup MySQL 5.6 if: matrix.db-type == 'mysql' && matrix.php-version != '7.4' @@ -93,7 +95,7 @@ jobs: run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} @@ -175,7 +177,7 @@ jobs: key: ${{ steps.key-date.outputs.date }} - name: Cache PHP extensions - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.php-ext-cache.outputs.dir }} key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} @@ -201,7 +203,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $env:GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} @@ -249,7 +251,7 @@ jobs: run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} diff --git a/VERSION.txt b/VERSION.txt index 94d5954885e..f1ba78b2efb 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -16,4 +16,4 @@ // @license https://opensource.org/licenses/mit-license.php MIT License // +--------------------------------------------------------------------------------------------+ // //////////////////////////////////////////////////////////////////////////////////////////////////// -4.5.2 +4.5.11 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bc35d89922e..831e9fe61b3 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -24,3 +24,8 @@ services: tags: - phpstan.broker.methodsClassReflectionExtension - phpstan.broker.propertiesClassReflectionExtension + + - + class: Cake\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension + tags: + - phpstan.phpDoc.typeNodeResolverExtension \ No newline at end of file diff --git a/src/Cache/Engine/MemcachedEngine.php b/src/Cache/Engine/MemcachedEngine.php index f490b363ef3..29c56954a4a 100644 --- a/src/Cache/Engine/MemcachedEngine.php +++ b/src/Cache/Engine/MemcachedEngine.php @@ -437,7 +437,7 @@ public function clear(): bool } foreach ($keys as $key) { - if (strpos($key, $this->_config['prefix']) === 0) { + if ($this->_config['prefix'] === '' || strpos($key, $this->_config['prefix']) === 0) { $this->_Memcached->delete($key); } } diff --git a/src/Datasource/QueryInterface.php b/src/Datasource/QueryInterface.php index fc7023a5577..47fede09005 100644 --- a/src/Datasource/QueryInterface.php +++ b/src/Datasource/QueryInterface.php @@ -279,6 +279,7 @@ public function toArray(): array; * * @param \Cake\Datasource\RepositoryInterface $repository The default repository object to use * @return $this + * @deprecated */ public function repository(RepositoryInterface $repository); diff --git a/src/Http/ServerRequestFactory.php b/src/Http/ServerRequestFactory.php index a4e48d07d9f..836c3c5b097 100644 --- a/src/Http/ServerRequestFactory.php +++ b/src/Http/ServerRequestFactory.php @@ -247,14 +247,7 @@ protected static function marshalUriFromSapi(array $server, array $headers): Uri $uri = marshalUriFromSapi($server, $headers); [$base, $webroot] = static::getBase($uri, $server); - // Look in PATH_INFO first, as this is the exact value we need prepared - // by PHP. - $pathInfo = Hash::get($server, 'PATH_INFO'); - if ($pathInfo) { - $uri = $uri->withPath($pathInfo); - } else { - $uri = static::updatePath($base, $uri); - } + $uri = static::updatePath($base, $uri); if (!$uri->getHost()) { $uri = $uri->withHost('localhost'); @@ -282,12 +275,18 @@ protected static function updatePath(string $base, UriInterface $uri): UriInterf if (empty($path) || $path === '/' || $path === '//' || $path === '/index.php') { $path = '/'; } - $endsWithIndex = '/' . (Configure::read('App.webroot') ?: 'webroot') . '/index.php'; - $endsWithLength = strlen($endsWithIndex); - if ( - strlen($path) >= $endsWithLength && - substr($path, -$endsWithLength) === $endsWithIndex - ) { + // Check for $webroot/index.php at the start and end of the path. + $search = ''; + if ($path[0] === '/') { + $search .= '/'; + } + $search .= (Configure::read('App.webroot') ?: 'webroot') . '/index.php'; + if (strpos($path, $search) === 0) { + $path = substr($path, strlen($search)); + } elseif (substr($path, -strlen($search)) === $search) { + $path = '/'; + } + if (!$path) { $path = '/'; } @@ -321,9 +320,9 @@ protected static function getBase(UriInterface $uri, array $server): array // Clean up additional / which cause following code to fail.. $base = preg_replace('#/+#', '/', $base); - $indexPos = strpos($base, '/' . $webroot . '/index.php'); + $indexPos = strpos($base, '/index.php'); if ($indexPos !== false) { - $base = substr($base, 0, $indexPos) . '/' . $webroot; + $base = substr($base, 0, $indexPos); } if ($webroot === basename($base)) { $base = dirname($base); diff --git a/src/I18n/DateFormatTrait.php b/src/I18n/DateFormatTrait.php index 942ce1a30cd..dca02329c48 100644 --- a/src/I18n/DateFormatTrait.php +++ b/src/I18n/DateFormatTrait.php @@ -262,7 +262,7 @@ protected function _formatObject($date, $format, ?string $locale): string static::$_formatters[$key] = $formatter; } - return static::$_formatters[$key]->format($date->format('U')); + return static::$_formatters[$key]->format($date); } /** diff --git a/src/I18n/MessagesFileLoader.php b/src/I18n/MessagesFileLoader.php index 2d02ab18c77..d47e8692398 100644 --- a/src/I18n/MessagesFileLoader.php +++ b/src/I18n/MessagesFileLoader.php @@ -181,6 +181,8 @@ public function translationsFolders(): array foreach ($localePaths as $path) { foreach ($folders as $folder) { $searchPaths[] = $path . $folder . DIRECTORY_SEPARATOR; + // gettext compatible paths, see https://www.php.net/manual/en/function.gettext.php + $searchPaths[] = $path . $folder . DIRECTORY_SEPARATOR . 'LC_MESSAGES' . DIRECTORY_SEPARATOR; } } @@ -188,6 +190,8 @@ public function translationsFolders(): array $basePath = App::path('locales', $this->_plugin)[0]; foreach ($folders as $folder) { $searchPaths[] = $basePath . $folder . DIRECTORY_SEPARATOR; + // gettext compatible paths, see https://www.php.net/manual/en/function.gettext.php + $searchPaths[] = $basePath . $folder . DIRECTORY_SEPARATOR . 'LC_MESSAGES' . DIRECTORY_SEPARATOR; } } diff --git a/src/ORM/Association/BelongsTo.php b/src/ORM/Association/BelongsTo.php index ad229448235..3072073a55e 100644 --- a/src/ORM/Association/BelongsTo.php +++ b/src/ORM/Association/BelongsTo.php @@ -31,6 +31,9 @@ * related to only one record in the target table. * * An example of a BelongsTo association would be Article belongs to Author. + * + * @template T of \Cake\ORM\Table + * @mixin T */ class BelongsTo extends Association { diff --git a/src/ORM/Association/BelongsToMany.php b/src/ORM/Association/BelongsToMany.php index 8c95cbc75c5..71b26e4a035 100644 --- a/src/ORM/Association/BelongsToMany.php +++ b/src/ORM/Association/BelongsToMany.php @@ -37,6 +37,9 @@ * * An example of a BelongsToMany association would be Article belongs to many Tags. * In this example 'Article' is the source table and 'Tags' is the target table. + * + * @template T of \Cake\ORM\Table + * @mixin T */ class BelongsToMany extends Association { diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index 9dbeac72b8b..5d95621da6d 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -33,6 +33,9 @@ * will have one or multiple records per each one in the source side. * * An example of a HasMany association would be Author has many Articles. + * + * @template T of \Cake\ORM\Table + * @mixin T */ class HasMany extends Association { diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php index 746caddfc02..ff239477d38 100644 --- a/src/ORM/Association/HasOne.php +++ b/src/ORM/Association/HasOne.php @@ -29,6 +29,9 @@ * related to only one record in the target table and vice versa. * * An example of a HasOne association would be User has one Profile. + * + * @template T of \Cake\ORM\Table + * @mixin T */ class HasOne extends Association { diff --git a/src/ORM/BehaviorRegistry.php b/src/ORM/BehaviorRegistry.php index e4b76e1c3de..9f006a1bdfd 100644 --- a/src/ORM/BehaviorRegistry.php +++ b/src/ORM/BehaviorRegistry.php @@ -203,6 +203,31 @@ protected function _getMethods(Behavior $instance, string $class, string $alias) return compact('methods', 'finders'); } + /** + * Remove an object from the registry. + * + * If this registry has an event manager, the object will be detached from any events as well. + * + * @param string $name The name of the object to remove from the registry. + * @return $this + */ + public function unload(string $name) + { + $instance = $this->get($name); + $result = parent::unload($name); + + $methods = array_change_key_case($instance->implementedMethods()); + foreach (array_keys($methods) as $method) { + unset($this->_methodMap[$method]); + } + $finders = array_change_key_case($instance->implementedFinders()); + foreach (array_keys($finders) as $finder) { + unset($this->_finderMap[$finder]); + } + + return $result; + } + /** * Check if any loaded behavior implements a method. * diff --git a/src/ORM/Query/SelectQuery.php b/src/ORM/Query/SelectQuery.php index 06942fabbd7..368b6d7b745 100644 --- a/src/ORM/Query/SelectQuery.php +++ b/src/ORM/Query/SelectQuery.php @@ -16,6 +16,7 @@ */ namespace Cake\ORM\Query; +use Cake\Database\Connection; use Cake\ORM\Query; /** @@ -89,4 +90,38 @@ public function set($key, $value = null, $types = []) return parent::set($key, $value, $types); } + + /** + * Sets the connection role. + * + * @param string $role Connection role ('read' or 'write') + * @return $this + */ + public function setConnectionRole(string $role) + { + assert($role === Connection::ROLE_READ || $role === Connection::ROLE_WRITE); + $this->connectionRole = $role; + + return $this; + } + + /** + * Sets the connection role to read. + * + * @return $this + */ + public function useReadRole() + { + return $this->setConnectionRole(Connection::ROLE_READ); + } + + /** + * Sets the connection role to write. + * + * @return $this + */ + public function useWriteRole() + { + return $this->setConnectionRole(Connection::ROLE_WRITE); + } } diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php index 73a34178eb4..c91e5ba9e6c 100644 --- a/src/Routing/RouteCollection.php +++ b/src/Routing/RouteCollection.php @@ -204,11 +204,19 @@ public function parse(string $url, string $method = ''): array public function parseRequest(ServerRequestInterface $request): array { $uri = $request->getUri(); - $urlPath = urldecode($uri->getPath()); + $urlPath = $uri->getPath(); + if (strpos($urlPath, '%') !== false) { + // decode urlencoded segments, but don't decode %2f aka / + $parts = explode('/', $urlPath); + $parts = array_map( + fn (string $part) => str_replace('/', '%2f', urldecode($part)), + $parts + ); + $urlPath = implode('/', $parts); + } if ($urlPath !== '/') { $urlPath = rtrim($urlPath, '/'); } - if (isset($this->staticPaths[$urlPath])) { foreach ($this->staticPaths[$urlPath] as $route) { $r = $route->parseRequest($request); @@ -217,7 +225,7 @@ public function parseRequest(ServerRequestInterface $request): array } if ($uri->getQuery()) { parse_str($uri->getQuery(), $queryParameters); - $r['?'] = $queryParameters; + $r['?'] = array_merge($r['?'] ?? [], $queryParameters); } return $r; diff --git a/src/TestSuite/StringCompareTrait.php b/src/TestSuite/StringCompareTrait.php index 0b388fbe856..b37298b9e9c 100644 --- a/src/TestSuite/StringCompareTrait.php +++ b/src/TestSuite/StringCompareTrait.php @@ -47,6 +47,11 @@ trait StringCompareTrait /** * Compare the result to the contents of the file * + * Set UPDATE_TEST_COMPARISON_FILES=1 in your environment + * to have this assertion *overwrite* comparison files. This + * is useful when you intentionally make a behavior change and + * want a quick way to capture the baseline output. + * * @param string $path partial path to test comparison file * @param string $result test result as a string * @return void diff --git a/src/Utility/Hash.php b/src/Utility/Hash.php index a9f182659df..32781929f28 100644 --- a/src/Utility/Hash.php +++ b/src/Utility/Hash.php @@ -336,7 +336,10 @@ public static function insert(array $data, string $path, $values = null): array foreach ($data as $k => $v) { if (static::_matchToken($k, $token)) { - if (!$conditions || static::_matches($v, $conditions)) { + if ( + !$conditions || + ((is_array($v) || $v instanceof ArrayAccess) && static::_matches($v, $conditions)) + ) { $data[$k] = $nextPath ? static::insert($v, $nextPath, $values) : array_merge($v, (array)$values); diff --git a/src/Utility/Inflector.php b/src/Utility/Inflector.php index 9cb8c1f4405..8abcd079de6 100644 --- a/src/Utility/Inflector.php +++ b/src/Utility/Inflector.php @@ -464,7 +464,7 @@ public static function delimit(string $string, string $delimiter = '_'): string } /** - * Returns corresponding table name for given model $className. ("people" for the model class "Person"). + * Returns corresponding table name for given model $className. ("people" for the class name "Person"). * * @param string $className Name of class to get database table name for * @return string Name of the database table for given class @@ -483,7 +483,7 @@ public static function tableize(string $className): string } /** - * Returns Cake model class name ("Person" for the database table "people".) for given database table. + * Returns a singular, CamelCase inflection for given database table. ("Person" for the table name "people") * * @param string $tableName Name of database table to get class name for * @return string Class name diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index f67ee04ec73..77bc7451c40 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -1396,9 +1396,13 @@ protected function setRequiredAndCustomValidity(string $fieldName, array $option $options['templateVars']['customValidityMessage'] = $message; if ($this->getConfig('autoSetCustomValidity')) { + $condition = 'this.value'; + if ($options['type'] === 'checkbox') { + $condition = 'this.checked'; + } $options['data-validity-message'] = $message; $options['oninvalid'] = "this.setCustomValidity(''); " - . 'if (!this.value) this.setCustomValidity(this.dataset.validityMessage)'; + . "if (!{$condition}) this.setCustomValidity(this.dataset.validityMessage)"; $options['oninput'] = "this.setCustomValidity('')"; } } diff --git a/src/View/View.php b/src/View/View.php index a26b13c00ad..c6ee22dbcf6 100644 --- a/src/View/View.php +++ b/src/View/View.php @@ -1411,8 +1411,9 @@ protected function _getTemplateFileName(?string $name = null): string $name .= $this->_ext; $paths = $this->_paths($plugin); foreach ($paths as $path) { - if (is_file($path . $name)) { - return $this->_checkFilePath($path . $name, $path); + $filepath = $path . $name; + if (is_file($filepath)) { + return $this->_checkFilePath($filepath, $plugin); } } @@ -1434,20 +1435,33 @@ protected function _inflectTemplateFileName(string $name): string * Check that a view file path does not go outside of the defined template paths. * * Only paths that contain `..` will be checked, as they are the ones most likely to - * have the ability to resolve to files outside of the template paths. + * have the ability to resolve to files outside of the template paths. A candidate + * that does not exist on the current root (realpath returning false) is passed + * through so the path cascade can try the next root. * * @param string $file The path to the template file. - * @param string $path Base path that $file should be inside of. + * @param ?string $plugin The plugin name or null. Used to generate template paths. * @return string The file path * @throws \InvalidArgumentException */ - protected function _checkFilePath(string $file, string $path): string + protected function _checkFilePath(string $file, ?string $plugin): string { if (strpos($file, '..') === false) { return $file; } $absolute = realpath($file); - if (strpos($absolute, $path) !== 0) { + if ($absolute === false) { + // Candidate does not exist on this root; let the path cascade continue. + return $file; + } + $found = false; + foreach ($this->_paths($plugin) as $path) { + if (str_starts_with($absolute, $path)) { + $found = true; + break; + } + } + if (!$found) { throw new InvalidArgumentException(sprintf( 'Cannot use "%s" as a template, it is not within any view template path.', $file @@ -1505,8 +1519,9 @@ protected function _getLayoutFileName(?string $name = null): string $name .= $this->_ext; foreach ($this->getLayoutPaths($plugin) as $path) { - if (is_file($path . $name)) { - return $this->_checkFilePath($path . $name, $path); + $filepath = $path . $name; + if (is_file($filepath)) { + return $this->_checkFilePath($filepath, $plugin); } } @@ -1547,9 +1562,11 @@ protected function _getElementFileName(string $name, bool $pluginCheck = true) [$plugin, $name] = $this->pluginSplit($name, $pluginCheck); $name .= $this->_ext; - foreach ($this->getElementPaths($plugin) as $path) { - if (is_file($path . $name)) { - return $path . $name; + $paths = iterator_to_array($this->getElementPaths($plugin)); + foreach ($paths as $path) { + $filepath = $path . $name; + if (is_file($filepath)) { + return $this->_checkFilePath($filepath, $plugin); } } diff --git a/tests/PHPStan/PhpDoc/TableAssociationTypeNodeResolverExtension.php b/tests/PHPStan/PhpDoc/TableAssociationTypeNodeResolverExtension.php new file mode 100644 index 00000000000..82e41bd3ae6 --- /dev/null +++ b/tests/PHPStan/PhpDoc/TableAssociationTypeNodeResolverExtension.php @@ -0,0 +1,87 @@ +` + * + * The type `\Cake\ORM\Association\BelongsTo&\App\Model\Table\UsersTable` is considered invalid (NeverType) by PHPStan + */ +class TableAssociationTypeNodeResolverExtension implements TypeNodeResolverExtension, TypeNodeResolverAwareExtension +{ + private TypeNodeResolver $typeNodeResolver; + + /** + * @var array + */ + protected array $associationTypes = [ + BelongsTo::class, + BelongsToMany::class, + HasMany::class, + HasOne::class, + Association::class, + ]; + + /** + * @param \PHPStan\PhpDoc\TypeNodeResolver $typeNodeResolver + * @return void + */ + public function setTypeNodeResolver(TypeNodeResolver $typeNodeResolver): void + { + $this->typeNodeResolver = $typeNodeResolver; + } + + /** + * @param \PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode + * @param \PHPStan\Analyser\NameScope $nameScope + * @return \PHPStan\Type\Type|null + */ + public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type + { + if (!$typeNode instanceof IntersectionTypeNode) { + return null; + } + $types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope); + $config = [ + 'association' => null, + 'table' => null, + ]; + foreach ($types as $type) { + if (!$type instanceof ObjectType) { + continue; + } + $className = $type->getClassName(); + if ($config['association'] === null && in_array($className, $this->associationTypes)) { + $config['association'] = $type; + } elseif ($config['table'] === null && str_ends_with($className, 'Table')) { + $config['table'] = $type; + } + } + if ($config['table'] && $config['association']) { + return new GenericObjectType( + $config['association']->getClassName(), + [$config['table']] + ); + } + + return null; + } +} diff --git a/tests/TestCase/Cache/Engine/MemcachedEngineTest.php b/tests/TestCase/Cache/Engine/MemcachedEngineTest.php index 6f9e54a30fb..f787604c208 100644 --- a/tests/TestCase/Cache/Engine/MemcachedEngineTest.php +++ b/tests/TestCase/Cache/Engine/MemcachedEngineTest.php @@ -818,6 +818,26 @@ public function testClear(): void Cache::clear('memcached2'); } + /** + * test clearing memcached with empty prefix. + */ + public function testClearWithEmptyPrefix(): void + { + Cache::setConfig('memcached2', [ + 'engine' => 'Memcached', + 'prefix' => '', + 'duration' => 3600, + 'servers' => ['127.0.0.1:' . $this->port], + ]); + + Cache::write('some_value', 'cache1', 'memcached2'); + sleep(1); + $this->assertTrue(Cache::clear('memcached2')); + $this->assertNull(Cache::read('some_value', 'memcached2')); + + Cache::clear('memcached2'); + } + /** * test that a 0 duration can successfully write. */ diff --git a/tests/TestCase/Http/ServerRequestFactoryTest.php b/tests/TestCase/Http/ServerRequestFactoryTest.php index d5b11ba9033..55e67ac2a2c 100644 --- a/tests/TestCase/Http/ServerRequestFactoryTest.php +++ b/tests/TestCase/Http/ServerRequestFactoryTest.php @@ -120,6 +120,23 @@ public function testFromGlobalsUrlBaseDefined(): void $this->assertSame('/posts/add', $res->getUri()->getPath()); } + /** + * Test fromGlobals with urlencoded path separators + */ + public function testFromGlobalsUrlEncoded(): void + { + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/branches/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts%2fadd', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('', $res->getAttribute('base')); + $this->assertSame('/', $res->getAttribute('webroot')); + $this->assertSame('/posts%2fadd', $res->getUri()->getPath()); + } + /** * Test fromGlobals with mod-rewrite server configuration. */ @@ -141,7 +158,7 @@ public function testFromGlobalsUrlModRewrite(): void $request = ServerRequestFactory::fromGlobals([ 'DOCUMENT_ROOT' => '/cake/repo/branches', 'PHP_SELF' => '/1.2.x.x/webroot/index.php', - 'PATH_INFO' => '/posts/view/1', + 'REQUEST_URI' => '/posts/view/1', ]); $this->assertSame('/1.2.x.x', $request->getAttribute('base')); $this->assertSame('/1.2.x.x/', $request->getAttribute('webroot')); @@ -275,23 +292,6 @@ public function testBaseUrlWithModRewriteAndIndexPhp(): void $this->assertSame('/bananas/eat/tasty_banana', $request->getRequestTarget()); } - /** - * Test that even if mod_rewrite is on, and the url contains index.php - * and there are numerous //s that the base/webroot is calculated correctly. - */ - public function testBaseUrlWithModRewriteAndExtraSlashes(): void - { - $request = ServerRequestFactory::fromGlobals([ - 'REQUEST_URI' => '/cakephp/webroot///index.php/bananas/eat', - 'PHP_SELF' => '/cakephp/webroot///index.php/bananas/eat', - 'PATH_INFO' => '/bananas/eat', - ]); - - $this->assertSame('/cakephp', $request->getAttribute('base')); - $this->assertSame('/cakephp/', $request->getAttribute('webroot')); - $this->assertSame('/bananas/eat', $request->getRequestTarget()); - } - /** * Test fromGlobals with mod-rewrite in the root dir. */ diff --git a/tests/TestCase/I18n/MessagesFileLoaderTest.php b/tests/TestCase/I18n/MessagesFileLoaderTest.php index ae7beb7b62e..25d37dde260 100644 --- a/tests/TestCase/I18n/MessagesFileLoaderTest.php +++ b/tests/TestCase/I18n/MessagesFileLoaderTest.php @@ -68,9 +68,13 @@ public function testTranslationFoldersSequence(): void $expected = [ ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en_' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en_' . DS . 'LC_MESSAGES' . DS, ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS . 'LC_MESSAGES' . DS, ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS . 'resources' . DS . 'locales' . DS . 'en_' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS . 'resources' . DS . 'locales' . DS . 'en_' . DS . 'LC_MESSAGES' . DS, ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS . 'LC_MESSAGES' . DS, ]; $result = $loader->translationsFolders(); $this->assertEquals($expected, $result); diff --git a/tests/TestCase/I18n/TimeTest.php b/tests/TestCase/I18n/TimeTest.php index 9b3c9b4d431..ccc860a7f4f 100644 --- a/tests/TestCase/I18n/TimeTest.php +++ b/tests/TestCase/I18n/TimeTest.php @@ -463,6 +463,12 @@ public function testI18nFormat(string $class): void $result = $time->i18nFormat(IntlDateFormatter::FULL, 'Asia/Tokyo', 'ja-JP@calendar=japanese'); $expected = '平成22年1月14日木曜日 22時59分28秒 日本標準時'; $this->assertTimeFormat($expected, $result); + + // Test with milliseconds + $timeMillis = new FrozenTime('2014-07-06T13:09:01.523000+00:00'); + $result = $timeMillis->i18nFormat("yyyy-MM-dd'T'HH':'mm':'ss.SSSxxx", null, 'en-US'); + $expected = '2014-07-06T13:09:01.523+00:00'; + $this->assertSame($expected, $result); } /** diff --git a/tests/TestCase/ORM/BehaviorRegistryTest.php b/tests/TestCase/ORM/BehaviorRegistryTest.php index 35a0474ef17..7062a3eb660 100644 --- a/tests/TestCase/ORM/BehaviorRegistryTest.php +++ b/tests/TestCase/ORM/BehaviorRegistryTest.php @@ -23,6 +23,7 @@ use Cake\ORM\Table; use Cake\TestSuite\TestCase; use LogicException; +use RuntimeException; /** * Test case for BehaviorRegistry. @@ -257,19 +258,8 @@ public function testHasFinder(): void public function testCall(): void { $this->Behaviors->load('Sluggable'); - $mockedBehavior = $this->getMockBuilder('Cake\ORM\Behavior') - ->addMethods(['slugify']) - ->disableOriginalConstructor() - ->getMock(); - $this->Behaviors->set('Sluggable', $mockedBehavior); - - $mockedBehavior - ->expects($this->once()) - ->method('slugify') - ->with(['some value']) - ->will($this->returnValue('some-thing')); - $return = $this->Behaviors->call('slugify', [['some value']]); - $this->assertSame('some-thing', $return); + $return = $this->Behaviors->call('slugify', ['some value']); + $this->assertSame('some-value', $return); } /** @@ -292,20 +282,12 @@ public function testCallError(): void public function testCallFinder(): void { $this->Behaviors->load('Sluggable'); - $mockedBehavior = $this->getMockBuilder('Cake\ORM\Behavior') - ->addMethods(['findNoSlug']) - ->disableOriginalConstructor() - ->getMock(); - $this->Behaviors->set('Sluggable', $mockedBehavior); $query = new Query($this->Table->getConnection(), $this->Table); - $mockedBehavior - ->expects($this->once()) - ->method('findNoSlug') - ->with($query, []) - ->will($this->returnValue($query)); $return = $this->Behaviors->callFinder('noSlug', [$query, []]); $this->assertSame($query, $return); + $sql = $query->sql(); + $this->assertMatchesRegularExpression('/slug[^ ]+ IS NULL/', $sql); } /** @@ -327,8 +309,13 @@ public function testUnloadBehaviorThenCall(): void $this->expectException(BadMethodCallException::class); $this->expectExceptionMessage('Cannot call "slugify" it does not belong to any attached behavior.'); $this->Behaviors->load('Sluggable'); + + $this->assertTrue($this->Behaviors->hasMethod('slugify')); + $this->assertTrue($this->Behaviors->hasMethod('camelCase')); $this->Behaviors->unload('Sluggable'); + $this->assertFalse($this->Behaviors->hasMethod('slugify'), 'should not have method anymore'); + $this->assertFalse($this->Behaviors->hasMethod('camelCase'), 'should not have method anymore'); $this->Behaviors->call('slugify'); } @@ -340,8 +327,10 @@ public function testUnloadBehaviorThenCallFinder(): void $this->expectException(BadMethodCallException::class); $this->expectExceptionMessage('Cannot call finder "noslug" it does not belong to any attached behavior.'); $this->Behaviors->load('Sluggable'); + $this->assertTrue($this->Behaviors->hasFinder('noSlug')); $this->Behaviors->unload('Sluggable'); + $this->assertFalse($this->Behaviors->hasFinder('noSlug')); $this->Behaviors->callFinder('noSlug'); } @@ -377,8 +366,8 @@ public function testUnload(): void */ public function testUnloadUnknown(): void { - $this->expectException(MissingBehaviorException::class); - $this->expectExceptionMessage('Behavior class FooBehavior could not be found.'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown object "Foo"'); $this->Behaviors->unload('Foo'); } diff --git a/tests/TestCase/ORM/QueryTest.php b/tests/TestCase/ORM/QueryTest.php index e87c97a2aa8..03ad13e4cf6 100644 --- a/tests/TestCase/ORM/QueryTest.php +++ b/tests/TestCase/ORM/QueryTest.php @@ -19,6 +19,7 @@ use Cake\Cache\Engine\FileEngine; use Cake\Collection\Collection; use Cake\Collection\Iterator\BufferedIterator; +use Cake\Database\Connection; use Cake\Database\Driver\Mysql; use Cake\Database\Driver\Sqlite; use Cake\Database\DriverInterface; @@ -4112,4 +4113,17 @@ public function testSelectLoaderAssociationsInheritHydrationAndResultsCastingMod ->disableResultsCasting() ->firstOrFail(); } + + public function testORMQueryUseReadRoleWorks(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + // Make sure it defaults to the write role + $query = $articles->find(); + $this->assertEquals(Connection::ROLE_WRITE, $query->getConnectionRole()); + + // Make sure it can be changed to the read role + $query = $articles->find()->useReadRole(); + $this->assertEquals(Connection::ROLE_READ, $query->getConnectionRole()); + } } diff --git a/tests/TestCase/ORM/TableTest.php b/tests/TestCase/ORM/TableTest.php index 6482deb7dad..cb8e55a4070 100644 --- a/tests/TestCase/ORM/TableTest.php +++ b/tests/TestCase/ORM/TableTest.php @@ -1789,6 +1789,20 @@ public function testRemoveBehavior(): void $this->assertSame($table, $result); } + /** + * Test removing a behavior from a table clears the method map for the behavior + */ + public function testRemoveBehaviorMethodMapCleared(): void + { + $table = new Table(['table' => 'articles']); + $table->addBehavior('Sluggable'); + $this->assertTrue($table->behaviors()->hasMethod('slugify'), 'slugify should be mapped'); + $this->assertSame('foo-bar', $table->slugify('foo bar')); + + $table->removeBehavior('Sluggable'); + $this->assertFalse($table->behaviors()->hasMethod('slugify'), 'slugify should not be callable'); + } + /** * Test adding multiple behaviors to a table. */ diff --git a/tests/TestCase/Routing/Route/RouteTest.php b/tests/TestCase/Routing/Route/RouteTest.php index e05a93b82eb..37395c61821 100644 --- a/tests/TestCase/Routing/Route/RouteTest.php +++ b/tests/TestCase/Routing/Route/RouteTest.php @@ -1062,7 +1062,7 @@ public function testParseRequestDelegates(): void $request = new ServerRequest([ 'environment' => [ 'REQUEST_METHOD' => 'GET', - 'PATH_INFO' => '/forward', + 'REQUEST_URI' => '/forward', ], ]); $result = $route->parseRequest($request); @@ -1083,7 +1083,7 @@ public function testParseRequestHostConditions(): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => 'a.example.com', - 'PATH_INFO' => '/fallback', + 'REQUEST_URI' => '/fallback', ], ]); $result = $route->parseRequest($request); @@ -1099,7 +1099,7 @@ public function testParseRequestHostConditions(): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => 'foo.bar.example.com', - 'PATH_INFO' => '/fallback', + 'REQUEST_URI' => '/fallback', ], ]); $result = $route->parseRequest($request); @@ -1791,7 +1791,7 @@ public function testSetHost(): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => 'a.example.com', - 'PATH_INFO' => '/reviews', + 'REQUEST_URI' => '/reviews', ], ]); $this->assertNull($route->parseRequest($request)); diff --git a/tests/TestCase/Routing/RouteCollectionTest.php b/tests/TestCase/Routing/RouteCollectionTest.php index 6b8350befd1..096f960e2c8 100644 --- a/tests/TestCase/Routing/RouteCollectionTest.php +++ b/tests/TestCase/Routing/RouteCollectionTest.php @@ -24,6 +24,7 @@ use Cake\Routing\RouteCollection; use Cake\TestSuite\TestCase; use RuntimeException; +use TestApp\Routing\Route\AddQueryParamRoute; class RouteCollectionTest extends TestCase { @@ -138,8 +139,8 @@ public function testParse(): void $this->assertEquals($expected, $result); }); } - /** + * Test parse() handling query strings. */ public function testParseQueryString(): void @@ -352,6 +353,31 @@ public function testParseRequestQueryString(): void $this->assertEquals($expected, $result); } + /** + * Test parseRequest() handling query strings. + */ + public function testParseRequestQueryStringFromRoute(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect( + '/test', + ['controller' => 'Articles', 'action' => 'view'], + ['routeClass' => AddQueryParamRoute::class], + ); + $request = new ServerRequest(['url' => '/test?y=2']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => [], + 'plugin' => null, + '_matchedRoute' => '/test', + '?' => ['x' => '1', 'y' => '2'], + ]; + $this->assertEquals($expected, $result); + } + /** * Test parseRequest() checks host conditions */ @@ -367,7 +393,7 @@ public function testParseRequestCheckHostCondition(): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => 'a.example.com', - 'PATH_INFO' => '/fallback', + 'REQUEST_URI' => '/fallback', ], ]); $result = $this->collection->parseRequest($request); @@ -384,7 +410,7 @@ public function testParseRequestCheckHostCondition(): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => 'foo.bar.example.com', - 'PATH_INFO' => '/fallback', + 'REQUEST_URI' => '/fallback', ], ]); $result = $this->collection->parseRequest($request); @@ -394,7 +420,7 @@ public function testParseRequestCheckHostCondition(): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => 'example.test.com', - 'PATH_INFO' => '/fallback', + 'REQUEST_URI' => '/fallback', ], ]); try { @@ -438,7 +464,7 @@ public function testParseRequestCheckHostConditionFail(string $host): void $request = new ServerRequest([ 'environment' => [ 'HTTP_HOST' => $host, - 'PATH_INFO' => '/fallback', + 'REQUEST_URI' => '/fallback', ], ]); $this->collection->parseRequest($request); @@ -566,6 +592,20 @@ public function testParseRequestUnicode(): void $this->assertEquals($expected, $result); } + /** + * Test parsing routes that match non-ascii urls + */ + public function testParseRequestNoDecode2f(): void + { + $routes = new RouteBuilder($this->collection, '/b', []); + $routes->connect('/media/confirm', ['controller' => 'Media', 'action' => 'confirm']); + + $request = new ServerRequest(['url' => '/b/media%2fconfirm']); + + $this->expectException(MissingRouteException::class); + $this->collection->parseRequest($request); + } + /** * Test match() throws an error on unknown routes. */ diff --git a/tests/TestCase/Utility/HashTest.php b/tests/TestCase/Utility/HashTest.php index 8f3c2e01823..54f9c8665c3 100644 --- a/tests/TestCase/Utility/HashTest.php +++ b/tests/TestCase/Utility/HashTest.php @@ -23,6 +23,7 @@ use Cake\Utility\Hash; use InvalidArgumentException; use RuntimeException; +use stdClass; /** * HashTest @@ -2004,6 +2005,30 @@ public function testInsertMulti(): void $this->assertSame($expected, $result); } + /** + * test insert() with {s} placeholders and conditions. + */ + public function testInsertMultiWord(): void + { + $data = static::articleData(); + + $result = Hash::insert($data, '{n}.{s}.insert', 'value'); + $this->assertSame('value', $result[0]['Article']['insert']); + $this->assertSame('value', $result[1]['Article']['insert']); + + $data = [ + 0 => ['obj' => new stdClass(), 'Item' => ['id' => 1, 'title' => 'first']], + 1 => ['float' => 1.5, 'Item' => ['id' => 2, 'title' => 'second']], + 2 => ['int' => 1, 'Item' => ['id' => 3, 'title' => 'third']], + 3 => ['str' => 'yes', 'Item' => ['id' => 3, 'title' => 'third']], + 4 => ['bool' => true, 'Item' => ['id' => 4, 'title' => 'fourth']], + 5 => ['null' => null, 'Item' => ['id' => 5, 'title' => 'fifth']], + 6 => ['arrayish' => new ArrayObject(['val']), 'Item' => ['id' => 6, 'title' => 'sixth']], + ]; + $result = Hash::insert($data, '{n}.{s}[id=4].new', 'value'); + $this->assertEquals('value', $result[4]['Item']['new']); + } + /** * Test that insert() can insert data over a string value. */ diff --git a/tests/TestCase/View/Helper/FormHelperTest.php b/tests/TestCase/View/Helper/FormHelperTest.php index 6eb3ba0d1a8..f84b284f0d9 100644 --- a/tests/TestCase/View/Helper/FormHelperTest.php +++ b/tests/TestCase/View/Helper/FormHelperTest.php @@ -7740,6 +7740,7 @@ public function testHtml5ErrorMessage(): void ->notEmptyString('email', 'Custom error message') ->requirePresence('password') ->alphaNumeric('password') + ->requirePresence('accept_tos') ->notBlank('phone'); $table = $this->getTableLocator()->get('Contacts', [ @@ -7809,6 +7810,28 @@ public function testHtml5ErrorMessage(): void ], ]; $this->assertHtml($expected, $result); + + $result = $this->Form->control('accept_tos', ['type' => 'checkbox']); + $expected = [ + ['input' => ['type' => 'hidden', 'name' => 'accept_tos', 'value' => '0']], + 'label' => ['for' => 'accept-tos'], + [ + 'input' => [ + 'aria-required' => 'true', + 'required' => 'required', + 'type' => 'checkbox', + 'name' => 'accept_tos', + 'id' => 'accept-tos', + 'value' => '1', + 'data-validity-message' => 'This field cannot be left empty', + 'oninput' => 'this.setCustomValidity('')', + 'oninvalid' => 'this.setCustomValidity(''); if (!this.checked) this.setCustomValidity(this.dataset.validityMessage)', + ], + ], + 'Accept Tos', + '/label', + ]; + $this->assertHtml($expected, $result); } /** diff --git a/tests/TestCase/View/ViewTest.php b/tests/TestCase/View/ViewTest.php index ffc2ef28838..ac5a078ed9d 100644 --- a/tests/TestCase/View/ViewTest.php +++ b/tests/TestCase/View/ViewTest.php @@ -674,6 +674,26 @@ public function testElementMissing(): void $this->View->element('nonexistent_element'); } + /** + * Test element name cannot escape the view template path. + */ + public function testElementPathEscape(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('it is not within any view template path.'); + $this->View->element('../../../check'); + } + + /** + * Test element name can use a template path + */ + public function testElementPathTemplate(): void + { + $this->View->setRequest($this->View->getRequest()->withParam('plugin', 'TestPlugin')); + $result = $this->View->element('../Tests/index'); + $this->assertEquals($result, 'test plugin index'); + } + /** * Test loading nonexistent plugin view element */ diff --git a/tests/test_app/TestApp/Model/Behavior/SluggableBehavior.php b/tests/test_app/TestApp/Model/Behavior/SluggableBehavior.php index 5f678fde030..2a411515164 100644 --- a/tests/test_app/TestApp/Model/Behavior/SluggableBehavior.php +++ b/tests/test_app/TestApp/Model/Behavior/SluggableBehavior.php @@ -27,7 +27,7 @@ class SluggableBehavior extends Behavior { - public function beforeFind(EventInterface $event, Query $query, array $options = []): Query + public function beforeFind(EventInterface $event, Query $query, $options = []): Query { $query->where(['slug' => 'test']); @@ -45,4 +45,9 @@ public function slugify(string $value): string { return Text::slug($value); } + + public function camelCase(): string + { + return 'camelCase'; + } } diff --git a/tests/test_app/TestApp/Routing/Route/AddQueryParamRoute.php b/tests/test_app/TestApp/Routing/Route/AddQueryParamRoute.php new file mode 100644 index 00000000000..06e8075e7c9 --- /dev/null +++ b/tests/test_app/TestApp/Routing/Route/AddQueryParamRoute.php @@ -0,0 +1,20 @@ +