diff --git a/AGENTS.md b/AGENTS.md index d3bdd57f..1ae0213e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ The `docs/` directory contains **what** applies to this project: - `docs/releasing.md` - Version scheme and release process - `docs/sitemap.md` - XML sitemap module, coverage and generation - `docs/seo.md` - Meta tags, social share cards and structured data +- `docs/csp.md` - The content security policy, its nonce and its derived script hash - `docs/related-content.md` - Related-content lists and topic pages - `docs/performance.md` - Image styles, self-hosted fonts and layout stability - `docs/preview-links.md` - sharing unpublished content by link diff --git a/docs/csp.md b/docs/csp.md new file mode 100644 index 00000000..68a03efc --- /dev/null +++ b/docs/csp.md @@ -0,0 +1,39 @@ +# Content security policy + +This document describes **what** the policy admits on this site and why. The module that emits it is [`csp`](https://www.drupal.org/project/csp). + +## Where the policy lives + +`config/default/csp.settings.yml` holds the enforced directives and their source lists. Two sources are not in that file, because they are per-response and are attached by `PageAttachmentsHook` instead: a nonce and a hash. + +## Inline scripts + +`script-src` carries no `unsafe-inline`, so an inline `#s', $template, $matches)) { + $this->logger->warning('No policy hash could be derived for the navigation toolbar: @path holds no inline script this can read. Any script it does render will be blocked.', ['@path' => $path]); + + return []; + } + + $hashes = []; + + foreach ($matches[1] as $script) { + // A hash has to match the rendered bytes, which Twig syntax makes + // unknowable from the template alone. + if (str_contains($script, '{{') || str_contains($script, '{%')) { + $this->logger->warning('An inline script of the navigation toolbar in @path carries Twig syntax, so no policy hash can be derived for it and it will be blocked.', ['@path' => $path]); + + continue; + } + + $hashes[] = 'sha256-' . base64_encode(hash('sha256', $script, TRUE)); + } + + return $hashes; + } + +} diff --git a/web/modules/custom/do_base/tests/fixtures/nav_no_script/layouts/navigation.html.twig b/web/modules/custom/do_base/tests/fixtures/nav_no_script/layouts/navigation.html.twig new file mode 100644 index 00000000..582c0331 --- /dev/null +++ b/web/modules/custom/do_base/tests/fixtures/nav_no_script/layouts/navigation.html.twig @@ -0,0 +1,3 @@ + diff --git a/web/modules/custom/do_base/tests/fixtures/nav_one_script/layouts/navigation.html.twig b/web/modules/custom/do_base/tests/fixtures/nav_one_script/layouts/navigation.html.twig new file mode 100644 index 00000000..4a2276ad --- /dev/null +++ b/web/modules/custom/do_base/tests/fixtures/nav_one_script/layouts/navigation.html.twig @@ -0,0 +1,4 @@ + + diff --git a/web/modules/custom/do_base/tests/fixtures/nav_twig_script/layouts/navigation.html.twig b/web/modules/custom/do_base/tests/fixtures/nav_twig_script/layouts/navigation.html.twig new file mode 100644 index 00000000..a8f14c65 --- /dev/null +++ b/web/modules/custom/do_base/tests/fixtures/nav_twig_script/layouts/navigation.html.twig @@ -0,0 +1,4 @@ + + diff --git a/web/modules/custom/do_base/tests/fixtures/nav_two_scripts/layouts/navigation.html.twig b/web/modules/custom/do_base/tests/fixtures/nav_two_scripts/layouts/navigation.html.twig new file mode 100644 index 00000000..147b82ee --- /dev/null +++ b/web/modules/custom/do_base/tests/fixtures/nav_two_scripts/layouts/navigation.html.twig @@ -0,0 +1,7 @@ + + + diff --git a/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php b/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php new file mode 100644 index 00000000..17d03882 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php @@ -0,0 +1,402 @@ +installEntitySchema('user'); + $this->installEntitySchema('file'); + $this->installEntitySchema('media'); + $this->installEntitySchema('node'); + $this->installSchema('file', ['file_usage']); + $this->installConfig(['field', 'system', 'image', 'media', 'node']); + + $style = ImageStyle::create(['name' => static::IMAGE_STYLE, 'label' => 'Banner background']); + $style->addImageEffect([ + 'id' => 'image_scale', + 'data' => ['width' => 1600], + ]); + $style->save(); + + NodeType::create(['type' => 'page', 'name' => 'Page'])->save(); + $this->createImageMediaType(); + $this->createBannerBackgroundField(); + } + + /** + * Tests that a nonce is asked for so core's inline scripts survive. + */ + public function testNonceIsAskedFor(): void { + // Prepare. + $this->setRoute('entity.node.canonical'); + + // Act. + $attachments = $this->attach(); + + // Assert. + $this->assertSame([Csp::POLICY_UNSAFE_INLINE], $attachments['#attached']['csp_nonce']['script']); + $this->assertContains('csp/nonce', $attachments['#attached']['library']); + } + + /** + * Tests that exactly one script hash is allowed, with a fallback source. + */ + public function testInlineScriptIsAllowedByHash(): void { + // Prepare. + $this->setRoute('entity.node.canonical'); + + // Act. + $attachments = $this->attach(); + + // Assert. + $this->assertSame([[Csp::POLICY_UNSAFE_INLINE]], array_values($attachments['#attached']['csp_hash']['script-src-elem'])); + } + + /** + * Tests that the hash allowed is the one for the script core renders. + * + * The hashes are derived from the shipped template, so this asserts that the + * template core currently ships is still one the derivation can read. A core + * release that restructures it fails here rather than only logging a warning. + */ + public function testAllowedHashMatchesTheTemplate(): void { + // Prepare. + $this->setRoute('entity.node.canonical'); + $template = file_get_contents($this->root . '/core/modules/navigation/layouts/navigation.html.twig'); + + // Act. + $attachments = $this->attach(); + $found = preg_match('##s', (string) $template, $matches); + + // Assert. + $this->assertSame(1, $found, 'The navigation layout is expected to render one inline script.'); + $this->assertSame( + 'sha256-' . base64_encode(hash('sha256', $matches[1], TRUE)), + array_key_first($attachments['#attached']['csp_hash']['script-src-elem']), + ); + } + + /** + * Tests that a site without the policy module gets no policy attachments. + */ + public function testSiteWithoutThePolicyModuleGetsNoPolicySources(): void { + // Prepare. + $this->setRoute('entity.node.canonical', ['node' => $this->createPage($this->createImageMedia())]); + $modules = $this->createMock(ModuleHandlerInterface::class); + $modules->method('moduleExists')->willReturn(FALSE); + + $hook = new PageAttachmentsHook( + $this->container->get('current_route_match'), + $this->container->get('entity_type.manager'), + $modules, + $this->container->get(NavigationScriptHash::class), + ); + + // Act. + $attachments = []; + $hook->attach($attachments); + + // Assert. + $this->assertArrayHasKey('html_head_link', $attachments['#attached'], 'The rest of the hook is expected to run without the policy module.'); + $this->assertArrayNotHasKey('csp_nonce', $attachments['#attached']); + $this->assertArrayNotHasKey('csp_hash', $attachments['#attached']); + } + + /** + * Tests that the banner background is preloaded on the pages showing it. + * + * @param string $route_name + * Name of the route being visited. + * @param string $parameter + * Name of the route parameter holding the node. + * @param array $options + * Options the route carries. + */ + #[DataProvider('dataProviderBannerBackgroundIsPreloaded')] + public function testBannerBackgroundIsPreloaded(string $route_name, string $parameter, array $options = []): void { + // Prepare. + $this->setRoute($route_name, [$parameter => $this->createPage($this->createImageMedia())], $options); + + // Act. + $attachments = $this->attach(); + + // Assert. + $this->assertSame([ + 'rel' => 'preload', + 'as' => 'image', + 'fetchpriority' => 'high', + ], array_diff_key($attachments['#attached']['html_head_link'][0][0], ['href' => NULL])); + $this->assertStringContainsString('/styles/' . static::IMAGE_STYLE . '/', $attachments['#attached']['html_head_link'][0][0]['href']); + } + + /** + * Data provider for testBannerBackgroundIsPreloaded. + */ + public static function dataProviderBannerBackgroundIsPreloaded(): \Iterator { + yield 'the node page' => ['entity.node.canonical', 'node']; + yield 'an older revision' => ['entity.node.revision', 'node_revision']; + yield 'the latest draft' => ['entity.node.latest_version', 'node']; + yield 'a preview link' => ['entity.node.preview_link', 'node', ['_preview_link_route' => TRUE]]; + } + + /** + * Tests that no preload is emitted on pages that draw no banner. + * + * @param string $route_name + * Name of the route being visited. + * @param string|null $parameter + * Name of the route parameter holding the node, or NULL for a route + * carrying no node at all. + */ + #[DataProvider('dataProviderBannerBackgroundIsNotPreloaded')] + public function testBannerBackgroundIsNotPreloaded(string $route_name, ?string $parameter): void { + // Prepare. + $parameters = $parameter === NULL ? [] : [$parameter => $this->createPage($this->createImageMedia())]; + $this->setRoute($route_name, $parameters); + + // Act. + $attachments = $this->attach(); + + // Assert. + $this->assertArrayNotHasKey('html_head_link', $attachments['#attached']); + } + + /** + * Data provider for testBannerBackgroundIsNotPreloaded. + */ + public static function dataProviderBannerBackgroundIsNotPreloaded(): \Iterator { + yield 'the edit form' => ['entity.node.edit_form', 'node']; + yield 'the delete form' => ['entity.node.delete_form', 'node']; + yield 'the revision list' => ['entity.node.version_history', 'node']; + yield 'a devel tab' => ['entity.node.devel_load', 'node']; + yield 'a page with no node behind it' => ['system.admin_content', NULL]; + } + + /** + * Tests that a page with no background of its own preloads nothing. + */ + public function testPageWithoutBackgroundPreloadsNothing(): void { + // Prepare. + $this->setRoute('entity.node.canonical', ['node' => $this->createPage()]); + + // Act. + $attachments = $this->attach(); + + // Assert. + $this->assertArrayNotHasKey('html_head_link', $attachments['#attached']); + } + + /** + * Runs the hook over an empty set of attachments. + * + * The hook is taken from the container, so the wiring it is registered with + * is asserted alongside its behaviour. + * + * @return array> + * The attachments the hook has added to. + */ + protected function attach(): array { + $hook = $this->container->get(PageAttachmentsHook::class); + + if (!$hook instanceof PageAttachmentsHook) { + throw new \UnexpectedValueException('The hook is expected to be registered as a service.'); + } + + $attachments = []; + $hook->attach($attachments); + + return $attachments; + } + + /** + * Puts a request for the given route on the stack. + * + * @param string $route_name + * Name of the route being visited. + * @param array $parameters + * Upcast route parameters, keyed by name. + * @param array $options + * Options the route carries. + */ + protected function setRoute(string $route_name, array $parameters = [], array $options = []): void { + // A route match only carries the parameters its path declares. + $path = '/test' . implode('', array_map(static fn(string $name): string => '/{' . $name . '}', array_keys($parameters))); + + $route = new Route($path); + foreach ($options as $name => $value) { + $route->setOption($name, $value); + } + + $request = Request::create($path); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route); + foreach ($parameters as $name => $value) { + $request->attributes->set($name, $value); + } + + $requests = $this->container->get('request_stack'); + $current = $requests->getCurrentRequest(); + // The kernel started a session on the request it booted with, and the test + // base clears that session on the current request as it tears down. + if ($current !== NULL && $current->hasSession()) { + $request->setSession($current->getSession()); + } + + $requests->push($request); + } + + /** + * Creates a page, optionally carrying a banner background. + */ + protected function createPage(?MediaInterface $background = NULL): NodeInterface { + $node = Node::create([ + 'type' => 'page', + 'title' => '[TEST] Page', + 'field_c_n_banner_background' => $background instanceof MediaInterface ? ['target_id' => $background->id()] : NULL, + ]); + $node->save(); + + return $node; + } + + /** + * Creates a media item wrapping an image file. + */ + protected function createImageMedia(): MediaInterface { + $media = Media::create([ + 'bundle' => static::MEDIA_TYPE, + 'name' => '[TEST] Banner Background', + 'field_c_m_image' => ['target_id' => $this->createImageFile()->id(), 'alt' => '[TEST] Background'], + ]); + $media->save(); + + return $media; + } + + /** + * Creates a file entity around a copy of a core fixture. + */ + protected function createImageFile(): FileInterface { + $uri = 'public://' . $this->randomMachineName() . '.png'; + file_put_contents($uri, (string) file_get_contents($this->root . '/core/tests/fixtures/files/image-1.png')); + + $file = File::create(['uri' => $uri]); + $file->setPermanent(); + $file->save(); + + return $file; + } + + /** + * Creates the media type banner backgrounds live in. + */ + protected function createImageMediaType(): void { + $media_type = MediaType::create([ + 'id' => static::MEDIA_TYPE, + 'label' => 'Image', + 'source' => 'image', + ]); + $media_type->save(); + + FieldStorageConfig::create([ + 'entity_type' => 'media', + 'field_name' => 'field_c_m_image', + 'type' => 'image', + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'media', + 'bundle' => static::MEDIA_TYPE, + 'field_name' => 'field_c_m_image', + 'label' => 'Image', + 'settings' => ['alt_field' => TRUE, 'file_extensions' => 'png jpg svg'], + ])->save(); + + $media_type->set('source_configuration', ['source_field' => 'field_c_m_image'])->save(); + } + + /** + * Adds the banner background field pages carry. + */ + protected function createBannerBackgroundField(): void { + FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_c_n_banner_background', + 'type' => 'entity_reference', + 'settings' => ['target_type' => 'media'], + ])->save(); + + FieldConfig::create([ + 'entity_type' => 'node', + 'bundle' => 'page', + 'field_name' => 'field_c_n_banner_background', + 'label' => 'Banner background', + 'settings' => [ + 'handler' => 'default:media', + 'handler_settings' => ['target_bundles' => [static::MEDIA_TYPE => static::MEDIA_TYPE]], + ], + ])->save(); + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/LibraryInfoAlterHookTest.php b/web/modules/custom/do_base/tests/src/Unit/LibraryInfoAlterHookTest.php new file mode 100644 index 00000000..450c0907 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/LibraryInfoAlterHookTest.php @@ -0,0 +1,227 @@ +editorLibrary(); + + // Act. + $this->hook()->alter($libraries, 'ckeditor5'); + + // Assert. + $this->assertSame([ + '/' . static::SUB_THEME_PATH . '/dist/styles.editor.css', + '/' . static::SUB_THEME_PATH . '/dist/styles.variables.css', + ], array_keys($libraries[static::EDITOR_LIBRARY]['css']['theme'])); + } + + /** + * Tests that the options of the stylesheets that remain are kept. + */ + public function testRemainingStylesheetsKeepTheirOptions(): void { + // Prepare. + $libraries = $this->editorLibrary(); + $libraries[static::EDITOR_LIBRARY]['css']['theme']['/' . static::SUB_THEME_PATH . '/dist/styles.editor.css'] = ['weight' => 10]; + + // Act. + $this->hook()->alter($libraries, 'ckeditor5'); + + // Assert. + $this->assertSame(['weight' => 10], $libraries[static::EDITOR_LIBRARY]['css']['theme']['/' . static::SUB_THEME_PATH . '/dist/styles.editor.css']); + } + + /** + * Tests that a stylesheet outside the base theme's own files is kept. + * + * @param string $path + * Path the stylesheet is keyed by. + */ + #[DataProvider('dataProviderStylesheetOutsideTheBaseThemeIsKept')] + public function testStylesheetOutsideTheBaseThemeIsKept(string $path): void { + // Prepare. + $libraries = $this->editorLibrary(); + $libraries[static::EDITOR_LIBRARY]['css']['theme'][$path] = []; + + // Act. + $this->hook()->alter($libraries, 'ckeditor5'); + + // Assert. + $this->assertArrayHasKey($path, $libraries[static::EDITOR_LIBRARY]['css']['theme']); + } + + /** + * Data provider for testStylesheetOutsideTheBaseThemeIsKept. + */ + public static function dataProviderStylesheetOutsideTheBaseThemeIsKept(): \Iterator { + yield 'a generated file' => ['/sites/default/files/css-variables.civictheme.css']; + yield 'an external stylesheet' => ['https://example.com/' . self::BASE_THEME_PATH . '/editor.css']; + yield 'a theme whose name starts the same' => ['/' . self::BASE_THEME_PATH . '_subtheme/dist/styles.editor.css']; + } + + /** + * Tests that the stylesheets are left alone without the base theme. + * + * A site that drops the base theme has no build of its own to fall back on. + */ + public function testStylesheetsAreLeftAloneWithoutTheBaseTheme(): void { + // Prepare. + $libraries = $this->editorLibrary(); + $expected = $libraries; + + // Act. + $this->hook(base_theme_exists: FALSE)->alter($libraries, 'ckeditor5'); + + // Assert. + $this->assertSame($expected, $libraries); + } + + /** + * Tests that a library list holding no editor stylesheets is left alone. + * + * @param array $libraries + * The library definitions as the extension declares them. + */ + #[DataProvider('dataProviderLibrariesWithoutEditorStylesheetsAreLeftAlone')] + public function testLibrariesWithoutEditorStylesheetsAreLeftAlone(array $libraries): void { + // Prepare. + $expected = $libraries; + + // Act. + $this->hook()->alter($libraries, 'ckeditor5'); + + // Assert. + $this->assertSame($expected, $libraries); + } + + /** + * Data provider for testLibrariesWithoutEditorStylesheetsAreLeftAlone. + */ + public static function dataProviderLibrariesWithoutEditorStylesheetsAreLeftAlone(): \Iterator { + yield 'no libraries at all' => [[]]; + yield 'no editor library' => [['ckeditor5' => ['js' => ['ckeditor5.js' => []]]]]; + yield 'editor library without stylesheets' => [[self::EDITOR_LIBRARY => []]]; + yield 'editor library with an empty stylesheet list' => [[self::EDITOR_LIBRARY => ['css' => ['theme' => []]]]]; + } + + /** + * Tests that the stylesheets of another extension are left alone. + */ + public function testStylesheetsOfAnotherExtensionAreLeftAlone(): void { + // Prepare. + $libraries = $this->editorLibrary(); + $expected = $libraries; + + // Act. + $this->hook()->alter($libraries, 'civictheme'); + + // Assert. + $this->assertSame($expected, $libraries); + } + + /** + * Tests that Gherkin support is added to the syntax highlighter. + */ + public function testGherkinIsAddedToTheSyntaxHighlighter(): void { + // Prepare. + $libraries = ['highlight_js.custom' => ['dependencies' => ['highlight_js/highlight_js']]]; + + // Act. + $this->hook()->alter($libraries, 'highlight_js'); + + // Assert. + $this->assertSame([ + 'highlight_js/highlight_js', + 'do_base/highlight_js.gherkin', + ], $libraries['highlight_js.custom']['dependencies']); + } + + /** + * Tests that a syntax highlighter without the custom bundle is left alone. + */ + public function testSyntaxHighlighterWithoutTheCustomBundleIsLeftAlone(): void { + // Prepare. + $libraries = ['highlight_js.other' => ['dependencies' => []]]; + $expected = $libraries; + + // Act. + $this->hook()->alter($libraries, 'highlight_js'); + + // Assert. + $this->assertSame($expected, $libraries); + } + + /** + * Builds the hook under test. + * + * @param bool $base_theme_exists + * Whether the base theme is installed. + */ + protected function hook(bool $base_theme_exists = TRUE): LibraryInfoAlterHook { + $themes = $this->createMock(ThemeExtensionList::class); + $themes->method('exists')->willReturn($base_theme_exists); + $themes->method('getPath')->willReturn(static::BASE_THEME_PATH); + + return new LibraryInfoAlterHook($themes); + } + + /** + * Builds the editor stylesheet library as CKEditor 5 assembles it. + * + * The base theme's stylesheets come first, keyed from the docroot with a + * leading slash, in the order Ckeditor5Hooks::themeCss() merges them. + * + * @return array>>> + * The library definitions of the ckeditor5 extension. + */ + protected function editorLibrary(): array { + return [ + static::EDITOR_LIBRARY => [ + 'css' => [ + 'theme' => [ + '/' . static::BASE_THEME_PATH . '/dist/civictheme.editor.css' => [], + '/' . static::BASE_THEME_PATH . '/dist/civictheme.variables.css' => [], + '/' . static::SUB_THEME_PATH . '/dist/styles.editor.css' => [], + '/' . static::SUB_THEME_PATH . '/dist/styles.variables.css' => [], + ], + ], + ], + ]; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/NavigationScriptHashTest.php b/web/modules/custom/do_base/tests/src/Unit/NavigationScriptHashTest.php new file mode 100644 index 00000000..fa8da9c2 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/NavigationScriptHashTest.php @@ -0,0 +1,239 @@ + + */ + protected array $written = []; + + /** + * Number of times the module path was resolved. + */ + protected int $lookups = 0; + + /** + * Tests that the hash of the template's inline script is returned. + */ + public function testHashIsDerivedFromTheTemplate(): void { + // Prepare. + $service = $this->service('nav_one_script'); + + // Act. + $hashes = $service->getHashes(); + + // Assert. + $this->assertSame([static::FIXTURE_HASH], $hashes); + $this->assertSame([], $this->warnings); + } + + /** + * Tests that a template holding several scripts yields a hash for each. + */ + public function testEveryInlineScriptIsHashed(): void { + // Prepare. + $service = $this->service('nav_two_scripts'); + + // Act. + $hashes = $service->getHashes(); + + // Assert. + $this->assertSame([ + static::FIXTURE_HASH, + 'sha256-HnXEzlQbnSQv+1FZ26Ok9doVoToGeqg+vJ5anDdB8FQ=', + ], $hashes); + $this->assertSame([], $this->warnings); + } + + /** + * Tests that a template this cannot read is reported and yields nothing. + * + * @param string $fixture + * Directory the template is looked for in. + * @param string $expected + * Text the logged warning is expected to carry. + */ + #[DataProvider('dataProviderUnusableTemplateIsReported')] + public function testUnusableTemplateIsReported(string $fixture, string $expected): void { + // Prepare. + $service = $this->service($fixture); + + // Act. + $hashes = $service->getHashes(); + + // Assert. + $this->assertSame([], $hashes); + $this->assertCount(1, $this->warnings); + $this->assertStringContainsString($expected, $this->warnings[0]); + } + + /** + * Data provider for testUnusableTemplateIsReported. + */ + public static function dataProviderUnusableTemplateIsReported(): \Iterator { + yield 'the template is not there' => ['nav_absent', 'is not readable']; + yield 'the template holds no script' => ['nav_no_script', 'holds no inline script']; + yield 'the script is built by Twig' => ['nav_twig_script', 'carries Twig syntax']; + } + + /** + * Tests that a site without the toolbar module is not reported. + * + * Nothing renders the script there, so there is nothing to allow and nothing + * worth telling an administrator about. + */ + public function testAbsentModuleIsNotReported(): void { + // Prepare. + $service = $this->service('nav_one_script', module_exists: FALSE); + + // Act. + $hashes = $service->getHashes(); + + // Assert. + $this->assertSame([], $hashes); + $this->assertSame([], $this->warnings); + } + + /** + * Tests that derived hashes are handed to the cache. + */ + public function testDerivedHashesAreCached(): void { + // Prepare. + $service = $this->service('nav_one_script'); + + // Act. + $service->getHashes(); + + // Assert. + $this->assertSame(['do_base:navigation_script_hashes' => [static::FIXTURE_HASH]], $this->written); + } + + /** + * Tests that a template this cannot read is cached as well. + * + * Without it, an unreadable template would log on every request. + */ + public function testUnusableTemplateIsCached(): void { + // Prepare. + $service = $this->service('nav_absent'); + + // Act. + $service->getHashes(); + + // Assert. + $this->assertSame(['do_base:navigation_script_hashes' => []], $this->written); + } + + /** + * Tests that cached hashes are used without reading the template again. + */ + public function testCachedHashesAreUsed(): void { + // Prepare. + $service = $this->service('nav_one_script', cached: ['sha256-cached']); + + // Act. + $hashes = $service->getHashes(); + + // Assert. + $this->assertSame(['sha256-cached'], $hashes); + $this->assertSame(0, $this->lookups, 'The template is not expected to be located when the cache holds hashes.'); + $this->assertSame([], $this->written); + } + + /** + * Tests that a cache entry holding something else is passed over. + */ + public function testUnusableCacheEntryIsPassedOver(): void { + // Prepare. + $service = $this->service('nav_one_script', cached: 'not an array'); + + // Act. + $hashes = $service->getHashes(); + + // Assert. + $this->assertSame([static::FIXTURE_HASH], $hashes); + } + + /** + * Tests that the template is read once however often hashes are asked for. + */ + public function testTemplateIsReadOncePerRequest(): void { + // Prepare. + $service = $this->service('nav_one_script'); + + // Act. + $service->getHashes(); + $service->getHashes(); + $service->getHashes(); + + // Assert. + $this->assertSame(1, $this->lookups); + } + + /** + * Builds the service under test. + * + * @param string $fixture + * Directory under tests/fixtures standing in for the module directory. + * @param bool $module_exists + * Whether the module rendering the toolbar is installed. + * @param mixed $cached + * Data a cache hit returns, or NULL for a cache miss. + */ + protected function service(string $fixture, bool $module_exists = TRUE, mixed $cached = NULL): NavigationScriptHash { + $modules = $this->createMock(ModuleExtensionList::class); + $modules->method('exists')->willReturn($module_exists); + $modules->method('getPath')->willReturnCallback(function () use ($fixture): string { + $this->lookups++; + + return $fixture; + }); + + $cache = $this->createMock(CacheBackendInterface::class); + $cache->method('get')->willReturn($cached === NULL ? FALSE : (object) ['data' => $cached]); + $cache->method('set')->willReturnCallback(function (string $cid, mixed $data): void { + $this->written[$cid] = $data; + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->method('warning')->willReturnCallback(function (string $message): void { + $this->warnings[] = $message; + }); + + return new NavigationScriptHash($modules, $cache, $logger, __DIR__ . '/../../fixtures'); + } + +}