Problem/Motivation

STR:

  • When a node is in a the middle of a book hierarchy
  • Visit one if it's child pages and observe the breadcrumb.
  • Then edit the node and change the title.
  • Visit the child page again and see the breadcrumb still has the old title.

In addition, it seems likely that if there is some custom node access scheme, the breadcrumb would be cached and could be shown to users that don't have access.

Proposed resolution

In \Drupal\book\BookBreadcrumbBuilder::build

For each node loaded call $breadcrumb->addCacheableDependency()

For the access call, set $return_as_object to TRUE and apply it also using $breadcrumb->addCacheableDependency()

Remaining tasks

write patch
write tests

User interface changes

n/a

API changes

n/a

Data model changes

n/a

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

pwolanin created an issue. See original summary.

pwolanin’s picture

Status: Active » Needs review
FileSize
962 bytes

Quick patch. NR for bot. Needs testing.

jibran’s picture

In addition, it seems likely that if there is some custom node access scheme, the breadcrumb would be cached and could be shown to users that don't have access.

Is this critical because of this?

pwolanin’s picture

@jibran - yes, that's why I marked it critical, but maintainers may, of course, triage it to lower.

Wim Leers’s picture

Issue tags: +D8 cacheability, +Security
Wim Leers’s picture

Patch looks good.

Wim Leers’s picture

Status: Needs review » Needs work

NW for tests.

pwolanin’s picture

Status: Needs work » Needs review
FileSize
2 KB

Here' some initial work on a test-only patch to show the bugs - should fail.

Status: Needs review » Needs work

The last submitted patch, 8: 2665410-8-test-only.patch, failed testing.

Wim Leers’s picture

Issue tags: -Needs tests

