diff --git a/core/modules/help_topics/help_topics/core.security.html.twig b/core/modules/help_topics/help_topics/core.security.html.twig index 63b12c4..6c04f7d 100644 --- a/core/modules/help_topics/help_topics/core.security.html.twig +++ b/core/modules/help_topics/help_topics/core.security.html.twig @@ -1,4 +1,4 @@ - +

{% trans %}The topics listed here will help you make and keep your site secure.{% endtrans %}

diff --git a/core/modules/help_topics/tests/src/Functional/HelpTopicsSyntaxTest.php b/core/modules/help_topics/tests/src/Functional/HelpTopicsSyntaxTest.php new file mode 100644 index 0000000..7b1f76c --- /dev/null +++ b/core/modules/help_topics/tests/src/Functional/HelpTopicsSyntaxTest.php @@ -0,0 +1,230 @@ +drupalLogin($this->rootUser); + + // Enable all modules and themes, so that all routes mentioned in topics + // will be defined. + $module_directories = $this->listDirectories('module'); + $this->container->get('module_installer')->install(array_keys($module_directories)); + $theme_directories = $this->listDirectories('theme'); + $this->container->get('theme_installer')->install(array_keys($theme_directories)); + + $directories = $module_directories + $theme_directories + + $this->listDirectories('profile'); + $directories['core'] = 'core/help_topics'; + + // Verify that a few key modules, themes, and profiles are listed, so that + // we can be certain our directory list is complete and we will be testing + // all existing help topics. If these lines in the test fail in the future, + // it is probably because something we chose to list here is being removed. + // Substitute another item of the same type that still exists, so that this + // test can continue. + $this->assertTrue(isset($directories['system']), 'System module is being scanned'); + $this->assertTrue(isset($directories['help']), 'Help module is being scanned'); + $this->assertTrue(isset($directories['seven']), 'Seven theme is being scanned'); + $this->assertTrue(isset($directories['standard']), 'Standard profile is being scanned'); + + $definitions = $this->discoverAllTopics($directories); + $this->assertTrue(count($definitions) > 1, 'At least 1 topic was found'); + $top_level = $this->findTopLevel($definitions); + + // Test each topic for compliance with standards. + foreach (array_keys($definitions) as $id) { + $this->verifyTopic($id, $definitions, $top_level); + } + + // Create a single topic that is expected to fail discovery. + vfsStream::setup('root'); + vfsStream::create([ + 'modules' => [ + 'test' => [ + 'help_topics' => [ + // An invalid topic missing required details. + 'test.topic.html.twig' => '', + ], + ], + ], + ]); + $fail_directories['expected_failure'] = vfsStream::url('root/modules/test/help_topics'); + + // Check that just this expected failure topic does throw an exception. + $this->expectException(DiscoveryException::class); + $this->expectExceptionMessage("vfs://root/modules/test/help_topics/test.topic.html.twig should begin with 'expected_failure.'"); + $this->discoverAllTopics($fail_directories); + } + + /** + * Verifies rendering and standards compliance of one help topic. + * + * @param string $id + * ID of the topic to verify. + * @param array $definitions + * Array of all topic definitions, keyed by ID. + * @param array $top_level + * Array of top-level topic definitions, keyed by ID. + */ + protected function verifyTopic($id, $definitions, $top_level) { + $definition = $definitions[$id]; + + // Visit the URL for the topic. + $this->drupalGet('admin/help/topic/' . $id); + + // Verify the title and response. + $session = $this->assertSession(); + $session->statusCodeEquals(200); + $session->titleEquals($definition['label'] . ' | Drupal'); + + // Verify that all the related topics exist. Also check to see if any of + // them are top-level (we will need that in the next section). + $has_top_level_related = FALSE; + if (isset($definition['related'])) { + foreach ($definition['related'] as $related_id) { + $this->assertTrue(isset($definitions[$related_id]), 'Topic ' . $id . ' is only related to topics that exist (' . $related_id . ')'); + $has_top_level_related = $has_top_level_related || isset($top_level[$related_id]); + } + } + + // Verify this is either top-level or related to a top-level topic. + $this->assertTrue(isset($top_level[$id]) || $has_top_level_related, + 'Topic ' . $id . ' is either top-level or related to at least one other top-level topic'); + + // Verify that the label is not empty. + $this->assertTrue(isset($definition['label']) && !empty($definition['label']), 'Topic ' . $id . ' has a non-empty label'); + + // Read in the file so we can run some tests on that. + $body = file_get_contents($definition[HelpTopicDiscovery::FILE_KEY]); + $this->assertNotEmpty($body, 'Topic ' . $id . ' has a non-empty Twig file'); + + // Remove the meta tags (already tested above), and Twig set and variable + // printouts from the file. + $body = preg_replace('|\|sU', '', $body); + $body = preg_replace('|\{\{.*\}\}|sU', '', $body); + $body = preg_replace('|\{\% set.*\%\}|sU', '', $body); + $this->assertNotEmpty($body, 'Topic ' . $id . ' Twig file contains some text outside of meta tags'); + + // Verify that if we remove all the translated text, whitespace, and + // HTML tags, there is nothing left (that is, all text is translated). + $text = preg_replace('|\{\% trans \%\}.*\{\% endtrans \%\}|sU', '', $body); + $text = strip_tags($text); + $text = preg_replace('|\s+|', '', $text); + $this->assertEmpty($text, 'Topic ' . $id . ' Twig file has all of its text translated'); + + // Load the topic body as HTML and verify that it parses. + $doc = new \DOMDocument(); + $doc->strictErrorChecking = TRUE; + $doc->validateOnParse = TRUE; + $doc->loadHTML($body); + + // Check for headings hierarchy. + $levels = [1, 2, 3, 4, 5, 6]; + foreach ($levels as $level) { + $num_headings[$level] = $doc->getElementsByTagName('h' . $level)->length; + if ($level == 1) { + $this->assertTrue($num_headings[1] == 0, 'Topic ' . $id . ' has no H1 tag'); + // Set num_headings to 1 for this level, so the rest of the hierarchy + // can be tested using simpler code. + $num_headings[1] = 1; + } + else { + // We should either not have this heading, or if we do have one at this + // level, we should also have the next-smaller level. That is, if we + // have an h3, we should have also had an h2. + $this->assertTrue($num_headings[$level - 1] > 0 || $num_headings[$level] == 0, + 'Topic ' . $id . ' has the correct H2-H6 heading hierarchy'); + } + } + } + + /** + * Lists the extension directories of a certain type. + * + * @param string $type + * The type of extension to list: module, theme, or profile. + * + * @return string[] + * An array of all of the directories of this type of extension, keyed by + * extension short name. + */ + protected function listDirectories($type) { + $directories = []; + + // Find the extensions of this type, even if they are not installed, but + // excluding test ones. + $lister = \Drupal::service('extension.list.' . $type); + foreach (array_keys($lister->getAllAvailableInfo()) as $name) { + $path = $lister->getPath($name); + // You can tell test modules because they are in package 'Testing', but + // test themes are only known by being found in test directories. So... + // exclude things in test directories. + if ((strpos($path, '/tests') === FALSE) && + (strpos($path, '/testing') === FALSE)) { + $directories[$name] = $path . '/help_topics'; + } + } + return $directories; + } + + /** + * Discovers the help topics in a list of locations. + * + * @param string[] $directories + * An array of directories to find topics in, keyed by extension short name. + * + * @return array + * Array of all topic definitions, keyed by topic ID, from non-test + * modules, themes, and profiles. + */ + protected function discoverAllTopics(array $directories) { + $discovery = new HelpTopicDiscovery($directories); + return $discovery->getDefinitions(); + } + + /** + * Finds the list of top-level topics. + * + * @param array $definitions + * An array of topic definitions, keyed by ID. + * + * @return array + * The items from $definitions that represent top-level topics, still + * keyed by ID. + */ + protected function findTopLevel(array $definitions) { + return array_filter($definitions, function ($definition) { + return isset($definition['top_level']) && $definition['top_level']; + }); + } + +}