Browse Source

Merge branch 'master' into 3.next

Mark Story 9 years ago
parent
commit
bcd7a34f08

+ 10 - 5
.travis.yml

@@ -4,6 +4,7 @@ php:
   - 7.0
   - 5.5
   - 5.6
+  - 7.1
 
 dist: trusty
 
@@ -43,6 +44,7 @@ matrix:
 
   allow_failures:
     - php: hhvm
+    - php: 7.1
 
 before_install:
   - if [ $HHVM != 1 && $TRAVIS_PHP_VERSION != 7.* ]; then phpenv config-rm xdebug.ini; fi
@@ -62,10 +64,10 @@ before_install:
 
   - if [[ $TRAVIS_PHP_VERSION != 'hhvm' ]] ; then echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi
   - if [[ $TRAVIS_PHP_VERSION != 'hhvm' ]] ; then echo 'extension = redis.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi
-  - if [[ $TRAVIS_PHP_VERSION != 'hhvm' ]] ; then echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi
-  - if [[ $TRAVIS_PHP_VERSION != 'hhvm' ]] ; then echo 'apc.enable_cli = 1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi
+  - if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != 7.1 ]] ; then echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi
+  - if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != 7.1 ]] ; then echo 'apc.enable_cli = 1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi
   - if [[ $TRAVIS_PHP_VERSION =~ 5.[56] ]] ; then echo yes | pecl install apcu-4.0.10; fi
-  - if [[ $TRAVIS_PHP_VERSION = 7.* ]] ; then echo yes | pecl install apcu; fi
+  - if [[ $TRAVIS_PHP_VERSION = 7.0 ]] ; then echo yes | pecl install apcu; fi
   - if [[ $TRAVIS_PHP_VERSION = 'hhvm' ]] ; then composer require lorenzo/multiple-iterator=~1.0; fi
 
   - phpenv rehash
@@ -75,10 +77,13 @@ before_script:
   - composer install --prefer-dist --no-interaction
 
 script:
-  - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION = 7.* ]]; then export CODECOVERAGE=1; vendor/bin/phpunit --coverage-clover=clover.xml; fi
-  - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION != 7.* ]]; then vendor/bin/phpunit; fi
+  - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION = 7.0 ]]; then export CODECOVERAGE=1; vendor/bin/phpunit --coverage-clover=clover.xml; fi
+  - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION != 7.0 ]]; then vendor/bin/phpunit; fi
 
   - if [[ $PHPCS = 1 ]]; then vendor/bin/phpcs -p --extensions=php --standard=vendor/cakephp/cakephp-codesniffer/CakePHP ./src ./tests; fi
 
