Browse Source

Merge branch '4.next' into 5.x

ADmad 4 years ago
parent
commit
8eabd396df
31 changed files with 762 additions and 526 deletions
  1. 1 1
      .github/workflows/ci.yml
  2. 2 2
      .github/workflows/stale.yml
  3. 6 8
      src/Collection/CollectionInterface.php
  4. 11 0
      src/Console/BaseCommand.php
  5. 9 3
      src/Console/Command/HelpCommand.php
  6. 5 3
      src/Controller/Controller.php
  7. 6 16
      src/Error/ErrorTrap.php
  8. 4 449
      src/Error/ExceptionRenderer.php
  9. 18 11
      src/Error/ExceptionTrap.php
  10. 2 2
      src/Error/Middleware/ErrorHandlerMiddleware.php
  11. 490 0
      src/Error/Renderer/WebExceptionRenderer.php
  12. 4 3
      src/Http/Cookie/CookieCollection.php
  13. 1 1
      src/Http/Middleware/CsrfProtectionMiddleware.php
  14. 1 0
      src/ORM/Association/BelongsToMany.php
  15. 2 2
      src/Routing/Middleware/AssetMiddleware.php
  16. 3 3
      src/TestSuite/IntegrationTestTrait.php
  17. 1 1
      src/Validation/Validator.php
  18. 21 6
      src/View/Form/FormContext.php
  19. 35 0
      tests/Fixture/ArticlesTagsBindingKeysFixture.php
  20. 1 0
      tests/TestCase/Console/Command/HelpCommandTest.php
  21. 15 1
      tests/TestCase/Controller/ControllerTest.php
  22. 20 7
      tests/TestCase/Error/ExceptionTrapTest.php
  23. 2 2
      tests/TestCase/Error/Middleware/ErrorHandlerMiddlewareTest.php
  24. 7 3
      tests/TestCase/Http/Cookie/CookieCollectionTest.php
  25. 20 0
      tests/TestCase/Http/Middleware/CsrfProtectionMiddlewareTest.php
  26. 23 0
      tests/TestCase/ORM/Association/BelongsToManyTest.php
  27. 1 1
      tests/TestCase/ORM/TableTest.php
  28. 21 0
      tests/TestCase/View/Form/FormContextTest.php
  29. 22 0
      tests/schema.php
  30. 6 1
      tests/test_app/TestApp/Command/DemoCommand.php
  31. 2 0
      tests/test_app/TestApp/Model/Table/FeaturedTagsTable.php

+ 1 - 1
.github/workflows/ci.yml

@@ -128,7 +128,7 @@ jobs:
 
     - name: Submit code coverage
       if: matrix.php-version == '8.1'
-      uses: codecov/codecov-action@v2
+      uses: codecov/codecov-action@v3
 
   testsuite-windows:
     runs-on: windows-2019

+ 2 - 2
.github/workflows/stale.yml

@@ -19,5 +19,5 @@ jobs:
         stale-pr-label: 'stale'
         days-before-stale: 120
         days-before-close: 15
-        exempt-issue-label: 'pinned'
-        exempt-pr-label: 'pinned'
+        exempt-issue-labels: 'pinned'
+        exempt-pr-labels: 'pinned'

+ 6 - 8
src/Collection/CollectionInterface.php

@@ -100,9 +100,8 @@ interface CollectionInterface extends Iterator, JsonSerializable, Countable
      * Returns true if all values in this collection pass the truth test provided
      * in the callback.
      *
-     * Each time the callback is executed it will receive the value of the element
-     * in the current iteration and  the key of the element as arguments, in that
-     * order.
+     * The callback is passed the value and key of the element being tested and should
+     * return true if the test passed.
      *
      * ### Example:
      *
@@ -112,7 +111,7 @@ interface CollectionInterface extends Iterator, JsonSerializable, Countable
      * });
      * ```
      *
-     * Empty collections always return true because it is a vacuous truth.
+     * Empty collections always return true.
      *
      * @param callable $callback a callback function
      * @return bool true if for all elements in this collection the provided
@@ -124,14 +123,13 @@ interface CollectionInterface extends Iterator, JsonSerializable, Countable
      * Returns true if any of the values in this collection pass the truth test
      * provided in the callback.
      *
-     * Each time the callback is executed it will receive the value of the element
-     * in the current iteration and the key of the element as arguments, in that
-     * order.
+     * The callback is passed the value and key of the element being tested and should
+     * return true if the test passed.
      *
      * ### Example:
      *
      * ```
