Browse Source

Merge branch '4.next' into 5.x

Corey Taylor 4 years ago
parent
commit
55c635bb08

+ 6 - 0
phpstan-baseline.neon

@@ -339,3 +339,9 @@ parameters:
 			message: "#^Call to an undefined method DateTimeInterface\\:\\:setTimezone\\(\\)\\.$#"
 			count: 1
 			path: src/View/Helper/TimeHelper.php
+
+		-
+			message: "#^Constructor of class Cake\\\\Error\\\\Renderer\\\\ConsoleExceptionRenderer has an unused parameter \\$request\\.$#"
+			count: 1
+			path: src/Error/Renderer/ConsoleExceptionRenderer.php
+

+ 10 - 0
psalm-baseline.xml

@@ -52,6 +52,16 @@
       <code>$request</code>
     </ArgumentTypeCoercion>
   </file>
+  <file src="src/ORM/Table.php">
+    <DeprecatedClass occurrences="6">
+      <code>SaveOptionsBuilder</code>
+      <code>\Cake\ORM\SaveOptionsBuilder</code>
+      <code>\Cake\ORM\SaveOptionsBuilder|\ArrayAccess|array</code>
+      <code>\Cake\ORM\SaveOptionsBuilder|\ArrayAccess|array</code>
+      <code>\Cake\ORM\SaveOptionsBuilder|\ArrayAccess|array</code>
+      <code>new SaveOptionsBuilder($this, $options)</code>
+    </DeprecatedClass>
+  </file>
   <file src="src/Routing/Middleware/RoutingMiddleware.php">
     <ArgumentTypeCoercion occurrences="2">
       <code>$request</code>

+ 54 - 0
src/Controller/Component/CheckHttpCacheComponent.php

@@ -0,0 +1,54 @@
+<?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(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Controller\Component;
+
+use Cake\Controller\Component;
+use Cake\Event\EventInterface;
+
+/**
+ * Use HTTP caching headers to see if rendering can be skipped.
+ *
+ * Checks if the response can be considered different according to the request
+ * headers, and caching headers in the response. If the response was not modified,
+ * then the controller and view render process is skipped. And the client will get a
+ * response with an empty body and a "304 Not Modified" header.
+ *
+ * To use this component your controller actions must set either the `Last-Modified`
+ * or `Etag` header. Without one of these headers being set this component
+ * will have no effect.
+ */
+class CheckHttpCacheComponent extends Component
+{
+    /**
+     * Before Render hook
+     *
+     * @param \Cake\Event\EventInterface $event The Controller.beforeRender event.
+     * @return void
+     */
+    public function beforeRender(EventInterface $event): void
+    {
+        $controller = $this->getController();
+        $response = $controller->getResponse();
+        $request = $controller->getRequest();
+        if (!$response->isNotModified($request)) {
+            return;
+        }
+
+        $controller->setResponse($response->withNotModified());
+        $event->stopPropagation();
+    }
+}

+ 3 - 7
src/Controller/Component/RequestHandlerComponent.php

@@ -219,14 +219,10 @@ class RequestHandlerComponent extends Component
             $response = $response->withCharset(Configure::read('App.encoding'));
         }
 