+after_success:
+  - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION = 7.0 ]]; then bash <(curl -s https://codecov.io/bash); fi
+
 notifications:
   email: false

+ 0 - 1
src/Controller/Controller.php

@@ -523,7 +523,6 @@ class Controller implements EventListenerInterface, EventDispatcherInterface
 
     /**
      * Redirects to given $url, after turning off $this->autoRender.
-     * Script execution is halted after the redirect.
      *
      * @param string|array $url A string or array-based URL pointing to another location within the app,
      *     or an absolute URL

+ 1 - 5
src/Database/Type/DateTimeType.php

@@ -122,10 +122,6 @@ class DateTimeType extends Type
             list($value) = explode('.', $value);
         }
 
-        if ($this->_datetimeInstance === null) {
-            $this->_datetimeInstance = new $this->_className;
-        }
-
         $instance = clone $this->_datetimeInstance;
 
         return $instance->modify($value);
@@ -261,7 +257,7 @@ class DateTimeType extends Type
             $class = $fallback;
         }
         $this->_className = $class;
-        $this->_datetimeInstance = null;
+        $this->_datetimeInstance = new $this->_className;
     }
 
     /**

+ 2 - 2
src/Database/Type/OptionalConvertInterface.php

@@ -22,8 +22,8 @@ interface OptionalConvertInterface
 {
 
     /**
-     * Returns whehter the cast to PHP is required to be invoked, since
-     * it is not a indentity function.
+     * Returns whether the cast to PHP is required to be invoked, since
+     * it is not a identity function.
      *
      * @return bool
      */

+ 35 - 0
src/Network/Exception/UnavailableForLegalReasonsException.php

@@ -0,0 +1,35 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @since         3.2.12
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Network\Exception;
+
+/**
+ * Represents an HTTP 451 error.
+ *
+ */
+class UnavailableForLegalReasonsException extends HttpException
+{
+
+    /**
+     * Constructor
+     *
+     * @param string|null $message If no message is given 'Unavailable For Legal Reasons' will be the message
+     * @param int $code Status code, defaults to 451
+     */
+    public function __construct($message = null, $code = 451)
+    {
+        if (empty($message)) {
+            $message = 'Unavailable For Legal Reasons';
+        }
+        parent::__construct($message, $code);
+    }
+}

+ 1 - 0
src/Network/Response.php

@@ -73,6 +73,7 @@ class Response
         417 => 'Expectation Failed',
         422 => 'Unprocessable Entity',
         429 => 'Too Many Requests',
+        451 => 'Unavailable For Legal Reasons',
         500 => 'Internal Server Error',
         501 => 'Not Implemented',
         502 => 'Bad Gateway',

+ 1 - 1
src/ORM/Association.php

@@ -135,7 +135,7 @@ abstract class Association
     /**
      * Whether or not cascaded deletes should also fire callbacks.
      *
-     * @var string
+     * @var bool
      */
     protected $_cascadeCallbacks = false;
 

+ 6 - 2
src/ORM/Association/BelongsToMany.php

@@ -643,15 +643,19 @@ class BelongsToMany extends Association
                 $entity = clone $entity;
             }
 
-            if ($table->save($entity, $options)) {
+            $saved = $table->save($entity, $options);
+            if ($saved) {
                 $entities[$k] = $entity;
                 $persisted[] = $entity;
                 continue;
             }
 
+            // Saving the new linked entity failed, copy errors back into the
+            // original entity if applicable and abort.
             if (!empty($options['atomic'])) {
                 $original[$k]->errors($entity->errors());
-
+            }
+            if (!$saved) {
                 return false;
             }
         }

+ 25 - 0
src/ORM/Exception/RolledbackTransactionException.php

@@ -0,0 +1,25 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
+ * @since         3.2.13
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\ORM\Exception;
+
+use Cake\Core\Exception\Exception;
+
+/**
+ * Used when a transaction was rolled back from a callback event.
+ *
+ */
+class RolledbackTransactionException extends Exception
+{
+
+    protected $_messageTemplate = 'The afterSave event in "%s" is aborting the transaction before the save process is done.';
+}

+ 3 - 2
src/ORM/Rule/ExistsIn.php

@@ -81,9 +81,10 @@ class ExistsIn
             $repository = $options['repository']->association($this->_repository);
             if (!$repository) {
                 throw new RuntimeException(sprintf(
-                    "ExistsIn rule for '%s' is invalid. The '%s' association is not defined.",
+                    "ExistsIn rule for '%s' is invalid. '%s' is not associated with '%s'.",
                     implode(', ', $this->_fields),
-                    $this->_repository
+                    $this->_repository,
+                    get_class($options['repository'])
                 ));
             }
             $this->_repository = $repository;

+ 48 - 23
src/ORM/Table.php

@@ -33,6 +33,7 @@ use Cake\ORM\Association\BelongsToMany;
 use Cake\ORM\Association\HasMany;
 use Cake\ORM\Association\HasOne;
 use Cake\ORM\Exception\MissingEntityException;
+use Cake\ORM\Exception\RolledbackTransactionException;
 use Cake\ORM\Rule\IsUnique;
 use Cake\Utility\Inflector;
 use Cake\Validation\Validation;
@@ -1384,13 +1385,12 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
      *   transaction (default: true)
      * - checkRules: Whether or not to check the rules on entity before saving, if the checking
      *   fails, it will abort the save operation. (default:true)
