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');
+ }
+
+}