-     * $hasYoungPeople = (new Collection([24, 45, 15]))->every(function ($value, $key) {
+     * $hasYoungPeople = (new Collection([24, 45, 15]))->some(function ($value, $key) {
      *  return $value < 21;
      * });
      * ```

+ 11 - 0
src/Console/BaseCommand.php

@@ -66,6 +66,16 @@ abstract class BaseCommand implements CommandInterface
     }
 
     /**
+     * Get the command description.
+     *
+     * @return string
+     */
+    public static function getDescription(): string
+    {
+        return '';
+    }
+
+    /**
      * Get the root command name.
      *
      * @return string
@@ -109,6 +119,7 @@ abstract class BaseCommand implements CommandInterface
         [$root, $name] = explode(' ', $this->name, 2);
         $parser = new ConsoleOptionParser($name);
         $parser->setRootName($root);
+        $parser->setDescription(static::getDescription());
 
         $parser = $this->buildOptionParser($parser);
         if ($parser->subcommands()) {

+ 9 - 3
src/Console/Command/HelpCommand.php

@@ -112,7 +112,10 @@ class HelpCommand extends BaseCommand implements CommandCollectionAwareInterface
                 [, $shortestName] = explode('.', $shortestName, 2);
             }
 
-            $grouped[$prefix][] = $shortestName;
+            $grouped[$prefix][] = [
+                'name' => $shortestName,
+                'description' => is_subclass_of($class, BaseCommand::class) ? $class::getDescription() : '',
+            ];
         }
         ksort($grouped);
 
@@ -122,8 +125,11 @@ class HelpCommand extends BaseCommand implements CommandCollectionAwareInterface
         foreach ($grouped as $prefix => $names) {
             $io->out("<info>{$prefix}</info>:");
             sort($names);
-            foreach ($names as $name) {
-                $io->out(' - ' . $name);
+            foreach ($names as $data) {
+                $io->out(' - ' . $data['name']);
+                if ($data['description']) {
+                    $io->info(str_pad(" \u{2514}", 13, "\u{2500}") . ' ' . $data['description']);
+                }
             }
             $io->out('');
         }

+ 5 - 3
src/Controller/Controller.php

@@ -747,9 +747,11 @@ class Controller implements EventListenerInterface, EventDispatcherInterface
         // Prefer the _ext route parameter if it is defined.
         $ext = $request->getParam('_ext');
         if ($ext) {
-            $extType = $this->response->getMimeType($ext);
-            if (isset($typeMap[$extType])) {
-                return $typeMap[$extType];
+            $extTypes = (array)($this->response->getMimeType($ext) ?: []);
+            foreach ($extTypes as $extType) {
+                if (isset($typeMap[$extType])) {
+                    return $typeMap[$extType];
+                }
             }
         }
 

+ 6 - 16
src/Error/ErrorTrap.php

@@ -147,20 +147,15 @@ class ErrorTrap
      */
     public function renderer(): ErrorRendererInterface
     {
-        $class = $this->_getConfig('errorRenderer');
-        if (!$class) {
-            $class = $this->chooseErrorRenderer();
-        }
+        /** @var class-string<\Cake\Error\ErrorRendererInterface> $class */
+        $class = $this->_getConfig('errorRenderer') ?: $this->chooseErrorRenderer();
         if (!in_array(ErrorRendererInterface::class, class_implements($class))) {
             throw new InvalidArgumentException(
                 "Cannot use {$class} as an error renderer. It must implement \Cake\Error\ErrorRendererInterface."
             );
         }
 
-        /** @var \Cake\Error\ErrorRendererInterface $instance */
-        $instance = new $class($this->_config);
-
-        return $instance;
+        return new $class($this->_config);
     }
 
     /**
@@ -170,19 +165,14 @@ class ErrorTrap
      */
     public function logger(): ErrorLoggerInterface
     {
-        $class = $this->_getConfig('logger');
-        if (!$class) {
-            $class = $this->_defaultConfig['logger'];
-        }
+        /** @var class-string<\Cake\Error\ErrorLoggerInterface> $class */
+        $class = $this->_getConfig('logger', $this->_defaultConfig['logger']);
         if (!in_array(ErrorLoggerInterface::class, class_implements($class))) {
             throw new InvalidArgumentException(
                 "Cannot use {$class} as an error logger. It must implement \Cake\Error\ErrorLoggerInterface."
             );
         }
 
-        /** @var \Cake\Error\ErrorLoggerInterface $instance */
-        $instance = new $class($this->_config);
-
-        return $instance;
+        return new $class($this->_config);
     }
 }

+ 4 - 449
src/Error/ExceptionRenderer.php

@@ -16,458 +16,13 @@ declare(strict_types=1);
  */
 namespace Cake\Error;
 
-use Cake\Controller\Controller;
-use Cake\Controller\ControllerFactory;
-use Cake\Controller\Exception\InvalidParameterException;
-use Cake\Controller\Exception\MissingActionException;
-use Cake\Core\App;
-use Cake\Core\Configure;
-use Cake\Core\Container;
-use Cake\Core\Exception\CakeException;
-use Cake\Core\Exception\MissingPluginException;
-use Cake\Datasource\Exception\RecordNotFoundException;
-use Cake\Datasource\Paging\Exception\PageOutOfBoundsException;
-use Cake\Http\Exception\HttpException;
-use Cake\Http\Exception\MissingControllerException;
-use Cake\Http\Response;
-use Cake\Http\ResponseEmitter;
-use Cake\Http\ServerRequest;
-use Cake\Http\ServerRequestFactory;
-use Cake\Routing\Exception\MissingRouteException;
-use Cake\Routing\Router;
-use Cake\Utility\Inflector;
-use Cake\View\Exception\MissingLayoutException;
-use Cake\View\Exception\MissingTemplateException;
-use PDOException;
-use Psr\Http\Message\ResponseInterface;
-use Throwable;
+use Cake\Error\Renderer\WebExceptionRenderer;
 
 /**
- * Exception Renderer.
+ * Backwards compatible Exception Renderer.
  *
- * Captures and handles all unhandled exceptions. Displays helpful framework errors when debug is true.
- * When debug is false a ExceptionRenderer will render 404 or 500 errors. If an uncaught exception is thrown
- * and it is a type that ExceptionHandler does not know about it will be treated as a 500 error.
- *
- * ### Implementing application specific exception rendering
- *
- * You can implement application specific exception handling by creating a subclass of
- * ExceptionRenderer and configure it to be the `exceptionRenderer` in config/error.php
- *
- * #### Using a subclass of ExceptionRenderer
- *
- * Using a subclass of ExceptionRenderer gives you full control over how Exceptions are rendered, you
- * can configure your class in your config/app.php.
+ * @deprecated 4.4.0 Use `Cake\Error\Renderer\WebExceptionRenderer` instead.
  */
-class ExceptionRenderer implements ExceptionRendererInterface
+class ExceptionRenderer extends WebExceptionRenderer
 {
-    /**
-     * The exception being handled.
-     *
-     * @var \Throwable
-     */
-    protected Throwable $error;
-
-    /**
-     * Controller instance.
-     *
-     * @var \Cake\Controller\Controller
-     */
-    protected Controller $controller;
-
-    /**
-     * Template to render for {@link \Cake\Core\Exception\CakeException}
-     *
-     * @var string
-     */
-    protected string $template = '';
-
-    /**
-     * The method corresponding to the Exception this object is for.
-     *
-     * @var string
-     */
-    protected string $method = '';
-
-    /**
-     * If set, this will be request used to create the controller that will render
-     * the error.
-     *
-     * @var \Cake\Http\ServerRequest|null
-     */
-    protected ?ServerRequest $request = null;
-
-    /**
-     * Map of exceptions to http status codes.
-     *
-     * This can be customized for users that don't want specific exceptions to throw 404 errors
-     * or want their application exceptions to be automatically converted.
-     *
-     * @var array<string, int>
-     * @psalm-var array<class-string<\Throwable>, int>
-     */
-    protected array $exceptionHttpCodes = [
-        // Controller exceptions
-        InvalidParameterException::class => 404,
-        MissingActionException::class => 404,
-        // Datasource exceptions
-        PageOutOfBoundsException::class => 404,
-        RecordNotFoundException::class => 404,
-        // Http exceptions
-        MissingControllerException::class => 404,
-        // Routing exceptions
-        MissingRouteException::class => 404,
-    ];
-
-    /**
-     * Creates the controller to perform rendering on the error response.
-     *
-     * @param \Throwable $exception Exception.
-     * @param \Cake\Http\ServerRequest|null $request The request if this is set it will be used
-     *   instead of creating a new one.
-     */
-    public function __construct(Throwable $exception, ?ServerRequest $request = null)
-    {
-        $this->error = $exception;
-        $this->request = $request;
-        $this->controller = $this->_getController();
-    }
-
-    /**
-     * Get the controller instance to handle the exception.
-     * Override this method in subclasses to customize the controller used.
-     * This method returns the built in `ErrorController` normally, or if an error is repeated
-     * a bare controller will be used.
-     *
-     * @return \Cake\Controller\Controller
-     * @triggers Controller.startup $controller
-     */
-    protected function _getController(): Controller
-    {
-        $request = $this->request;
-        $routerRequest = Router::getRequest();
-        // Fallback to the request in the router or make a new one from
-        // $_SERVER
-        $request ??= $routerRequest ?: ServerRequestFactory::fromGlobals();
-
-        // If the current request doesn't have routing data, but we
-        // found a request in the router context copy the params over
-        if ($request->getParam('controller') === null && $routerRequest !== null) {
-            $request = $request->withAttribute('params', $routerRequest->getAttribute('params'));
-        }
-
-        $errorOccured = false;
-        try {
-            $params = $request->getAttribute('params');
-            $params['controller'] = 'Error';
-
-            $factory = new ControllerFactory(new Container());
-            $class = $factory->getControllerClass($request->withAttribute('params', $params));
-
-            if (!$class) {
-                /** @var string $class */
-                $class = App::className('Error', 'Controller', 'Controller');
-            }
-
-            /** @var \Cake\Controller\Controller $controller */
-            $controller = new $class($request);
-            $controller->startupProcess();
-        } catch (Throwable) {
-        }
-
-        if (!isset($controller)) {
-            return new Controller($request);
-        }
-
-        return $controller;
-    }
-
-    /**
-     * Clear output buffers so error pages display properly.
-     *
-     * @return void
-     */
-    protected function clearOutput(): void
-    {
-        if (in_array(PHP_SAPI, ['cli', 'phpdbg'])) {
-            return;
-        }
-        while (ob_get_level()) {
-            ob_end_clean();
-        }
-    }
-
-    /**
-     * Renders the response for the exception.
-     *
-     * @return \Cake\Http\Response The response to be sent.
-     */
-    public function render(): ResponseInterface
-    {
-        $exception = $this->error;
-        $code = $this->getHttpCode($exception);
-        $method = $this->_method($exception);
-        $template = $this->_template($exception, $method, $code);
-        $this->clearOutput();
-
-        if (method_exists($this, $method)) {
-            return $this->_customMethod($method, $exception);
-        }
-
-        $message = $this->_message($exception, $code);
-        $url = $this->controller->getRequest()->getRequestTarget();
-        $response = $this->controller->getResponse();
-
-        if ($exception instanceof HttpException) {
-            foreach ($exception->getHeaders() as $name => $value) {
-                $response = $response->withHeader($name, $value);
-            }
-        }
-        $response = $response->withStatus($code);
-
-        $viewVars = [
-            'message' => $message,
-            'url' => h($url),
-            'error' => $exception,
-            'code' => $code,
-        ];
-        $serialize = ['message', 'url', 'code'];
-
-        $isDebug = Configure::read('debug');
-        if ($isDebug) {
-            $trace = (array)Debugger::formatTrace($exception->getTrace(), [
-                'format' => 'array',
-                'args' => false,
-            ]);
-            $origin = [
-                'file' => $exception->getFile() ?: 'null',
-                'line' => $exception->getLine() ?: 'null',
-            ];
-            // Traces don't include the origin file/line.
-            array_unshift($trace, $origin);
-            $viewVars['trace'] = $trace;
-            $viewVars += $origin;
-            $serialize[] = 'file';
-            $serialize[] = 'line';
-        }
-        $this->controller->set($viewVars);
-        $this->controller->viewBuilder()->setOption('serialize', $serialize);
-
-        if ($exception instanceof CakeException && $isDebug) {
-            $this->controller->set($exception->getAttributes());
-        }
-        $this->controller->setResponse($response);
-
-        return $this->_outputMessage($template);
-    }
-
-    /**
-     * Emit the response content
-     *
-     * @param \Psr\Http\Message\ResponseInterface|string $output The response to output.
-     * @return void
-     */
-    public function write(ResponseInterface|string $output): void
-    {
-        if (is_string($output)) {
-            echo $output;
-
-            return;
-        }
-
-        $emitter = new ResponseEmitter();
-        $emitter->emit($output);
-    }
-
-    /**
-     * Render a custom error method/template.
-     *
-     * @param string $method The method name to invoke.
-     * @param \Throwable $exception The exception to render.
-     * @return \Cake\Http\Response The response to send.
-     */
-    protected function _customMethod(string $method, Throwable $exception): Response
-    {
-        $result = $this->{$method}($exception);
-        $this->_shutdown();
-        if (is_string($result)) {
-            $result = $this->controller->getResponse()->withStringBody($result);
-        }
-
-        return $result;
-    }
-
-    /**
-     * Get method name
-     *
-     * @param \Throwable $exception Exception instance.
-     * @return string
-     */
-    protected function _method(Throwable $exception): string
-    {
-        [, $baseClass] = namespaceSplit(get_class($exception));
-
-        if (substr($baseClass, -9) === 'Exception') {
-            $baseClass = substr($baseClass, 0, -9);
-        }
-
-        // $baseClass would be an empty string if the exception class is \Exception.
-        $method = $baseClass === '' ? 'error500' : Inflector::variable($baseClass);
-
-        return $this->method = $method;
-    }
-
-    /**
-     * Get error message.
-     *
-     * @param \Throwable $exception Exception.
-     * @param int $code Error code.
-     * @return string Error message
-     */
-    protected function _message(Throwable $exception, int $code): string
-    {
-        $message = $exception->getMessage();
-
-        if (
-            !Configure::read('debug') &&
-            !($exception instanceof HttpException)
-        ) {
-            if ($code < 500) {
-                $message = __d('cake', 'Not Found');
-            } else {
-                $message = __d('cake', 'An Internal Error Has Occurred.');
-            }
-        }
-
-        return $message;
-    }
-
-    /**
-     * Get template for rendering exception info.
-     *
-     * @param \Throwable $exception Exception instance.
-     * @param string $method Method name.
-     * @param int $code Error code.
-     * @return string Template name
-     */
-    protected function _template(Throwable $exception, string $method, int $code): string
-    {
-        if ($exception instanceof HttpException || !Configure::read('debug')) {
-            return $this->template = $code < 500 ? 'error400' : 'error500';
-        }
-
-        if ($exception instanceof PDOException) {
-            return $this->template = 'pdo_error';
-        }
-
-        return $this->template = $method;
-    }
-
-    /**
-     * Gets the appropriate http status code for exception.
-     *
-     * @param \Throwable $exception Exception.
-     * @return int A valid HTTP status code.
-     */
-    protected function getHttpCode(Throwable $exception): int
-    {
-        if ($exception instanceof HttpException) {
-            return $exception->getCode();
-        }
-
-        return $this->exceptionHttpCodes[get_class($exception)] ?? 500;
-    }
-
-    /**
-     * Generate the response using the controller object.
-     *
-     * @param string $template The template to render.
-     * @return \Cake\Http\Response A response object that can be sent.
-     */
-    protected function _outputMessage(string $template): Response
-    {
-        try {
-            $this->controller->render($template);
-
-            return $this->_shutdown();
-        } catch (MissingTemplateException $e) {
-            $attributes = $e->getAttributes();
-            if (
-                $e instanceof MissingLayoutException ||
-                str_contains($attributes['file'], 'error500')
-            ) {
-                return $this->_outputMessageSafe('error500');
-            }
-
-            return $this->_outputMessage('error500');
-        } catch (MissingPluginException $e) {
-            $attributes = $e->getAttributes();
-            if (isset($attributes['plugin']) && $attributes['plugin'] === $this->controller->getPlugin()) {
-                $this->controller->setPlugin(null);
-            }
-
-            return $this->_outputMessageSafe('error500');
-        } catch (Throwable $outer) {
-            try {
-                return $this->_outputMessageSafe('error500');
-            } catch (Throwable $inner) {
-                throw $outer;
-            }
-        }
-    }
-
-    /**
-     * A safer way to render error messages, replaces all helpers, with basics
-     * and doesn't call component methods.
-     *
-     * @param string $template The template to render.
-     * @return \Cake\Http\Response A response object that can be sent.
-     */
-    protected function _outputMessageSafe(string $template): Response
-    {
-        $builder = $this->controller->viewBuilder();
-        $builder
-            ->setHelpers([])
-            ->setLayoutPath('')
-            ->setTemplatePath('Error');
-        $view = $this->controller->createView('View');
-
-        $response = $this->controller->getResponse()
-            ->withType('html')
-            ->withStringBody($view->render($template, 'error'));
-        $this->controller->setResponse($response);
-
-        return $response;
-    }
-
-    /**
-     * Run the shutdown events.
-     *
-     * Triggers the afterFilter and afterDispatch events.
-     *
-     * @return \Cake\Http\Response The response to serve.
-     */
-    protected function _shutdown(): Response
-    {
-        $this->controller->dispatchEvent('Controller.shutdown');
-
-        return $this->controller->getResponse();
-    }
-
-    /**
-     * Returns an array that can be used to describe the internal state of this
-     * object.
-     *
-     * @return array<string, mixed>
-     */
-    public function __debugInfo(): array
-    {
-        return [
-            'error' => $this->error,
-            'request' => $this->request,
-            'controller' => $this->controller,
-            'template' => $this->template,
-            'method' => $this->method,
-        ];
-    }
 }

+ 18 - 11
src/Error/ExceptionTrap.php

@@ -98,12 +98,25 @@ class ExceptionTrap
 
         /** @var callable|class-string $class */
         $class = $this->_getConfig('exceptionRenderer');
-        if (!$class) {
+        $deprecatedConfig = ($class === ExceptionRenderer::class && PHP_SAPI === 'cli');
+        if ($deprecatedConfig) {
+            deprecationWarning(
+                '4.4.0',
+                'Your application is using a deprecated `Error.exceptionRenderer`. ' .
+                'You can either remove the `Error.exceptionRenderer` config key to have CakePHP choose ' .
+                'one of the default exception renderers, or define a class that is not `Cake\Error\ExceptionRenderer`.'
+            );
+        }
+        if (!$class || $deprecatedConfig) {
+            // Default to detecting the exception renderer if we're
+            // in a CLI context and the Web renderer is currently selected.
+            // This indicates old configuration or user error, in both scenarios
+            // it is preferrable to use the Console renderer instead.
             $class = $this->chooseRenderer();
         }
 
         if (is_string($class)) {
-            /** @var class-string $class */
+            /** @var class-string<\Cake\Error\ExceptionRendererInterface> $class */
             if (!is_subclass_of($class, ExceptionRendererInterface::class)) {
                 throw new InvalidArgumentException(
                     "Cannot use {$class} as an `exceptionRenderer`. " .
@@ -111,7 +124,6 @@ class ExceptionTrap
                 );
             }
 
-            /** @var \Cake\Error\ExceptionRendererInterface */
             return new $class($exception, $request, $this->_config);
         }
 
@@ -136,10 +148,8 @@ class ExceptionTrap
      */
     public function logger(): ErrorLoggerInterface
     {
-        $class = $this->_getConfig('logger');
-        if (!$class) {
-            $class = $this->_defaultConfig['logger'];
-        }
+        /** @var class-string<\Cake\Error\ErrorLoggerInterface> $class */
+        $class = $this->_getConfig('logger', $this->_defaultConfig['logger']);
         if (!in_array(ErrorLoggerInterface::class, class_implements($class))) {
             throw new InvalidArgumentException(
                 "Cannot use {$class} as an exception logger. " .
@@ -147,10 +157,7 @@ class ExceptionTrap
             );
         }
 
-        /** @var \Cake\Error\ErrorLoggerInterface $instance */
-        $instance = new $class($this->_config);
-
-        return $instance;
+        return new $class($this->_config);
     }
 
     /**

+ 2 - 2
src/Error/Middleware/ErrorHandlerMiddleware.php

@@ -19,8 +19,8 @@ namespace Cake\Error\Middleware;
 use Cake\Core\App;
 use Cake\Core\Configure;
 use Cake\Core\InstanceConfigTrait;
-use Cake\Error\ExceptionRenderer;
 use Cake\Error\ExceptionTrap;
+use Cake\Error\Renderer\WebExceptionRenderer;
 use Cake\Http\Exception\RedirectException;
 use Cake\Http\Response;
 use Laminas\Diactoros\Response\RedirectResponse;
@@ -64,7 +64,7 @@ class ErrorHandlerMiddleware implements MiddlewareInterface
         'skipLog' => [],
         'log' => true,
         'trace' => false,
-        'exceptionRenderer' => ExceptionRenderer::class,
+        'exceptionRenderer' => WebExceptionRenderer::class,
     ];
 
     /**

+ 490 - 0
src/Error/Renderer/WebExceptionRenderer.php

@@ -0,0 +1,490 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Error\Renderer;
+
+use Cake\Controller\Controller;
+use Cake\Controller\ControllerFactory;
+use Cake\Controller\Exception\InvalidParameterException;
+use Cake\Controller\Exception\MissingActionException;
+use Cake\Core\App;
+use Cake\Core\Configure;
+use Cake\Core\Container;
+use Cake\Core\Exception\CakeException;
+use Cake\Core\Exception\MissingPluginException;
+use Cake\Datasource\Exception\RecordNotFoundException;
+use Cake\Datasource\Paging\Exception\PageOutOfBoundsException;
+use Cake\Error\Debugger;
+use Cake\Error\ExceptionRendererInterface;
+use Cake\Event\Event;
+use Cake\Http\Exception\HttpException;
+use Cake\Http\Exception\MissingControllerException;
+use Cake\Http\Response;
+use Cake\Http\ResponseEmitter;
+use Cake\Http\ServerRequest;
+use Cake\Http\ServerRequestFactory;
+use Cake\Routing\Exception\MissingRouteException;
+use Cake\Routing\Router;
+use Cake\Utility\Inflector;
+use Cake\View\Exception\MissingLayoutException;
+use Cake\View\Exception\MissingTemplateException;
+use PDOException;
+use Psr\Http\Message\ResponseInterface;
+use Throwable;
+
+/**
+ * Web Exception Renderer.
+ *
+ * Captures and handles all unhandled exceptions. Displays helpful framework errors when debug is true.
+ * When debug is false, WebExceptionRenderer will render 404 or 500 errors. If an uncaught exception is thrown
+ * and it is a type that WebExceptionHandler does not know about it will be treated as a 500 error.
+ *
+ * ### Implementing application specific exception rendering
+ *
+ * You can implement application specific exception handling by creating a subclass of
+ * WebExceptionRenderer and configure it to be the `exceptionRenderer` in config/error.php
+ *
+ * #### Using a subclass of WebExceptionRenderer
+ *
+ * Using a subclass of WebExceptionRenderer gives you full control over how Exceptions are rendered, you
+ * can configure your class in your config/app.php.
+ */
+class WebExceptionRenderer implements ExceptionRendererInterface
+{
+    /**
+     * The exception being handled.
+     *
+     * @var \Throwable
+     */
+    protected $error;
+
+    /**
+     * Controller instance.
+     *
+     * @var \Cake\Controller\Controller
+     */
+    protected $controller;
+
+    /**
+     * Template to render for {@link \Cake\Core\Exception\CakeException}
+     *
+     * @var string
+     */
+    protected $template = '';
+
+    /**
+     * The method corresponding to the Exception this object is for.
+     *
+     * @var string
+     */
+    protected $method = '';
+
+    /**
+     * If set, this will be request used to create the controller that will render
+     * the error.
+     *
+     * @var \Cake\Http\ServerRequest|null
+     */
+    protected $request;
+
+    /**
+     * Map of exceptions to http status codes.
+     *
+     * This can be customized for users that don't want specific exceptions to throw 404 errors
+     * or want their application exceptions to be automatically converted.
+     *
+     * @var array<string, int>
+     * @psalm-var array<class-string<\Throwable>, int>
+     */
+    protected $exceptionHttpCodes = [
+        // Controller exceptions
+        InvalidParameterException::class => 404,
+        MissingActionException::class => 404,
+        // Datasource exceptions
+        PageOutOfBoundsException::class => 404,
+        RecordNotFoundException::class => 404,
+        // Http exceptions
+        MissingControllerException::class => 404,
+        // Routing exceptions
+        MissingRouteException::class => 404,
+    ];
+
+    /**
+     * Creates the controller to perform rendering on the error response.
+     *
+     * @param \Throwable $exception Exception.
+     * @param \Cake\Http\ServerRequest|null $request The request if this is set it will be used
+     *   instead of creating a new one.
+     */
+    public function __construct(Throwable $exception, ?ServerRequest $request = null)
+    {
+        $this->error = $exception;
+        $this->request = $request;
+        $this->controller = $this->_getController();
+    }
+
+    /**
+     * Get the controller instance to handle the exception.
+     * Override this method in subclasses to customize the controller used.
+     * This method returns the built in `ErrorController` normally, or if an error is repeated
+     * a bare controller will be used.
+     *
+     * @return \Cake\Controller\Controller
+     * @triggers Controller.startup $controller
+     */
+    protected function _getController(): Controller
+    {
+        $request = $this->request;
+        $routerRequest = Router::getRequest();
+        // Fallback to the request in the router or make a new one from
+        // $_SERVER
+        if ($request === null) {
+            $request = $routerRequest ?: ServerRequestFactory::fromGlobals();
+        }
+
+        // If the current request doesn't have routing data, but we
+        // found a request in the router context copy the params over
+        if ($request->getParam('controller') === null && $routerRequest !== null) {
+            $request = $request->withAttribute('params', $routerRequest->getAttribute('params'));
+        }
+
+        $errorOccured = false;
+        try {
+            $params = $request->getAttribute('params');
+            $params['controller'] = 'Error';
+
+            $factory = new ControllerFactory(new Container());
+            $class = $factory->getControllerClass($request->withAttribute('params', $params));
+
+            if (!$class) {
+                /** @var string $class */
+                $class = App::className('Error', 'Controller', 'Controller');
+            }
+
+            /** @var \Cake\Controller\Controller $controller */
+            $controller = new $class($request);
+            $controller->startupProcess();
+        } catch (Throwable $e) {
+            $errorOccured = true;
+        }
+
+        if (!isset($controller)) {
+            return new Controller($request);
+        }
+
+        // Retry RequestHandler, as another aspect of startupProcess()
+        // could have failed. Ignore any exceptions out of startup, as
+        // there could be userland input data parsers.
+        if ($errorOccured && isset($controller->RequestHandler)) {
+            try {
+                $event = new Event('Controller.startup', $controller);
+                $controller->RequestHandler->startup($event);
+            } catch (Throwable $e) {
+            }
+        }
+
+        return $controller;
+    }
+
+    /**
+     * Clear output buffers so error pages display properly.
+     *
+     * @return void
+     */
+    protected function clearOutput(): void
+    {
+        if (in_array(PHP_SAPI, ['cli', 'phpdbg'])) {
+            return;
+        }
+        while (ob_get_level()) {
+            ob_end_clean();
+        }
+    }
+
+    /**
+     * Renders the response for the exception.
+     *
+     * @return \Cake\Http\Response The response to be sent.
+     */
+    public function render(): ResponseInterface
+    {
+        $exception = $this->error;
+        $code = $this->getHttpCode($exception);
+        $method = $this->_method($exception);
+        $template = $this->_template($exception, $method, $code);
+        $this->clearOutput();
+
+        if (method_exists($this, $method)) {
+            return $this->_customMethod($method, $exception);
+        }
+
+        $message = $this->_message($exception, $code);
+        $url = $this->controller->getRequest()->getRequestTarget();
+        $response = $this->controller->getResponse();
+
+        if ($exception instanceof HttpException) {
+            foreach ($exception->getHeaders() as $name => $value) {
+                $response = $response->withHeader($name, $value);
+            }
+        }
+        $response = $response->withStatus($code);
+
+        $viewVars = [
+            'message' => $message,
+            'url' => h($url),
+            'error' => $exception,
+            'code' => $code,
+        ];
+        $serialize = ['message', 'url', 'code'];
+
+        $isDebug = Configure::read('debug');
+        if ($isDebug) {
+            $trace = (array)Debugger::formatTrace($exception->getTrace(), [
+                'format' => 'array',
+                'args' => false,
+            ]);
+            $origin = [
+                'file' => $exception->getFile() ?: 'null',
+                'line' => $exception->getLine() ?: 'null',
+            ];
+            // Traces don't include the origin file/line.
+            array_unshift($trace, $origin);
+            $viewVars['trace'] = $trace;
+            $viewVars += $origin;
+            $serialize[] = 'file';
+            $serialize[] = 'line';
+        }
+        $this->controller->set($viewVars);
+        $this->controller->viewBuilder()->setOption('serialize', $serialize);
+
+        if ($exception instanceof CakeException && $isDebug) {
+            $this->controller->set($exception->getAttributes());
+        }
+        $this->controller->setResponse($response);
+
+        return $this->_outputMessage($template);
+    }
+
+    /**
+     * Emit the response content
+     *
+     * @param \Psr\Http\Message\ResponseInterface|string $output The response to output.
+     * @return void
+     */
+    public function write($output): void
+    {
+        if (is_string($output)) {
+            echo $output;
+
+            return;
+        }
+
+        $emitter = new ResponseEmitter();
+        $emitter->emit($output);
+    }
+
+    /**
+     * Render a custom error method/template.
+     *
+     * @param string $method The method name to invoke.
+     * @param \Throwable $exception The exception to render.
+     * @return \Cake\Http\Response The response to send.
+     */
+    protected function _customMethod(string $method, Throwable $exception): Response
+    {
+        $result = $this->{$method}($exception);
+        $this->_shutdown();
+        if (is_string($result)) {
+            $result = $this->controller->getResponse()->withStringBody($result);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Get method name
+     *
+     * @param \Throwable $exception Exception instance.
+     * @return string
+     */
+    protected function _method(Throwable $exception): string
+    {
+        [, $baseClass] = namespaceSplit(get_class($exception));
+
+        if (substr($baseClass, -9) === 'Exception') {
+            $baseClass = substr($baseClass, 0, -9);
+        }
+
+        // $baseClass would be an empty string if the exception class is \Exception.
+        $method = $baseClass === '' ? 'error500' : Inflector::variable($baseClass);
+
+        return $this->method = $method;
+    }
+
+    /**
+     * Get error message.
+     *
+     * @param \Throwable $exception Exception.
+     * @param int $code Error code.
+     * @return string Error message
+     */
+    protected function _message(Throwable $exception, int $code): string
+    {
+        $message = $exception->getMessage();
+
+        if (
+            !Configure::read('debug') &&
+            !($exception instanceof HttpException)
+        ) {
+            if ($code < 500) {
+                $message = __d('cake', 'Not Found');
+            } else {
+                $message = __d('cake', 'An Internal Error Has Occurred.');
+            }
+        }
+
+        return $message;
+    }
+
+    /**
+     * Get template for rendering exception info.
+     *
+     * @param \Throwable $exception Exception instance.
+     * @param string $method Method name.
+     * @param int $code Error code.
+     * @return string Template name
+     */
+    protected function _template(Throwable $exception, string $method, int $code): string
+    {
+        if ($exception instanceof HttpException || !Configure::read('debug')) {
+            return $this->template = $code < 500 ? 'error400' : 'error500';
+        }
+
+        if ($exception instanceof PDOException) {
+            return $this->template = 'pdo_error';
+        }
+
+        return $this->template = $method;
+    }
+
+    /**
+     * Gets the appropriate http status code for exception.
+     *
+     * @param \Throwable $exception Exception.
+     * @return int A valid HTTP status code.
+     */
+    protected function getHttpCode(Throwable $exception): int
+    {
+        if ($exception instanceof HttpException) {
+            return $exception->getCode();
+        }
+
+        return $this->exceptionHttpCodes[get_class($exception)] ?? 500;
+    }
+
+    /**
+     * Generate the response using the controller object.
+     *
+     * @param string $template The template to render.
+     * @return \Cake\Http\Response A response object that can be sent.
+     */
+    protected function _outputMessage(string $template): Response
+    {
+        try {
+            $this->controller->render($template);
+
+            return $this->_shutdown();
+        } catch (MissingTemplateException $e) {
+            $attributes = $e->getAttributes();
+            if (
+                $e instanceof MissingLayoutException ||
+                strpos($attributes['file'], 'error500') !== false
+            ) {
+                return $this->_outputMessageSafe('error500');
+            }
+
+            return $this->_outputMessage('error500');
+        } catch (MissingPluginException $e) {
+            $attributes = $e->getAttributes();
+            if (isset($attributes['plugin']) && $attributes['plugin'] === $this->controller->getPlugin()) {
+                $this->controller->setPlugin(null);
+            }
+
+            return $this->_outputMessageSafe('error500');
+        } catch (Throwable $outer) {
+            try {
+                return $this->_outputMessageSafe('error500');
+            } catch (Throwable $inner) {
+                throw $outer;
+            }
+        }
+    }
+
+    /**
+     * A safer way to render error messages, replaces all helpers, with basics
+     * and doesn't call component methods.
+     *
+     * @param string $template The template to render.
+     * @return \Cake\Http\Response A response object that can be sent.
+     */
+    protected function _outputMessageSafe(string $template): Response
+    {
+        $builder = $this->controller->viewBuilder();
+        $builder
+            ->setHelpers([], false)
+            ->setLayoutPath('')
+            ->setTemplatePath('Error');
+        $view = $this->controller->createView('View');
+
+        $response = $this->controller->getResponse()
+            ->withType('html')
+            ->withStringBody($view->render($template, 'error'));
+        $this->controller->setResponse($response);
+
+        return $response;
+    }
+
+    /**
+     * Run the shutdown events.
+     *
+     * Triggers the afterFilter and afterDispatch events.
+     *
+     * @return \Cake\Http\Response The response to serve.
+     */
+    protected function _shutdown(): Response
+    {
+        $this->controller->dispatchEvent('Controller.shutdown');
+
+        return $this->controller->getResponse();
+    }
+
+    /**
+     * Returns an array that can be used to describe the internal state of this
+     * object.
+     *
+     * @return array<string, mixed>
+     */
+    public function __debugInfo(): array
+    {
+        return [
+            'error' => $this->error,
+            'request' => $this->request,
+            'controller' => $this->controller,
+            'template' => $this->template,
+            'method' => $this->method,
+        ];
+    }
+}

+ 4 - 3
src/Http/Cookie/CookieCollection.php

@@ -26,6 +26,7 @@ use Psr\Http\Message\RequestInterface;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Traversable;
+use TypeError;
 
 /**
  * Cookie Collection
@@ -68,7 +69,7 @@ class CookieCollection implements IteratorAggregate, Countable
         foreach ($header as $value) {
             try {
                 $cookies[] = Cookie::createFromHeaderString($value, $defaults);
-            } catch (Exception) {
+            } catch (Exception | TypeError $e) {
                 // Don't blow up on invalid cookies
             }
         }
@@ -237,10 +238,10 @@ class CookieCollection implements IteratorAggregate, Countable
             $uri->getHost(),
             $uri->getPath() ?: '/'
         );
-        $cookies = array_merge($cookies, $extraCookies);
+        $cookies = $extraCookies + $cookies;
         $cookiePairs = [];
         foreach ($cookies as $key => $value) {
-            $cookie = sprintf('%s=%s', rawurlencode($key), rawurlencode($value));
+            $cookie = sprintf('%s=%s', rawurlencode((string)$key), rawurlencode($value));
             $size = strlen($cookie);
             if ($size > 4096) {
                 triggerWarning(sprintf(

+ 1 - 1
src/Http/Middleware/CsrfProtectionMiddleware.php

@@ -306,7 +306,7 @@ class CsrfProtectionMiddleware implements MiddlewareInterface
         } else {
             $decoded = base64_decode($token, true);
         }
-        if (strlen($decoded) <= static::TOKEN_VALUE_LENGTH) {
+        if (!$decoded || strlen($decoded) <= static::TOKEN_VALUE_LENGTH) {
             return false;
         }
 

+ 1 - 0
src/ORM/Association/BelongsToMany.php

@@ -420,6 +420,7 @@ class BelongsToMany extends Association
 
         if (!$junction->hasAssociation($sAlias)) {
             $junction->belongsTo($sAlias, [
+                'bindingKey' => $this->getBindingKey(),
                 'foreignKey' => $this->getForeignKey(),
                 'targetTable' => $source,
             ]);

+ 2 - 2
src/Routing/Middleware/AssetMiddleware.php

@@ -150,13 +150,13 @@ class AssetMiddleware implements MiddlewareInterface
 
         $response = new Response(['stream' => $stream]);
 
-        $contentType = $response->getMimeType($file->getExtension()) ?: 'application/octet-stream';
+        $contentType = (array)($response->getMimeType($file->getExtension()) ?: 'application/octet-stream');
         $modified = $file->getMTime();
         $expire = strtotime($this->cacheTime);
         $maxAge = $expire - time();
 
         return $response
-            ->withHeader('Content-Type', $contentType)
+            ->withHeader('Content-Type', $contentType[0])
             ->withHeader('Cache-Control', 'public,max-age=' . $maxAge)
             ->withHeader('Date', gmdate(DATE_RFC7231, time()))
             ->withHeader('Last-Modified', gmdate(DATE_RFC7231, $modified))

+ 3 - 3
src/TestSuite/IntegrationTestTrait.php

@@ -18,7 +18,7 @@ namespace Cake\TestSuite;
 use Cake\Controller\Controller;
 use Cake\Core\Configure;
 use Cake\Database\Exception\DatabaseException;
-use Cake\Error\ExceptionRenderer;
+use Cake\Error\Renderer\WebExceptionRenderer;
 use Cake\Event\EventInterface;
 use Cake\Event\EventManager;
 use Cake\Form\FormProtector;
@@ -558,9 +558,9 @@ trait IntegrationTestTrait
     {
         $class = Configure::read('Error.exceptionRenderer');
         if (empty($class) || !class_exists($class)) {
-            $class = ExceptionRenderer::class;
+            $class = WebExceptionRenderer::class;
         }
-        /** @var \Cake\Error\ExceptionRenderer $instance */
+        /** @var \Cake\Error\Renderer\WebExceptionRenderer $instance */
         $instance = new $class($exception);
         $this->_response = $instance->render();
     }

+ 1 - 1
src/Validation/Validator.php

@@ -781,7 +781,7 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable
     }
 
     /**
-     * Requires a field to be not be an empty string.
+     * Requires a field to not be an empty string.
      *
      * Opposite to allowEmptyString()
      *

+ 21 - 6
src/View/Form/FormContext.php

@@ -16,6 +16,7 @@ declare(strict_types=1);
  */
 namespace Cake\View\Form;
 
+use Cake\Core\Exception\CakeException;
 use Cake\Form\Form;
 use Cake\Utility\Hash;
 
@@ -35,16 +36,30 @@ class FormContext implements ContextInterface
     protected Form $_form;
 
     /**
+     * Validator name.
+     *
+     * @var string|null
+     */
+    protected $_validator = null;
+
+    /**
      * Constructor.
      *
      * @param array $context Context info.
+     *
+     * Keys:
+     *
+     * - `entity` The Form class instance this context is operating on. **(required)**
+     * - `validator` Optional name of the validation method to call on the Form object.
      */
     public function __construct(array $context)
     {
-        $context += [
-            'entity' => null,
-        ];
+        if (!isset($context['entity']) || !$context['entity'] instanceof Form) {
+            throw new CakeException('`$context[\'entity\']` must be an instance of Cake\Form\Form');
+        }
+
         $this->_form = $context['entity'];
+        $this->_validator = $context['validator'] ?? null;
     }
 
     /**
@@ -114,7 +129,7 @@ class FormContext implements ContextInterface
      */
     public function isRequired(string $field): ?bool
     {
-        $validator = $this->_form->getValidator();
+        $validator = $this->_form->getValidator($this->_validator);
         if (!$validator->hasField($field)) {
             return null;
         }
@@ -132,7 +147,7 @@ class FormContext implements ContextInterface
     {
         $parts = explode('.', $field);
 
-        $validator = $this->_form->getValidator();
+        $validator = $this->_form->getValidator($this->_validator);
         $fieldName = array_pop($parts);
         if (!$validator->hasField($fieldName)) {
             return null;
@@ -151,7 +166,7 @@ class FormContext implements ContextInterface
      */
     public function getMaxLength(string $field): ?int
     {
-        $validator = $this->_form->getValidator();
+        $validator = $this->_form->getValidator($this->_validator);
         if (!$validator->hasField($field)) {
             return null;
         }

+ 35 - 0
tests/Fixture/ArticlesTagsBindingKeysFixture.php

@@ -0,0 +1,35 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.3.7
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\Fixture;
+
+use Cake\TestSuite\Fixture\TestFixture;
+
+/**
+ * Fixture for testing bindingKey in belongstomany associations.
+ */
+class ArticlesTagsBindingKeysFixture extends TestFixture
+{
+    /**
+     * records property
+     *
+     * @var array
+     */
+    public $records = [
+        ['article_id' => 1, 'tagname' => 'tag1'],
+        ['article_id' => 1, 'tagname' => 'tag2'],
+        ['article_id' => 2, 'tagname' => 'tag1'],
+        ['article_id' => 2, 'tagname' => 'tag3'],
+    ];
+}

+ 1 - 0
tests/TestCase/Console/Command/HelpCommandTest.php

@@ -94,6 +94,7 @@ class HelpCommandTest extends TestCase
         $this->assertOutputContains('- abort', 'command object');
         $this->assertOutputContains('To run a command', 'more info present');
         $this->assertOutputContains('To get help', 'more info present');
+        $this->assertOutputContains('This is a demo command', 'command description missing');
     }
 
     /**

+ 15 - 1
tests/TestCase/Controller/ControllerTest.php

@@ -314,7 +314,7 @@ class ControllerTest extends TestCase
         $this->assertStringContainsString('hello world', $response->getBody() . '');
     }
 
-    public function testRenderViewClassesUsesExt()
+    public function testRenderViewClassesUsesSingleMimeExt()
     {
         $request = new ServerRequest([
             'url' => '/',
@@ -328,6 +328,20 @@ class ControllerTest extends TestCase
         $this->assertNotEmpty(json_decode($response->getBody() . ''), 'Body should be json');
     }
 
+    public function testRenderViewClassesUsesMultiMimeExt()
+    {
+        $request = new ServerRequest([
+            'url' => '/',
+            'environment' => [],
+            'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all', '_ext' => 'xml'],
+        ]);
+        $controller = new ContentTypesController($request, new Response());
+        $controller->all();
+        $response = $controller->render();
+        $this->assertSame('application/xml; charset=UTF-8', $response->getHeaderLine('Content-Type'));
+        $this->assertTextStartsWith('<?xml', $response->getBody() . '', 'Body should be xml');
+    }
+
     /**
      * test view rendering changing response
      */

+ 20 - 7
tests/TestCase/Error/ExceptionTrapTest.php

@@ -21,6 +21,7 @@ use Cake\Error\ExceptionRenderer;
 use Cake\Error\ExceptionTrap;
 use Cake\Error\Renderer\ConsoleExceptionRenderer;
 use Cake\Error\Renderer\TextExceptionRenderer;
+use Cake\Error\Renderer\WebExceptionRenderer;
 use Cake\Http\Exception\MissingControllerException;
 use Cake\Log\Log;
 use Cake\TestSuite\Stub\ConsoleOutput;
@@ -58,6 +59,18 @@ class ExceptionTrapTest extends TestCase
         $trap->renderer($error);
     }
 
+    public function testConfigExceptionRendererFallbackInCli()
+    {
+        $this->deprecated(function () {
+            $output = new ConsoleOutput();
+            $trap = new ExceptionTrap(['exceptionRenderer' => ExceptionRenderer::class, 'stderr' => $output]);
+            $error = new InvalidArgumentException('nope');
+            // Even though we asked for ExceptionRenderer we should get a
+            // ConsoleExceptionRenderer as we're in a CLI context.
+            $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
+        });
+    }
+
     public function testConfigExceptionRendererFallback()
     {
         $output = new ConsoleOutput();
@@ -68,18 +81,18 @@ class ExceptionTrapTest extends TestCase
 
     public function testConfigExceptionRenderer()
     {
-        $trap = new ExceptionTrap(['exceptionRenderer' => ExceptionRenderer::class]);
+        $trap = new ExceptionTrap(['exceptionRenderer' => WebExceptionRenderer::class]);
         $error = new InvalidArgumentException('nope');
-        $this->assertInstanceOf(ExceptionRenderer::class, $trap->renderer($error));
+        $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error));
     }
 
     public function testConfigExceptionRendererFactory()
     {
         $trap = new ExceptionTrap(['exceptionRenderer' => function ($err, $req) {
-            return new ExceptionRenderer($err, $req);
+            return new WebExceptionRenderer($err, $req);
         }]);
         $error = new InvalidArgumentException('nope');
-        $this->assertInstanceOf(ExceptionRenderer::class, $trap->renderer($error));
+        $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error));
     }
 
     public function testConfigRendererHandleUnsafeOverwrite()
