Commit c049f3f1 authored by catch's avatar catch

Issue #2005644 by Wim Leers, damiankloip, beejeebus, amateescu: Use...

Issue #2005644 by Wim Leers, damiankloip, beejeebus, amateescu: Use client-side cache tags & caching to eliminate 1 HTTP requests/page for in-place editing metadata, introduce drupalSettings.user.permissionsHash.
parent eae9a2a5
......@@ -26,7 +26,7 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array('action' => 'core/modules/action/action.module');
$this->directoryList = array('action' => 'core/modules/action');
parent::setUp();
}
......
......@@ -26,7 +26,7 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array('aggregator' => 'core/modules/aggregator/aggregator.module');
$this->directoryList = array('aggregator' => 'core/modules/aggregator');
parent::setUp();
}
......
......@@ -26,9 +26,9 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array(
'block' => 'core/modules/block/block.module',
'custom_block' => 'core/modules/block/custom_block/custom_block.module',
$this->directoryList = array(
'block' => 'core/modules/block',
'custom_block' => 'core/modules/block/custom_block',
);
parent::setUp();
}
......
......@@ -26,7 +26,7 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array('block' => 'core/modules/block/block.module');
$this->directoryList = array('block' => 'core/modules/block');
parent::setUp();
$config_factory = $this->getConfigFactoryStub(array('system.theme' => array(
......
......@@ -26,9 +26,9 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array(
'book' => 'core/modules/book/book.module',
'node' => 'core/modules/node/node.module',
$this->directoryList = array(
'book' => 'core/modules/book',
'node' => 'core/modules/node',
);
parent::setUp();
}
......
......@@ -26,7 +26,7 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array('config' => 'core/modules/config/config.module');
$this->directoryList = array('config' => 'core/modules/config');
parent::setUp();
}
......
......@@ -8,6 +8,7 @@
namespace Drupal\content_translation\Tests\Menu;
use Drupal\Tests\Core\Menu\LocalTaskIntegrationTest;
use Drupal\content_translation\Plugin\Derivative\ContentTranslationLocalTasks;;
/**
* Tests existence of block local tasks.
......@@ -26,9 +27,9 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array(
'content_translation' => 'core/modules/content_translation/content_translation.module',
'node' => 'core/modules/node/node.module',
$this->directoryList = array(
'content_translation' => 'core/modules/content_translation',
'node' => 'core/modules/node',
);
parent::setUp();
......@@ -47,6 +48,24 @@ public function setUp() {
\Drupal::getContainer()->set('content_translation.manager', $content_translation_manager);
}
/**
* {@inheritdoc}
*/
protected function getLocalTaskManager($modules, $route_name, $route_params) {
$manager = parent::getLocalTaskManager($modules, $route_name, $route_params);
// Duplicate content_translation_local_tasks_alter()'s code here to avoid
// having to load the .module file.
$this->moduleHandler->expects($this->once())
->method('alter')
->will($this->returnCallback(function ($hook, &$local_tasks) {
// Alters in tab_root_id onto the content translation local task.
$derivative = ContentTranslationLocalTasks::create(\Drupal::getContainer(), 'content_translation.local_tasks');
$derivative->alterLocalTasks($local_tasks);
}));
return $manager;
}
/**
* Tests the block admin display local tasks.
*
......
......@@ -17,7 +17,7 @@
* is not yet known whether the user has permission to edit at >=1 of them.
*/
(function ($, _, Backbone, Drupal, drupalSettings) {
(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
"use strict";
......@@ -68,6 +68,12 @@ Drupal.behaviors.edit = {
// Initialize the Edit app once per page load.
$('body').once('edit-init', initEdit);
// Find all in-place editable fields, if any.
var $fields = $(context).find('[data-edit-field-id]').once('edit');
if ($fields.length === 0) {
return;
}
// Process each entity element: identical entities that appear multiple
// times will get a numeric identifier, starting at 0.
$(context).find('[data-edit-entity-id]').once('edit').each(function (index, entityElement) {
......@@ -89,10 +95,17 @@ Drupal.behaviors.edit = {
// immediately. New fields will be unable to be processed immediately, but
// will instead be queued to have their metadata fetched, which occurs below
// in fetchMissingMetaData().
$(context).find('[data-edit-field-id]').once('edit').each(function (index, fieldElement) {
$fields.each(function (index, fieldElement) {
processField(fieldElement);
});
// Entities and fields on the page have been detected, try to set up the
// contextual links for those entities that already have the necessary meta-
// data in the client-side cache.
contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
return !initializeEntityContextualLink(contextualLink);
});
// Fetch metadata for any fields that are queued to retrieve it.
fetchMissingMetadata(function (fieldElementsWithFreshMetadata) {
// Metadata has been fetched, reprocess fields whose metadata was missing.
......@@ -128,17 +141,47 @@ Drupal.edit = {
// Per-field metadata that indicates whether in-place editing is allowed,
// which in-place editor should be used, etc.
metadata: {
has: function (fieldID) { return _.has(this.data, fieldID); },
add: function (fieldID, metadata) { this.data[fieldID] = metadata; },
has: function (fieldID) {
return storage.getItem(this._prefixFieldID(fieldID)) !== null;
},
add: function (fieldID, metadata) {
storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
},
get: function (fieldID, key) {
return (key === undefined) ? this.data[fieldID] : this.data[fieldID][key];
var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
return (key === undefined) ? metadata : metadata[key];
},
_prefixFieldID: function (fieldID) {
return 'Drupal.edit.metadata.' + fieldID;
},
intersection: function (fieldIDs) { return _.intersection(fieldIDs, _.keys(this.data)); },
// Contains the actual metadata, keyed by field ID.
data: {}
_unprefixFieldID: function (fieldID) {
// Strip "Drupal.edit.metadata.", which is 21 characters long.
return fieldID.substring(21);
},
intersection: function (fieldIDs) {
var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
var intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
return _.map(intersection, this._unprefixFieldID);
}
}
};
// Clear the Edit metadata cache whenever the current user's set of permissions
// changes.
var permissionsHashKey = Drupal.edit.metadata._prefixFieldID('permissionsHash');
var permissionsHashValue = storage.getItem(permissionsHashKey);
var permissionsHash = drupalSettings.user.permissionsHash;
if (permissionsHashValue !== permissionsHash) {
if (typeof permissionsHash === 'string') {
_.chain(storage).keys().each(function (key) {
if (key.substring(0, 21) === 'Drupal.edit.metadata.') {
storage.removeItem(key);
}
});
}
storage.setItem(permissionsHashKey, permissionsHash);
}
/**
* Detect contextual links on entities annotated by Edit; queue these to be
* processed.
......@@ -291,7 +334,7 @@ function fetchMissingMetadata (callback) {
var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
// Ensure we only request entityIDs for which we don't have metadata yet.
entityIDs = _.difference(entityIDs, _.keys(Drupal.edit.metadata.data));
entityIDs = _.difference(entityIDs, Drupal.edit.metadata.intersection(entityIDs));
fieldsMetadataQueue = [];
$.ajax({
......@@ -520,4 +563,4 @@ function deleteContainedModelsAndQueues($context) {
});
}
})(jQuery, _, Backbone, Drupal, drupalSettings);
})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
......@@ -26,8 +26,8 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array(
'language' => 'core/modules/language/language.module',
$this->directoryList = array(
'language' => 'core/modules/language',
);
parent::setUp();
}
......
......@@ -26,8 +26,8 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array(
'locale' => 'core/modules/locale/locale.module',
$this->directoryList = array(
'locale' => 'core/modules/locale',
);
parent::setUp();
}
......
......@@ -26,9 +26,9 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array(
'shortcut' => 'core/modules/shortcut/shortcut.module',
'user' => 'core/modules/user/user.module',
$this->directoryList = array(
'shortcut' => 'core/modules/shortcut',
'user' => 'core/modules/user',
);
parent::setUp();
}
......
......@@ -26,7 +26,7 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array('taxonomy' => 'core/modules/taxonomy/taxonomy.module');
$this->directoryList = array('taxonomy' => 'core/modules/taxonomy');
parent::setUp();
}
......
......@@ -7,6 +7,7 @@
namespace Drupal\user\Entity;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityStorageControllerInterface;
use Drupal\user\RoleInterface;
......@@ -133,13 +134,24 @@ public function preSave(EntityStorageControllerInterface $storage_controller) {
}
}
/**
* {@inheritdoc}
*/
public function postSave(EntityStorageControllerInterface $storage_controller, $update = TRUE) {
parent::postSave($storage_controller, $update);
Cache::invalidateTags(array('role' => $this->id()));
}
/**
* {@inheritdoc}
*/
public static function postDelete(EntityStorageControllerInterface $storage_controller, array $entities) {
parent::postDelete($storage_controller, $entities);
$storage_controller->deleteRoleReferences(array_keys($entities));
$ids = array_keys($entities);
$storage_controller->deleteRoleReferences($ids);
Cache::invalidateTags(array('role' => $ids));
}
}
<?php
/**
* @file
* Contains \Drupal\user\PermissionsHash.
*/
namespace Drupal\user;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\PrivateKey;
use Drupal\Core\Cache\CacheBackendInterface;
/**
* Generates and caches the permissions hash for a user.
*/
class PermissionsHash implements PermissionsHashInterface {
/**
* The private key service.
*
* @var \Drupal\Core\PrivateKey
*/
protected $privateKey;
/**
* The cache backend interface to use for the permission hash cache.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* Constructs a PermissionsHash object.
*
* @param \Drupal\Core\PrivateKey $private_key
* The private key service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend interface to use for the permission hash cache.
*/
public function __construct(PrivateKey $private_key, CacheBackendInterface $cache) {
$this->privateKey = $private_key;
$this->cache = $cache;
}
/**
* {@inheritdoc}
*
* Cached by role, invalidated whenever permissions change.
*/
public function generate(AccountInterface $account) {
$sorted_roles = $account->getRoles();
sort($sorted_roles);
$role_list = implode(',', $sorted_roles);
if ($cache = $this->cache->get("user_permissions_hash:$role_list")) {
$permissions_hash = $cache->data;
}
else {
$permissions_hash = $this->doGenerate($sorted_roles);
$this->cache->set("user_permissions_hash:$role_list", $permissions_hash, CacheBackendInterface::CACHE_PERMANENT, array('role' => $sorted_roles));
}
return $permissions_hash;
}
/**
* Generates a hash that uniquely identifies the user's permissions.
*
* @param \Drupal\user\Entity\Role[] $roles
* The user's roles.
*
* @return string
* The permissions hash.
*/
protected function doGenerate(array $roles) {
// @todo Once Drupal gets rid of user_role_permissions(), we should be able
// to inject the user role controller and call a method on that instead.
$permissions_by_role = user_role_permissions($roles);
foreach ($permissions_by_role as $role => $permissions) {
sort($permissions);
$permissions_by_role[$role] = $permissions;
}
return hash('sha256', $this->privateKey->get() . drupal_get_hash_salt() . serialize($permissions_by_role));
}
}
<?php
/**
* @file
* Contains Drupal\user\PermissionsHashInterface.
*/
namespace Drupal\user;
use Drupal\Core\Session\AccountInterface;
/**
* Defines the user permissions hash interface.
*/
interface PermissionsHashInterface {
/**
* Generates a hash that uniquely identifies a user's permissions.
*
* @param \Drupal\Core\Session\AccountInterface $account
* The user account for which to get the permissions hash.
*
* @return string
* A permissions hash.
*/
public function generate(AccountInterface $account);
}
......@@ -37,9 +37,13 @@ function setUp() {
* Change user permissions and check user_access().
*/
function testUserPermissionChanges() {
$permissions_hash_generator = $this->container->get('user.permissions_hash');
$this->drupalLogin($this->admin_user);
$rid = $this->rid;
$account = $this->admin_user;
$previous_permissions_hash = $permissions_hash_generator->generate($account);
$this->assertIdentical($previous_permissions_hash, $permissions_hash_generator->generate($this->loggedInUser));
// Add a permission.
$this->assertFalse(user_access('administer nodes', $account), 'User does not have "administer nodes" permission.');
......@@ -50,6 +54,10 @@ function testUserPermissionChanges() {
$storage_controller = $this->container->get('entity.manager')->getStorageController('user_role');
$storage_controller->resetCache();
$this->assertTrue(user_access('administer nodes', $account), 'User now has "administer nodes" permission.');
$current_permissions_hash = $permissions_hash_generator->generate($account);
$this->assertIdentical($current_permissions_hash, $permissions_hash_generator->generate($this->loggedInUser));
$this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
$previous_permissions_hash = $current_permissions_hash;
// Remove a permission.
$this->assertTrue(user_access('access user profiles', $account), 'User has "access user profiles" permission.');
......@@ -59,6 +67,9 @@ function testUserPermissionChanges() {
$this->assertText(t('The changes have been saved.'), 'Successful save message displayed.');
$storage_controller->resetCache();
$this->assertFalse(user_access('access user profiles', $account), 'User no longer has "access user profiles" permission.');
$current_permissions_hash = $permissions_hash_generator->generate($account);
$this->assertIdentical($current_permissions_hash, $permissions_hash_generator->generate($this->loggedInUser));
$this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
}
/**
......@@ -87,8 +98,11 @@ function testAdministratorRole() {
* Verify proper permission changes by user_role_change_permissions().
*/
function testUserRoleChangePermissions() {
$permissions_hash_generator = $this->container->get('user.permissions_hash');
$rid = $this->rid;
$account = $this->admin_user;
$previous_permissions_hash = $permissions_hash_generator->generate($account);
// Verify current permissions.
$this->assertFalse(user_access('administer nodes', $account), 'User does not have "administer nodes" permission.');
......@@ -106,5 +120,10 @@ function testUserRoleChangePermissions() {
$this->assertTrue(user_access('administer nodes', $account), 'User now has "administer nodes" permission.');
$this->assertFalse(user_access('access user profiles', $account), 'User no longer has "access user profiles" permission.');
$this->assertTrue(user_access('administer site configuration', $account), 'User still has "administer site configuration" permission.');
// Verify the permissions hash has changed.
$current_permissions_hash = $permissions_hash_generator->generate($account);
$this->assertNotEqual($previous_permissions_hash, $current_permissions_hash, 'Permissions hash has changed.');
}
}
......@@ -26,7 +26,7 @@ public static function getInfo() {
}
public function setUp() {
$this->moduleList = array('user' => 'core/modules/user/user.module');
$this->directoryList = array('user' => 'core/modules/user');
parent::setUp();
}
......
<?php
/**
* @file
* Contains \Drupal\user\Tests\PermissionsHashTest.
*/
namespace Drupal\user\Tests {
use Drupal\Tests\UnitTestCase;
use Drupal\Component\Utility\Crypt;
use Drupal\user\PermissionsHash;
/**
* Tests the user permissions hash generator service.
*
* @group Drupal
* @group User
*
* @see \Drupal\user\PermissionsHash
*/
class PermissionsHashTest extends UnitTestCase {
/**
* A mocked account.
*
* @var \Drupal\user\UserInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $account_1;
/**
* An "updated" mocked account.
*
* @var \Drupal\user\UserInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $account_1_updated;
/**
* A different account.
*
* @var \Drupal\user\UserInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $account_2;
/**
* The mocked private key service.
*
* @var \Drupal\Core\PrivateKey|\PHPUnit_Framework_MockObject_MockObject
*/
protected $private_key;
/**
* The mocked cache backend.
*
* @var \Drupal\Core\Cache\CacheBackendInterface|\PHPUnit_Framework_MockObject_MockObject
*/
protected $cache;
/**
* The permission hash class being tested.
*
* @var \Drupal\user\PermissionsHashInterface
*/
protected $permissionsHash;
/**
* {@inheritdoc}
*/
public static function getInfo() {
return array(
'name' => 'Permission hash generator service',
'description' => 'Tests the user permission hash generator service',
'group' => 'User',
);
}
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Account 1: 'administrator' and 'authenticated' roles.
$roles_1 = array('administrator', 'authenticated');
$this->account_1 = $this->getMockBuilder('Drupal\user\Entity\User')
->disableOriginalConstructor()
->setMethods(array('getRoles'))
->getMock();
$this->account_1->expects($this->any())
->method('getRoles')
->will($this->returnValue($roles_1));
// Account 2: 'authenticated' and 'administrator' roles (different order).
$roles_2 = array('authenticated', 'administrator');
$this->account_2 = $this->getMockBuilder('Drupal\user\Entity\User')
->disableOriginalConstructor()
->setMethods(array('getRoles'))
->getMock();
$this->account_2->expects($this->any())
->method('getRoles')
->will($this->returnValue($roles_2));
// Updated account 1: now also 'editor' role.
$roles_1_updated = array('editor', 'administrator', 'authenticated');
$this->account_1_updated = $this->getMockBuilder('Drupal\user\Entity\User')
->disableOriginalConstructor()
->setMethods(array('getRoles'))
->getMock();
$this->account_1_updated->expects($this->any())
->method('getRoles')
->will($this->returnValue($roles_1_updated));
// Mocked private key + cache services.
$random = Crypt::randomStringHashed(55);
$this->private_key = $this->getMockBuilder('Drupal\Core\PrivateKey')
->disableOriginalConstructor()
->setMethods(array('get'))
->getMock();
$this->private_key->expects($this->any())
->method('get')
->will($this->returnValue($random));
$this->cache = $this->getMockBuilder('Drupal\Core\Cache\CacheBackendInterface')
->disableOriginalConstructor()
->getMock();
$this->permissionsHash = new PermissionsHash($this->private_key, $this->cache);
}
/**
* Tests the generate() method.
*/
public function testGenerate() {
// Ensure that two user accounts with the same roles generate the same hash.
$hash_1 = $this->permissionsHash->generate($this->account_1);
$hash_2 = $this->permissionsHash->generate($this->account_2);
$this->assertSame($hash_1, $hash_2, 'Different users with the same roles generate the same permissions hash.');
// Compare with hash for user account 1 with an additional role.
$updated_hash_1 = $this->permissionsHash->generate($this->account_1_updated);
$this->assertNotSame($hash_1, $updated_hash_1, 'Same user with updated roles generates different permissions hash.');
}
/**
* Tests the generate method with cache returned.
*/
public function testGenerateCache() {
// Set expectations for the mocked cache backend.
$expected_cid = 'user_permissions_hash:administrator,authenticated';
$mock_cache = new \stdClass();
$mock_cache->data = 'test_hash_here';
$this->cache->expects($this->once())
->method('get')
->with($expected_cid)
->will($this->returnValue($mock_cache));
$this->cache->expects($this->never())
->method('set');
$this->permissionsHash->generate($this->account_1);
}
/**
* Tests the generate method with no cache returned.
*/
public function testGenerateNoCache() {
// Set expectations for the mocked cache backend.
$expected_cid = 'user_permissions_hash:administrator,authenticated';
$this->cache->expects($this->once())
->method('get')
->with($expected_cid)
->will($this->returnValue(FALSE));
$this->cache->expects($this->once())
->method('set')
->with($expected_cid, $this->isType('string'));
$this->permissionsHash->generate($this->account_1);
}
}
}
namespace {
// @todo remove once user_role_permissions() can be injected.
if (!function_exists('user_role_permissions')) {
function user_role_permissions(array $roles) {
$role_permissions = array();
foreach ($roles as $rid) {
$role_permissions[$rid] = array();
}
return $role_permissions;
}
}
// @todo remove once drupal_get_hash_salt() can be injected.
if (!function_exists('drupal_get_hash_salt')) {
function drupal_get_hash_salt() {
static $salt;
if (!isset($salt)) {
$salt = Drupal\Component\Utility\Crypt::randomStringHashed(55);
}
return $salt;
}