From b3c3ce896aa4769fb23e4a2490229a976c3af3d9 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Thu, 13 Aug 2026 12:52:28 +1000 Subject: [PATCH 1/5] [#311] Stopped loading the base theme's stylesheets into CKEditor 5. --- .../do_base/src/Hook/LibraryInfoAlterHook.php | 52 +++- .../src/Unit/LibraryInfoAlterHookTest.php | 227 ++++++++++++++++++ 2 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 web/modules/custom/do_base/tests/src/Unit/LibraryInfoAlterHookTest.php diff --git a/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php b/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php index 98736981..067af5db 100644 --- a/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php +++ b/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php @@ -4,24 +4,68 @@ namespace Drupal\do_base\Hook; +use Drupal\Core\Extension\ThemeExtensionList; use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Hook\Order\OrderAfter; /** * Library info alter hooks for do_base module. */ final class LibraryInfoAlterHook { + /** + * Library holding the stylesheets CKEditor 5 loads for the editing area. + */ + protected const string EDITOR_STYLESHEETS = 'internal.drupal.ckeditor5.stylesheets'; + + /** + * Theme whose editor stylesheets the sub-theme rebuilds. + */ + protected const string BASE_THEME = 'civictheme'; + + public function __construct( + protected readonly ThemeExtensionList $themeList, + ) { + } + /** * Implements hook_library_info_alter(). - * - * Attaches Gherkin language support whenever Highlight.js is loaded. - * The CDN common bundle does not include Gherkin, so we load it separately. */ - #[Hook('library_info_alter')] + #[Hook('library_info_alter', order: new OrderAfter(modules: ['ckeditor5']))] public function alter(array &$libraries, string $extension): void { if ($extension === 'highlight_js' && isset($libraries['highlight_js.custom'])) { + // The CDN common bundle carries no Gherkin grammar, so it is loaded + // separately. $libraries['highlight_js.custom']['dependencies'][] = 'do_base/highlight_js.gherkin'; } + + if ($extension === 'ckeditor5' && isset($libraries[static::EDITOR_STYLESHEETS]['css']['theme'])) { + // The base theme's stylesheets are merged into this list and .info.yml + // cannot override them. Its build imports Google Fonts, which the policy + // blocks, and a blocked @import fails the whole stylesheet. + $libraries[static::EDITOR_STYLESHEETS]['css']['theme'] = $this->withoutBaseTheme($libraries[static::EDITOR_STYLESHEETS]['css']['theme']); + } + } + + /** + * Filters out stylesheets that belong to the base theme. + * + * @param array> $stylesheets + * Stylesheets keyed by path. A theme's own files are keyed from the + * docroot, with a leading slash; the key can also be an external URL or a + * path into the files directory. + * + * @return array> + * The stylesheets that are not files of the base theme. + */ + protected function withoutBaseTheme(array $stylesheets): array { + if (!$this->themeList->exists(static::BASE_THEME)) { + return $stylesheets; + } + + $base_theme_path = $this->themeList->getPath(static::BASE_THEME) . '/'; + + return array_filter($stylesheets, static fn(string $path): bool => !str_starts_with(ltrim($path, '/'), $base_theme_path), ARRAY_FILTER_USE_KEY); } } 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' => [], + ], + ], + ], + ]; + } + +} From 590ee5b03fa6b6804950bb24a06ddd5bec00208a Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Thu, 13 Aug 2026 12:52:38 +1000 Subject: [PATCH 2/5] [#311] Allowed the toolbar's inline script and limited the banner preload. --- web/modules/custom/do_base/do_base.module | 64 +++- .../tests/src/Kernel/PageAttachmentsTest.php | 361 ++++++++++++++++++ 2 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php diff --git a/web/modules/custom/do_base/do_base.module b/web/modules/custom/do_base/do_base.module index 91a9506b..8bbabb78 100644 --- a/web/modules/custom/do_base/do_base.module +++ b/web/modules/custom/do_base/do_base.module @@ -14,6 +14,22 @@ use Drupal\file\FileInterface; use Drupal\image\Entity\ImageStyle; use Drupal\media\MediaInterface; +/** + * Routes that render a node's banner. + */ +const DO_BASE_BANNER_ROUTES = [ + 'entity.node.canonical', + 'entity.node.revision', + 'entity.node.latest_version', +]; + +/** + * Hash of the inline script rendered by core's navigation toolbar. + * + * @see core/modules/navigation/layouts/navigation.html.twig + */ +const DO_BASE_NAVIGATION_SCRIPT_HASH = 'sha256-CaN42Zi+a+oATitdYvGRVlyS6mCZIxrLFXhTbgp6HCI='; + /** * Implements hook_mail_alter(). */ @@ -31,8 +47,12 @@ function do_base_mail_alter(array &$message): void { */ function do_base_page_attachments(array &$attachments): void { _do_base_attach_preview_link_robots($attachments); - _do_base_attach_csp_nonce($attachments); _do_base_attach_banner_preload($attachments); + + if (class_exists(Csp::class)) { + _do_base_attach_csp_nonce($attachments); + _do_base_attach_csp_script_hashes($attachments); + } } /** @@ -42,6 +62,10 @@ function do_base_page_attachments(array &$attachments): void { * file until the stylesheet has been fetched and parsed. */ function _do_base_attach_banner_preload(array &$attachments): void { + if (!_do_base_route_renders_banner()) { + return; + } + $url = _do_base_banner_background_url(); if ($url === NULL) { @@ -98,6 +122,30 @@ function _do_base_banner_background_url(): ?string { return $style->buildUrl($file->getFileUri()); } +/** + * Checks whether the current route renders a node's banner. + * + * Every other route carrying a node parameter - the forms, the delete + * confirmation, the revision list - resolves the same background without ever + * drawing it. + * + * @return bool + * TRUE when the route renders the node page itself. + */ +function _do_base_route_renders_banner(): bool { + $route_match = \Drupal::routeMatch(); + + if (in_array($route_match->getRouteName(), DO_BASE_BANNER_ROUTES, TRUE)) { + return TRUE; + } + + $route = $route_match->getRouteObject(); + + // A preview link route is named after the entity type it was built for, so + // the option it carries is the only stable way to recognise one. + return $route !== NULL && $route->getOption('_preview_link_route') === TRUE; +} + /** * Keeps preview link pages out of search indexes. */ @@ -127,10 +175,6 @@ function _do_base_attach_preview_link_robots(array &$attachments): void { * Attaches a CSP nonce so core's inline scripts survive a strict policy. */ function _do_base_attach_csp_nonce(array &$attachments): void { - if (!class_exists(Csp::class)) { - return; - } - // The 'unsafe-inline' fallback is only used by browsers without CSP3 nonce // support; modern browsers ignore it once a nonce is present. $existing = $attachments['#attached']['csp_nonce']['script'] ?? []; @@ -142,6 +186,16 @@ function _do_base_attach_csp_nonce(array &$attachments): void { } } +/** + * Attaches hashes for the inline scripts that carry no nonce. + */ +function _do_base_attach_csp_script_hashes(array &$attachments): void { + // Core's navigation toolbar sets the sidebar state before the first paint and + // renders that script with no nonce, so a hash is the only source under which + // it still runs. + $attachments['#attached']['csp_hash']['script-src-elem'][DO_BASE_NAVIGATION_SCRIPT_HASH] = [Csp::POLICY_UNSAFE_INLINE]; +} + /** * Implements hook_xmlsitemap_link_alter(). */ 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..564e6844 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php @@ -0,0 +1,361 @@ +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 the navigation toolbar's inline script is allowed by hash. + */ + public function testNavigationScriptIsAllowedByHash(): void { + // Prepare. + $this->setRoute('entity.node.canonical'); + + // Act. + $attachments = $this->attach(); + + // Assert. + $this->assertSame( + [DO_BASE_NAVIGATION_SCRIPT_HASH => [Csp::POLICY_UNSAFE_INLINE]], + $attachments['#attached']['csp_hash']['script-src-elem'], + ); + } + + /** + * Tests that the hash still matches the script core renders. + * + * A hash covers the exact bytes of the script, so a core release that edits + * it fails here. Recompute the constant from the template named beside it. + */ + public function testNavigationScriptHashMatchesTheTemplate(): void { + // Prepare. + $template = file_get_contents($this->root . '/core/modules/navigation/layouts/navigation.html.twig'); + + // Act. + $found = preg_match('##s', (string) $template, $matches); + + // Assert. + $this->assertSame(1, $found, 'The navigation layout is expected to render one inline script.'); + $this->assertSame(DO_BASE_NAVIGATION_SCRIPT_HASH, 'sha256-' . base64_encode(hash('sha256', $matches[1], TRUE))); + } + + /** + * 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 testPageWithoutABackgroundPreloadsNothing(): 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. + * + * @return array> + * The attachments the hook has added to. + */ + protected function attach(): array { + $attachments = []; + do_base_page_attachments($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(); + } + +} From a9bf14b58a0ba9af2c2db45242a190c43931fda2 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Thu, 13 Aug 2026 13:08:05 +1000 Subject: [PATCH 3/5] [#311] Documented the editor font imports and the preload's route gate. --- docs/performance.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index 6a381dd3..decbe48e 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -52,6 +52,10 @@ Checking the markup is not enough to catch this, because the markup is correct. Lexend and Rubik ship in `assets/fonts/` and are declared in `components/00-base/fonts/fonts.scss`. Nothing is fetched from `fonts.googleapis.com`, which is why the CSP no longer allows it. +The editing area needs its own arrangement to hold that line. CKEditor 5 merges the base theme's `ckeditor5-stylesheets` into this theme's list, `.info.yml` has no override for that key, and CivicTheme's build of the editor stylesheet opens with two `@import` statements pointing at Google Fonts. `LibraryInfoAlterHook` drops the base theme's files from that list; `dist/styles.editor.css` is this theme's build of the same partials, with the self-hosted faces in place of the imports, and contributes every selector the base theme's copy did. + +Leaving those imports in place cost more than a blocked request. A stylesheet whose `@import` is blocked fires `error` rather than `load`, so an Ajax response that attached the editor stylesheets reported the aggregate as unloadable and abandoned the commands queued behind it. + They are declared by hand rather than through CivicTheme's `$ct-fonts` map, because the generator that map feeds emits one `@font-face` per weight and supports neither the variable weight ranges these faces ship as nor the `unicode-range` and `font-display` descriptors a self-hosted face needs. The map still names the families, with an empty `types` list so it emits nothing. Only the Latin subsets are preloaded, by `_drevops_attach_font_preloads()`. The extended subsets are needed by a small minority of pages, and a preload the page does not use is a wasted request. @@ -64,6 +68,8 @@ The banner paints its background from CSS, so the browser cannot discover the fi `_do_base_attach_banner_preload()` emits a `rel="preload"` for it. The URL has to be the one the stylesheet asks for, or the file is fetched twice: the preload resolves the same image style, and skips the whole thing when the file has no derivative. +It runs only on the routes listed in `DO_BASE_BANNER_ROUTES` and on a preview link, which are the pages that draw a banner. The edit form, the delete confirmation and the revision list all carry a node parameter and resolve the same background without ever rendering it, and a preload the page does not use is a wasted request for a full-width derivative. + ## Verifying a change ```bash From 1b1f2c1fa1a36a627ebabf1b4b0b28f131ef62ad Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Thu, 13 Aug 2026 13:29:15 +1000 Subject: [PATCH 4/5] [#311] Applied code style and replaced the module constants with locals. --- docs/performance.md | 2 +- web/modules/custom/do_base/do_base.module | 31 +++++++------------ .../do_base/src/Hook/LibraryInfoAlterHook.php | 16 +++++----- .../tests/src/Kernel/PageAttachmentsTest.php | 25 ++++++++------- 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index decbe48e..d9e2cbab 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -68,7 +68,7 @@ The banner paints its background from CSS, so the browser cannot discover the fi `_do_base_attach_banner_preload()` emits a `rel="preload"` for it. The URL has to be the one the stylesheet asks for, or the file is fetched twice: the preload resolves the same image style, and skips the whole thing when the file has no derivative. -It runs only on the routes listed in `DO_BASE_BANNER_ROUTES` and on a preview link, which are the pages that draw a banner. The edit form, the delete confirmation and the revision list all carry a node parameter and resolve the same background without ever rendering it, and a preload the page does not use is a wasted request for a full-width derivative. +It runs only on the routes `_do_base_route_renders_banner()` accepts, which are the pages that draw a banner: the canonical route, a revision, the latest version, and a preview link. The edit form, the delete confirmation and the revision list all carry a node parameter and resolve the same background without ever rendering it, and a preload the page does not use is a wasted request for a full-width derivative. ## Verifying a change diff --git a/web/modules/custom/do_base/do_base.module b/web/modules/custom/do_base/do_base.module index 8bbabb78..f9f00576 100644 --- a/web/modules/custom/do_base/do_base.module +++ b/web/modules/custom/do_base/do_base.module @@ -14,22 +14,6 @@ use Drupal\file\FileInterface; use Drupal\image\Entity\ImageStyle; use Drupal\media\MediaInterface; -/** - * Routes that render a node's banner. - */ -const DO_BASE_BANNER_ROUTES = [ - 'entity.node.canonical', - 'entity.node.revision', - 'entity.node.latest_version', -]; - -/** - * Hash of the inline script rendered by core's navigation toolbar. - * - * @see core/modules/navigation/layouts/navigation.html.twig - */ -const DO_BASE_NAVIGATION_SCRIPT_HASH = 'sha256-CaN42Zi+a+oATitdYvGRVlyS6mCZIxrLFXhTbgp6HCI='; - /** * Implements hook_mail_alter(). */ @@ -133,9 +117,15 @@ function _do_base_banner_background_url(): ?string { * TRUE when the route renders the node page itself. */ function _do_base_route_renders_banner(): bool { + $routes = [ + 'entity.node.canonical', + 'entity.node.revision', + 'entity.node.latest_version', + ]; + $route_match = \Drupal::routeMatch(); - if (in_array($route_match->getRouteName(), DO_BASE_BANNER_ROUTES, TRUE)) { + if (in_array($route_match->getRouteName(), $routes, TRUE)) { return TRUE; } @@ -192,8 +182,11 @@ function _do_base_attach_csp_nonce(array &$attachments): void { function _do_base_attach_csp_script_hashes(array &$attachments): void { // Core's navigation toolbar sets the sidebar state before the first paint and // renders that script with no nonce, so a hash is the only source under which - // it still runs. - $attachments['#attached']['csp_hash']['script-src-elem'][DO_BASE_NAVIGATION_SCRIPT_HASH] = [Csp::POLICY_UNSAFE_INLINE]; + // it still runs. PageAttachmentsTest recomputes this one from the template. + // @see core/modules/navigation/layouts/navigation.html.twig + $hash = 'sha256-CaN42Zi+a+oATitdYvGRVlyS6mCZIxrLFXhTbgp6HCI='; + + $attachments['#attached']['csp_hash']['script-src-elem'][$hash] = [Csp::POLICY_UNSAFE_INLINE]; } /** diff --git a/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php b/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php index 067af5db..cd1dd2b5 100644 --- a/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php +++ b/web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php @@ -11,20 +11,20 @@ /** * Library info alter hooks for do_base module. */ -final class LibraryInfoAlterHook { +final readonly class LibraryInfoAlterHook { /** * Library holding the stylesheets CKEditor 5 loads for the editing area. */ - protected const string EDITOR_STYLESHEETS = 'internal.drupal.ckeditor5.stylesheets'; + private const string EDITOR_STYLESHEETS = 'internal.drupal.ckeditor5.stylesheets'; /** * Theme whose editor stylesheets the sub-theme rebuilds. */ - protected const string BASE_THEME = 'civictheme'; + private const string BASE_THEME = 'civictheme'; public function __construct( - protected readonly ThemeExtensionList $themeList, + protected ThemeExtensionList $themeExtensionList, ) { } @@ -39,11 +39,11 @@ public function alter(array &$libraries, string $extension): void { $libraries['highlight_js.custom']['dependencies'][] = 'do_base/highlight_js.gherkin'; } - if ($extension === 'ckeditor5' && isset($libraries[static::EDITOR_STYLESHEETS]['css']['theme'])) { + if ($extension === 'ckeditor5' && isset($libraries[self::EDITOR_STYLESHEETS]['css']['theme'])) { // The base theme's stylesheets are merged into this list and .info.yml // cannot override them. Its build imports Google Fonts, which the policy // blocks, and a blocked @import fails the whole stylesheet. - $libraries[static::EDITOR_STYLESHEETS]['css']['theme'] = $this->withoutBaseTheme($libraries[static::EDITOR_STYLESHEETS]['css']['theme']); + $libraries[self::EDITOR_STYLESHEETS]['css']['theme'] = $this->withoutBaseTheme($libraries[self::EDITOR_STYLESHEETS]['css']['theme']); } } @@ -59,11 +59,11 @@ public function alter(array &$libraries, string $extension): void { * The stylesheets that are not files of the base theme. */ protected function withoutBaseTheme(array $stylesheets): array { - if (!$this->themeList->exists(static::BASE_THEME)) { + if (!$this->themeExtensionList->exists(self::BASE_THEME)) { return $stylesheets; } - $base_theme_path = $this->themeList->getPath(static::BASE_THEME) . '/'; + $base_theme_path = $this->themeExtensionList->getPath(self::BASE_THEME) . '/'; return array_filter($stylesheets, static fn(string $path): bool => !str_starts_with(ltrim($path, '/'), $base_theme_path), ARRAY_FILTER_USE_KEY); } diff --git a/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php b/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php index 564e6844..0259bc58 100644 --- a/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php +++ b/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php @@ -97,9 +97,9 @@ public function testNonceIsAskedFor(): void { } /** - * Tests that the navigation toolbar's inline script is allowed by hash. + * Tests that exactly one script hash is allowed, with a fallback source. */ - public function testNavigationScriptIsAllowedByHash(): void { + public function testInlineScriptIsAllowedByHash(): void { // Prepare. $this->setRoute('entity.node.canonical'); @@ -107,28 +107,31 @@ public function testNavigationScriptIsAllowedByHash(): void { $attachments = $this->attach(); // Assert. - $this->assertSame( - [DO_BASE_NAVIGATION_SCRIPT_HASH => [Csp::POLICY_UNSAFE_INLINE]], - $attachments['#attached']['csp_hash']['script-src-elem'], - ); + $this->assertSame([[Csp::POLICY_UNSAFE_INLINE]], array_values($attachments['#attached']['csp_hash']['script-src-elem'])); } /** - * Tests that the hash still matches the script core renders. + * Tests that the hash allowed is the one for the script core renders. * * A hash covers the exact bytes of the script, so a core release that edits - * it fails here. Recompute the constant from the template named beside it. + * it fails here. Recompute the value in _do_base_attach_csp_script_hashes() + * from the template this reads. */ - public function testNavigationScriptHashMatchesTheTemplate(): void { + 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(DO_BASE_NAVIGATION_SCRIPT_HASH, 'sha256-' . base64_encode(hash('sha256', $matches[1], TRUE))); + $this->assertSame( + 'sha256-' . base64_encode(hash('sha256', $matches[1], TRUE)), + array_key_first($attachments['#attached']['csp_hash']['script-src-elem']), + ); } /** @@ -204,7 +207,7 @@ public static function dataProviderBannerBackgroundIsNotPreloaded(): \Iterator { /** * Tests that a page with no background of its own preloads nothing. */ - public function testPageWithoutABackgroundPreloadsNothing(): void { + public function testPageWithoutBackgroundPreloadsNothing(): void { // Prepare. $this->setRoute('entity.node.canonical', ['node' => $this->createPage()]); From 86b6f19e89e93b651faecb99de7874858dfbbd22 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Thu, 13 Aug 2026 14:25:59 +1000 Subject: [PATCH 5/5] [#311] Derived the toolbar script hash at runtime and moved the hook to a class. --- AGENTS.md | 1 + docs/csp.md | 39 +++ docs/performance.md | 4 +- web/modules/custom/do_base/do_base.module | 168 ------------ .../custom/do_base/do_base.services.yml | 5 + .../do_base/src/Hook/PageAttachmentsHook.php | 196 ++++++++++++++ .../do_base/src/NavigationScriptHash.php | 123 +++++++++ .../layouts/navigation.html.twig | 3 + .../layouts/navigation.html.twig | 4 + .../layouts/navigation.html.twig | 4 + .../layouts/navigation.html.twig | 7 + .../tests/src/Kernel/PageAttachmentsTest.php | 46 +++- .../src/Unit/NavigationScriptHashTest.php | 239 ++++++++++++++++++ 13 files changed, 665 insertions(+), 174 deletions(-) create mode 100644 docs/csp.md create mode 100644 web/modules/custom/do_base/src/Hook/PageAttachmentsHook.php create mode 100644 web/modules/custom/do_base/src/NavigationScriptHash.php create mode 100644 web/modules/custom/do_base/tests/fixtures/nav_no_script/layouts/navigation.html.twig create mode 100644 web/modules/custom/do_base/tests/fixtures/nav_one_script/layouts/navigation.html.twig create mode 100644 web/modules/custom/do_base/tests/fixtures/nav_twig_script/layouts/navigation.html.twig create mode 100644 web/modules/custom/do_base/tests/fixtures/nav_two_scripts/layouts/navigation.html.twig create mode 100644 web/modules/custom/do_base/tests/src/Unit/NavigationScriptHashTest.php 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 index 0259bc58..17d03882 100644 --- a/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php +++ b/web/modules/custom/do_base/tests/src/Kernel/PageAttachmentsTest.php @@ -4,8 +4,11 @@ namespace Drupal\Tests\do_base\Kernel; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Routing\RouteObjectInterface; use Drupal\csp\Csp; +use Drupal\do_base\Hook\PageAttachmentsHook; +use Drupal\do_base\NavigationScriptHash; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\file\Entity\File; @@ -113,9 +116,9 @@ public function testInlineScriptIsAllowedByHash(): void { /** * Tests that the hash allowed is the one for the script core renders. * - * A hash covers the exact bytes of the script, so a core release that edits - * it fails here. Recompute the value in _do_base_attach_csp_script_hashes() - * from the template this reads. + * 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. @@ -134,6 +137,32 @@ public function testAllowedHashMatchesTheTemplate(): void { ); } + /** + * 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. * @@ -221,12 +250,21 @@ public function testPageWithoutBackgroundPreloadsNothing(): void { /** * 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 = []; - do_base_page_attachments($attachments); + $hook->attach($attachments); return $attachments; } 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'); + } + +}