Problem/Motivation

\Drupal\Tests\system\Functional\Menu\AssertBreadcrumbTrait::assertBreadcrumbParts() is vulnerable to false positive because if

$parts = $this->getBreadcrumbParts();

returns an empty array it will always pass when it should fail. There's never a case for calling \Drupal\Tests\system\Functional\Menu\AssertBreadcrumbTrait::assertBreadcrumb() when there is no breadcrumb.

Proposed resolution

Change

    $parts = $this->getBreadcrumbParts();
    $pass = TRUE;

to

    $parts = $this->getBreadcrumbParts();
    // Fail the test if there is no breadcrumb to assert against.
    $pass = !empty($parts);

Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

alexpott created an issue. See original summary.

alexpott’s picture

Status: Active » Needs review
FileSize
860 bytes

Let's see if anything breaks.

alexpott’s picture

Here's a test that shows the problem and that the fix fixes it.

The last submitted patch, 3: 3025580-3.test-only.patch, failed testing. View results

Kristen Pol’s picture

Thanks for the patch. These are nitpicks of the comments that could be applied.

  1. +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
    @@ -381,4 +382,44 @@ public function testBreadCrumbs() {
    +      // No trail should fail.
    ...
    +      // Incorrect trail should fail.
    

    Nitpick: This wording seems unclear. "No trail should fail" sounds to me like there is no trail that should fail. Perhaps a slight change like:

    If there is no trail, this assert should fail.

    Or

    If there is no trail, this should fail.

  2. +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
    @@ -381,4 +382,44 @@ public function testBreadCrumbs() {
    +      // No trail should fail.
    ...
    +      $this->fail('Breadcrumb assertion should fail with empty trail.');
    +    }
    +    catch (\PHPUnit_Framework_ExpectationFailedException $e) {
    +      $this->assertTrue(TRUE, 'Breadcrumb assertion should fail with empty trail.');
    

    Nitpick: Since this is used twice and is used for comparison, consider pulling out string into a variable.

  3. +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
    @@ -381,4 +382,44 @@ public function testBreadCrumbs() {
    +      $this->assertTrue(TRUE, 'Breadcrumb assertion should fail with empty trail.');
    ...
    +      $this->fail('Breadcrumb assertion should fail with incorrect trail.');
    +    }
    +    catch (\PHPUnit_Framework_ExpectationFailedException $e) {
    +      $this->assertTrue(TRUE, 'Breadcrumb assertion should fail with incorrect trail.');
    

    Nitpick: Since this text is used twice and is used for comparison, consider pulling out string into a variable.

  4. +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
    @@ -381,4 +382,44 @@ public function testBreadCrumbs() {
    +      // Incorrect trail should fail.
    

    Nitpick: If the other phrasing is changed above, consider updating this one as well:

    If the trail is incorrect, this assert should fail.

    Or

    If the trail is incorrect, this should fail.

  5. +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
    @@ -381,4 +382,44 @@ public function testBreadCrumbs() {
    +    // No trail should pass as there is no breadcrumb.
    

    Nitpick: Similar to previous suggestion, perhaps:

    If there is no trail, this should pass as there is no breadcrumb.

    Or, use a wording similar to the next comment:

    There is no breadcrumb, so testing without a trail should pass.

  6. +++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
    @@ -381,4 +382,44 @@ public function testBreadCrumbs() {
    +      $this->fail('Breadcrumb assertion should fail when breadcrumb block deleted and there is a trail.');
    +    }
    +    catch (\PHPUnit_Framework_ExpectationFailedException $e) {
    +      $this->assertTrue(TRUE, 'Breadcrumb assertion should fail when breadcrumb block deleted and there is a trail.');
    

    Nitpick: Since this text is used twice and is used for comparison, consider pulling out string into a variable.

Kristen Pol’s picture

I'm not sure how to test this (since it's only for testing code) but one of my notes from previous comment wasn't saved in dreditor so adding here:

+++ b/core/modules/system/tests/src/Functional/Menu/BreadcrumbTest.php
@@ -381,4 +382,44 @@ public function testBreadCrumbs() {
+    // Ensure the test trait works as expected.
+    $home = ['' => 'Home'];
+    $trail = $home + ['menu-test' => t('Menu test root')];

Nitpick: For those who don't know about the test routes, consider changes comment to something like:

Ensure the test trait works as expected using menu_test routes.

Kristen Pol’s picture

Status: Needs review » Needs work
alexpott’s picture

Thanks for the review @Kristen Pol - I hope I've addressed all your feedback. I've used your suggestions. With respect to testing the patch - the test only patch is a good test - as you can see that the assertion doesn't fail when you'd expect it to. I.e. the following case.

    // If there is a trail, this should fail as there is no breadcrumb.
    $message = 'Breadcrumb assertion should fail when breadcrumb block deleted and there is a trail.';
    try {
      $this->assertBreadcrumb('menu-test/breadcrumb1', $trail);
      $this->fail($message);
    }
    catch (\PHPUnit_Framework_ExpectationFailedException $e) {
      $this->assertTrue(TRUE, $message);
    }
Kristen Pol’s picture

Wow. You are fast. :) Ok, I'm going to review the changes in a couple hours (going for a walk). Thanks!

The last submitted patch, 8: 3025580-8.test-only.patch, failed testing. View results

Kristen Pol’s picture

Thanks for the updates. I reviewed the interdiff and the changes look good to me. I see the failure in the tests (screenshot attached). Should I be doing anything else to test this?

Kristen Pol’s picture

Status: Needs review » Reviewed & tested by the community

I'll mark RTBC but please see the last comment.

larowlan’s picture

Status: Reviewed & tested by the community » Needs review

Should we be doing the same in the deprecated \Drupal\system\Tests\Menu\AssertBreadcrumbTrait ?

+++ b/core/modules/system/tests/src/Functional/Menu/AssertBreadcrumbTrait.php
@@ -60,6 +60,10 @@ protected function assertBreadcrumbParts($trail) {
+      $pass = FALSE;

why not just $this->fail() here instead of continuing?

Krzysztof Domański’s picture

1. Refactoring assertBreadcrumbParts().
Check in a loop if both (trait and breadcrumb) are not empty.
Fail if there is no breadcrumb and we have a trail or we have breadcrumb but trait is empty.

protected function assertBreadcrumbParts($trail) {
  // Compare paths with actual breadcrumb.
  $parts = $this->getBreadcrumbParts();
  $pass = TRUE;

  if (!empty($trail) && !empty($parts)) {
    foreach ($trail as $path => $title) {
      if ($path == '') {
        $url = Url::fromRoute('<front>')->toString();
      }
      elseif ($path[0] != '/') {
        $url = Url::fromUri('base:' . $path)->toString();
      }
      else {
        $url = $path;
      }
      $part = array_shift($parts);
      $pass = ($pass && $part['href'] === $url && $part['text'] === Html::escape($title));
    }
    // No parts must be left, or an expected "Home" will always pass.
    $pass = ($pass && empty($parts));
  }
  else if (!empty($trail) && empty($parts) || empty($trail) && !empty($parts)) {
    $pass = FALSE;
  }

  $this->assertTrue($pass, format_string('Breadcrumb %parts found on @path.', [
    '%parts' => implode(' » ', $trail),
    '@path' => $this->getUrl(),
  ]));
}

2. IMO this comments is not necessary.

--- a/core/modules/system/tests/src/Functional/Menu/AssertBreadcrumbTrait.php
+++ b/core/modules/system/tests/src/Functional/Menu/AssertBreadcrumbTrait.php
@@ -64,10 +64,6 @@ protected function assertBreadcrumbParts($trail) {
     // this test would go into an infinite loop, so we need to check that too.
     while ($trail && !empty($parts)) {
       foreach ($trail as $path => $title) {
-        // If the path is empty, generate the path from the <front> route.  If
-        // the path does not start with a leading slash, then run it through
-        // Url::fromUri('base:')->toString() to get the correct base
-        // prepended.
         if ($path == '') {
           $url = Url::fromRoute('<front>')->toString();
         }
         elseif ($path[0] != '/') {
           $url = Url::fromUri('base:' . $path)->toString();
         }
alexpott’s picture

@Krzysztof Domański mixing a refactor with a bug fix is not how to do things. It makes it harder to understand the changes. If you want to do that open a task.

@larowlan we could but then we'd have to duplicate the later message and message consistency is nice. So for me this is better. Re-uploading #8.

alexpott’s picture

Also I think we should be leaving deprecated code alone. Otherwise we have to add a test for this and there is no test for the old trait atm.

Krzysztof Domański’s picture

Status: Needs review » Reviewed & tested by the community

#15

+++ b/core/modules/system/tests/src/Functional/Menu/AssertBreadcrumbTrait.php
@@ -60,6 +60,10 @@ protected function assertBreadcrumbParts($trail) {
     // Compare paths with actual breadcrumb.
     $parts = $this->getBreadcrumbParts();
     $pass = TRUE;
+    // Fail if there is no breadcrumb and we have a trail.
+    if (!empty($trail) && empty($parts)) {
+      $pass = FALSE;
+    }
// There may be more than one breadcrumb on the page. If $trail is empty
// this test would go into an infinite loop, so we need to check that too.
while ($trail && !empty($parts)) {
  foreach ($trail as $path => $title) {

    (...)

    $part = array_shift($parts);
    $pass = ($pass && $part['href'] === $url && $part['text'] === Html::escape($title));
  }
}
// No parts must be left, or an expected "Home" will always pass.
$pass = ($pass && empty($parts));

I think the while loop is more difficult to analyze. If empty($trail) && !empty($parts) loop will not execute so $pass will be also FALSE. It can be written more simply:

#14

else if (!empty($trail) && empty($parts) || empty($trail) && !empty($parts)) {
  $pass = FALSE;
}
mixing a refactor with a bug fix is not how to do things

Considering that #15 solving a problem so RTBC. I will add a new tusk to refactor while loop after fix this issue.

  • larowlan committed aba8c85 on 8.7.x
    Issue #3025580 by alexpott, Krzysztof Domański, Kristen Pol: \Drupal\...
larowlan’s picture

Status: Reviewed & tested by the community » Fixed

Committed aba8c85 and pushed to 8.7.x. Thanks!

Also I think we should be leaving deprecated code alone. Otherwise we have to add a test for this and there is no test for the old trait atm.

sounds good

we could but then we'd have to duplicate the later message and message consistency is nice

not sure that is a reason on its own - we have local variables, but don't care enough to push the issue - let's just get this in

Krzysztof Domański’s picture

I added the issue in order to replace the while loop to conditional statement (#14.1).

#3030403: Improve the readability of the code in assertBreadcrumbParts

Status: Fixed » Closed (fixed)

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