Commit 537dbb1a authored by catch's avatar catch

Issue #2858431 by amateescu, plach, catch, timmillwood, jhodgdon, pwolanin,...

Issue #2858431 by amateescu, plach, catch, timmillwood, jhodgdon, pwolanin, dawehner, vijaycs85, larowlan, yoroy: Book storage and UI is not revision aware, changes to drafts leak into live site
parent e3ceb190
......@@ -82,7 +82,8 @@ function book_entity_type_build(array &$entity_types) {
$entity_types['node']
->setFormClass('book_outline', 'Drupal\book\Form\BookOutlineForm')
->setLinkTemplate('book-outline-form', '/node/{node}/outline')
->setLinkTemplate('book-remove-form', '/node/{node}/outline/remove');
->setLinkTemplate('book-remove-form', '/node/{node}/outline/remove')
->addConstraint('BookOutline', []);
}
/**
......
......@@ -275,6 +275,20 @@ public function updateOutline(NodeInterface $node) {
// handle it here if it did not.
$node->book['pid'] = $node->book['bid'];
}
// Prevent changes to the book outline if the node being saved is not the
// default revision.
$updated = FALSE;
if (!$new) {
$original = $this->loadBookLink($node->id(), FALSE);
if ($node->book['bid'] != $original['bid'] || $node->book['pid'] != $original['pid'] || $node->book['weight'] != $original['weight']) {
$updated = TRUE;
}
}
if (($new || $updated) && !$node->isDefaultRevision()) {
return FALSE;
}
return $this->saveBookLink($node->book, $new);
}
......
<?php
namespace Drupal\book\Plugin\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* Validation constraint for changing the book outline in forward revisions.
*
* @Constraint(
* id = "BookOutline",
* label = @Translation("Book outline.", context = "Validation"),
* )
*/
class BookOutlineConstraint extends Constraint {
public $message = 'You can only change the book outline for the <em>published</em> version of this content.';
}
<?php
namespace Drupal\book\Plugin\Validation\Constraint;
use Drupal\book\BookManagerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Constraint validator for changing the book outline in forward revisions.
*/
class BookOutlineConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The book manager.
*
* @var \Drupal\book\BookManagerInterface
*/
protected $bookManager;
/**
* Creates a new BookOutlineConstraintValidator instance.
*
* @param \Drupal\book\BookManagerInterface $book_manager
* The book manager.
*/
public function __construct(BookManagerInterface $book_manager) {
$this->bookManager = $book_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('book.manager')
);
}
/**
* {@inheritdoc}
*/
public function validate($entity, Constraint $constraint) {
if (isset($entity) && !$entity->isNew() && !$entity->isDefaultRevision()) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $original */
$original = $this->bookManager->loadBookLink($entity->id(), FALSE) ?: [
'bid' => 0,
'weight' => 0,
];
if (empty($original['pid'])) {
$original['pid'] = -1;
}
if ($entity->book['bid'] != $original['bid']) {
$this->context->buildViolation($constraint->message)
->atPath('book.bid')
->setInvalidValue($entity)
->addViolation();
}
if ($entity->book['pid'] != $original['pid']) {
$this->context->buildViolation($constraint->message)
->atPath('book.pid')
->setInvalidValue($entity)
->addViolation();
}
if ($entity->book['weight'] != $original['weight']) {
$this->context->buildViolation($constraint->message)
->atPath('book.weight')
->setInvalidValue($entity)
->addViolation();
}
}
}
}
<?php
namespace Drupal\Tests\book\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests Book and Content Moderation integration.
*
* @group book
*/
class BookContentModerationTest extends BrowserTestBase {
use BookTestTrait;
/**
* Modules to install.
*
* @var array
*/
public static $modules = ['book', 'block', 'book_test', 'content_moderation'];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->drupalPlaceBlock('system_breadcrumb_block');
$this->drupalPlaceBlock('page_title_block');
$workflow = Workflow::load('editorial');
$workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'book');
$workflow->save();
// We need a user with additional content moderation permissions.
$this->bookAuthor = $this->drupalCreateUser(['create new books', 'create book content', 'edit own book content', 'add content to books', 'access printer-friendly version', 'view any unpublished content', 'use editorial transition create_new_draft', 'use editorial transition publish']);
}
/**
* Tests that book drafts can not modify the book outline.
*/
public function testBookWithForwardRevisions() {
// Create two books.
$book_1_nodes = $this->createBook(t('Save and Publish'));
$book_1 = $this->book;
$this->createBook(t('Save and Publish'));
$book_2 = $this->book;
$this->drupalLogin($this->bookAuthor);
// Check that book pages display along with the correct outlines.
$this->book = $book_1;
$this->checkBookNode($book_1, [$book_1_nodes[0], $book_1_nodes[3], $book_1_nodes[4]], FALSE, FALSE, $book_1_nodes[0], []);
$this->checkBookNode($book_1_nodes[0], [$book_1_nodes[1], $book_1_nodes[2]], $book_1, $book_1, $book_1_nodes[1], [$book_1]);
// Create a new book page without actually attaching it to a book and create
// a draft.
$edit = ['title[0][value]' => $this->randomString()];
$this->drupalPostForm('node/add/book', $edit, t('Save and Publish'));
$node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
$this->assertTrue($node);
$this->drupalPostForm('node/' . $node->id() . '/edit', [], t('Save and Create New Draft'));
$this->assertSession()->pageTextNotContains('You can only change the book outline for the published version of this content.');
// Create a book draft with no changes, then publish it.
$this->drupalPostForm('node/' . $book_1->id() . '/edit', [], t('Save and Create New Draft'));
$this->assertSession()->pageTextNotContains('You can only change the book outline for the published version of this content.');
$this->drupalPostForm('node/' . $book_1->id() . '/edit', [], t('Save and Publish'));
// Try to move Node 2 to a different parent.
$edit['book[pid]'] = $book_1_nodes[3]->id();
$this->drupalPostForm('node/' . $book_1_nodes[1]->id() . '/edit', $edit, t('Save and Create New Draft'));
$this->assertSession()->pageTextContains('You can only change the book outline for the published version of this content.');
// Check that the book outline did not change.
$this->book = $book_1;
$this->checkBookNode($book_1, [$book_1_nodes[0], $book_1_nodes[3], $book_1_nodes[4]], FALSE, FALSE, $book_1_nodes[0], []);
$this->checkBookNode($book_1_nodes[0], [$book_1_nodes[1], $book_1_nodes[2]], $book_1, $book_1, $book_1_nodes[1], [$book_1]);
// Try to move Node 2 to a different book.
$edit['book[bid]'] = $book_2->id();
$this->drupalPostForm('node/' . $book_1_nodes[1]->id() . '/edit', $edit, t('Save and Create New Draft'));
$this->assertSession()->pageTextContains('You can only change the book outline for the published version of this content.');
// Check that the book outline did not change.
$this->book = $book_1;
$this->checkBookNode($book_1, [$book_1_nodes[0], $book_1_nodes[3], $book_1_nodes[4]], FALSE, FALSE, $book_1_nodes[0], []);
$this->checkBookNode($book_1_nodes[0], [$book_1_nodes[1], $book_1_nodes[2]], $book_1, $book_1, $book_1_nodes[1], [$book_1]);
// Try to change the weight of Node 2.
$edit['book[weight]'] = 2;
$this->drupalPostForm('node/' . $book_1_nodes[1]->id() . '/edit', $edit, t('Save and Create New Draft'));
$this->assertSession()->pageTextContains('You can only change the book outline for the published version of this content.');
// Check that the book outline did not change.
$this->book = $book_1;
$this->checkBookNode($book_1, [$book_1_nodes[0], $book_1_nodes[3], $book_1_nodes[4]], FALSE, FALSE, $book_1_nodes[0], []);
$this->checkBookNode($book_1_nodes[0], [$book_1_nodes[1], $book_1_nodes[2]], $book_1, $book_1, $book_1_nodes[1], [$book_1]);
// Save a new draft revision for the node without any changes and check that
// the error message is not displayed.
$this->drupalPostForm('node/' . $book_1_nodes[1]->id() . '/edit', [], t('Save and Create New Draft'));
$this->assertSession()->pageTextNotContains('You can only change the book outline for the published version of this content.');
}
}
......@@ -2,9 +2,7 @@
namespace Drupal\Tests\book\Functional;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\RoleInterface;
......@@ -15,6 +13,8 @@
*/
class BookTest extends BrowserTestBase {
use BookTestTrait;
/**
* Modules to install.
*
......@@ -22,20 +22,6 @@ class BookTest extends BrowserTestBase {
*/
public static $modules = ['book', 'block', 'node_access_test', 'book_test'];
/**
* A book node.
*
* @var \Drupal\node\NodeInterface
*/
protected $book;
/**
* A user with permission to create and edit books.
*
* @var object
*/
protected $bookAuthor;
/**
* A user with permission to view a book and access printer-friendly version.
*
......@@ -75,39 +61,6 @@ protected function setUp() {
$this->adminUser = $this->drupalCreateUser(['create new books', 'create book content', 'edit any book content', 'delete any book content', 'add content to books', 'administer blocks', 'administer permissions', 'administer book outlines', 'node test view', 'administer content types', 'administer site configuration']);
}
/**
* Creates a new book with a page hierarchy.
*
* @return \Drupal\node\NodeInterface[]
*/
public function createBook() {
// Create new book.
$this->drupalLogin($this->bookAuthor);
$this->book = $this->createBookNode('new');
$book = $this->book;
/*
* Add page hierarchy to book.
* Book
* |- Node 0
* |- Node 1
* |- Node 2
* |- Node 3
* |- Node 4
*/
$nodes = [];
$nodes[] = $this->createBookNode($book->id()); // Node 0.
$nodes[] = $this->createBookNode($book->id(), $nodes[0]->book['nid']); // Node 1.
$nodes[] = $this->createBookNode($book->id(), $nodes[0]->book['nid']); // Node 2.
$nodes[] = $this->createBookNode($book->id()); // Node 3.
$nodes[] = $this->createBookNode($book->id()); // Node 4.
$this->drupalLogout();
return $nodes;
}
/**
* Tests the book navigation cache context.
*
......@@ -229,147 +182,6 @@ public function testBook() {
$book->save();
}
/**
* Checks the outline of sub-pages; previous, up, and next.
*
* Also checks the printer friendly version of the outline.
*
* @param \Drupal\Core\Entity\EntityInterface $node
* Node to check.
* @param $nodes
* Nodes that should be in outline.
* @param $previous
* (optional) Previous link node. Defaults to FALSE.
* @param $up
* (optional) Up link node. Defaults to FALSE.
* @param $next
* (optional) Next link node. Defaults to FALSE.
* @param array $breadcrumb
* The nodes that should be displayed in the breadcrumb.
*/
public function checkBookNode(EntityInterface $node, $nodes, $previous = FALSE, $up = FALSE, $next = FALSE, array $breadcrumb) {
// $number does not use drupal_static as it should not be reset
// since it uniquely identifies each call to checkBookNode().
static $number = 0;
$this->drupalGet('node/' . $node->id());
// Check outline structure.
if ($nodes !== NULL) {
$this->assertPattern($this->generateOutlinePattern($nodes), format_string('Node @number outline confirmed.', ['@number' => $number]));
}
else {
$this->pass(format_string('Node %number does not have outline.', ['%number' => $number]));
}
// Check previous, up, and next links.
if ($previous) {
/** @var \Drupal\Core\Url $url */
$url = $previous->urlInfo();
$url->setOptions(['attributes' => ['rel' => ['prev'], 'title' => t('Go to previous page')]]);
$text = SafeMarkup::format('<b>‹</b> @label', ['@label' => $previous->label()]);
$this->assertRaw(\Drupal::l($text, $url), 'Previous page link found.');
}
if ($up) {
/** @var \Drupal\Core\Url $url */
$url = $up->urlInfo();
$url->setOptions(['attributes' => ['title' => t('Go to parent page')]]);
$this->assertRaw(\Drupal::l('Up', $url), 'Up page link found.');
}
if ($next) {
/** @var \Drupal\Core\Url $url */
$url = $next->urlInfo();
$url->setOptions(['attributes' => ['rel' => ['next'], 'title' => t('Go to next page')]]);
$text = SafeMarkup::format('@label <b>›</b>', ['@label' => $next->label()]);
$this->assertRaw(\Drupal::l($text, $url), 'Next page link found.');
}
// Compute the expected breadcrumb.
$expected_breadcrumb = [];
$expected_breadcrumb[] = \Drupal::url('<front>');
foreach ($breadcrumb as $a_node) {
$expected_breadcrumb[] = $a_node->url();
}
// Fetch links in the current breadcrumb.
$links = $this->xpath('//nav[@class="breadcrumb"]/ol/li/a');
$got_breadcrumb = [];
foreach ($links as $link) {
$got_breadcrumb[] = $link->getAttribute('href');
}
// Compare expected and got breadcrumbs.
$this->assertIdentical($expected_breadcrumb, $got_breadcrumb, 'The breadcrumb is correctly displayed on the page.');
// Check printer friendly version.
$this->drupalGet('book/export/html/' . $node->id());
$this->assertText($node->label(), 'Printer friendly title found.');
$this->assertRaw($node->body->processed, 'Printer friendly body found.');
$number++;
}
/**
* Creates a regular expression to check for the sub-nodes in the outline.
*
* @param array $nodes
* An array of nodes to check in outline.
*
* @return string
* A regular expression that locates sub-nodes of the outline.
*/
public function generateOutlinePattern($nodes) {
$outline = '';
foreach ($nodes as $node) {
$outline .= '(node\/' . $node->id() . ')(.*?)(' . $node->label() . ')(.*?)';
}
return '/<nav id="book-navigation-' . $this->book->id() . '"(.*?)<ul(.*?)' . $outline . '<\/ul>/s';
}
/**
* Creates a book node.
*
* @param int|string $book_nid
* A book node ID or set to 'new' to create a new book.
* @param int|null $parent
* (optional) Parent book reference ID. Defaults to NULL.
*
* @return \Drupal\node\NodeInterface
* The created node.
*/
public function createBookNode($book_nid, $parent = NULL) {
// $number does not use drupal_static as it should not be reset
// since it uniquely identifies each call to createBookNode().
static $number = 0; // Used to ensure that when sorted nodes stay in same order.
$edit = [];
$edit['title[0][value]'] = str_pad($number, 2, '0', STR_PAD_LEFT) . ' - SimpleTest test node ' . $this->randomMachineName(10);
$edit['body[0][value]'] = 'SimpleTest test body ' . $this->randomMachineName(32) . ' ' . $this->randomMachineName(32);
$edit['book[bid]'] = $book_nid;
if ($parent !== NULL) {
$this->drupalPostForm('node/add/book', $edit, t('Change book (update list of parents)'));
$edit['book[pid]'] = $parent;
$this->drupalPostForm(NULL, $edit, t('Save'));
// Make sure the parent was flagged as having children.
$parent_node = \Drupal::entityManager()->getStorage('node')->loadUnchanged($parent);
$this->assertFalse(empty($parent_node->book['has_children']), 'Parent node is marked as having children');
}
else {
$this->drupalPostForm('node/add/book', $edit, t('Save'));
}
// Check to make sure the book node was created.
$node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
$this->assertNotNull(($node === FALSE ? NULL : $node), 'Book node found in database.');
$number++;
return $node;
}
/**
* Tests book export ("printer-friendly version") functionality.
*/
......
<?php
namespace Drupal\Tests\book\Functional;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Entity\EntityInterface;
/**
* Provides common functionality for Book test classes.
*/
trait BookTestTrait {
/**
* A book node.
*
* @var \Drupal\node\NodeInterface
*/
protected $book;
/**
* A user with permission to create and edit books.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $bookAuthor;
/**
* Creates a new book with a page hierarchy.
*
* @param string $submit
* (optional) Value of the submit button whose click is to be emulated.
* Defaults to 'Save'.
*
* @return \Drupal\node\NodeInterface[]
*/
public function createBook($submit = NULL) {
// Create new book.
$this->drupalLogin($this->bookAuthor);
$this->book = $this->createBookNode('new', NULL, $submit);
$book = $this->book;
/*
* Add page hierarchy to book.
* Book
* |- Node 0
* |- Node 1
* |- Node 2
* |- Node 3
* |- Node 4
*/
$nodes = [];
$nodes[] = $this->createBookNode($book->id(), NULL, $submit); // Node 0.
$nodes[] = $this->createBookNode($book->id(), $nodes[0]->book['nid'], $submit); // Node 1.
$nodes[] = $this->createBookNode($book->id(), $nodes[0]->book['nid'], $submit); // Node 2.
$nodes[] = $this->createBookNode($book->id(), NULL, $submit); // Node 3.
$nodes[] = $this->createBookNode($book->id(), NULL, $submit); // Node 4.
$this->drupalLogout();
return $nodes;
}
/**
* Checks the outline of sub-pages; previous, up, and next.
*
* Also checks the printer friendly version of the outline.
*
* @param \Drupal\Core\Entity\EntityInterface $node
* Node to check.
* @param $nodes
* Nodes that should be in outline.
* @param $previous
* (optional) Previous link node. Defaults to FALSE.
* @param $up
* (optional) Up link node. Defaults to FALSE.
* @param $next
* (optional) Next link node. Defaults to FALSE.
* @param array $breadcrumb
* The nodes that should be displayed in the breadcrumb.
*/
public function checkBookNode(EntityInterface $node, $nodes, $previous = FALSE, $up = FALSE, $next = FALSE, array $breadcrumb) {
// $number does not use drupal_static as it should not be reset
// since it uniquely identifies each call to checkBookNode().
static $number = 0;
$this->drupalGet('node/' . $node->id());
// Check outline structure.
if ($nodes !== NULL) {
$this->assertPattern($this->generateOutlinePattern($nodes), format_string('Node @number outline confirmed.', ['@number' => $number]));
}
else {
$this->pass(format_string('Node %number does not have outline.', ['%number' => $number]));
}
// Check previous, up, and next links.
if ($previous) {
/** @var \Drupal\Core\Url $url */
$url = $previous->urlInfo();
$url->setOptions(['attributes' => ['rel' => ['prev'], 'title' => t('Go to previous page')]]);
$text = SafeMarkup::format('<b>‹</b> @label', ['@label' => $previous->label()]);
$this->assertRaw(\Drupal::l($text, $url), 'Previous page link found.');
}
if ($up) {
/** @var \Drupal\Core\Url $url */
$url = $up->urlInfo();
$url->setOptions(['attributes' => ['title' => t('Go to parent page')]]);
$this->assertRaw(\Drupal::l('Up', $url), 'Up page link found.');
}
if ($next) {
/** @var \Drupal\Core\Url $url */
$url = $next->urlInfo();
$url->setOptions(['attributes' => ['rel' => ['next'], 'title' => t('Go to next page')]]);
$text = SafeMarkup::format('@label <b>›</b>', ['@label' => $next->label()]);
$this->assertRaw(\Drupal::l($text, $url), 'Next page link found.');
}
// Compute the expected breadcrumb.
$expected_breadcrumb = [];
$expected_breadcrumb[] = \Drupal::url('<front>');
foreach ($breadcrumb as $a_node) {
$expected_breadcrumb[] = $a_node->url();
}
// Fetch links in the current breadcrumb.
$links = $this->xpath('//nav[@class="breadcrumb"]/ol/li/a');
$got_breadcrumb = [];
foreach ($links as $link) {
$got_breadcrumb[] = $link->getAttribute('href');
}
// Compare expected and got breadcrumbs.
$this->assertIdentical($expected_breadcrumb, $got_breadcrumb, 'The breadcrumb is correctly displayed on the page.');
// Check printer friendly version.
$this->drupalGet('book/export/html/' . $node->id());
$this->assertText($node->label(), 'Printer friendly title found.');
$this->assertRaw($node->body->processed, 'Printer friendly body found.');
$number++;
}
/**
* Creates a regular expression to check for the sub-nodes in the outline.
*
* @param array $nodes
* An array of nodes to check in outline.
*
* @return string
* A regular expression that locates sub-nodes of the outline.
*/
public function generateOutlinePattern($nodes) {
$outline = '';
foreach ($nodes as $node) {
$outline .= '(node\/' . $node->id() . ')(.*?)(' . $node->label() . ')(.*?)';
}
return '/<nav id="book-navigation-' . $this->book->id() . '"(.*?)<ul(.*?)' . $outline . '<\/ul>/s';
}
/**
* Creates a book node.
*
* @param int|string $book_nid
* A book node ID or set to 'new' to create a new book.
* @param int|null $parent
* (optional) Parent book reference ID. Defaults to NULL.
* @param string $submit
* (optional) Value of the submit button whose click is to be emulated.
* Defaults to 'Save'.
*
* @return \Drupal\node\NodeInterface
* The created node.
*/
public function createBookNode($book_nid, $parent = NULL, $submit = NULL) {
// $number does not use drupal_static as it should not be reset
// since it uniquely identifies each call to createBookNode().
static $number = 0; // Used to ensure that when sorted nodes stay in same order.
$submit = $submit ?: t('Save');
$edit = [];
$edit['title[0][value]'] = str_pad($number, 2, '0', STR_PAD_LEFT) . ' - SimpleTest test node ' . $this->randomMachineName(10);
$edit['body[0][value]'] = 'SimpleTest test body ' . $this->randomMachineName(32) . ' ' . $this->randomMachineName(32);
$edit['book[bid]'] = $book_nid;
if ($parent !== NULL) {
$this->drupalPostForm('node/add/book', $edit, t('Change book (update list of parents)'));
$edit['book[pid]'] = $parent;
$this->drupalPostForm(NULL, $edit, $submit);
// Make sure the parent was flagged as having children.
$parent_node = \Drupal::entityManager()->getStorage('node')->loadUnchanged($parent);
$this->assertFalse(empty($parent_node->book['has_children']), 'Parent node is marked as having children');
}
else {
$this->drupalPostForm('node/add/book', $edit, $submit);
}
// Check to make sure the book node was created.
$node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
$this->assertNotNull(($node === FALSE ? NULL : $node), 'Book node found in database.');
$number++;
return $node;
}
}
<?php
namespace Drupal\Tests\book\Kernel;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests that the Book module handles forward revisions correctly.
*
* @group book
*/
class BookForwardRevisionTest extends KernelTestBase {