Browse Source

Merge branch '4.next' into 5.x

Corey Taylor 4 years ago
parent
commit
458937953f

+ 29 - 0
.github/workflows/api-docs.yml

@@ -0,0 +1,29 @@
+---
+name: 'api-docs-deploy'
+
+on:
+  push:
+    tags:
+      - 4.*
+      - 5.*
+  workflow_dispatch:
+
+jobs:
+  trigger-api:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get Cakebot App Token
+        id: app-token
+        uses: getsentry/action-github-app-token@v1
+        with:
+          app_id: ${{ secrets.CAKEBOT_APP_ID }}
+          private_key: ${{ secrets.CAKEBOT_APP_PRIVATE_KEY }}
+
+      - name: Trigger API build
+        run: >
+          curl -XPOST
+          -H 'Authorization: Bearer ${{ steps.app-token.outputs.token }}'
+          -H 'Accept: application/vnd.github.v3+json'
+          -H 'Content-Type: application/json'
+          https://api.github.com/repos/cakephp/cakephp-api-docs/actions/workflows/deploy_2x.yml/dispatches
+          --data '{"ref":"2.x"}'

+ 5 - 0
.github/workflows/ci.yml

@@ -29,6 +29,8 @@ jobs:
             prefer-lowest: 'prefer-lowest'
           - php-version: '8.1'
             db-type: 'sqlite'
+          - php-version: '8.2'
+            db-type: 'sqlite'
 
     services:
       redis:
@@ -88,6 +90,8 @@ jobs:
       run: |
         if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then
           composer update --prefer-lowest --prefer-stable
+        elif ${{ matrix.php-version == '8.2' }}; then
+          composer update --ignore-platform-req=php
         else
           composer update
         fi
@@ -118,6 +122,7 @@ jobs:
           vendor/bin/phpunit
           CAKE_TEST_AUTOQUOTE=1 vendor/bin/phpunit --testsuite=database
         fi
+      continue-on-error: ${{ matrix.php-version == '8.2' }}
 
     - name: Prefer lowest check
       if: matrix.prefer-lowest == 'prefer-lowest'

+ 1 - 0
src/Command/Command.php

@@ -29,6 +29,7 @@ use Cake\ORM\Locator\LocatorAwareTrait;
  * Includes traits that integrate logging
  * and ORM models to console commands.
  */
+#[\AllowDynamicProperties]
 class Command extends BaseCommand
 {
     use LocatorAwareTrait;

+ 1 - 1
src/Command/PluginAssetsCopyCommand.php

@@ -71,7 +71,7 @@ class PluginAssetsCopyCommand extends Command
             'Copy plugin assets to app\'s webroot.',
         ])->addArgument('name', [
             'help' => 'A specific plugin you want to copy assets for.',
-            'optional' => true,
+            'required' => false,
         ])->addOption('overwrite', [
             'help' => 'Overwrite existing symlink / folder / files.',
             'default' => false,

+ 1 - 1
src/Command/PluginAssetsRemoveCommand.php

@@ -80,7 +80,7 @@ class PluginAssetsRemoveCommand extends Command
             'Remove plugin assets from app\'s webroot.',
         ])->addArgument('name', [
             'help' => 'A specific plugin you want to remove.',
-            'optional' => true,
+            'required' => false,
         ]);
 
         return $parser;

+ 1 - 1
src/Command/PluginAssetsSymlinkCommand.php

@@ -72,7 +72,7 @@ class PluginAssetsSymlinkCommand extends Command
             'Symlink (copy as fallback) plugin assets to app\'s webroot.',
         ])->addArgument('name', [
             'help' => 'A specific plugin you want to symlink assets for.',
-            'optional' => true,
+            'required' => false,
         ])->addOption('overwrite', [
             'help' => 'Overwrite existing symlink / folder / files.',
             'default' => false,

+ 1 - 1
src/Command/SchemacacheBuildCommand.php

@@ -85,7 +85,7 @@ class SchemacacheBuildCommand extends Command
             'default' => 'default',
         ])->addArgument('name', [
             'help' => 'A specific table you want to refresh cached data for.',
-            'optional' => true,
+            'required' => false,
         ]);
 
         return $parser;