Test coverage looks good. Just nits for it. Upon fixing the nits & merging #2, this is RTBC as far as I'm concerned.

  1. +++ b/core/modules/book/src/Tests/BookTest.php
    @@ -754,4 +754,52 @@ public function testHookNodeLoadAccess() {
    +    /*
    

    Super nit: missing trailing asterisk.

  2. +++ b/core/modules/book/src/Tests/BookTest.php
    @@ -754,4 +754,52 @@ public function testHookNodeLoadAccess() {
    +    $got_breadcrumb = array();
    ...
    +    $got_breadcrumb = array();
    

    Nit: []

  3. +++ b/core/modules/book/src/Tests/BookTest.php
    @@ -754,4 +754,52 @@ public function testHookNodeLoadAccess() {
    +  }
     }
    

    Nit: needs newline in between.

pwolanin’s picture

@Wim Leers - I think maybe we should test changes to the access result also? Any examples you know for that?

Wim Leers’s picture

pwolanin’s picture

Status: Needs work » Needs review
FileSize
10.77 KB

So, am I missing somethign - seems like the access result cache tags are not being propagated?

Test-only is the interdiff to #2

pwolanin’s picture

The last submitted patch, 13: 2665410-13.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 14: 2665410-13-test-only.patch, failed testing.

Wim Leers’s picture

So, am I missing somethign - seems like the access result cache tags are not being propagated?

I don't see any cache tag assertions in #13/#14.

pwolanin’s picture

@Wim Leers - in the module hook I'm doing: $access->addCacheableDependency($config);

Changing that config makes the access check return denied. The last test line verifies the node page itself is a 403. But just before that, the denied node title is still found in the breadcrumb after the config is changed.

catch’s picture

+++ b/core/modules/book/tests/modules/book_breadcrumb_test/book_breadcrumb_test.module
@@ -0,0 +1,26 @@
+  $access->addCacheableDependency($config);

Since the access depends on the node title shouldn't this add the $node as a cacheable dependency too?

pwolanin’s picture

The node is already added as a cacheable dependency in the patch. That's the fix the 1st test verifies.

The variables just are named badly:

            $breadcrumb->addCacheableDependency($parent_book);

Though possibly we should add that as a cacheable dependency even if access is not allowed?

davidhernandez’s picture

Just confirming the patch in #13 works while manually testing.

catch’s picture

Though possibly we should add that as a cacheable dependency even if access is not allowed?

Yes it needs to be a dependency either way.

Wim Leers’s picture

Though possibly we should add that as a cacheable dependency even if access is not allowed?

Yes.

But it's already doing that:

+++ b/core/modules/book/tests/modules/book_breadcrumb_test/book_breadcrumb_test.module
@@ -0,0 +1,26 @@
+  if ($config->get('hide') && $node->getTitle() == "you can't see me" && $operation == 'view') {
+    $access = new AccessResultForbidden();
+  }
+  else {
+    $access = new AccessResultNeutral();
+  }
+  $access->addCacheableDependency($config);
+  return $access;

Unless I'm overlooking some code? Or some other code in book module is short-circuiting this incorrectly?

pwolanin’s picture

@Wim Leers - the question was about adding all the nodes as cacheable dependencies, or just the ones with allowed access, though a proper implementation of hook_node_access or hook_entity_access should already be doing that if the access is per node.

Wim Leers’s picture

Oops, I misread. Yes, the access result's cacheability metadata should already include the information it depends on, so that would indicate a bug in the access check logic involved there.

catch’s picture

Status: Needs work » Needs review
FileSize
10.81 KB
637 bytes

The test implementation only sets $config as a dependency, but it also varies on $node->title(), so surely should set $node as a dependency too.

Here's a patch that does that, untested though...

Status: Needs review » Needs work

The last submitted patch, 26: 2665410.patch, failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
10.17 KB
1.97 KB

OK that fixes 2 out of 4 assertions.

I think the other issue is that static config objects don't have cache tags by default, so

+++ b/core/modules/book/src/Tests/BookBreadcrumbTest.php
@@ -0,0 +1,207 @@
+    $config->set('hide', TRUE)->save();

This is not actually invalidating any cache tags.

However https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Cache%21R...

This should set max age to 0 given static config doesn't implement CacheableDependencyInterface, so you'd expect it to still pass.

But, we don't bubble max age yet per #2352009: [pp-3] Bubbling of elements' max-age to the page's headers and the page cache.

So the $config cacheable dependency does nothing to the breadcrumbs.

Since this is only for a test, I've changed it to use state and a raw cache tag. Tests pass locally for me now.

Wim Leers’s picture

static config objects don't have cache tags by default

Well, no, look at \Drupal\Core\Config\Config::save():

    $this->storage->write($this->name, $this->data);
    if (!$this->isNew) {
      Cache::invalidateTags($this->getCacheTags());
    }

So I don't think #28 is a good idea, it makes the test depend on state, which indeed by definition is uncacheable.

catch’s picture

FileSize
879 bytes
2.07 MB

Doh completely forgot that then missed it when looking at this.

That snippet you posted explains the test fail though - because the isNew() check will fail there - the test is the first time it gets saved so the cache tag never gets invalidated.

This should pass, interdiff against #26.

Status: Needs review » Needs work

The last submitted patch, 30: 2665410.patch, failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
10.98 KB

Patch fail...

Wim Leers’s picture

+++ b/core/modules/book/src/Tests/BookBreadcrumbTest.php
@@ -0,0 +1,210 @@
+    // Because the configuration isn't provided in config/install, save this
+    // twice to ensure the cache tag is invalidated.

OMG!!!! I don't yet understand how this can happen. But I think this should happen in setUp() then, because this is just extremely confusing.

catch’s picture

It would probably be least confusing if we provided the default config in config/install with a value other than 'hide' - then it'll be installed when the module is, and the first save in the test will be an update instead of insert with no workaround in the test.

But this is also partly why I wanted to use state + cache tags because it's a fair bit of boilerplate just to get the access result to change between requests.

If no-one gets to it overnight I'll have a look tomorrow my time though.

Wim Leers’s picture

It would probably be least confusing if we provided the default config in config/install with a value other than 'hide' - then it'll be installed when the module is, and the first save in the test will be an update instead of insert with no workaround in the test.

The value wouldn't even have to be something else than hide? Because Config isn't smart enough to not save anything if no actual changes are made?

So, yeah, +1 to putting default config in config/install. Not doing that is really the source of all confusion here AFAICT!

catch’s picture

Status: Needs review » Needs work

The value wouldn't even have to be something else than hide? Because Config isn't smart enough to not save anything if no actual changes are made?

The test relies on it not being 'hide' until it gets saved, but yeah otherwise this would be fine. Shifting to needs work for the default config change.

Wim Leers’s picture

Okay, then yes, let the default installed config set it to something other than 'hide'.

Phew, glad that it was just that.

pwolanin’s picture

Thanks for finding that behavior - not clearing the cache tag on creation seems like a possible core bug in the case shown here - that the module doesn't provide a default config?

Wim Leers’s picture

#2040135: Caches dependent on simple config are only invalidated on form submissions introduced the code quoted in #29. I thought it was to not do unnecessary invalidations while installing. But turns out it was @Berdir that suggested this in #2040135-69: Caches dependent on simple config are only invalidated on form submissions. Because it's consistent with how we do cache tag invalidation for content entities — see \Drupal\Core\Entity\Entity::invalidateTagsOnSave():

    if ($update) {
      // An existing entity was updated, also invalidate its unique cache tag.
      $tags = Cache::mergeTags($tags, $this->getCacheTagsToInvalidate());
    }

AFAICT that still makes sense.

catch’s picture

Status: Needs work » Needs review
FileSize
879 bytes
11.21 KB

Here's the default config and reverting #31.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

Perfect.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 70ca793 and pushed to 8.0.x and 8.1.x. Thanks!

At first I looked at the new config file added by the test module and wondered why it was not state but the test is neat as it uses the config to add a cacheable dependencies.

diff --git a/core/modules/book/src/Tests/BookBreadcrumbTest.php b/core/modules/book/src/Tests/BookBreadcrumbTest.php
index 2c126aa..a7b9684 100644
--- a/core/modules/book/src/Tests/BookBreadcrumbTest.php
+++ b/core/modules/book/src/Tests/BookBreadcrumbTest.php
@@ -62,7 +62,7 @@ protected function setUp() {
    *
    * @return \Drupal\node\NodeInterface[]
    */
-  function createBreadcrumbBook() {
+  protected function createBreadcrumbBook() {
     // Create new book.
     $this->drupalLogin($this->bookAuthor);
 
@@ -105,7 +105,7 @@ function createBreadcrumbBook() {
    * @return \Drupal\node\NodeInterface
    *   The created node.
    */
-  function createBookNode($book_nid, $parent = NULL) {
+  protected 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.

Added visibility to a couple of functions where it was missing.

diff --git a/core/modules/book/src/Tests/BookBreadcrumbTest.php b/core/modules/book/src/Tests/BookBreadcrumbTest.php
index a7b9684..7080a27 100644
--- a/core/modules/book/src/Tests/BookBreadcrumbTest.php
+++ b/core/modules/book/src/Tests/BookBreadcrumbTest.php
@@ -61,6 +61,7 @@ protected function setUp() {
    * Creates a new book with a page hierarchy.
    *
    * @return \Drupal\node\NodeInterface[]
+   *   The created book nodes.
    */
   protected function createBreadcrumbBook() {
     // Create new book.
@@ -81,13 +82,13 @@ protected function createBreadcrumbBook() {
      *  |- Node 6
      */
     $nodes = array();
-    $nodes[0] = $this->createBookNode($book->id()); // Node 0.
-    $nodes[1] = $this->createBookNode($book->id(), $nodes[0]->id()); // Node 1.
-    $nodes[2] = $this->createBookNode($book->id(), $nodes[0]->id()); // Node 2.
-    $nodes[3] = $this->createBookNode($book->id(), $nodes[2]->id()); // Node 3.
-    $nodes[4] = $this->createBookNode($book->id(), $nodes[3]->id()); // Node 4.
-    $nodes[5] = $this->createBookNode($book->id(), $nodes[4]->id()); // Node 5.
-    $nodes[6] = $this->createBookNode($book->id()); // Node 6.
+    $nodes[0] = $this->createBookNode($book->id());
+    $nodes[1] = $this->createBookNode($book->id(), $nodes[0]->id());
+    $nodes[2] = $this->createBookNode($book->id(), $nodes[0]->id());
+    $nodes[3] = $this->createBookNode($book->id(), $nodes[2]->id());
+    $nodes[4] = $this->createBookNode($book->id(), $nodes[3]->id());
+    $nodes[5] = $this->createBookNode($book->id(), $nodes[4]->id());
+    $nodes[6] = $this->createBookNode($book->id());
 
     $this->drupalLogout();
 
@@ -106,9 +107,10 @@ protected function createBreadcrumbBook() {
    *   The created node.
    */
   protected 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.
+    // $number does not use drupal_static as it should not be reset since it
+    // uniquely identifies each call to createBookNode(). It is used to ensure
+    // that when sorted nodes stay in same order.
+    static $number = 0;
 
     $edit = array();
     $edit['title[0][value]'] = str_pad($number, 2, '0', STR_PAD_LEFT) . ' - SimpleTest test node ' . $this->randomMachineName(10);

Fixed some coding standards in the test. The standards are: Comments may not appear after statements, Return comment must be on the next line.

  • alexpott committed 5580fe9 on 8.1.x
    Issue #2665410 by catch, pwolanin, Wim Leers: Book module breadcrumb...

  • alexpott committed 70ca793 on 8.0.x
    Issue #2665410 by catch, pwolanin, Wim Leers: Book module breadcrumb...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.