-     * - associated: If true it will save all associated entities as they are found
+     * - associated: If `true` it will save 1st level associated entities as they are found
      *   in the passed `$entity` whenever the property defined for the association
-     *   is marked as dirty. Associated records are saved recursively unless told
-     *   otherwise. If an array, it will be interpreted as the list of associations
+     *   is marked as dirty. If an array, it will be interpreted as the list of associations
      *   to be saved. It is possible to provide different options for saving on associated
      *   table objects using this key by making the custom options the array value.
-     *   If false no associated records will be saved. (default: true)
+     *   If `false` no associated records will be saved. (default: `true`)
      * - checkExisting: Whether or not to check if the entity already exists, assuming that the
      *   entity is marked as not new, and the primary key has been set.
      *
@@ -1455,6 +1455,8 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
      * $articles->save($entity, ['associated' => false]);
      * ```
      *
+     * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction
+     *   is aborted in the afterSave event.
      */
     public function save(EntityInterface $entity, $options = [])
     {
@@ -1505,6 +1507,8 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
      * @param \ArrayObject $options the options to use for the save operation
      * @return \Cake\Datasource\EntityInterface|bool
      * @throws \RuntimeException When an entity is missing some of the primary keys.
+     * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction
+     *   is aborted in the afterSave event.
      */
     protected function _processSave($entity, $options)
     {
@@ -1552,32 +1556,53 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
         }
 
         if ($success) {
-            $success = $this->_associations->saveChildren(
-                $this,
-                $entity,
-                $options['associated'],
-                ['_primary' => false] + $options->getArrayCopy()
-            );
-            if ($success || !$options['atomic']) {
-                $this->dispatchEvent('Model.afterSave', compact('entity', 'options'));
-                $entity->clean();
-                if (!$options['atomic'] && !$options['_primary']) {
-                    $entity->isNew(false);
-                    $entity->source($this->registryAlias());
-                }
-                $success = true;
-            }
+            $success = $this->_onSaveSuccess($entity, $options);
         }
 
         if (!$success && $isNew) {
             $entity->unsetProperty($this->primaryKey());
             $entity->isNew(true);
         }
-        if ($success) {
-            return $entity;
+
+        return $success ? $entity : false;
+    }
+
+    /**
+     * Handles the saving of children associations and executing the afterSave logic
+     * once the entity for this table has been saved successfully.
+     *
+     * @param \Cake\Datasource\EntityInterface $entity the entity to be saved
+     * @param \ArrayObject $options the options to use for the save operation
+     * @return bool True on success
+     * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction
+     *   is aborted in the afterSave event.
+     */
+    protected function _onSaveSuccess($entity, $options)
+    {
+        $success = $this->_associations->saveChildren(
+            $this,
+            $entity,
+            $options['associated'],
+            ['_primary' => false] + $options->getArrayCopy()
+        );
+
+        if (!$success && $options['atomic']) {
+            return false;
+        }
+
+        $this->dispatchEvent('Model.afterSave', compact('entity', 'options'));
+
+        if ($options['atomic'] && !$this->connection()->inTransaction()) {
+            throw new RolledbackTransactionException(['table' => get_class($this)]);
+        }
+
+        $entity->clean();
+        if (!$options['atomic'] && !$options['_primary']) {
+            $entity->isNew(false);
+            $entity->source($this->registryAlias());
         }
 
-        return false;
+        return true;
     }
 
     /**
@@ -1720,7 +1745,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc
      *
      * @param array|\Cake\ORM\ResultSet $entities Entities to save.
      * @param array|\ArrayAccess $options Options used when calling Table::save() for each entity.
-     * @return bool|array|\Cake\ORM\ResultSet False on failure, entities list on succcess.
+     * @return bool|array|\Cake\ORM\ResultSet False on failure, entities list on success.
      */
     public function saveMany($entities, $options = [])
     {

+ 3 - 3
src/Routing/Router.php

@@ -725,7 +725,7 @@ class Router
             $params['?'] = $url;
         }
 
-        return Router::url($params, $full);
+        return static::url($params, $full);
     }
 
     /**
@@ -740,12 +740,12 @@ class Router
     public static function normalize($url = '/')
     {
         if (is_array($url)) {
-            $url = Router::url($url);
+            $url = static::url($url);
         }
         if (preg_match('/^[a-z\-]+:\/\//', $url)) {
             return $url;
         }
-        $request = Router::getRequest();
+        $request = static::getRequest();
 
         if (!empty($request->base) && stristr($url, $request->base)) {
             $url = preg_replace('/^' . preg_quote($request->base, '/') . '/', '', $url, 1);

+ 9 - 1
src/Utility/Text.php

@@ -506,12 +506,20 @@ class Text
     /**
      * Strips given text of all links (<a href=....).
      *
+     * *Warning* This method is not an robust solution in preventing XSS
+     * or malicious HTML.
+     *
      * @param string $text Text
      * @return string The text without links
+     * @deprecated 3.2.12 This method will be removed in 4.0.0
      */
     public static function stripLinks($text)
     {
-        return preg_replace('|<a\s+[^>]+>|im', '', preg_replace('|<\/a>|im', '', $text));
+        do {
+            $text = preg_replace('#</?a([/\s][^>]*)?(>|$)#i', '', $text, -1, $count);
+        } while ($count);
+
+        return $text;
     }
 
     /**

+ 1 - 1
src/Validation/Validator.php

@@ -1558,7 +1558,7 @@ class Validator implements ArrayAccess, IteratorAggregate, Countable
      * the specified amount of elements
      *
      * @param string $field The field you want to apply the rule to.
-     * @param int $count The number maximim amount of elements the field should have
+     * @param int $count The number maximum amount of elements the field should have
      * @param string|null $message The error message when the rule fails.
      * @param string|callable|null $when Either 'create' or 'update' or a callable that returns
      *   true when the validation rule should be applied.

+ 1 - 1
src/View/Helper/PaginatorHelper.php

@@ -1013,7 +1013,7 @@ class PaginatorHelper extends Helper
         }
 
         $out = '';
-        $lower = $params['pageCount'] - $last + 1;
+        $lower = (int)$params['pageCount'] - (int)$last + 1;
 
         if (is_int($last) && $params['page'] <= $lower) {
             for ($i = $lower; $i <= $params['pageCount']; $i++) {

+ 2 - 1
tests/TestCase/Controller/Component/CsrfComponentTest.php

@@ -94,7 +94,8 @@ class CsrfComponentTest extends TestCase
     public static function safeHttpMethodProvider()
     {
         return [
-            ['GET', 'HEAD']
+            ['GET'],
+            ['HEAD'],
         ];
     }
 

+ 2 - 2
tests/TestCase/Database/Schema/TableTest.php

@@ -342,7 +342,7 @@ class TableTest extends TestCase
      *
      * @return array
      */
-    public static function addConstaintErrorProvider()
+    public static function addConstraintErrorProvider()
     {
         return [
             // No properties
@@ -361,7 +361,7 @@ class TableTest extends TestCase
      * Test that an exception is raised when constraints
      * are added for fields that do not exist.
      *
-     * @dataProvider addConstaintErrorProvider
+     * @dataProvider addConstraintErrorProvider
      * @expectedException \Cake\Database\Exception
      * @return void
      */

+ 3 - 3
tests/TestCase/Network/ResponseTest.php

@@ -460,7 +460,7 @@ class ResponseTest extends TestCase
     {
         $response = new Response();
         $result = $response->httpCodes();
-        $this->assertEquals(42, count($result));
+        $this->assertEquals(43, count($result));
 
         $result = $response->httpCodes(100);
         $expected = [100 => 'Continue'];
@@ -473,7 +473,7 @@ class ResponseTest extends TestCase
 
         $result = $response->httpCodes($codes);
         $this->assertTrue($result);
-        $this->assertEquals(44, count($response->httpCodes()));
+        $this->assertEquals(45, count($response->httpCodes()));
 
         $result = $response->httpCodes(381);
         $expected = [381 => 'Unicorn Moved'];
@@ -482,7 +482,7 @@ class ResponseTest extends TestCase
         $codes = [404 => 'Sorry Bro'];
         $result = $response->httpCodes($codes);
         $this->assertTrue($result);
-        $this->assertEquals(44, count($response->httpCodes()));
+        $this->assertEquals(45, count($response->httpCodes()));
 
         $result = $response->httpCodes(404);
         $expected = [404 => 'Sorry Bro'];

+ 40 - 2
tests/TestCase/ORM/Association/BelongsToManyTest.php

@@ -635,7 +635,8 @@ class BelongsToManyTest extends TestCase
             new Entity(['name' => 'net new']),
         ];
 
-        $assoc->replaceLinks($entity, $tagData, ['associated' => false]);
+        $result = $assoc->replaceLinks($entity, $tagData, ['associated' => false]);
+        $this->assertTrue($result);
         $this->assertSame($tagData, $entity->tags, 'Tags should match replaced objects');
         $this->assertFalse($entity->dirty('tags'), 'Should be clean');
 
@@ -669,7 +670,8 @@ class BelongsToManyTest extends TestCase
         ]);
         $entity = $articles->get(1, ['contain' => 'Tags']);
 
-        $assoc->replaceLinks($entity, [], ['associated' => false]);
+        $result = $assoc->replaceLinks($entity, [], ['associated' => false]);
+        $this->assertTrue($result);
         $this->assertSame([], $entity->tags, 'Tags should match replaced objects');
         $this->assertFalse($entity->dirty('tags'), 'Should be clean');
 
@@ -681,6 +683,42 @@ class BelongsToManyTest extends TestCase
     }
 
     /**
+     * Tests replaceLinks with failing domain rules and new link targets.
+     *
+     * @return void
+     */
+    public function testReplaceLinkFailingDomainRules()
+    {
+        $articles = TableRegistry::get('Articles');
+        $tags = TableRegistry::get('Tags');
+        $tags->eventManager()->on('Model.buildRules', function ($event, $rules) {
+            $rules->add(function () {
+                return false;
+            }, 'rule', ['errorField' => 'name', 'message' => 'Bad data']);
+        });
+
+        $assoc = $articles->belongsToMany('Tags', [
+            'sourceTable' => $articles,
+            'targetTable' => $tags,
+            'through' => TableRegistry::get('ArticlesTags'),
+            'joinTable' => 'articles_tags',
+        ]);
+        $entity = $articles->get(1, ['contain' => 'Tags']);
+        $originalCount = count($entity->tags);
+
+        $tags = [
+            new Entity(['name' => 'tag99', 'description' => 'Best tag'])
+        ];
+        $result = $assoc->replaceLinks($entity, $tags);
+        $this->assertFalse($result, 'replace should have failed.');
+        $this->assertNotEmpty($tags[0]->errors(), 'Bad entity should have errors.');
+
+        $entity = $articles->get(1, ['contain' => 'Tags']);
+        $this->assertCount($originalCount, $entity->tags, 'Should not have changed.');
+        $this->assertEquals('tag1', $entity->tags[0]->name);
+    }
+
+    /**
      * Provider for empty values
      *
      * @return array

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

@@ -351,7 +351,7 @@ class CompositeKeyTest extends TestCase
     }
 
     /**
-     * Tests loding hasOne with composite keys
+     * Tests loading hasOne with composite keys
      *
      * @dataProvider strategiesProviderHasOne
      * @return void

+ 2 - 2
tests/TestCase/ORM/EntityTest.php

@@ -1390,7 +1390,7 @@ class EntityTest extends TestCase
         return [[''], [null], [false]];
     }
     /**
-     * Tests that trying to get an empty propery name throws exception
+     * Tests that trying to get an empty propetry name throws exception
      *
      * @dataProvider emptyNamesProvider
      * @expectedException \InvalidArgumentException
@@ -1403,7 +1403,7 @@ class EntityTest extends TestCase
     }
 
     /**
-     * Tests that setitng an empty property name does nothing
+     * Tests that setting an empty property name does nothing
      *
      * @expectedException \InvalidArgumentException
      * @dataProvider emptyNamesProvider

+ 4 - 1
tests/TestCase/ORM/QueryRegressionTest.php

@@ -244,7 +244,10 @@ class QueryRegressionTest extends TestCase
      */
     public function strategyProvider()
     {
-        return [['append', 'replace']];
+        return [
+            ['append'],
+            ['replace'],
+        ];
     }
 
     /**

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

@@ -634,7 +634,7 @@ class RulesCheckerIntegrationTest extends TestCase
      *
      * @group save
      * @expectedException RuntimeException
-     * @expectedExceptionMessage ExistsIn rule for 'author_id' is invalid. The 'NotValid' association is not defined.
+     * @expectedExceptionMessage ExistsIn rule for 'author_id' is invalid. 'NotValid' is not associated with 'Cake\ORM\Table'.
      * @return void
      */
     public function testExistsInInvalidAssociation()

+ 70 - 0
tests/TestCase/ORM/TableRegressionTest.php

@@ -0,0 +1,70 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (http://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. (http://cakefoundation.org)
+ * @link          http://cakephp.org CakePHP(tm) Project
+ * @since         3.2.13
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\ORM;
+
+use Cake\Core\Plugin;
+use Cake\I18n\Time;
+use Cake\ORM\TableRegistry;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Contains regression test for the Table class
+ *
+ */
+class TableRegressionTest extends TestCase
+{
+
+    /**
+     * Fixture to be used
+     *
+     * @var array
+     */
+    public $fixtures = [
+        'core.authors',
+    ];
+
+    /**
+     * Tear down
+     *
+     * @return void
+     */
+    public function tearDown()
+    {
+        parent::tearDown();
+
+        TableRegistry::clear();
+    }
+
+    /**
+     * Tests that an exception is thrown if the transaction is aborted
+     * in the afterSave callback
+     *
+     * @see https://github.com/cakephp/cakephp/issues/9079
+     * @expectedException Cake\ORM\Exception\RolledbackTransactionException
+     * @return void
+     */
+    public function testAfterSaveRollbackTransaction()
+    {
+        $table = TableRegistry::get('Authors');
+        $table->eventManager()->on(
+            'Model.afterSave',
+            function () use ($table) {
+                $table->connection()->rollback();
+            }
+        );
+        $entity = $table->newEntity(['name' => 'Jon']);
+        $table->save($entity);
+    }
+}

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

@@ -2427,7 +2427,7 @@ class TableTest extends TestCase
         $config = ConnectionManager::config('test');
 
         $connection = $this->getMockBuilder('\Cake\Database\Connection')
-            ->setMethods(['begin', 'commit'])
+            ->setMethods(['begin', 'commit', 'inTransaction'])
             ->setConstructorArgs([$config])
             ->getMock();
         $connection->driver($this->connection->driver());
@@ -2441,6 +2441,7 @@ class TableTest extends TestCase
 
         $connection->expects($this->once())->method('begin');
         $connection->expects($this->once())->method('commit');
+        $connection->expects($this->any())->method('inTransaction')->will($this->returnValue(true));
         $data = new \Cake\ORM\Entity([
             'username' => 'superuser',
             'created' => new Time('2013-10-10 00:00'),

+ 9 - 0
tests/TestCase/Utility/TextTest.php

@@ -809,6 +809,15 @@ HTML;
         $expected = 'This <strong>is</strong> a test and <abbr>some</abbr> other text';
         $result = $this->Text->stripLinks($text);
         $this->assertEquals($expected, $result);
+
+        $text = '<a<a h> href=\'bla\'>test</a</a>>';
+        $this->assertEquals('test', $this->Text->stripLinks($text));
+
+        $text = '<a/href="#">test</a/>';
+        $this->assertEquals('test', $this->Text->stripLinks($text));
+
+        $text = '<a href="#"';
+        $this->assertEquals('', $this->Text->stripLinks($text));
     }
 
     /**