+ 1 - 1
src/Command/SchemacacheClearCommand.php

@@ -85,7 +85,7 @@ class SchemacacheClearCommand extends Command
             'default' => 'default',
         ])->addArgument('name', [
             'help' => 'A specific table you want to clear cached data for.',
-            'optional' => true,
+            'required' => false,
         ]);
 
         return $parser;

+ 5 - 22
src/Controller/Component/PaginatorComponent.php

@@ -23,6 +23,7 @@ use Cake\Datasource\Paginator;
 use Cake\Datasource\ResultSetInterface;
 use Cake\Http\Exception\NotFoundException;
 use InvalidArgumentException;
+use UnexpectedValueException;
 
 /**
  * This component is used to handle automatic model data pagination. The primary way to use this
@@ -39,28 +40,6 @@ use InvalidArgumentException;
 class PaginatorComponent extends Component
 {
     /**
-     * Default pagination settings.
-     *
-     * When calling paginate() these settings will be merged with the configuration
-     * you provide.
-     *
-     * - `maxLimit` - The maximum limit users can choose to view. Defaults to 100
-     * - `limit` - The initial number of items per page. Defaults to 20.
-     * - `page` - The starting page, defaults to 1.
-     * - `allowedParameters` - A list of parameters users are allowed to set using request
-     *   parameters. Modifying this list will allow users to have more influence
-     *   over pagination, be careful with what you permit.
-     *
-     * @var array<string, mixed>
-     */
-    protected array $_defaultConfig = [
-        'page' => 1,
-        'limit' => 20,
-        'maxLimit' => 100,
-        'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
-    ];
-
-    /**
      * Datasource paginator instance.
      *
      * @var \Cake\Datasource\Paginator
@@ -72,6 +51,10 @@ class PaginatorComponent extends Component
      */
     public function __construct(ComponentRegistry $registry, array $config = [])
     {
+        if (!empty($this->_defaultConfig)) {
+            throw new UnexpectedValueException('Default configuration must be set using a custom Paginator class.');
+        }
+
         if (isset($config['paginator'])) {
             if (!$config['paginator'] instanceof Paginator) {
                 throw new InvalidArgumentException('Paginator must be an instance of ' . Paginator::class);

+ 2 - 3
src/Core/functions.php

@@ -252,10 +252,9 @@ if (!function_exists('triggerWarning')) {
      */
     function triggerWarning(string $message): void
     {
-        $stackFrame = 1;
         $trace = debug_backtrace();
-        if (isset($trace[$stackFrame])) {
-            $frame = $trace[$stackFrame];
+        if (isset($trace[1])) {
+            $frame = $trace[1];
             $frame += ['file' => '[internal]', 'line' => '??'];
             $message = sprintf(
                 '%s - %s, line: %s',

+ 20 - 0
src/Http/Client/Auth/Oauth.php

@@ -220,8 +220,11 @@ class Oauth
             $credentials['privateKeyPassphrase'] = $passphrase;
         }
         $privateKey = openssl_pkey_get_private($credentials['privateKey'], $credentials['privateKeyPassphrase']);
+        $this->checkSslError();
+
         $signature = '';
         openssl_sign($baseString, $signature, $privateKey);
+        $this->checkSslError();
 
         $values['oauth_signature'] = base64_encode($signature);
 
@@ -364,4 +367,21 @@ class Oauth
     {
         return str_replace(['%7E', '+'], ['~', ' '], rawurlencode($value));
     }
+
+    /**
+     * Check for SSL errors and raise if one is encountered.
+     *
+     * @return void
+     */
+    protected function checkSslError(): void
+    {
+        $error = '';
+        while ($text = openssl_error_string()) {
+            $error .= $text;
+        }
+
+        if (strlen($error) > 0) {
+            throw new RuntimeException('openssl error: ' . $error);
+        }
+    }
 }

+ 23 - 32
src/ORM/Table.php

@@ -1264,10 +1264,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
      */
     public function find(string $type = 'all', array $options = []): Query
     {
-        $query = $this->query();
-        $query->select();
-
-        return $this->callFinder($type, $query, $options);
+        return $this->callFinder($type, $this->query()->select(), $options);
     }
 
     /**
@@ -1733,11 +1730,11 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
         QueryExpression|Closure|array|string $fields,
         QueryExpression|Closure|array|string|null $conditions
     ): int {
-        $query = $this->query();
-        $query->update()
+        $statement = $this->query()
+            ->update()
             ->set($fields)
-            ->where($conditions);
-        $statement = $query->execute();
+            ->where($conditions)
+            ->execute();
         $statement->closeCursor();
 
         return $statement->rowCount();
@@ -1759,10 +1756,10 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
      */
     public function deleteAll(QueryExpression|Closure|array|string|null $conditions): int
     {
-        $query = $this->query()
+        $statement = $this->query()
             ->delete()
-            ->where($conditions);
-        $statement = $query->execute();
+            ->where($conditions)
+            ->execute();
         $statement->closeCursor();
 
         return $statement->rowCount();
@@ -2094,15 +2091,15 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
             }
         }
 
-        $success = false;
         if (empty($data)) {
-            return $success;
+            return false;
         }
 
         $statement = $this->query()->insert(array_keys($data))
             ->values($data)
             ->execute();
 
+        $success = false;
         if ($statement->rowCount() !== 0) {
             $success = $entity;
             $entity->set($filteredKeys, ['guard' => false]);
@@ -2179,16 +2176,13 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
             throw new InvalidArgumentException($message);
         }
 
-        $query = $this->query();
-        $statement = $query->update()
+        $statement = $this->query()
+            ->update()
             ->set($data)
             ->where($primaryKey)
             ->execute();
 
-        $success = false;
-        if ($statement->errorCode() === '00000') {
-            $success = $entity;
-        }
+        $success = $statement->errorCode() === '00000' ? $entity : false;
         $statement->closeCursor();
 
         return $success;
@@ -2501,15 +2495,13 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
             return $success;
         }
 
-        $query = $this->query();
-        $conditions = $entity->extract($primaryKey);
-        $statement = $query->delete()
-            ->where($conditions)
+        $statement = $this->query()
+            ->delete()
+            ->where($entity->extract($primaryKey))
             ->execute();
 
-        $success = $statement->rowCount() > 0;
-        if (!$success) {
-            return $success;
+        if ($statement->rowCount() < 1) {
+            return false;
         }
 
         $this->dispatchEvent('Model.afterDelete', [
@@ -2517,7 +2509,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
             'options' => $options,
         ]);
 
-        return $success;
+        return true;
     }
 
     /**
@@ -2534,12 +2526,11 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
     }
 
     /**
-     * Calls a finder method directly and applies it to the passed query,
-     * if no query is passed a new one will be created and returned
+     * Calls a finder method and applies it to the passed query.
      *
-     * @param string $type name of the finder to be called
-     * @param \Cake\ORM\Query $query The query object to apply the finder options to
-     * @param array<string, mixed> $options List of options to pass to the finder
+     * @param string $type Name of the finder to be called.
+     * @param \Cake\ORM\Query $query The query object to apply the finder options to.
+     * @param array<string, mixed> $options List of options to pass to the finder.
      * @return \Cake\ORM\Query
      * @throws \BadMethodCallException
      * @uses findAll()

+ 4 - 4
src/Routing/Route/Route.php

@@ -405,10 +405,10 @@ class Route
      * Checks to see if the given URL can be parsed by this route.
      *
      * If the route can be parsed an array of parameters will be returned; if not
-     * false will be returned.
+     * `null` will be returned.
      *
      * @param \Psr\Http\Message\ServerRequestInterface $request The URL to attempt to parse.
-     * @return array|null An array of request parameters, or null on failure.
+     * @return array|null An array of request parameters, or `null` on failure.
      */
     public function parseRequest(ServerRequestInterface $request): ?array
     {
@@ -424,11 +424,11 @@ class Route
      * Checks to see if the given URL can be parsed by this route.
      *
      * If the route can be parsed an array of parameters will be returned; if not
-     * false will be returned. String URLs are parsed if they match a routes regular expression.
+     * `null` will be returned. String URLs are parsed if they match a routes regular expression.
      *
      * @param string $url The URL to attempt to parse.
      * @param string $method The HTTP method of the request being parsed.
-     * @return array|null An array of request parameters, or null on failure.
+     * @return array|null An array of request parameters, or `null` on failure.
      * @throws \InvalidArgumentException When method is not an empty string or in `VALID_METHODS` list.
      */
     public function parse(string $url, string $method): ?array

+ 16 - 5
src/Routing/Router.php

@@ -616,18 +616,29 @@ class Router
         }
         $pass = $params['pass'] ?? [];
 
+        $template = $params['_matchedRoute'] ?? null;
         unset(
             $params['pass'],
             $params['_matchedRoute'],
             $params['_name']
         );
-        foreach ($pass as $i => $passedValue) {
-            // Remove passed values that are also route keys
-            if (in_array($passedValue, $params, true)) {
-                unset($pass[$i]);
+        $route = null;
+        if ($template) {
+            // Locate the route that was used to match this route
+            // so we can access the pass parameter configuration.
+            foreach (static::getRouteCollection()->routes() as $maybe) {
+                if ($maybe->template === $template) {
+                    $route = $maybe;
+                    break;
+                }
             }
         }
-        $params = array_merge($params, array_values($pass));
+        if ($route) {
+            // If we found a route, slice off the number of passed args.
+            $routePass = $route->options['pass'] ?? [];
+            $pass = array_slice($pass, count($routePass));
+        }
+        $params = array_merge($params, $pass);
 
         return $params;
     }

+ 1 - 1
tests/TestCase/Command/PluginAssetsCommandsTest.php

@@ -116,7 +116,7 @@ class PluginAssetsCommandsTest extends TestCase
             ->addMethods(['in'])
             ->getMock();
         $parser = new ConsoleOptionParser('cake example');
-        $parser->addArgument('name', ['optional' => true]);
+        $parser->addArgument('name', ['required' => false]);
         $parser->addOption('overwrite', ['default' => false, 'boolean' => true]);
 
         $command = $this->getMockBuilder('Cake\Command\PluginAssetsSymlinkCommand')

+ 8 - 0
tests/TestCase/Controller/Component/PaginatorComponentTest.php

@@ -31,7 +31,9 @@ use Cake\ORM\Query;
 use Cake\TestSuite\TestCase;
 use InvalidArgumentException;
 use stdClass;
+use TestApp\Controller\Component\CustomPaginatorComponent;
 use TestApp\Datasource\CustomPaginator;
+use UnexpectedValueException;
 
 class PaginatorComponentTest extends TestCase
 {
@@ -101,6 +103,12 @@ class PaginatorComponentTest extends TestCase
         $this->assertSame($paginator, $component->getPaginator());
     }
 
+    public function testInvalidDefaultConfig(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        new CustomPaginatorComponent($this->registry);
+    }
+
     /**
      * Test that an exception is thrown when paginator option is invalid.
      */

+ 59 - 74
tests/TestCase/Http/Client/Auth/OauthTest.php

@@ -19,47 +19,34 @@ use Cake\Core\Exception\CakeException;
 use Cake\Http\Client\Auth\Oauth;
 use Cake\Http\Client\Request;
 use Cake\TestSuite\TestCase;
+use RuntimeException;
 
 /**
  * Oauth test.
  */
 class OauthTest extends TestCase
 {
-    private $privateKeyString = '-----BEGIN RSA PRIVATE KEY-----
-MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALRiMLAh9iimur8V
-A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d
-7rjVJ0BLB+ubPK8iA/Tw3hLQgXMRRGRXXCn8ikfuQfjUS1uZSatdLB81mydBETlJ
-hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtleUzavkbrPjy0T5FMou8H
-X9u2AC2ry8vD/l7cqedtwMPp9k7TubgNFo+NGvKsl2ynyprOZR1xjQ7WgrgVB+mm
-uScOM/5HVceFuGRDhYTCObE+y1kxRloNYXnx3ei1zbeYLPCHdhxRYW7T0qcynNmw
-rn05/KO2RLjgQNalsQJBANeA3Q4Nugqy4QBUCEC09SqylT2K9FrrItqL2QKc9v0Z
-zO2uwllCbg0dwpVuYPYXYvikNHHg+aCWF+VXsb9rpPsCQQDWR9TT4ORdzoj+Nccn
-qkMsDmzt0EfNaAOwHOmVJ2RVBspPcxt5iN4HI7HNeG6U5YsFBb+/GZbgfBT3kpNG
-WPTpAkBI+gFhjfJvRw38n3g/+UeAkwMI2TJQS4n8+hid0uus3/zOjDySH3XHCUno
-cn1xOJAyZODBo47E+67R4jV1/gzbAkEAklJaspRPXP877NssM5nAZMU0/O/NGCZ+
-3jPgDUno6WbJn5cqm8MqWhW1xGkImgRk+fkDBquiq4gPiT898jusgQJAd5Zrr6Q8
-AO/0isr/3aa6O6NLQxISLKcPDk2NOccAfS/xOtfOz4sJYM3+Bs4Io9+dZGSDCA54
-Lw03eHTNQghS0A==
------END RSA PRIVATE KEY-----';
-
-    private $privateKeyStringEnc = '-----BEGIN RSA PRIVATE KEY-----
-Proc-Type: 4,ENCRYPTED
-DEK-Info: DES-CBC,E65DB7AE7A05EF23
-
-QCXAQ/Uj1+7uQp0MyDUPlKvW/28PhbT4GxflBYmU6SxKZ2CVFPk0M8RgB6gkJyVv
-mwjo1Ch2Tlt7/VrNfLWGIh1XPhsC3gatv8Wv+g0keWWifaHlhXulgMGREJ7QeJg0
-5THvdFuIs2qQnOzPCAwONjM6yMxPb2qxvwq0UKAL5V/CYVFWS6PYdR25f9ogXxBz
-c3QjvvnhQ7ipNjpjVp/XKYMYnZPCYkNYvRX+BcsWlqYtclO3m+xPG+mPAFs9hnBI
-wHI4yC2fl52giRc7XnSl7NNjun6RpHT/Cn7JDH6ql86pgMO0dw6PDzPf0KY9DCrR
-ldQyzQ8WjN3FU55+En+8zmSnxUu7EbdqZwhVEF+UwfJ7IqJUnHll0aDTUA/qq0dk
-DqtMKIXvRnDVZJqKxHyRvARf8Zp8USsq3cVdlA9PhtcKrs4CbTDL0lJ3eWj1bDS1
-kIHXYo19lBqcS1oX+6TqvEs69oW/aG8UZIONN0Xh5TbxuJMedXD1dexV9oOA9lGR
-cS6Ye0wC7fCdnA6jfAmHFJ5t2qk7FOzcFZwap7m+EWn11z+72GVqz3BDSe5qH2m2
-XOHl59rVtJsZFtjyQEV34IFYyb2qBHHqUUdKwIwT1JOZIq+IdTJxaieIb1mnlmDw
-DDf4Kwr0C9tti1R1IsPaAmjF7eH0PGbDGAB3fJSCXbHf7EXTz1AUdknd2MHXQ7wO
-UBABkD2ETB+EotdHTly5FQt0jwbHfF2najBmezxtEjIygCnDb02Rtuei4HTansBu
-shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
------END RSA PRIVATE KEY-----';
+    /**
+     * @var string
+     */
+    private $privateKeyString;
+
+    /**
+     * @var string
+     */
+    private $privateKeyStringEnc;
+
+    /**
+     * Setup
+     *
+     * @return void
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->privateKeyString = file_get_contents(TEST_APP . DS . 'config' . DS . 'key.pem');
+        $this->privateKeyStringEnc = file_get_contents(TEST_APP . DS . 'config' . DS . 'key_with_passphrase.pem');
+    }
 
     public function testExceptionUnknownSigningMethod(): void
     {
@@ -336,11 +323,7 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'tR3+Ty81lMeYAr/Fid0kMTYa/WM=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
     }
 
     /**
@@ -365,11 +348,7 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = '2hr/eoFyTSuWc6SfZIvkhpeRHdM=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
     }
 
     /**
@@ -399,11 +378,29 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'jvTp/wX1TYtByB1m+Pbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2/9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW//e+RinhejgCuzoH26dyF8iY2ZZ/5D1ilgeijhV/vBka5twt399mXwaYdCwFYE=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
+        $this->assertSignatureFormat($result);
+    }
+
+    public function testRsaSigningInvalidKey(): void
+    {
+        $request = new Request(
+            'http://photos.example.net/photos',
+            'GET',
+            [],
+            ['file' => 'vacaction.jpg', 'size' => 'original']
         );
+
+        $options = [
+            'method' => 'RSA-SHA1',
+            'consumerKey' => 'dpf43f3p2l4k3l03',
+            'nonce' => '13917289812797014437',
+            'timestamp' => '1196666512',
+            'privateKey' => 'not a private key',
+        ];
+        $auth = new Oauth();
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('openssl error');
+        $auth->authentication($request, $options);
     }
 
     /**
@@ -433,11 +430,7 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'jvTp/wX1TYtByB1m+Pbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2/9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW//e+RinhejgCuzoH26dyF8iY2ZZ/5D1ilgeijhV/vBka5twt399mXwaYdCwFYE=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
     }
 
     /**
@@ -469,11 +462,7 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'jvTp/wX1TYtByB1m+Pbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2/9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW//e+RinhejgCuzoH26dyF8iY2ZZ/5D1ilgeijhV/vBka5twt399mXwaYdCwFYE=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
     }
 
     /**
@@ -505,11 +494,7 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'jvTp/wX1TYtByB1m+Pbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2/9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW//e+RinhejgCuzoH26dyF8iY2ZZ/5D1ilgeijhV/vBka5twt399mXwaYdCwFYE=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
     }
 
     /**
@@ -543,11 +528,7 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'jvTp/wX1TYtByB1m+Pbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2/9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW//e+RinhejgCuzoH26dyF8iY2ZZ/5D1ilgeijhV/vBka5twt399mXwaYdCwFYE=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
         $expected = 0;
         $this->assertSame($expected, ftell($passphrase));
     }
@@ -583,12 +564,16 @@ shqoyFXJvizZzje7HaTQv/eJTuA6rUOzu/sAv/eBx2YAPkA8oa3qUw==
         $request = $auth->authentication($request, $options);
 
         $result = $request->getHeaderLine('Authorization');
-        $expected = 'jvTp/wX1TYtByB1m+Pbyo0lnCOLIsyGCH7wke8AUs3BpnwZJtAuEJkvQL2/9n4s5wUmUl4aCI4BwpraNx4RtEXMe5qg5T1LVTGliMRpKasKsW//e+RinhejgCuzoH26dyF8iY2ZZ/5D1ilgeijhV/vBka5twt399mXwaYdCwFYE=';
-        $this->assertStringContainsString(
-            'oauth_signature="' . $expected . '"',
-            urldecode($result)
-        );
+        $this->assertSignatureFormat($result);
         $expected = 0;
         $this->assertSame($expected, ftell($passphrase));
     }
+
+    protected function assertSignatureFormat($result)
+    {
+        $this->assertMatchesRegularExpression(
+            '/oauth_signature="[a-zA-Z0-9\/=+]+"/',
+            urldecode($result)
+        );
+    }
 }

+ 56 - 0
tests/TestCase/Routing/RouterTest.php

@@ -2585,12 +2585,68 @@ class RouterTest extends TestCase
                 'action' => 'view',
                 'pass' => ['first-post'],
                 'slug' => 'first-post',
+                '_matchedRoute' => '/articles/{slug}',
             ],
         ]);
         $result = Router::reverse($request);
         $this->assertSame('/articles/first-post', $result);
     }
 
+    public function testReverseRouteKeyAndPassDuplicateValues(): void
+    {
+        Router::reload();
+        $routes = Router::createRouteBuilder('/');
+        $routes->connect('/authors/{author_id}/articles/{id}', ['controller' => 'Articles', 'action' => 'view'])
+            ->setPass(['id']);
+
+        $request = new ServerRequest([
+            'url' => '/authors/1/articles/1',
+            'params' => [
+                'controller' => 'Articles',
+                'action' => 'view',
+                'pass' => ['1'],
+                'author_id' => '1',
+                'id' => '1',
+                '_matchedRoute' => '/authors/{author_id}/articles/{id}',
+            ],
+        ]);
+        $result = Router::reverse($request);
+        $this->assertSame('/authors/1/articles/1', $result);
+
+        Router::reload();
+        $routes = Router::createRouteBuilder('/');
+        $routes->connect('/authors/{author_id}/articles/{id}/*', ['controller' => 'Articles', 'action' => 'view'])
+            ->setPass(['id', 'author_id']);
+
+        $request = new ServerRequest([
+            'url' => '/authors/88/articles/11',
+            'params' => [
+                'controller' => 'Articles',
+                'action' => 'view',
+                'pass' => ['11', '88', '99'],
+                'author_id' => '88',
+                'id' => '11',
+                '_matchedRoute' => '/authors/{author_id}/articles/{id}/*',
+            ],
+        ]);
+        $result = Router::reverse($request);
+        $this->assertSame('/authors/88/articles/11/99', $result);
+
+        $request = new ServerRequest([
+            'url' => '/authors/1/articles/1/1',
+            'params' => [
+                'controller' => 'Articles',
+                'action' => 'view',
+                'pass' => ['1', '1', '1'],
+                'author_id' => '1',
+                'id' => '1',
+                '_matchedRoute' => '/authors/{author_id}/articles/{id}/*',
+            ],
+        ]);
+        $result = Router::reverse($request);
+        $this->assertSame('/authors/1/articles/1/1', $result);
+    }
+
     public function testReverseArrayQuery(): void
     {
         $routes = Router::createRouteBuilder('/');

+ 16 - 0
tests/test_app/TestApp/Controller/Component/CustomPaginatorComponent.php

@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+namespace TestApp\Controller\Component;
+
+use Cake\Controller\Component\PaginatorComponent;
+
+class CustomPaginatorComponent extends PaginatorComponent
+{
+    protected $_defaultConfig = [
+        'page' => 1,
+        'limit' => 20,
+        'maxLimit' => 100,
+        'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
+    ];
+}

+ 25 - 14
tests/test_app/config/key.pem

@@ -1,16 +1,27 @@
 -----BEGIN RSA PRIVATE KEY-----
-MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALRiMLAh9iimur8V
-A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d
-7rjVJ0BLB+ubPK8iA/Tw3hLQgXMRRGRXXCn8ikfuQfjUS1uZSatdLB81mydBETlJ
-hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtleUzavkbrPjy0T5FMou8H
-X9u2AC2ry8vD/l7cqedtwMPp9k7TubgNFo+NGvKsl2ynyprOZR1xjQ7WgrgVB+mm
-uScOM/5HVceFuGRDhYTCObE+y1kxRloNYXnx3ei1zbeYLPCHdhxRYW7T0qcynNmw
-rn05/KO2RLjgQNalsQJBANeA3Q4Nugqy4QBUCEC09SqylT2K9FrrItqL2QKc9v0Z
-zO2uwllCbg0dwpVuYPYXYvikNHHg+aCWF+VXsb9rpPsCQQDWR9TT4ORdzoj+Nccn
-qkMsDmzt0EfNaAOwHOmVJ2RVBspPcxt5iN4HI7HNeG6U5YsFBb+/GZbgfBT3kpNG
-WPTpAkBI+gFhjfJvRw38n3g/+UeAkwMI2TJQS4n8+hid0uus3/zOjDySH3XHCUno
-cn1xOJAyZODBo47E+67R4jV1/gzbAkEAklJaspRPXP877NssM5nAZMU0/O/NGCZ+
-3jPgDUno6WbJn5cqm8MqWhW1xGkImgRk+fkDBquiq4gPiT898jusgQJAd5Zrr6Q8
-AO/0isr/3aa6O6NLQxISLKcPDk2NOccAfS/xOtfOz4sJYM3+Bs4Io9+dZGSDCA54
-Lw03eHTNQghS0A==
+MIIEogIBAAKCAQEA1Z4aywZhNQAbiai7J8UG7QkrRfMhqueX2pqUgz07C+fkslwB
+Muy6/qY7PUrjRa7CxJP++bLQbRk6UcXUBUD3VQTBo3k1/H+6qsLdTCHt9lciLi06
+ieuM6jVZZNMiQTSiLoyPbrKYsMgRb8mPckBxd2EmzMyzNOoSx++1G5B/LgsB2I1D
+XC+JDX3d1plRu7BB2bjmSn5R94QzO6XddorCs3ifJWcb4ZtFGzDfJd2ognZMkulB
+Gph5KjfT4O9GXQXkhj92Hm6jPJmozfhe2lL2w+NTLAe2H0cUPk5aCfq326YykOnG
+T8M/2QOU7HQF9iseC4Dkh6hFBC5syyxWn4HJKwIDAQABAoIBAHn156Qsg0lIQ3Cn
+1hrRoa+pgXxRYNJ7oTZc9Res2M5mWir+3mxdvjFvZPkMjY+WRHsJaBTV46u2MJbJ
+VNCfE9cFfSzBInKD1mZyFPjHkl4Hx2sHxZlC09RQGza2WKNT0pizBZ0U+JpNz668
+LFr1shKPdCCPam12irx9/i+7ovD8qi8tHCiWu4ZxkScjpmGVBY8xGrqSjhyGguc4
+L1agj042OS24lqLP8m2GNBtA1l7uemxyuR33hCR+J/nJ30uBpE2O9NR5bwrjkvTk
+tTNbDlUBv/UKRqUm7e5dQnV8jLYV6EUMUFJSEovnQmlT4DZoCKfROcYWFiHPBAfp
+Eef9ixECgYEA+khFLwuxa3nT48hfJ2R9tO8ijUPFoWKtSCWLqhDenFMoeZ56mG06
+6QjNBp1QcAviLlypr42CpbkyvOZnDvwrg0am9tHzHzufuo+4TVef03T5qfnbzOQN
+9f/pihxeW54KZXXREk3aHf387ogsZuDuOq4LVRbQSwdqbpEk9TZUbJMCgYEA2n9o
+W1IfKgOWqIRTNa7IlyL1kpavU1O1RHE1EJLZgTWr2f5srb23dEni71rEQjbgIosU
+M4h4NB2/BQMuYf5N1jNxX4W/IvUWfUnc9RGW1eLKgqoSmThkva7ps6/ZhlV+96YF
+tS/PRyhDtgHvAmrSAwXtCOh8wpAFVx2weiMBKAkCgYAEY1H7MixJIxio7LFmYmel
+zW+ApIiJfM5m7mmVcLhGa1rRTwr9MyUOQt49WHK5lCvB/lPnRQbeWvHdx/hUle05
+Xvq8Zw/pI0V4ot5rVLbzoSBjb4MAA5uPDY6NolOxLYMnJjqlJIJHdlWB5RdKMnVa
+yARg2IaMWjPuflL0jaBLSwKBgDlSHWV/uM9D271f4Zh+vv7vW+9V+q7okfBfpqUv
+cUI1e10YIxi9YahvTcqvTDd2v/wv8l/GmIpLl3ZQLFXm6jKckkyWANvB4mGCBCaC
+s8hu0+PNjE2H/t3ISmUqZ+2W9lUvx+WNolovlPvlq/c9YNUMM/AXVcuRDuWY01hn
+YIFJAoGAMzArFlJfSjiwZq6Eg/W0txESR4ULh0lkCxF+L55cFRyYVKruSnJu59Bi
+ImBAhQfOBsah6CtQPGS8HWh4l9IyGYIqQUHf+BLQuPIAUV0jeCagxo7qup/+khMI
+jnCTQE/xd+iKf3kWSZkOf7bQ2VCBjMJCrxYUYr+KZTCGLFK6vcI=
 -----END RSA PRIVATE KEY-----

+ 38 - 14
tests/test_app/config/key_with_passphrase.pem

@@ -1,18 +1,42 @@
 -----BEGIN RSA PRIVATE KEY-----
 Proc-Type: 4,ENCRYPTED
-DEK-Info: DES-CBC,E65DB7AE7A05EF23
+DEK-Info: AES-128-CBC,E634910BEDC73C123DE64ACFF88B339C
 
-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
+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
 -----END RSA PRIVATE KEY-----