-        if (
-            $this->_config['checkHttpCache'] &&
-            $response->checkNotModified($controller->getRequest())
-        ) {
-            $controller->setResponse($response);
+        $request = $controller->getRequest();
+        if ($this->_config['checkHttpCache'] && $response->isNotModified($request)) {
+            $response = $response->withNotModified();
             $event->stopPropagation();
-
-            return;
         }
 
         $controller->setResponse($response);

+ 15 - 0
src/Database/Driver/Sqlite.php

@@ -104,6 +104,7 @@ class Sqlite extends Driver
      */
     protected array $featureVersions = [
         'cte' => '3.8.3',
+        'returning' => '3.35.0',
         'window' => '3.28.0',
     ];
 
@@ -234,6 +235,20 @@ class Sqlite extends Driver
     /**
      * @inheritDoc
      */
+    protected function _insertQueryTranslator(Query $query): Query
+    {
+        if (version_compare($this->version(), $this->featureVersions['returning'], '>=')) {
+            if (!$query->clause('epilog')) {
+                $query->epilog('RETURNING *');
+            }
+        }
+
+        return $query;
+    }
+
+    /**
+     * @inheritDoc
+     */
     protected function _expressionTranslators(): array
     {
         return [

+ 6 - 0
src/Error/BaseErrorHandler.php

@@ -88,6 +88,12 @@ abstract class BaseErrorHandler
      */
     public function register(): void
     {
+        deprecationWarning(
+            'Use of `BaseErrorHandler` and subclasses are deprecated. ' .
+            'Upgrade to the new `ErrorTrap` and `ExceptionTrap` subsystem. ' .
+            'See https://book.cakephp.org/4/en/appendices/4-4-migration-guide.html'
+        );
+
         $level = $this->_config['errorLevel'] ?? -1;
         error_reporting($level);
         set_error_handler([$this, 'handleError'], $level);

+ 5 - 4
src/Error/ErrorTrap.php

@@ -51,9 +51,6 @@ class ErrorTrap
     public function __construct(array $options = [])
     {
         $this->setConfig($options);
-        if ($this->_getConfig('errorRenderer') === null) {
-            $this->setConfig('errorRenderer', $this->chooseErrorRenderer());
-        }
     }
 
     /**
@@ -114,11 +111,15 @@ class ErrorTrap
         if (!(error_reporting() & $code)) {
             return false;
         }
-        $debug = Configure::read('debug');
+        if ($code === E_USER_ERROR || $code === E_ERROR || $code === E_PARSE) {
+            throw new FatalErrorException($description, $code, $file, $line);
+        }
+
         /** @var array $trace */
         $trace = Debugger::trace(['start' => 1, 'format' => 'points']);
         $error = new PhpError($code, $description, $file, $line, $trace);
 
+        $debug = Configure::read('debug');
         $renderer = $this->renderer();
         $logger = $this->logger();
 

+ 18 - 4
src/Error/ExceptionTrap.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 namespace Cake\Error;
 
 use Cake\Core\InstanceConfigTrait;
+use Cake\Error\Renderer\ConsoleExceptionRenderer;
 use Cake\Event\EventDispatcherTrait;
 use Cake\Routing\Router;
 use InvalidArgumentException;
@@ -34,10 +35,9 @@ class ExceptionTrap
      *
      * @var array<string, mixed>
      */
-    protected array $_defaultConfig = [
-        'exceptionRenderer' => ExceptionRenderer::class,
+    protected $_defaultConfig = [
+        'exceptionRenderer' => null,
         'logger' => ErrorLogger::class,
-        // Used by ConsoleExceptionRenderer (coming soon)
         'stderr' => null,
         'log' => true,
         'trace' => false,
@@ -93,6 +93,9 @@ class ExceptionTrap
 
         /** @var callable|class-string $class */
         $class = $this->_getConfig('exceptionRenderer');
+        if (!$class) {
+            $class = $this->chooseRenderer();
+        }
 
         if (is_string($class)) {
             /** @var class-string $class */
@@ -104,7 +107,7 @@ class ExceptionTrap
             }
 
             /** @var \Cake\Error\ExceptionRendererInterface $instance */
-            $instance = new $class($exception, $request);
+            $instance = new $class($exception, $request, $this->_config);
 
             return $instance;
         }
@@ -113,6 +116,17 @@ class ExceptionTrap
     }
 
     /**
+     * Choose an exception renderer based on config or the SAPI
+     *
+     * @return class-string<\Cake\Error\ExceptionRendererInterface>
+     */
+    protected function chooseRenderer(): string
+    {
+        /** @var class-string<\Cake\Error\ExceptionRendererInterface> */
+        return PHP_SAPI === 'cli' ? ConsoleExceptionRenderer::class : ExceptionRenderer::class;
+    }
+
+    /**
      * Get an instance of the logger.
      *
      * @return \Cake\Error\ErrorLoggerInterface

+ 19 - 4
src/Error/Renderer/ConsoleErrorRenderer.php

@@ -16,23 +16,38 @@ declare(strict_types=1);
  */
 namespace Cake\Error\Renderer;
 
+use Cake\Console\ConsoleOutput;
 use Cake\Error\ErrorRendererInterface;
 use Cake\Error\PhpError;
 
 /**
  * Plain text error rendering with a stack trace.
  *
- * Writes to STDERR for console environments
+ * Writes to STDERR via a Cake\Console\ConsoleOutput instance for console environments
  */
 class ConsoleErrorRenderer implements ErrorRendererInterface
 {
     /**
+     * @var \Cake\Console\ConsoleOutput
+     */
+    protected $output;
+
+    /**
+     * Constructor.
+     *
+     * @param array $config Error handling configuration.
+     */
+    public function __construct(array $config)
+    {
+        $this->output = $config['stderr'] ?? new ConsoleOutput('php://stderr');
+    }
+
+    /**
      * @inheritDoc
      */
     public function write(string $out): void
     {
-        // Write to stderr which is useful in console environments.
-        fwrite(STDERR, $out);
+        $this->output->write($out);
     }
 
     /**
@@ -41,7 +56,7 @@ class ConsoleErrorRenderer implements ErrorRendererInterface
     public function render(PhpError $error, bool $debug): string
     {
         return sprintf(
-            "%s: %s :: %s on line %s of %s\nTrace:\n%s",
+            "<error>%s: %s :: %s</error> on line %s of %s\n<info>Trace:</info>\n%s",
             $error->getLabel(),
             $error->getCode(),
             $error->getMessage(),

+ 112 - 0
src/Error/Renderer/ConsoleExceptionRenderer.php

@@ -0,0 +1,112 @@
+<?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(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Error\Renderer;
+
+use Cake\Console\ConsoleOutput;
+use Cake\Core\Configure;
+use Cake\Core\Exception\CakeException;
+use Psr\Http\Message\ServerRequestInterface;
+use Throwable;
+
+/**
+ * Plain text exception rendering with a stack trace.
+ *
+ * Useful in CI or plain text environments.
+ *
+ * @todo 5.0 Implement \Cake\Error\ExceptionRendererInterface. This implementation can't implement
+ *  the concrete interface because the return types are not compatible.
+ */
+class ConsoleExceptionRenderer
+{
+    /**
+     * @var \Throwable
+     */
+    private $error;
+
+    /**
+     * @var \Cake\Console\ConsoleOutput
+     */
+    private $output;
+
+    /**
+     * @var bool
+     */
+    private $trace;
+
+    /**
+     * Constructor.
+     *
+     * @param \Throwable $error The error to render.
+     * @param \Psr\Http\Message\ServerRequestInterface|null $request Not used.
+     * @param array $config Error handling configuration.
+     */
+    public function __construct(Throwable $error, ?ServerRequestInterface $request, array $config)
+    {
+        $this->error = $error;
+        $this->output = $config['stderr'] ?? new ConsoleOutput('php://stderr');
+        $this->trace = $config['trace'] ?? true;
+    }
+
+    /**
+     * Render an exception into a plain text message.
+     *
+     * @return \Psr\Http\Message\ResponseInterface|string
+     */
+    public function render()
+    {
+        $out = [];
+        $out[] = sprintf(
+            '<error>[%s] %s</error> in %s on line %s',
+            get_class($this->error),
+            $this->error->getMessage(),
+            $this->error->getFile(),
+            $this->error->getLine()
+        );
+
+        $debug = Configure::read('debug');
+        if ($debug && $this->error instanceof CakeException) {
+            $attributes = $this->error->getAttributes();
+            if ($attributes) {
+                $out[] = '';
+                $out[] = '<info>Exception Attributes</info>';
+                $out[] = '';
+                $out[] = var_export($this->error->getAttributes(), true);
+            }
+        }
+
+        if ($this->trace) {
+            $out[] = '';
+            $out[] = '<info>Stack Trace:</info>';
+            $out[] = '';
+            $out[] = $this->error->getTraceAsString();
+            $out[] = '';
+        }
+
+        return join("\n", $out);
+    }
+
+    /**
+     * Write output to the output stream
+     *
+     * @param string $output The output to print.
+     * @return void
+     */
+    public function write($output): void
+    {
+        $this->output->write($output);
+    }
+}

+ 41 - 12
src/Http/Response.php

@@ -991,10 +991,15 @@ class Response implements ResponseInterface, Stringable
      *
      * *Warning* This method mutates the response in-place and should be avoided.
      *
+     * @deprecated 4.4.0 Use `withNotModified()` instead.
      * @return void
      */
     public function notModified(): void
     {
+        deprecationWarning(
+            'The `notModified()` method is deprecated. ' .
+            'Use `withNotModified() instead, and remember immutability of with* methods.'
+        );
         $this->_createStream();
         $this->_setStatus(304);
 
@@ -1193,19 +1198,16 @@ class Response implements ResponseInterface, Stringable
     /**
      * Checks whether a response has not been modified according to the 'If-None-Match'
      * (Etags) and 'If-Modified-Since' (last modification date) request
-     * headers. If the response is detected to be not modified, it
-     * is marked as so accordingly so the client can be informed of that.
-     *
-     * In order to mark a response as not modified, you need to set at least
-     * the Last-Modified etag response header before calling this method. Otherwise
-     * a comparison will not be possible.
+     * headers.
      *
-     * *Warning* This method mutates the response in-place and should be avoided.
+     * In order to interact with this method you must mark responses as not modified.
+     * You need to set at least one of the `Last-Modified` or `Etag` response headers
+     * before calling this method. Otherwise a comparison will not be possible.
      *
      * @param \Cake\Http\ServerRequest $request Request object
-     * @return bool Whether the response was marked as not modified or not.
+     * @return bool Whether the response is 'modified' based on cache headers.
      */
-    public function checkNotModified(ServerRequest $request): bool
+    public function isNotModified(ServerRequest $request): bool
     {
         $etags = preg_split('/\s*,\s*/', $request->getHeaderLine('If-None-Match'), 0, PREG_SPLIT_NO_EMPTY);
         $responseTag = $this->getHeaderLine('Etag');
@@ -1222,12 +1224,39 @@ class Response implements ResponseInterface, Stringable
         if ($etagMatches === null && $timeMatches === null) {
             return false;
         }
-        $notModified = $etagMatches !== false && $timeMatches !== false;
-        if ($notModified) {
+
+        return $etagMatches !== false && $timeMatches !== false;
+    }
+
+    /**
+     * Checks whether a response has not been modified according to the 'If-None-Match'
+     * (Etags) and 'If-Modified-Since' (last modification date) request
+     * headers. If the response is detected to be not modified, it
+     * is marked as so accordingly so the client can be informed of that.
+     *
+     * In order to mark a response as not modified, you need to set at least
+     * the Last-Modified etag response header before calling this method. Otherwise
+     * a comparison will not be possible.
+     *
+     * *Warning* This method mutates the response in-place and should be avoided.
+     *
+     * @param \Cake\Http\ServerRequest $request Request object
+     * @return bool Whether the response was marked as not modified or not.
+     * @deprecated 4.4.0 Use `isNotModified()` and `withNotModified()` instead.
+     */
+    public function checkNotModified(ServerRequest $request): bool
+    {
+        deprecationWarning(
+            'The `checkNotModified()` method is deprecated. ' .
+            'Use `isNotModified() instead and `withNoModified()` instead.'
+        );
+        if ($this->isNotModified($request)) {
             $this->notModified();
+
+            return true;
         }
 
-        return $notModified;
+        return false;
     }
 
     /**

+ 1 - 0
src/ORM/SaveOptionsBuilder.php

@@ -26,6 +26,7 @@ use RuntimeException;
  * you to avoid mistakes by validating the options as you build them.
  *
  * @see \Cake\Datasource\RulesChecker
+ * @deprecated 4.4.4 Use an array of options instead.
  */
 class SaveOptionsBuilder extends ArrayObject
 {

+ 2 - 0
src/ORM/Table.php

@@ -1871,6 +1871,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
         SaveOptionsBuilder|array $options = []
     ): EntityInterface|false {
         if ($options instanceof SaveOptionsBuilder) {
+            deprecationWarning('4.4.0', 'SaveOptionsBuilder is deprecated. Use a normal array for options instead.');
             $options = $options->toArray();
         }
 
@@ -2241,6 +2242,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
         SaveOptionsBuilder|array $options = []
     ): ResultSetInterface|array {
         if ($options instanceof SaveOptionsBuilder) {
+            deprecationWarning('4.4.0', 'SaveOptionsBuilder is deprecated. Use a normal array for options instead.');
             $options = $options->toArray();
         }
 

+ 26 - 0
src/Routing/Exception/FailedRouteCacheException.php

@@ -0,0 +1,26 @@
+<?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(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Routing\Exception;
+
+use Cake\Core\Exception\CakeException;
+
+/**
+ * Thrown when unable to cache route collection.
+ */
+class FailedRouteCacheException extends CakeException
+{
+}

+ 18 - 3
src/Routing/Middleware/RoutingMiddleware.php

@@ -17,13 +17,16 @@ declare(strict_types=1);
 namespace Cake\Routing\Middleware;
 
 use Cake\Cache\Cache;
+use Cake\Cache\InvalidArgumentException;
 use Cake\Core\PluginApplicationInterface;
 use Cake\Http\Exception\RedirectException;
 use Cake\Http\MiddlewareQueue;
 use Cake\Http\Runner;
+use Cake\Routing\Exception\FailedRouteCacheException;
 use Cake\Routing\RouteCollection;
 use Cake\Routing\Router;
 use Cake\Routing\RoutingApplicationInterface;
+use Exception;
 use Laminas\Diactoros\Response\RedirectResponse;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
@@ -93,9 +96,21 @@ class RoutingMiddleware implements MiddlewareInterface
     protected function buildRouteCollection(): RouteCollection
     {
         if (Cache::enabled() && $this->cacheConfig !== null) {
-            return Cache::remember(static::ROUTE_COLLECTION_CACHE_KEY, function () {
-                return $this->prepareRouteCollection();
-            }, $this->cacheConfig);
+            try {
+                return Cache::remember(static::ROUTE_COLLECTION_CACHE_KEY, function () {
+                    return $this->prepareRouteCollection();
+                }, $this->cacheConfig);
+            } catch (InvalidArgumentException $e) {
+                throw $e;
+            } catch (Exception $e) {
+                throw new FailedRouteCacheException(
+                    'Unable to cache route collection. Cached routes must be serializable. Check for route-specific
+                    middleware or other unserializable settings in your routes. The original exception message can
+                    show what type of object failed to serialize.',
+                    null,
+                    $e
+                );
+            }
         }
 
         return $this->prepareRouteCollection();

+ 77 - 0
tests/TestCase/Controller/Component/CheckHttpCacheComponentTest.php

@@ -0,0 +1,77 @@
+<?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(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Controller\Component;
+
+use Cake\Controller\Component\CheckHttpCacheComponent;
+use Cake\Controller\Controller;
+use Cake\Event\Event;
+use Cake\Http\ServerRequest;
+use Cake\TestSuite\TestCase;
+
+/**
+ * CheckHttpCacheComponentTest class
+ */
+class CheckHttpCacheComponentTest extends TestCase
+{
+    /**
+     * @var \Cake\Controller\Component\CheckHTtpCacheComponent
+     */
+    protected $Component;
+
+    /**
+     * @var \Cake\Controller\Controller
+     */
+    protected $Controller;
+
+    /**
+     * setUp method
+     */
+    public function setUp(): void
+    {
+        parent::setUp();
+        static::setAppNamespace();
+        $request = (new ServerRequest())
+            ->withHeader('If-Modified-Since', '2012-01-01 00:00:00')
+            ->withHeader('If-None-Match', '*');
+        $this->Controller = new Controller($request);
+        $this->Component = new CheckHttpCacheComponent($this->Controller->components());
+    }
+
+    public function testBeforeRenderSuccess()
+    {
+        $response = $this->Controller->getResponse()
+            ->withEtag('something', true);
+        $this->Controller->setResponse($response);
+
+        $event = new Event('Controller.beforeRender', $this->Controller);
+        $this->Component->beforeRender($event);
+
+        $this->assertTrue($event->isStopped());
+        $response = $this->Controller->getResponse();
+        $this->assertSame(304, $response->getStatusCode());
+    }
+
+    public function testBeforeRenderNoOp()
+    {
+        $event = new Event('Controller.beforeRender', $this->Controller);
+        $this->Component->beforeRender($event);
+
+        $this->assertFalse($event->isStopped());
+        $response = $this->Controller->getResponse();
+        $this->assertSame(200, $response->getStatusCode());
+    }
+}

+ 25 - 0
tests/TestCase/Database/Driver/SqliteTest.php

@@ -141,6 +141,31 @@ class SqliteTest extends TestCase
     }
 
     /**
+     * Tests that insert queries get a "RETURNING *" string at the end
+     */
+    public function testInsertReturning(): void
+    {
+        $driver = ConnectionManager::get('test')->getDriver();
+        $this->skipIf(!$driver instanceof Sqlite || version_compare($driver->version(), '3.35.0', '<'));
+
+        $query = ConnectionManager::get('test')->newQuery()
+            ->insert(['id', 'title'])
+            ->into('articles')
+            ->values([1, 'foo']);
+        $translator = $driver->queryTranslator('insert');
+        $query = $translator($query);
+        $this->assertSame('RETURNING *', $query->clause('epilog'));
+
+        $query = ConnectionManager::get('test')->newQuery()
+            ->insert(['id', 'title'])
+            ->into('articles')
+            ->values([1, 'foo'])
+            ->epilog('CUSTOM EPILOG');
+        $query = $translator($query);
+        $this->assertSame('CUSTOM EPILOG', $query->clause('epilog'));
+    }
+
+    /**
      * Data provider for schemaValue()
      *
      * @return array

+ 35 - 26
tests/TestCase/Error/ErrorHandlerTest.php

@@ -37,8 +37,6 @@ use TestApp\Error\TestErrorHandler;
  */
 class ErrorHandlerTest extends TestCase
 {
-    protected $_restoreError = false;
-
     /**
      * @var \Cake\Log\Engine\ArrayLog
      */
@@ -80,10 +78,6 @@ class ErrorHandlerTest extends TestCase
         parent::tearDown();
         Log::reset();
         $this->clearPlugins();
-        if ($this->_restoreError) {
-            restore_error_handler();
-            restore_exception_handler();
-        }
         error_reporting(self::$errorLevel);
     }
 
@@ -110,16 +104,23 @@ class ErrorHandlerTest extends TestCase
 
     /**
      * test error handling when debug is on, an error should be printed from Debugger.
+     *
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function testHandleErrorDebugOn(): void
     {
+        Configure::write('debug', true);
         $errorHandler = new ErrorHandler();
-        $errorHandler->register();
-        $this->_restoreError = true;
 
-        ob_start();
-        $wrong = $wrong + 1;
-        $result = ob_get_clean();
+        $result = '';
+        $this->deprecated(function () use ($errorHandler, &$result) {
+            $errorHandler->register();
+
+            ob_start();
+            $wrong = $wrong + 1;
+            $result = ob_get_clean();
+        });
 
         $this->assertMatchesRegularExpression('/<div class="cake-error">/', $result);
         if (version_compare(PHP_VERSION, '8.0.0-dev', '<')) {
@@ -130,7 +131,7 @@ class ErrorHandlerTest extends TestCase
             $this->assertMatchesRegularExpression('/variable \$wrong/', $result);
         }
         $this->assertStringContainsString(
-            'ErrorHandlerTest.php, line ' . (__LINE__ - 12),
+            'ErrorHandlerTest.php, line ' . (__LINE__ - 13),
             $result,
             'Should contain file and line reference'
         );
@@ -177,32 +178,37 @@ class ErrorHandlerTest extends TestCase
     /**
      * test error mappings
      *
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      * @dataProvider errorProvider
      */
     public function testErrorMapping(int $error, string $expected): void
     {
         $errorHandler = new ErrorHandler();
-        $errorHandler->register();
-        $this->_restoreError = true;
+        $this->deprecated(function () use ($errorHandler, $error, $expected) {
+            $errorHandler->register();
 
-        ob_start();
-        trigger_error('Test error', $error);
-        $result = ob_get_clean();
+            ob_start();
+            trigger_error('Test error', $error);
 
-        $this->assertStringContainsString('<b>' . $expected . '</b>', $result);
+            $this->assertStringContainsString('<b>' . $expected . '</b>', ob_get_clean());
+        });
     }
 
     /**
      * Test that errors go into Cake Log when debug = 0.
+     *
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function testHandleErrorDebugOff(): void
     {
         Configure::write('debug', false);
         $errorHandler = new ErrorHandler();
-        $errorHandler->register();
-        $this->_restoreError = true;
-
-        $out = $out + 1;
+        $this->deprecated(function () use ($errorHandler) {
+            $errorHandler->register();
+            $out = $out + 1;
+        });
 
         $messages = $this->logger->read();
         $this->assertMatchesRegularExpression('/^(notice|debug|warning)/', $messages[0]);
@@ -215,15 +221,18 @@ class ErrorHandlerTest extends TestCase
 
     /**
      * Test that errors going into Cake Log include traces.
+     *
+     * @runInSeparateProcess
+     * @preserveGlobalState disabled
      */
     public function testHandleErrorLoggingTrace(): void
     {
         Configure::write('debug', false);
         $errorHandler = new ErrorHandler(['trace' => true]);
-        $errorHandler->register();
-        $this->_restoreError = true;
-
-        $out = $out + 1;
+        $this->deprecated(function () use ($errorHandler) {
+            $errorHandler->register();
+            $out = $out + 1;
+        });
 
         $messages = $this->logger->read();
         $this->assertMatchesRegularExpression('/^(notice|debug|warning)/', $messages[0]);

+ 16 - 0
tests/TestCase/Error/ErrorTrapTest.php

@@ -19,6 +19,7 @@ namespace Cake\Test\TestCase\Error;
 use Cake\Core\Configure;
 use Cake\Error\ErrorLogger;
 use Cake\Error\ErrorTrap;
+use Cake\Error\FatalErrorException;
 use Cake\Error\PhpError;
 use Cake\Error\Renderer\ConsoleErrorRenderer;
 use Cake\Error\Renderer\HtmlErrorRenderer;
@@ -95,6 +96,21 @@ class ErrorTrapTest extends TestCase
         $this->assertStringContainsString('Oh no it was bad', $output);
     }
 
+    public function testRegisterAndHandleFatalUserError()
+    {
+        $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]);
+        $trap->register();
+        try {
+            trigger_error('Oh no it was bad', E_USER_ERROR);
+            $this->fail('Should raise a fatal error');
+        } catch (FatalErrorException $e) {
+            $this->assertEquals('Oh no it was bad', $e->getMessage());
+            $this->assertEquals(E_USER_ERROR, $e->getCode());
+        } finally {
+            restore_error_handler();
+        }
+    }
+
     public function testRegisterAndLogging()
     {
         Log::setConfig('test_error', [

+ 60 - 6
tests/TestCase/Error/ExceptionTrapTest.php

@@ -19,8 +19,11 @@ namespace Cake\Test\TestCase\Error;
 use Cake\Error\ErrorLogger;
 use Cake\Error\ExceptionRenderer;
 use Cake\Error\ExceptionTrap;
+use Cake\Error\Renderer\ConsoleExceptionRenderer;
 use Cake\Error\Renderer\TextExceptionRenderer;
+use Cake\Http\Exception\MissingControllerException;
 use Cake\Log\Log;
+use Cake\TestSuite\Stub\ConsoleOutput;
 use Cake\TestSuite\TestCase;
 use Cake\Utility\Text;
 use InvalidArgumentException;
@@ -58,10 +61,10 @@ class ExceptionTrapTest extends TestCase
 
     public function testConfigExceptionRendererFallback()
     {
-        $this->markTestIncomplete();
-        $trap = new ExceptionTrap(['exceptionRenderer' => null]);
+        $output = new ConsoleOutput();
+        $trap = new ExceptionTrap(['exceptionRenderer' => null, 'stderr' => $output]);
         $error = new InvalidArgumentException('nope');
-        $this->assertInstanceOf(ConsoleRenderer::class, $trap->renderer($error));
+        $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
     }
 
     public function testConfigExceptionRenderer()
@@ -82,11 +85,11 @@ class ExceptionTrapTest extends TestCase
 
     public function testConfigRendererHandleUnsafeOverwrite()
     {
-        $this->markTestIncomplete();
-        $trap = new ExceptionTrap();
+        $output = new ConsoleOutput();
+        $trap = new ExceptionTrap(['stderr' => $output]);
         $trap->setConfig('exceptionRenderer', null);
         $error = new InvalidArgumentException('nope');
-        $this->assertInstanceOf(ConsoleRenderer::class, $trap->renderer($error));
+        $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error));
     }
 
     public function testLoggerConfigInvalid()
@@ -124,6 +127,57 @@ class ExceptionTrapTest extends TestCase
         $this->assertStringContainsString('ExceptionTrapTest', $out);
     }
 
+    public function testHandleExceptionConsoleRenderingNoStack()
+    {
+        $output = new ConsoleOutput();
+        $trap = new ExceptionTrap([
+            'exceptionRenderer' => ConsoleExceptionRenderer::class,
+            'stderr' => $output,
+        ]);
+        $error = new InvalidArgumentException('nope');
+
+        $trap->handleException($error);
+        $out = $output->messages();
+
+        $this->assertStringContainsString('nope', $out[0]);
+        $this->assertStringNotContainsString('Stack', $out[0]);
+    }
+
+    public function testHandleExceptionConsoleRenderingWithStack()
+    {
+        $output = new ConsoleOutput();
+        $trap = new ExceptionTrap([
+            'exceptionRenderer' => ConsoleExceptionRenderer::class,
+            'stderr' => $output,
+            'trace' => true,
+        ]);
+        $error = new InvalidArgumentException('nope');
+
+        $trap->handleException($error);
+        $out = $output->messages();
+
+        $this->assertStringContainsString('nope', $out[0]);
+        $this->assertStringContainsString('Stack', $out[0]);
+        $this->assertStringContainsString('->testHandleExceptionConsoleRenderingWithStack', $out[0]);
+    }
+
+    public function testHandleExceptionConsoleWithAttributes()
+    {
+        $output = new ConsoleOutput();
+        $trap = new ExceptionTrap([
+            'exceptionRenderer' => ConsoleExceptionRenderer::class,
+            'stderr' => $output,
+        ]);
+        $error = new MissingControllerException(['name' => 'Articles']);
+
+        $trap->handleException($error);
+        $out = $output->messages();
+
+        $this->assertStringContainsString('Controller class Articles', $out[0]);
+        $this->assertStringContainsString('Exception Attributes', $out[0]);
+        $this->assertStringContainsString('Articles', $out[0]);
+    }
+
     /**
      * Test integration with HTML exception rendering
      *

+ 4 - 1
tests/TestCase/Error/Middleware/ErrorHandlerMiddlewareTest.php

@@ -19,6 +19,7 @@ namespace Cake\Test\TestCase\Error\Middleware;
 use Cake\Core\Configure;
 use Cake\Datasource\Exception\RecordNotFoundException;
 use Cake\Error\ErrorHandler;
+use Cake\Error\ExceptionRenderer;
 use Cake\Error\ExceptionRendererInterface;
 use Cake\Error\ExceptionTrap;
 use Cake\Error\Middleware\ErrorHandlerMiddleware;
@@ -132,7 +133,9 @@ class ErrorHandlerMiddlewareTest extends TestCase
     public function testHandleExceptionWithExceptionTrap(): void
     {
         $request = ServerRequestFactory::fromGlobals();
-        $middleware = new ErrorHandlerMiddleware(new ExceptionTrap());
+        $middleware = new ErrorHandlerMiddleware(new ExceptionTrap([
+            'exceptionRenderer' => ExceptionRenderer::class,
+        ]));
         $handler = new TestRequestHandler(function (): void {
             throw new NotFoundException('whoops');
         });

+ 44 - 16
tests/TestCase/Http/ResponseTest.php

@@ -583,8 +583,9 @@ class ResponseTest extends TestCase
             ->withLength(100)
             ->withModified('now');
 
-        $response->notModified();
-
+        $this->deprecated(function () use ($response) {
+            $response->notModified();
+        });
         $this->assertFalse($response->hasHeader('Content-Length'));
         $this->assertFalse($response->hasHeader('Modified'));
         $this->assertEmpty((string)$response->getBody());
@@ -629,8 +630,12 @@ class ResponseTest extends TestCase
         $response = new Response();
         $response = $response->withEtag('something')
             ->withHeader('Content-Length', 99);
-        $this->assertTrue($response->checkNotModified($request));
-        $this->assertFalse($response->hasHeader('Content-Type'), 'etags match, should be unmodified');
+        $this->assertTrue($response->isNotModified($request));
+
+        $this->deprecated(function () use ($response, $request) {
+            $this->assertTrue($response->checkNotModified($request));
+            $this->assertFalse($response->hasHeader('Content-Type'), 'etags match, should be unmodified');
+        });
     }
 
     /**
@@ -644,8 +649,12 @@ class ResponseTest extends TestCase
         $response = new Response();
         $response = $response->withEtag('something', true)
             ->withHeader('Content-Length', 99);
-        $this->assertTrue($response->checkNotModified($request));
-        $this->assertFalse($response->hasHeader('Content-Type'), 'etags match, should be unmodified');
+        $this->assertTrue($response->isNotModified($request));
+
+        $this->deprecated(function () use ($request, $response) {
+            $this->assertTrue($response->checkNotModified($request));
+            $this->assertFalse($response->hasHeader('Content-Type'), 'etags match, should be unmodified');
+        });
     }
 
     /**
@@ -661,8 +670,12 @@ class ResponseTest extends TestCase
         $response = $response->withModified('2012-01-01 00:00:00')
             ->withEtag('something', true)
             ->withHeader('Content-Length', 99);
-        $this->assertTrue($response->checkNotModified($request));
-        $this->assertFalse($response->hasHeader('Content-Length'), 'etags match, should be unmodified');
+        $this->assertTrue($response->isNotModified($request));
+
+        $this->deprecated(function () use ($request, $response) {
+            $this->assertTrue($response->checkNotModified($request));
+            $this->assertFalse($response->hasHeader('Content-Length'), 'etags match, should be unmodified');
+        });
     }
 
     /**
@@ -678,8 +691,12 @@ class ResponseTest extends TestCase
         $response = $response->withModified('2012-01-01 00:00:01')
             ->withEtag('something', true)
             ->withHeader('Content-Length', 99);
-        $this->assertFalse($response->checkNotModified($request));
-        $this->assertTrue($response->hasHeader('Content-Length'), 'timestamp in response is newer');
+        $this->assertFalse($response->isNotModified($request));
+
+        $this->deprecated(function () use ($request, $response) {
+            $this->assertFalse($response->checkNotModified($request));
+            $this->assertTrue($response->hasHeader('Content-Length'), 'timestamp in response is newer');
+        });
     }
 
     /**
@@ -695,8 +712,11 @@ class ResponseTest extends TestCase
         $response = $response->withModified('2012-01-01 00:00:00')
             ->withEtag('something', true)
             ->withHeader('Content-Length', 99);
-        $this->assertFalse($response->checkNotModified($request));
-        $this->assertTrue($response->hasHeader('Content-Length'), 'etags do not match');
+        $this->assertFalse($response->isNotModified($request));
+        $this->deprecated(function () use ($request, $response) {
+            $this->assertFalse($response->checkNotModified($request));
+            $this->assertTrue($response->hasHeader('Content-Length'), 'etags do not match');
+        });
     }
 
     /**
@@ -710,8 +730,12 @@ class ResponseTest extends TestCase
         $response = new Response();
         $response = $response->withModified('2012-01-01 00:00:00')
             ->withHeader('Content-Length', 99);
-        $this->assertTrue($response->checkNotModified($request));
-        $this->assertFalse($response->hasHeader('Content-Length'), 'modified time matches');
+        $this->assertTrue($response->isNotModified($request));
+
+        $this->deprecated(function () use ($request, $response) {
+            $this->assertTrue($response->checkNotModified($request));
+            $this->assertFalse($response->hasHeader('Content-Length'), 'modified time matches');
+        });
     }
 
     /**
@@ -723,8 +747,12 @@ class ResponseTest extends TestCase
         $request = $request->withHeader('If-None-Match', 'W/"something", "other"')
             ->withHeader('If-Modified-Since', '2012-01-01 00:00:00');
         $response = new Response();
-        $this->assertFalse($response->checkNotModified($request));
-        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->isNotModified($request));
+
+        $this->deprecated(function () use ($request, $response) {
+            $this->assertFalse($response->checkNotModified($request));
+            $this->assertSame(200, $response->getStatusCode());
+        });
     }
 
     /**

+ 6 - 2
tests/TestCase/ORM/TableTest.php

@@ -3936,7 +3936,9 @@ class TableTest extends TestCase
             ],
         ]);
 
-        $articles->save($entity, $optionBuilder);
+        $this->deprecated(function () use ($articles, $entity, $optionBuilder) {
+            $articles->save($entity, $optionBuilder);
+        });
         $this->assertFalse($entity->isNew());
         $this->assertSame('test save options', $entity->title);
         $this->assertNotEmpty($entity->id);
@@ -3954,7 +3956,9 @@ class TableTest extends TestCase
             'associated' => [],
         ]);
 
-        $articles->save($entity, $optionBuilder);
+        $this->deprecated(function () use ($articles, $entity, $optionBuilder) {
+            $articles->save($entity, $optionBuilder);
+        });
         $this->assertFalse($entity->isNew());
         $this->assertSame('test save options 2', $entity->title);
         $this->assertNotEmpty($entity->id);

+ 34 - 8
tests/TestCase/Routing/Middleware/RoutingMiddlewareTest.php

@@ -21,6 +21,7 @@ use Cake\Cache\InvalidArgumentException as CacheInvalidArgumentException;
 use Cake\Core\Configure;
 use Cake\Core\HttpApplicationInterface;
 use Cake\Http\ServerRequestFactory;
+use Cake\Routing\Exception\FailedRouteCacheException;
 use Cake\Routing\Exception\MissingRouteException;
 use Cake\Routing\Middleware\RoutingMiddleware;
 use Cake\Routing\Route\Route;
@@ -32,6 +33,7 @@ use Laminas\Diactoros\Response;
 use TestApp\Application;
 use TestApp\Http\TestRequestHandler;
 use TestApp\Middleware\DumbMiddleware;
+use TestApp\Middleware\UnserializableMiddleware;
 
 /**
  * Test for RoutingMiddleware
@@ -60,6 +62,17 @@ class RoutingMiddlewareTest extends TestCase
         Configure::write('App.base', '');
     }
 
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        Cache::enable();
+        if (in_array('_cake_router_', Cache::configured(), true)) {
+            Cache::clear('_cake_router_');
+        }
+        Cache::drop('_cake_router_');
+    }
+
     /**
      * Test redirect responses from redirect routes
      */
@@ -463,9 +476,6 @@ class RoutingMiddlewareTest extends TestCase
         $app = new Application(CONFIG);
         $middleware = new RoutingMiddleware($app, $cacheConfigName);
         $middleware->process($request, $handler);
-
-        Cache::clear($cacheConfigName);
-        Cache::drop($cacheConfigName);
     }
 
     /**
@@ -490,10 +500,6 @@ class RoutingMiddlewareTest extends TestCase
         $app = new Application(CONFIG);
         $middleware = new RoutingMiddleware($app, $cacheConfigName);
         $middleware->process($request, $handler);
-
-        Cache::clear($cacheConfigName);
-        Cache::drop($cacheConfigName);
-        Cache::enable();
     }
 
     /**
@@ -512,8 +518,28 @@ class RoutingMiddlewareTest extends TestCase
         $app = new Application(CONFIG);
         $middleware = new RoutingMiddleware($app, 'notfound');
         $middleware->process($request, new TestRequestHandler());
+    }
 
-        Cache::drop('_cake_router_');
+    public function testFailedRouteCache(): void
+    {
+        Cache::setConfig('_cake_router_', [
+            'engine' => 'File',
+            'path' => CACHE,
+        ]);
+
+        $app = $this->createMock(Application::class);
+        $app
+            ->method('routes')
+            ->will($this->returnCallback(function (RouteBuilder $routes) use ($app) {
+                return $routes->registerMiddleware('should fail', new UnserializableMiddleware($app));
+            }));
+
+        $middleware = new RoutingMiddleware($app, '_cake_router_');
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/articles']);
+
+        $this->expectException(FailedRouteCacheException::class);
+        $this->expectExceptionMessage('Unable to cache route collection.');
+        $middleware->process($request, new TestRequestHandler());
     }
 
     /**

+ 41 - 0
tests/test_app/TestApp/Middleware/UnserializableMiddleware.php

@@ -0,0 +1,41 @@
+<?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(tm) Project
+ * @since         3.3.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace TestApp\Middleware;
+
+use Cake\Core\HttpApplicationInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Testing stub for middleware tests.
+ */
+class UnserializableMiddleware implements MiddlewareInterface
+{
+    protected HttpApplicationInterface $app;
+
+    public function __construct(HttpApplicationInterface $app)
+    {
+        $this->app = $app;
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        return $request;
+    }
+}