@@ -188,7 +201,7 @@ class ExceptionTrapTest extends TestCase
     public function testHandleExceptionHtmlRendering()
     {
         $trap = new ExceptionTrap([
-            'exceptionRenderer' => ExceptionRenderer::class,
+            'exceptionRenderer' => WebExceptionRenderer::class,
         ]);
         $error = new InvalidArgumentException('nope');
 
@@ -225,7 +238,7 @@ class ExceptionTrapTest extends TestCase
             'className' => 'Array',
         ]);
         $trap = new ExceptionTrap([
-            'exceptionRenderer' => ExceptionRenderer::class,
+            'exceptionRenderer' => WebExceptionRenderer::class,
             'skipLog' => [InvalidArgumentException::class],
         ]);
 
@@ -304,7 +317,7 @@ class ExceptionTrapTest extends TestCase
     public function testHandleFatalErrorHtmlRendering()
     {
         $trap = new ExceptionTrap([
-            'exceptionRenderer' => ExceptionRenderer::class,
+            'exceptionRenderer' => WebExceptionRenderer::class,
         ]);
 
         ob_start();

+ 2 - 2
tests/TestCase/Error/Middleware/ErrorHandlerMiddlewareTest.php

@@ -18,10 +18,10 @@ namespace Cake\Test\TestCase\Error\Middleware;
 
 use Cake\Core\Configure;
 use Cake\Datasource\Exception\RecordNotFoundException;
-use Cake\Error\ExceptionRenderer;
 use Cake\Error\ExceptionRendererInterface;
 use Cake\Error\ExceptionTrap;
 use Cake\Error\Middleware\ErrorHandlerMiddleware;
+use Cake\Error\Renderer\WebExceptionRenderer;
 use Cake\Http\Exception\MissingControllerException;
 use Cake\Http\Exception\NotFoundException;
 use Cake\Http\Exception\RedirectException;
@@ -133,7 +133,7 @@ class ErrorHandlerMiddlewareTest extends TestCase
     {
         $request = ServerRequestFactory::fromGlobals();
         $middleware = new ErrorHandlerMiddleware(new ExceptionTrap([
-            'exceptionRenderer' => ExceptionRenderer::class,
+            'exceptionRenderer' => WebExceptionRenderer::class,
         ]));
         $handler = new TestRequestHandler(function (): void {
             throw new NotFoundException('whoops');

+ 7 - 3
tests/TestCase/Http/Cookie/CookieCollectionTest.php

@@ -408,7 +408,7 @@ class CookieCollectionTest extends TestCase
             ->add(new Cookie('expired', 'ex', new DateTime('-2 seconds'), '/', 'example.com'));
         $request = new ClientRequest('http://example.com/api');
         $request = $collection->addToRequest($request, ['b' => 'B']);
-        $this->assertSame('api=A; b=B', $request->getHeaderLine('Cookie'));
+        $this->assertSame('b=B; api=A', $request->getHeaderLine('Cookie'));
 
         $request = new ClientRequest('http://example.com/api');
         $request = $collection->addToRequest($request, ['api' => 'custom']);
@@ -454,15 +454,19 @@ class CookieCollectionTest extends TestCase
     {
         $header = [
             'http=name; HttpOnly; Secure;',
-            'expires=expiring; Expires=Wed, 15-Jun-2022 10:22:22; Path=/api; HttpOnly; Secure;',
+            'expires=expiring; Expires=Mon, 17-Apr-2023 10:22:22; Path=/api; HttpOnly; Secure;',
             'expired=expired; version=1; Expires=Wed, 15-Jun-2015 10:22:22;',
+            'invalid=invalid-secure; Expires=Mon, 17-Apr-2023 10:22:22; Secure=true; SameSite=none',
+            '7=numeric',
         ];
         $cookies = CookieCollection::createFromHeader($header);
-        $this->assertCount(3, $cookies);
+        $this->assertCount(4, $cookies);
         $this->assertTrue($cookies->has('http'));
         $this->assertTrue($cookies->has('expires'));
         $this->assertFalse($cookies->has('version'));
         $this->assertTrue($cookies->has('expired'), 'Expired cookies should be present');
+        $this->assertFalse($cookies->has('invalid'), 'Invalid cookies should not be present');
+        $this->assertTrue($cookies->has('7'));
     }
 
     /**

+ 20 - 0
tests/TestCase/Http/Middleware/CsrfProtectionMiddlewareTest.php

@@ -396,6 +396,26 @@ class CsrfProtectionMiddlewareTest extends TestCase
     }
 
     /**
+     * Test that empty value cookies are rejected
+     *
+     * @return void
+     */
+    public function testInvalidTokenEmptyStringCookies()
+    {
+        $this->expectException(InvalidCsrfTokenException::class);
+        $request = new ServerRequest([
+            'environment' => [
+                'REQUEST_METHOD' => 'POST',
+            ],
+            'post' => ['_csrfToken' => '*(&'],
+            // Invalid data that can't be base64 decoded.
+            'cookies' => ['csrfToken' => '*(&'],
+        ]);
+        $middleware = new CsrfProtectionMiddleware();
+        $middleware->process($request, $this->_getRequestHandler());
+    }
+
+    /**
      * Test that request non string cookies are ignored.
      */
     public function testInvalidTokenNonStringCookies(): void

+ 23 - 0
tests/TestCase/ORM/Association/BelongsToManyTest.php

@@ -51,6 +51,7 @@ class BelongsToManyTest extends TestCase
         'core.Tags',
         'core.SpecialTags',
         'core.ArticlesTags',
+        'core.ArticlesTagsBindingKeys',
         'core.BinaryUuidItems',
         'core.BinaryUuidTags',
         'core.BinaryUuidItemsBinaryUuidTags',
@@ -1692,4 +1693,26 @@ class BelongsToManyTest extends TestCase
         $this->assertCount(1, $results);
         $this->assertCount(3, $results[0]->special_tags);
     }
+
+    /**
+     * Test custom binding key for target table association
+     */
+    public function testBindingKeyMatching(): void
+    {
+        $table = $this->getTableLocator()->get('Tags');
+        $table->belongsToMany('Articles', [
+            'through' => 'ArticlesTagsBindingKeys',
+            'foreignKey' => 'tagname',
+            'targetForeignKey' => 'article_id',
+            'bindingKey' => 'name',
+        ]);
+        $query = $table->find()
+            ->matching('Articles', function ($q) {
+                return $q->where(['Articles.id >' => 0]);
+            });
+        $results = $query->all();
+
+        // 4 records in the junction table.
+        $this->assertCount(4, $results);
+    }
 }

+ 1 - 1
tests/TestCase/ORM/TableTest.php

@@ -959,7 +959,7 @@ class TableTest extends TestCase
             ],
         ];
 
