Browse Source

Merge branch '3.next' into 4.x

Mark Story 7 years ago
parent
commit
31247fe45c

+ 1 - 0
composer.json

@@ -38,6 +38,7 @@
     },
     "suggest": {
         "ext-openssl": "To use Security::encrypt() or have secure CSRF token generation.",
+        "ext-curl": "To enable more efficient network calls in Http\\Client.",
         "lib-ICU": "The intl PHP library, to use Text::transliterate() or Text::slug()"
     },
     "require-dev": {

+ 9 - 0
src/Command/HelpCommand.php

@@ -87,6 +87,9 @@ class HelpCommand extends Command implements CommandCollectionAwareInterface
     {
         $invert = [];
         foreach ($commands as $name => $class) {
+            if (is_object($class)) {
+                $class = get_class($class);
+            }
             if (!isset($invert[$class])) {
                 $invert[$class] = [];
             }
@@ -94,6 +97,9 @@ class HelpCommand extends Command implements CommandCollectionAwareInterface
         }
 
         foreach ($commands as $name => $class) {
+            if (is_object($class)) {
+                $class = get_class($class);
+            }
             if (count($invert[$class]) == 1) {
                 $io->out('- ' . $name);
             }
@@ -126,6 +132,9 @@ class HelpCommand extends Command implements CommandCollectionAwareInterface
     {
         $shells = new SimpleXMLElement('<shells></shells>');
         foreach ($commands as $name => $class) {
+            if (is_object($class)) {
+                $class = get_class($class);
+            }
             $shell = $shells->addChild('shell');
             $shell->addAttribute('name', $name);
             $shell->addAttribute('call_as', $name);

+ 1 - 1
src/Database/Statement/PDOStatement.php

@@ -126,7 +126,7 @@ class PDOStatement extends StatementDecorator
             return $this->_statement->fetchAll(PDO::FETCH_ASSOC);
         }
         if ($type === static::FETCH_TYPE_OBJ) {
-            return $this->_statement->fetch(PDO::FETCH_OBJ);
+            return $this->_statement->fetchAll(PDO::FETCH_OBJ);
         }
 
         return $this->_statement->fetchAll($type);

+ 22 - 6
src/Datasource/EntityTrait.php

@@ -715,10 +715,10 @@ trait EntityTrait
      *
      * @param string $property the field to set or check status for
      * @param bool $isDirty true means the property was changed, false means
-     * it was not changed
+     * it was not changed. Defaults to true.
      * @return $this
      */
-    public function setDirty($property, $isDirty)
+    public function setDirty($property, $isDirty = true)
     {
         if ($isDirty === false) {
             unset($this->_dirty[$property]);
@@ -844,7 +844,7 @@ trait EntityTrait
      *
      * ```
      * // Sets the error messages for multiple fields at once
-     * $entity->setErrors(['salary' => ['message'], 'name' => ['another message']);
+     * $entity->setErrors(['salary' => ['message'], 'name' => ['another message']]);
      * ```
      *
      * @param array $fields The array of errors to set.
@@ -853,11 +853,27 @@ trait EntityTrait
      */
     public function setErrors(array $fields, $overwrite = false)
     {
+        if ($overwrite) {
+            foreach ($fields as $f => $error) {
+                $this->_errors[$f] = (array)$error;
+            }
+
+            return $this;
+        }
+
         foreach ($fields as $f => $error) {
             $this->_errors += [$f => []];
-            $this->_errors[$f] = $overwrite ?
-                (array)$error :
-                array_merge($this->_errors[$f], (array)$error);
+
+            // String messages are appended to the list,
+            // while more complex error structures need their
+            // keys perserved for nested validator.
+            if (is_string($error)) {
+                $this->_errors[$f][] = $error;
+            } else {
+                foreach ($error as $k => $v) {
+                    $this->_errors[$f][$k] = $v;
+                }
+            }
         }
 
         return $this;

+ 23 - 5
src/Http/Client.php

@@ -16,6 +16,9 @@ namespace Cake\Http;
 use Cake\Core\App;
 use Cake\Core\Exception\Exception;
 use Cake\Core\InstanceConfigTrait;
+use Cake\Http\Client\AdapterInterface;
+use Cake\Http\Client\Adapter\Curl;
+use Cake\Http\Client\Adapter\Stream;
 use Cake\Http\Client\Request;
 use Cake\Http\Cookie\CookieCollection;
 use Cake\Http\Cookie\CookieInterface;
@@ -104,7 +107,7 @@ class Client
      * @var array
      */
     protected $_defaultConfig = [
-        'adapter' => 'Cake\Http\Client\Adapter\Stream',
+        'adapter' => null,
         'host' => null,
         'port' => null,
         'scheme' => 'http',
@@ -127,10 +130,9 @@ class Client
     protected $_cookies;
 
     /**
-     * Adapter for sending requests. Defaults to
-     * Cake\Http\Client\Adapter\Stream
+     * Adapter for sending requests.
      *
-     * @var \Cake\Http\Client\Adapter\Stream
+     * @var \Cake\Http\Client\AdapterInterface
      */
     protected $_adapter;
 
@@ -154,6 +156,9 @@ class Client
      * - ssl_verify_host - Verify that the certificate and hostname match.
      *   Defaults to true.
      * - redirect - Number of redirects to follow. Defaults to false.
+     * - adapter - The adapter class name or instance. Defaults to
+     *   \Cake\Http\Client\Adapter\Curl if `curl` extension is loaded else
+     *   \Cake\Http\Client\Adapter\Stream.
      *
      * @param array $config Config options for scoped clients.
      */
@@ -162,10 +167,23 @@ class Client
         $this->setConfig($config);
 
         $adapter = $this->_config['adapter'];
-        $this->setConfig('adapter', null);
+        if ($adapter === null) {
+            $adapter = Curl::class;
+
+            if (!extension_loaded('curl')) {
+                $adapter = Stream::class;
+            }
+        } else {
+            $this->setConfig('adapter', null);
+        }
+
         if (is_string($adapter)) {
             $adapter = new $adapter();
         }
+
+        if (!$adapter instanceof AdapterInterface) {
+            throw new InvalidArgumentException('Adapter must be an instance of Cake\Http\Client\AdapterInterface');
+        }
         $this->_adapter = $adapter;
 
         if (!empty($this->_config['cookieJar'])) {

+ 161 - 0
src/Http/Client/Adapter/Curl.php

@@ -0,0 +1,161 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * 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.7.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Client\Adapter;
+
+use Cake\Http\Client\AdapterInterface;
+use Cake\Http\Client\Request;
+use Cake\Http\Client\Response;
+use Cake\Http\Exception\HttpException;
+
+/**
+ * Implements sending Cake\Http\Client\Request via ext/curl.
+ *
+ * In addition to the standard options documented in Cake\Http\Client,
+ * this adapter supports all available curl options. Additional curl options
+ * can be set via the `curl` option key when making requests or configuring
+ * a client.
+ */
+class Curl implements AdapterInterface
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function send(Request $request, array $options)
+    {
+        $ch = curl_init();
+        $options = $this->buildOptions($request, $options);
+        curl_setopt_array($ch, $options);
+
+        $body = $this->exec($ch);
+        if ($body === false) {
+            $errorCode = curl_errno($ch);
+            $error = curl_error($ch);
+            curl_close($ch);
+
+            $status = 500;
+            if ($error === 28) {
+                $status = 504;
+            }
+            throw new HttpException("cURL Error ({$errorCode}) {$error}", $status);
+        }
+
+        $responses = $this->createResponse($ch, $body);
+        curl_close($ch);
+
+        return $responses;
+    }
+
+    /**
+     * Convert client options into curl options.
+     *
+     * @param \Cake\Http\Client\Request $request The request.
+     * @param array $options The client options
+     * @return array
+     */
+    public function buildOptions(Request $request, array $options)
+    {
+        $headers = [];
+        foreach ($request->getHeaders() as $key => $values) {
+            $headers[] = $key . ': ' . implode(', ', $values);
+        }
+
+        $out = [
+            CURLOPT_URL => (string)$request->getUri(),
+            CURLOPT_HTTP_VERSION => $request->getProtocolVersion(),
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => $headers
+        ];
+        switch ($request->getMethod()) {
+            case Request::METHOD_GET:
+                $out[CURLOPT_HTTPGET] = true;
+                break;
+
+            case Request::METHOD_POST:
+                $out[CURLOPT_POST] = true;
+                break;
+
+            default:
+                $out[CURLOPT_POST] = true;
+                $out[CURLOPT_CUSTOMREQUEST] = $request->getMethod();
+                break;
+        }
+
+        $body = $request->getBody();
+        if ($body) {
+            $body->rewind();
+            $out[CURLOPT_POSTFIELDS] = $body->getContents();
+        }
+
+        if (empty($options['ssl_cafile'])) {
+            $options['ssl_cafile'] = CORE_PATH . 'config' . DIRECTORY_SEPARATOR . 'cacert.pem';
+        }
+        if (!empty($options['ssl_verify_host'])) {
+            // Value of 1 or true is deprecated. Only 2 or 0 should be used now.
+            $options['ssl_verify_host'] = 2;
+        }
+        $optionMap = [
+            'timeout' => CURLOPT_TIMEOUT,
+            'ssl_verify_peer' => CURLOPT_SSL_VERIFYPEER,
+            'ssl_verify_host' => CURLOPT_SSL_VERIFYHOST,
+            'ssl_cafile' => CURLOPT_CAINFO,
+            'ssl_local_cert' => CURLOPT_SSLCERT,
+            'ssl_passphrase' => CURLOPT_SSLCERTPASSWD,
+        ];
+        foreach ($optionMap as $option => $curlOpt) {
+            if (isset($options[$option])) {
+                $out[$curlOpt] = $options[$option];
+            }
+        }
+        if (isset($options['proxy']['proxy'])) {
+            $out[CURLOPT_PROXY] = $options['proxy']['proxy'];
+        }
+        if (isset($options['curl']) && is_array($options['curl'])) {
+            // Can't use array_merge() because keys will be re-ordered.
+            foreach ($options['curl'] as $key => $value) {
+                $out[$key] = $value;
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * Convert the raw curl response into an Http\Client\Response
+     *
+     * @param resource $handle Curl handle
+     * @param string $responseData string The response data from curl_exec
+     * @return \Cake\Http\Client\Response
+     */
+    protected function createResponse($handle, $responseData)
+    {
+        $meta = curl_getinfo($handle);
+        $headers = trim(substr($responseData, 0, $meta['header_size']));
+        $body = substr($responseData, $meta['header_size']);
+        $response = new Response(explode("\r\n", $headers), $body);
+
+        return [$response];
+    }
+
+    /**
+     * Execute the curl handle.
+     *
+     * @param resource $ch Curl Resource handle
+     * @return string
+     */
+    protected function exec($ch)
+    {
+        return curl_exec($ch);
+    }
+}

+ 3 - 6
src/Http/Client/Adapter/Stream.php

@@ -14,6 +14,7 @@
 namespace Cake\Http\Client\Adapter;
 
 use Cake\Core\Exception\Exception;
+use Cake\Http\Client\AdapterInterface;
 use Cake\Http\Client\Request;
 use Cake\Http\Client\Response;
 use Cake\Http\Exception\HttpException;
@@ -24,7 +25,7 @@ use Cake\Http\Exception\HttpException;
  *
  * This approach and implementation is partly inspired by Aura.Http
  */
-class Stream
+class Stream implements AdapterInterface
 {
 
     /**
@@ -63,11 +64,7 @@ class Stream
     protected $_connectionErrors = [];
 
     /**
-     * Send a request and get a response back.
-     *
-     * @param \Cake\Http\Client\Request $request The request object to send.
-     * @param array $options Array of options for the stream.
-     * @return array Array of populated Response objects
+     * {@inheritDoc}
      */
     public function send(Request $request, array $options)
     {

+ 28 - 0
src/Http/Client/AdapterInterface.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * 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.7.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Client;
+
+use Cake\Http\Client\Request;
+
+interface AdapterInterface
+{
+    /**
+     * Send a request and get a response back.
+     *
+     * @param \Cake\Http\Client\Request $request The request object to send.
+     * @param array $options Array of options for the stream.
+     * @return \Cake\Http\Client\Response[] Array of populated Response objects
+     */
+    public function send(Request $request, array $options);
+}

+ 28 - 11
src/Routing/RouteBuilder.php

@@ -561,28 +561,45 @@ class RouteBuilder
      * the current RouteBuilder instance.
      *
      * @param string $name The plugin name
-     * @param string $file The routes file to load. Defaults to `routes.php`
+     * @param string $file The routes file to load. Defaults to `routes.php`. This parameter
+     *   is deprecated and will be removed in 4.0
      * @return void
      * @throws \Cake\Core\Exception\MissingPluginException When the plugin has not been loaded.
      * @throws \InvalidArgumentException When the plugin does not have a routes file.
      */
     public function loadPlugin($name, $file = 'routes.php')
     {
-        if (!Plugin::loaded($name)) {
+        $plugins = Plugin::getCollection();
+        if (!$plugins->has($name)) {
             throw new MissingPluginException(['plugin' => $name]);
         }
+        $plugin = $plugins->get($name);
 
-        $path = Plugin::configPath($name) . DIRECTORY_SEPARATOR . $file;
-        if (!file_exists($path)) {
-            throw new InvalidArgumentException(sprintf(
-                'Cannot load routes for the plugin named %s. The %s file does not exist.',
-                $name,
-                $path
-            ));
+        // @deprecated This block should be removed in 4.0
+        if ($file !== 'routes.php') {
+            deprecationWarning(
+                'Loading plugin routes now uses the routes() hook method on the plugin class. ' .
+                'Loading non-standard files will be removed in 4.0'
+            );
+
+            $path = $plugin->getConfigPath() . DIRECTORY_SEPARATOR . $file;
+            if (!file_exists($path)) {
+                throw new InvalidArgumentException(sprintf(
+                    'Cannot load routes for the plugin named %s. The %s file does not exist.',
+                    $name,
+                    $path
+                ));
+            }
+
+            $routes = $this;
+            include $path;
+
+            return;
         }
+        $plugin->routes($this);
 
-        $routes = $this;
-        include $path;
+        // Disable the routes hook to prevent duplicate route issues.
+        $plugin->disable('routes');
     }
 
     /**

+ 45 - 0
src/Utility/Xml.php

@@ -170,6 +170,51 @@ class Xml
     }
 
     /**
+     * Parse the input html string and create either a SimpleXmlElement object or a DOMDocument.
+     *
+     * @param string $input The input html string to load.
+     * @param array $options The options to use. See Xml::build()
+     * @return \SimpleXMLElement|\DOMDocument
+     * @throws \Cake\Utility\Exception\XmlException
+     */
+    public static function loadHtml($input, $options = [])
+    {
+        $defaults = [
+            'return' => 'simplexml',
+            'loadEntities' => false,
+        ];
+        $options += $defaults;
+
+        $hasDisable = function_exists('libxml_disable_entity_loader');
+        $internalErrors = libxml_use_internal_errors(true);
+        if ($hasDisable && !$options['loadEntities']) {
+            libxml_disable_entity_loader(true);
+        }
+        $flags = 0;
+        if (!empty($options['parseHuge'])) {
+            $flags |= LIBXML_PARSEHUGE;
+        }
+        try {
+            $xml = new DOMDocument();
+            $xml->loadHTML($input, $flags);
+
+            if ($options['return'] === 'simplexml' || $options['return'] === 'simplexmlelement') {
+                $flags |= LIBXML_NOCDATA;
+                $xml = simplexml_import_dom($xml);
+            }
+
+            return $xml;
+        } catch (Exception $e) {
+            throw new XmlException('Xml cannot be read. ' . $e->getMessage(), null, $e);
+        } finally {
+            if ($hasDisable && !$options['loadEntities']) {
+                libxml_disable_entity_loader(false);
+            }
+            libxml_use_internal_errors($internalErrors);
+        }
+    }
+
+    /**
      * Transform an array into a SimpleXMLElement
      *
      * ### Options

+ 1 - 1
src/View/View.php

@@ -55,7 +55,7 @@ use RuntimeException;
  * `plugins/SuperHot/Template/Posts/index.ctp`. If a theme template
  * is not found for the current action the default app template file is used.
  *
- * @property \Cake\View\Helper\BreadCrumbsHelper $BreadCrumbs
+ * @property \Cake\View\Helper\BreadcrumbsHelper $Breadcrumbs
  * @property \Cake\View\Helper\FlashHelper $Flash
  * @property \Cake\View\Helper\FormHelper $Form
  * @property \Cake\View\Helper\HtmlHelper $Html

+ 14 - 0
tests/Fixture/sample.html

@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html><body>
+
+<p>Browsers usually indent blockquote elements.</p>
+
+<blockquote cite="http://www.worldwildlife.org/who/index.html">
+For 50 years, WWF has been protecting the future of nature.
+The world's leading conservation organization,
+WWF works in 100 countries and is supported by
+1.2 million members in the United States and
+close to 5 million globally.
+</blockquote>
+
+</body></html>

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

@@ -76,6 +76,7 @@ class HelpCommandTest extends ConsoleIntegrationTestCase
         $this->assertOutputContains('- test_plugin.sample', 'Long plugin name');
         $this->assertOutputContains('- routes', 'core shell');
         $this->assertOutputContains('- example', 'short plugin name');
+        $this->assertOutputContains('- abort', 'command object');
         $this->assertOutputContains('To run a command', 'more info present');
         $this->assertOutputContains('To get help', 'more info present');
     }

+ 1 - 1
tests/TestCase/Database/QueryTest.php

@@ -3457,7 +3457,7 @@ class QueryTest extends TestCase
         $result = $query
             ->select(['d' => $query->func()->now('date')])
             ->execute();
-        $this->assertEquals([['d' => date('Y-m-d')]], $result->fetchAll('assoc'));
+        $this->assertEquals([(object)['d' => date('Y-m-d')]], $result->fetchAll('obj'));
 
         $query = new Query($this->connection);
         $result = $query

+ 307 - 0
tests/TestCase/Http/Client/Adapter/CurlTest.php

@@ -0,0 +1,307 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * 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.7.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http\Client\Adapter;
+
+use Cake\Http\Client\Adapter\Curl;
+use Cake\Http\Client\Request;
+use Cake\Http\Client\Response;
+use Cake\TestSuite\TestCase;
+
+/**
+ * HTTP curl adapter test.
+ */
+class CurlTest extends TestCase
+{
+
+    public function setUp()
+    {
+        parent::setUp();
+        $this->skipIf(!function_exists('curl_init'), 'Skipping as ext/curl is not installed.');
+
+        $this->curl = new Curl();
+        $this->caFile = CORE_PATH . 'config' . DIRECTORY_SEPARATOR . 'cacert.pem';
+    }
+
+    /**
+     * Test the send method
+     *
+     * @return void
+     */
+    public function testSendLive()
+    {
+        $request = new Request('http://localhost', 'GET', [
+            'User-Agent' => 'CakePHP TestSuite',
+            'Cookie' => 'testing=value'
+        ]);
+        try {
+            $responses = $this->curl->send($request, []);
+        } catch (\Cake\Core\Exception\Exception $e) {
+            $this->markTestSkipped('Could not connect to localhost, skipping');
+        }
+        $this->assertCount(1, $responses);
+
+        $response = $responses[0];
+        $this->assertInstanceOf(Response::class, $response);
+        $this->assertNotEmpty($response->getHeaders());
+        $this->assertNotEmpty($response->getBody()->getContents());
+    }
+
+    /**
+     * Test the send method
+     *
+     * @return void
+     */
+    public function testSendLiveResponseCheck()
+    {
+        $request = new Request('https://api.cakephp.org/3.0/', 'GET', [
+            'User-Agent' => 'CakePHP TestSuite',
+        ]);
+        try {
+            $responses = $this->curl->send($request, []);
+        } catch (\Cake\Core\Exception\Exception $e) {
+            $this->markTestSkipped('Could not connect to book.cakephp.org, skipping');
+        }
+        $this->assertCount(1, $responses);
+
+        $response = $responses[0];
+        $this->assertInstanceOf(Response::class, $response);
+        $this->assertTrue($response->hasHeader('Date'));
+        $this->assertTrue($response->hasHeader('Content-type'));
+        $this->assertContains('<html', $response->getBody()->getContents());
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsGet()
+    {
+        $options = [
+            'timeout' => 5
+        ];
+        $request = new Request(
+            'http://localhost/things',
+            'GET',
+            ['Cookie' => 'testing=value']
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Cookie: testing=value',
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_TIMEOUT => 5,
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsPost()
+    {
+        $options = [];
+        $request = new Request(
+            'http://localhost/things',
+            'POST',
+            ['Cookie' => 'testing=value'],
+            ['name' => 'cakephp', 'yes' => 1]
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Cookie: testing=value',
+                'Connection: close',
+                'User-Agent: CakePHP',
+                'Content-Type: application/x-www-form-urlencoded',
+            ],
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => 'name=cakephp&yes=1',
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsPut()
+    {
+        $options = [];
+        $request = new Request(
+            'http://localhost/things',
+            'PUT',
+            ['Cookie' => 'testing=value']
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Cookie: testing=value',
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_POST => true,
+            CURLOPT_CUSTOMREQUEST => 'PUT',
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsJsonPost()
+    {
+        $options = [];
+        $content = json_encode(['a' => 1, 'b' => 2]);
+        $request = new Request(
+            'http://localhost/things',
+            'POST',
+            ['Content-type' => 'application/json'],
+            $content
+        );
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Content-type: application/json',
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => $content,
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsSsl()
+    {
+        $options = [
+            'ssl_verify_host' => true,
+            'ssl_verify_peer' => true,
+            'ssl_verify_peer_name' => true,
+            // These options do nothing in curl.
+            'ssl_verify_depth' => 9000,
+            'ssl_allow_self_signed' => false,
+        ];
+        $request = new Request('http://localhost/things', 'GET');
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_SSL_VERIFYPEER => true,
+            CURLOPT_SSL_VERIFYHOST => 2,
+            CURLOPT_CAINFO => $this->caFile,
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsProxy()
+    {
+        $options = [
+            'proxy' => [
+                'proxy' => '127.0.0.1:8080'
+            ]
+        ];
+        $request = new Request('http://localhost/things', 'GET');
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_CAINFO => $this->caFile,
+            CURLOPT_PROXY => '127.0.0.1:8080',
+        ];
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Test converting client options into curl ones.
+     *
+     * @return void
+     */
+    public function testBuildOptionsCurlOptions()
+    {
+        $options = [
+            'curl' => [
+                CURLOPT_USERAGENT => 'Super-secret'
+            ]
+        ];
+        $request = new Request('http://localhost/things', 'GET');
+        $result = $this->curl->buildOptions($request, $options);
+        $expected = [
+            CURLOPT_URL => 'http://localhost/things',
+            CURLOPT_HTTP_VERSION => '1.1',
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+            CURLOPT_HTTPHEADER => [
+                'Connection: close',
+                'User-Agent: CakePHP',
+            ],
+            CURLOPT_HTTPGET => true,
+            CURLOPT_CAINFO => $this->caFile,
+            CURLOPT_USERAGENT => 'Super-secret'
+        ];
+        $this->assertSame($expected, $result);
+    }
+}

+ 14 - 0
tests/TestCase/Http/ClientTest.php

@@ -19,6 +19,7 @@ use Cake\Http\Client\Response;
 use Cake\Http\Cookie\Cookie;
 use Cake\Http\Cookie\CookieCollection;
 use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
 
 /**
  * HTTP client test.
@@ -60,6 +61,19 @@ class ClientTest extends TestCase
     }
 
     /**
+     * testAdapterInstanceCheck
+     *
+     * @return void
+     */
+    public function testAdapterInstanceCheck()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Adapter must be an instance of Cake\Http\Client\AdapterInterface');
+
+        new Client(['adapter' => 'stdClass']);
+    }
+
+    /**
      * Data provider for buildUrl() tests
      *
      * @return array

+ 11 - 1
tests/TestCase/ORM/EntityTest.php

@@ -812,7 +812,7 @@ class EntityTest extends TestCase
         ], ['markClean' => true]);
 
         $this->assertFalse($entity->isDirty());
-        $this->assertSame($entity, $entity->setDirty('title', true));
+        $this->assertSame($entity, $entity->setDirty('title'));
         $this->assertSame($entity, $entity->setDirty('id', false));
 
         $entity->setErrors(['title' => ['badness']]);
@@ -1191,6 +1191,16 @@ class EntityTest extends TestCase
         ];
         $result = $entity->getErrors();
         $this->assertEquals($expected, $result);
+
+        $indexedErrors = [2 => ['foo' => 'bar']];
+        $entity = new Entity();
+        $entity->setError('indexes', $indexedErrors);
+
+        $expectedIndexed = [
+            'indexes' => ['2' => ['foo' => 'bar']]
+        ];
+        $result = $entity->getErrors();
+        $this->assertEquals($expectedIndexed, $result);
     }
 
     /**

+ 10 - 5
tests/TestCase/Routing/RouteBuilderTest.php

@@ -1180,11 +1180,13 @@ class RouteBuilderTest extends TestCase
      */
     public function testLoadPluginBadFile()
     {
-        $this->expectException(\InvalidArgumentException::class);
-        $this->expectExceptionMessage('Cannot load routes for the plugin named TestPlugin.');
-        Plugin::load('TestPlugin');
-        $routes = new RouteBuilder($this->collection, '/');
-        $routes->loadPlugin('TestPlugin', 'nope.php');
+        $this->deprecated(function () {
+            $this->expectException(\InvalidArgumentException::class);
+            $this->expectExceptionMessage('Cannot load routes for the plugin named TestPlugin.');
+            Plugin::load('TestPlugin');
+            $routes = new RouteBuilder($this->collection, '/');
+            $routes->loadPlugin('TestPlugin', 'nope.php');
+        });
     }
 
     /**
@@ -1199,5 +1201,8 @@ class RouteBuilderTest extends TestCase
         $routes->loadPlugin('TestPlugin');
         $this->assertCount(1, $this->collection->routes());
         $this->assertNotEmpty($this->collection->parse('/test_plugin', 'GET'));
+
+        $plugin = Plugin::getCollection()->get('TestPlugin');
+        $this->assertFalse($plugin->isEnabled('routes'), 'Hook should be disabled preventing duplicate routes');
     }
 }

+ 20 - 0
tests/TestCase/TestSuite/IntegrationTestTraitTest.php

@@ -15,6 +15,7 @@
 namespace Cake\Test\TestCase\TestSuite;
 
 use Cake\Core\Configure;
+use Cake\Core\Plugin;
 use Cake\Event\EventManager;
 use Cake\Http\Middleware\EncryptedCookieMiddleware;
 use Cake\Http\Response;
@@ -61,6 +62,8 @@ class IntegrationTestTraitTest extends IntegrationTestCase
             $routes->options('/options/:controller/:action', []);
             $routes->connect('/:controller/:action/*', []);
         });
+
+        $this->configApplication(Configure::read('App.namespace') . '\Application', null);
     }
 
     /**
@@ -262,6 +265,23 @@ class IntegrationTestTraitTest extends IntegrationTestCase
      *
      * @return void
      */
+    public function testGetUsingApplicationWithPluginRoutes()
+    {
+        // first clean routes to have Router::$initailized === false
+        Router::reload();
+        Plugin::unload();
+
+        $this->configApplication(Configure::read('App.namespace') . '\ApplicationWithPluginRoutes', null);
+
+        $this->get('/test_plugin');
+        $this->assertResponseOk();
+    }
+
+    /**
+     * Test sending get request and using default `test_app/config/routes.php`.
+     *
+     * @return void
+     */
     public function testGetUsingApplicationWithDefaultRoutes()
     {
         // first clean routes to have Router::$initailized === false

+ 48 - 0
tests/TestCase/Utility/XmlTest.php

@@ -248,6 +248,54 @@ class XmlTest extends TestCase
     }
 
     /**
+     * testLoadHtml method
+     *
+     * @return void
+     */
+    public function testLoadHtml()
+    {
+        $htmlFile = CORE_TESTS . 'Fixture/sample.html';
+        $html = file_get_contents($htmlFile);
+        $paragraph = 'Browsers usually indent blockquote elements.';
+        $blockquote = "
+For 50 years, WWF has been protecting the future of nature.
+The world's leading conservation organization,
+WWF works in 100 countries and is supported by
+1.2 million members in the United States and
+close to 5 million globally.
+";
+
+        $xml = Xml::loadHtml($html);
+        $this->assertTrue(isset($xml->body->p), 'Paragraph present');
+        $this->assertEquals($paragraph, (string)$xml->body->p);
+        $this->assertTrue(isset($xml->body->blockquote), 'Blockquote present');
+        $this->assertEquals($blockquote, (string)$xml->body->blockquote);
+
+        $xml = Xml::loadHtml($html, ['parseHuge' => true]);
+        $this->assertTrue(isset($xml->body->p), 'Paragraph present');
+        $this->assertEquals($paragraph, (string)$xml->body->p);
+        $this->assertTrue(isset($xml->body->blockquote), 'Blockquote present');
+        $this->assertEquals($blockquote, (string)$xml->body->blockquote);
+
+        $xml = Xml::loadHtml($html);
+        $this->assertEquals($html, "<!DOCTYPE html>\n" . $xml->asXML() . "\n");
+
+        $xml = Xml::loadHtml($html, ['return' => 'dom']);
+        $this->assertEquals($html, $xml->saveHTML());
+    }
+
+    /**
+     * test loadHtml with a empty html string
+     *
+     * @return void
+     */
+    public function testLoadHtmlEmptyHtml()
+    {
+        $this->expectException(XmlException::class);
+        Xml::loadHtml(null);
+    }
+
+    /**
      * testFromArray method
      *
      * @return void

+ 5 - 1
tests/test_app/Plugin/TestPlugin/config/routes.php

@@ -4,5 +4,9 @@ use Cake\Core\Configure;
 Configure::write('PluginTest.test_plugin.routes', 'loaded plugin routes');
 
 if (isset($routes)) {
-    $routes->get('/test_plugin', ['controller' => 'TestPlugin', 'plugin' => 'TestPlugin', 'action' => 'index']);
+    $routes->get(
+        '/test_plugin',
+        ['controller' => 'TestPlugin', 'plugin' => 'TestPlugin', 'action' => 'index'],
+        'test_plugin:index'
+    );
 }

+ 1 - 1
tests/test_app/TestApp/Application.php

@@ -29,7 +29,7 @@ class Application extends BaseApplication
     public function console($commands)
     {
         return $commands
-            ->add('abort_command', AbortCommand::class)
+            ->add('abort_command', new AbortCommand())
             ->addMany($commands->autoDiscover());
     }
 

+ 48 - 0
tests/test_app/TestApp/ApplicationWithPluginRoutes.php

@@ -0,0 +1,48 @@
+<?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         3.6.6
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace TestApp;
+
+use Cake\Http\BaseApplication;
+use Cake\Routing\Middleware\RoutingMiddleware;
+
+class ApplicationWithPluginRoutes extends BaseApplication
+{
+    public function bootstrap()
+    {
+        parent::bootstrap();
+        $this->addPlugin('TestPlugin');
+    }
+
+    public function middleware($middleware)
+    {
+        $middleware->add(new RoutingMiddleware($this));
+
+        return $middleware;
+    }
+
+    /**
+     * Routes hook, used for testing with RoutingMiddleware.
+     *
+     * @param \Cake\Routing\RouteBuilder $routes
+     * @return void
+     */
+    public function routes($routes)
+    {
+        $routes->scope('/app', function ($routes) {
+            $routes->connect('/articles', ['controller' => 'Articles']);
+        });
+        $routes->loadPlugin('TestPlugin');
+    }
+}