-        $table = new Table(['table' => 'dates']);
+        $table = new Table(['table' => 'members']);
         $result = $table->addAssociations($params);
         $this->assertSame($table, $result);
 

+ 21 - 0
tests/TestCase/View/Form/FormContextTest.php

@@ -16,6 +16,7 @@ declare(strict_types=1);
  */
 namespace Cake\Test\TestCase\View\Form;
 
+use Cake\Core\Exception\CakeException;
 use Cake\Form\Form;
 use Cake\TestSuite\TestCase;
 use Cake\Validation\Validator;
@@ -34,6 +35,14 @@ class FormContextTest extends TestCase
         parent::setUp();
     }
 
+    public function testConstructor()
+    {
+        $this->expectException(CakeException::class);
+        $this->expectExceptionMessage('`$context[\'entity\']` must be an instance of Cake\Form\Form');
+
+        new FormContext([]);
+    }
+
     /**
      * tests getRequiredMessage
      */
@@ -144,6 +153,18 @@ class FormContextTest extends TestCase
         $this->assertTrue($context->isRequired('email'));
         $this->assertNull($context->isRequired('body'));
         $this->assertNull($context->isRequired('Prefix.body'));
+
+        // Non-default validator name.
+        $form = new Form();
+        $form->setValidator('custom', new Validator());
+        $form->getValidator('custom')
+            ->notEmptyString('title');
+        $form->validate([
+            'title' => '',
+        ], 'custom');
+
+        $context = new FormContext(['entity' => $form, 'validator' => 'custom']);
+        $this->assertTrue($context->isRequired('title'));
     }
 
     /**

+ 22 - 0
tests/schema.php

@@ -900,6 +900,28 @@ return [
         ],
     ],
     [
+        'table' => 'articles_tags_binding_keys',
+        'columns' => [
+            'article_id' => [
+                'type' => 'integer',
+                'null' => false,
+            ],
+            'tagname' => [
+                'type' => 'string',
+                'null' => false,
+            ],
+        ],
+        'constraints' => [
+            'unique_tag' => [
+                'type' => 'primary',
+                'columns' => [
+                    'article_id',
+                    'tagname',
+                ],
+            ],
+        ],
+    ],
+    [
         'table' => 'profiles',
         'columns' => [
             'id' => [

+ 6 - 1
tests/test_app/TestApp/Command/DemoCommand.php

@@ -9,7 +9,12 @@ use Cake\Console\ConsoleIo;
 
 class DemoCommand extends Command
 {
-    public function execute(Arguments $args, ConsoleIo $io)
+    public static function getDescription(): string
+    {
+        return 'This is a demo command';
+    }
+
+    public function execute(Arguments $args, ConsoleIo $io): ?int
     {
         $io->quiet('Quiet!');
         $io->out('Demo Command!');

+ 2 - 0
tests/test_app/TestApp/Model/Table/FeaturedTagsTable.php

@@ -22,6 +22,8 @@ use Cake\ORM\TableRegistry;
  */
 class FeaturedTagsTable extends Table
 {
+    protected $Posts;
+
     public function initialize(array $config): void
     {
         // Used to reproduce https://github.com/cakephp/cakephp/issues/16373