From a1d33a532aae4233ba9efda93810c1ac010e1add Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:27:46 +1000 Subject: [PATCH 01/11] Added 'preview_link' module to share unpublished content by link. --- composer.json | 1 + composer.lock | 76 ++++++++++++++++++- config/default/core.extension.yml | 1 + .../entity_clone.cloneable_entities.yml | 1 + config/default/preview_link.settings.yml | 7 ++ .../user.role.civictheme_content_approver.yml | 2 + .../user.role.civictheme_content_author.yml | 2 + ...ser.role.civictheme_site_administrator.yml | 3 + 8 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 config/default/preview_link.settings.yml diff --git a/composer.json b/composer.json index 6530465c..a6f96561 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "drupal/moderated_content_bulk_publish": "^2.0", "drupal/navigation_extra_tools": "^1.3.2", "drupal/pathauto": "^1.15", + "drupal/preview_link": "^2.2.1", "drupal/purge": "^3.7", "drupal/purge_control": "^2.1", "drupal/recaptcha_v3": "^2.0.5", diff --git a/composer.lock b/composer.lock index c9d2fce1..74df1691 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "572debe49aeb0ec27544ffda1c9ff193", + "content-hash": "7a6d90396c70152e71e7d425ce2f9a56", "packages": [ { "name": "asm89/stack-cors", @@ -4804,6 +4804,80 @@ "documentation": "https://www.drupal.org/docs/8/modules/pathauto" } }, + { + "name": "drupal/preview_link", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://git.drupalcode.org/project/preview_link.git", + "reference": "2.2.1" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/preview_link-2.2.1.zip", + "reference": "2.2.1", + "shasum": "a54a66a0fbca9dc4d8eba80174ce0a041c48dda8" + }, + "require": { + "drupal/core": "^10.2 || ^11", + "drupal/dynamic_entity_reference": "^3 || ^4", + "php": ">=8.1" + }, + "require-dev": { + "drupal/entity_reference_revisions": "*", + "drupal/paragraphs": "^1" + }, + "type": "drupal-module", + "extra": { + "drupal": { + "version": "2.2.1", + "datestamp": "1776652947", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "acbramley", + "homepage": "https://www.drupal.org/user/1036766" + }, + { + "name": "benjy", + "homepage": "https://www.drupal.org/user/1852732" + }, + { + "name": "dpi", + "homepage": "https://www.drupal.org/user/81431" + }, + { + "name": "larowlan", + "homepage": "https://www.drupal.org/user/395439" + }, + { + "name": "mstrelan", + "homepage": "https://www.drupal.org/user/314289" + }, + { + "name": "sam152", + "homepage": "https://www.drupal.org/user/1485048" + } + ], + "description": "Allows anyone to preview unpublished content with a unique link.", + "homepage": "https://www.drupal.org/project/preview_link", + "keywords": [ + "Drupal" + ], + "support": { + "source": "https://git.drupalcode.org/project/preview_link", + "issues": "https://www.drupal.org/project/issues/preview_link" + } + }, { "name": "drupal/purge", "version": "3.7.0", diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index 02d7a62c..ddab57cf 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -84,6 +84,7 @@ module: paragraphs_library: 0 path: 0 path_alias: 0 + preview_link: 0 purge: 0 purge_drush: 0 purge_processor_cron: 0 diff --git a/config/default/entity_clone.cloneable_entities.yml b/config/default/entity_clone.cloneable_entities.yml index d77be822..3438f184 100644 --- a/config/default/entity_clone.cloneable_entities.yml +++ b/config/default/entity_clone.cloneable_entities.yml @@ -64,3 +64,4 @@ cloneable_entities: - ai_prompt_type - ai_prompt - xmlsitemap + - preview_link diff --git a/config/default/preview_link.settings.yml b/config/default/preview_link.settings.yml new file mode 100644 index 00000000..20d03660 --- /dev/null +++ b/config/default/preview_link.settings.yml @@ -0,0 +1,7 @@ +_core: + default_config_hash: zsRG48Wp1H1fGnUoDCdsnWJMnrrwtZPwGLvGYuvKCNU +display_message: subsequent +enabled_entity_types: + node: { } +multiple_entities: true +expiry_seconds: 604800 diff --git a/config/default/user.role.civictheme_content_approver.yml b/config/default/user.role.civictheme_content_approver.yml index f59f2b23..7559ee53 100644 --- a/config/default/user.role.civictheme_content_approver.yml +++ b/config/default/user.role.civictheme_content_approver.yml @@ -16,6 +16,7 @@ dependencies: - moderated_content_bulk_publish - navigation - node + - preview_link - scheduled_transitions - system _core: @@ -33,6 +34,7 @@ permissions: - 'administer linkit profiles' - 'administer scheduled transitions' - 'generate ai alt tags' + - 'generate preview links' - 'moderated content bulk archive' - 'moderated content bulk publish' - 'moderated content bulk unpublish' diff --git a/config/default/user.role.civictheme_content_author.yml b/config/default/user.role.civictheme_content_author.yml index b0365d69..b23f6080 100644 --- a/config/default/user.role.civictheme_content_author.yml +++ b/config/default/user.role.civictheme_content_author.yml @@ -29,6 +29,7 @@ dependencies: - navigation - node - path + - preview_link - scheduled_transitions - system - taxonomy @@ -119,6 +120,7 @@ permissions: - 'edit own project content' - 'edit terms in civictheme_media_tags' - 'generate ai alt tags' + - 'generate preview links' - 'revert all revisions' - 'revert blog revisions' - 'revert civictheme_alert revisions' diff --git a/config/default/user.role.civictheme_site_administrator.yml b/config/default/user.role.civictheme_site_administrator.yml index a5fd4cc7..17d07f39 100644 --- a/config/default/user.role.civictheme_site_administrator.yml +++ b/config/default/user.role.civictheme_site_administrator.yml @@ -41,6 +41,7 @@ dependencies: - paragraphs_library - path - pathauto + - preview_link - redirect - scheduled_transitions - system @@ -81,6 +82,7 @@ permissions: - 'administer menu' - 'administer nodes' - 'administer paragraphs library' + - 'administer preview link settings' - 'administer redirects' - 'administer scheduled transitions' - 'administer taxonomy' @@ -199,6 +201,7 @@ permissions: - 'edit webform twig' - 'edit webform variants' - 'generate ai alt tags' + - 'generate preview links' - 'link to any page' - 'moderated content bulk archive' - 'moderated content bulk publish' From 0d17b0130caeb7637c61ab53ba1d9c83a69eabd6 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:32:17 +1000 Subject: [PATCH 02/11] Kept preview link pages out of search indexes and shared caches. --- web/modules/custom/do_base/do_base.module | 46 ++++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/web/modules/custom/do_base/do_base.module b/web/modules/custom/do_base/do_base.module index 9d029fc9..b7cdd514 100644 --- a/web/modules/custom/do_base/do_base.module +++ b/web/modules/custom/do_base/do_base.module @@ -26,15 +26,51 @@ function do_base_mail_alter(array &$message): void { * Implements hook_page_attachments(). */ function do_base_page_attachments(array &$attachments): void { - // Attach a CSP nonce to script-src on every page so that Drupal core's - // inline scripts (BigPipe placeholders, drupalSettings, etc.) continue to - // run under a strict Content-Security-Policy. The fallback 'unsafe-inline' - // is only used by browsers that do not support CSP3 nonces; modern - // browsers ignore it when a nonce is present. + _do_base_protect_preview_link_page($attachments); + _do_base_attach_csp_nonce($attachments); +} + +/** + * Confines a preview link page to the person holding the token. + */ +function _do_base_protect_preview_link_page(array &$attachments): void { + $route = \Drupal::routeMatch()->getRouteObject(); + + if ($route === NULL || $route->getOption('_preview_link_route') !== TRUE) { + return; + } + + // A preview link renders unpublished content to anyone holding the token, + // and the URL is meant to be pasted into mail and chat clients that follow + // links, so the page must never reach a search index. + $attachments['#attached']['html_head'][] = [ + [ + '#tag' => 'meta', + '#attributes' => [ + 'name' => 'robots', + 'content' => 'noindex, nofollow', + ], + ], + 'do_base_preview_link_robots', + ]; + + // Rendering the page issues a session cookie and the response would + // otherwise be publicly cacheable, so a shared cache could keep serving the + // content after the link expires or is regenerated. Previews are rare + // enough that rendering each one costs nothing worth keeping. + \Drupal::service('page_cache_kill_switch')->trigger(); +} + +/** + * 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'] ?? []; $attachments['#attached']['csp_nonce']['script'] = array_values(array_unique(array_merge($existing, [Csp::POLICY_UNSAFE_INLINE]))); From 836ddb52edbdf1850d8980bd7139371eabb537f5 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:41:32 +1000 Subject: [PATCH 03/11] Added tests for preview link access, expiry, indexing and caching. --- .../tests/src/Functional/PreviewLinkTest.php | 185 ++++++++++++++++++ .../tests/src/Traits/ExportedConfigTrait.php | 38 ++++ .../tests/src/Unit/DateFormatConfigTest.php | 14 +- .../src/Unit/PathautoPatternConfigTest.php | 14 +- .../tests/src/Unit/PreviewLinkConfigTest.php | 151 ++++++++++++++ 5 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php create mode 100644 web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php create mode 100644 web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php diff --git a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php new file mode 100644 index 00000000..a10f7e84 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php @@ -0,0 +1,185 @@ +config('system.performance')->set('cache.page.max_age', 900)->save(); + $this->config('preview_link.settings')->set('enabled_entity_types', ['node' => []])->save(); + + $this->drupalCreateContentType(['type' => 'page', 'name' => 'Page']); + + $workflow = $this->createEditorialWorkflow(); + $this->addEntityTypeAndBundleToWorkflow($workflow, 'node', 'page'); + } + + /** + * Tests that the recipient of a link reads content the public cannot. + */ + public function testRecipientReadsUnpublishedContent(): void { + // Prepare. + $node = $this->createModeratedNode('[TEST] Draft Page', 'draft'); + $preview_link = $this->createPreviewLink($node); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertSession()->statusCodeEquals(403); + + // Act. + $this->drupalGet($preview_link->getUrl($node)); + + // Assert. + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('[TEST] Draft Page'); + } + + /** + * Tests that a draft awaiting review is previewed, not the published page. + * + * The editorial workflow keeps a draft off the default revision, so a page + * that is already live still renders its published text everywhere except + * here. Previewing the pending revision is why this module was chosen. + */ + public function testPendingDraftIsPreviewedOverThePublishedRevision(): void { + // Prepare. + $node = $this->createModeratedNode('[TEST] Published Page', 'published'); + $node->setTitle('[TEST] Pending Draft'); + $node->set('moderation_state', 'draft'); + $node->setNewRevision(); + $node->save(); + + $preview_link = $this->createPreviewLink($node); + + // Act. + $this->drupalGet($preview_link->getUrl($node)); + + // Assert. + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('[TEST] Pending Draft'); + $this->assertSession()->pageTextNotContains('[TEST] Published Page'); + } + + /** + * Tests that a link stops working once it expires. + */ + public function testExpiredLinkIsRefused(): void { + // Prepare. + $node = $this->createModeratedNode('[TEST] Expired Draft', 'draft'); + $preview_link = $this->createPreviewLink($node); + $url = $preview_link->getUrl($node); + + $preview_link->setExpiry(new \DateTime('-1 minute')); + $preview_link->save(); + + // Act. + $this->drupalGet($url); + + // Assert. + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Tests that a preview page is kept out of search indexes. + */ + public function testPreviewPageIsNotIndexable(): void { + // Prepare. + $node = $this->createModeratedNode('[TEST] Unindexed Draft', 'draft'); + $preview_link = $this->createPreviewLink($node); + + // Act. + $this->drupalGet($preview_link->getUrl($node)); + + // Assert. + $this->assertSession()->elementExists('css', 'meta[name="robots"][content="noindex, nofollow"]'); + } + + /** + * Tests that a preview page is never held by a shared cache. + * + * A cached copy would outlive the link, so an expired or regenerated token + * would keep serving the content until the cache entry lapsed. + */ + public function testPreviewPageIsNotStoredBySharedCaches(): void { + // Prepare. + $node = $this->createModeratedNode('[TEST] Uncached Draft', 'draft'); + $preview_link = $this->createPreviewLink($node); + $published = $this->createModeratedNode('[TEST] Cached Page', 'published'); + + // Act. + $this->drupalGet($published->toUrl()); + + // Assert. + $this->assertStringContainsString('public', (string) $this->getSession()->getResponseHeader('Cache-Control'), 'An ordinary node page is expected to stay publicly cacheable.'); + + // Act. + $this->drupalGet($preview_link->getUrl($node)); + + // Assert. + $cache_control = (string) $this->getSession()->getResponseHeader('Cache-Control'); + $this->assertStringContainsString('private', $cache_control); + $this->assertStringNotContainsString('public', $cache_control); + } + + /** + * Creates a node in the given moderation state. + */ + protected function createModeratedNode(string $title, string $moderation_state): NodeInterface { + $node = $this->drupalCreateNode([ + 'type' => 'page', + 'title' => $title, + 'moderation_state' => $moderation_state, + ]); + + return $node; + } + + /** + * Creates a preview link covering the given node. + */ + protected function createPreviewLink(NodeInterface $node): PreviewLinkInterface { + $preview_link = $this->container->get('entity_type.manager')->getStorage('preview_link')->create(['entities' => [$node]]); + $preview_link->save(); + + return $preview_link; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php b/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php new file mode 100644 index 00000000..78207017 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php @@ -0,0 +1,38 @@ +root) . '/config/default/' . $file_name; + $this->assertFileExists($path); + + return Yaml::parseFile($path); + } + + /** + * Lists the bundle names of an entity type from its exported config files. + */ + protected function loadBundleNames(string $config_prefix): array { + $files = glob(dirname($this->root) . '/config/default/' . $config_prefix . '.*.yml'); + $this->assertNotEmpty($files, sprintf('No exported bundles found for "%s".', $config_prefix)); + + return array_map(static fn(string $file): string => substr(basename($file, '.yml'), strlen($config_prefix) + 1), $files); + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php index 0ea4ebad..6c4def78 100644 --- a/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/DateFormatConfigTest.php @@ -5,10 +5,10 @@ namespace Drupal\Tests\do_base\Unit; use Drupal\Tests\UnitTestCase; +use Drupal\Tests\do_base\Traits\ExportedConfigTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Symfony\Component\Yaml\Yaml; /** * Tests that the exported date configuration renders Australian dates. @@ -22,6 +22,8 @@ #[Group('do_base')] class DateFormatConfigTest extends UnitTestCase { + use ExportedConfigTrait; + /** * Reference date rendered through each pattern: 4 August 2026, 2:30pm. * @@ -113,14 +115,4 @@ public static function dataProviderRegionalSetting(): \Iterator { yield 'week starts on Monday' => [['first_day'], 1]; } - /** - * Reads an exported configuration file from the default config directory. - */ - protected function loadConfig(string $file_name): array { - $path = dirname($this->root) . '/config/default/' . $file_name; - $this->assertFileExists($path); - - return Yaml::parseFile($path); - } - } diff --git a/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php index cfa48bd6..8f537f4a 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PathautoPatternConfigTest.php @@ -5,10 +5,10 @@ namespace Drupal\Tests\do_base\Unit; use Drupal\Tests\UnitTestCase; +use Drupal\Tests\do_base\Traits\ExportedConfigTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use Symfony\Component\Yaml\Yaml; /** * Tests the URL alias patterns exported for each content type. @@ -21,6 +21,8 @@ #[Group('do_base')] class PathautoPatternConfigTest extends UnitTestCase { + use ExportedConfigTrait; + /** * Tests that a content type generates aliases under its expected prefix. */ @@ -68,14 +70,4 @@ public static function dataProviderPatternIsScopedToItsBundle(): \Iterator { yield 'project' => ['project']; } - /** - * Reads an exported configuration file from the default config directory. - */ - protected function loadConfig(string $file_name): array { - $path = dirname($this->root) . '/config/default/' . $file_name; - $this->assertFileExists($path); - - return Yaml::parseFile($path); - } - } diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php new file mode 100644 index 00000000..f11440e5 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -0,0 +1,151 @@ +loadConfig('core.extension.yml')['module']; + + // Assert. + $this->assertArrayHasKey('preview_link', $modules); + } + + /** + * Tests that a preview link's bounds are exported as intended. + */ + #[DataProvider('dataProviderSetting')] + public function testSetting(string $key, mixed $expected): void { + // Act. + $settings = $this->loadConfig('preview_link.settings.yml'); + + // Assert. + $this->assertSame($expected, $settings[$key]); + } + + /** + * Data provider for testSetting. + */ + public static function dataProviderSetting(): \Iterator { + yield 'links expire after a week' => ['expiry_seconds', 604800]; + // A page is assembled from paragraphs and media, which have to travel with + // the node for the preview to render the way the published page will. + yield 'a link can carry referenced entities' => ['multiple_entities', TRUE]; + yield 'the editor is told a link was created' => ['display_message', 'subsequent']; + } + + /** + * Tests that every content type can be previewed. + * + * An empty bundle list means the entity type is enabled for all of its + * bundles, which is what keeps a content type added later from silently + * shipping without preview links. + */ + public function testEveryContentTypeIsPreviewable(): void { + // Prepare. + $enabled = $this->loadConfig('preview_link.settings.yml')['enabled_entity_types']; + $this->assertArrayHasKey('node', $enabled, 'Preview links are not enabled for any content type.'); + + if ($enabled['node'] === []) { + return; + } + + // Act. + $missing = array_diff($this->loadBundleNames('node.type'), $enabled['node']); + + // Assert. + $this->assertSame([], array_values($missing), 'Content types are missing from the preview link settings.'); + } + + /** + * Tests that the editorial roles can mint a preview link. + */ + #[DataProvider('dataProviderRoleCanGenerate')] + public function testRoleCanGenerate(string $role_id): void { + // Act. + $permissions = $this->loadConfig('user.role.' . $role_id . '.yml')['permissions']; + + // Assert. + $this->assertContains('generate preview links', $permissions); + } + + /** + * Data provider for testRoleCanGenerate. + */ + public static function dataProviderRoleCanGenerate(): \Iterator { + yield 'content author' => ['civictheme_content_author']; + yield 'content approver' => ['civictheme_content_approver']; + yield 'site administrator' => ['civictheme_site_administrator']; + } + + /** + * Tests that only site administrators can change the preview link bounds. + */ + #[DataProvider('dataProviderRoleCannotAdminister')] + public function testRoleCannotAdminister(string $role_id): void { + // Act. + $permissions = $this->loadConfig('user.role.' . $role_id . '.yml')['permissions']; + + // Assert. + $this->assertNotContains('administer preview link settings', $permissions); + } + + /** + * Data provider for testRoleCannotAdminister. + */ + public static function dataProviderRoleCannotAdminister(): \Iterator { + yield 'content author' => ['civictheme_content_author']; + yield 'content approver' => ['civictheme_content_approver']; + yield 'anonymous' => ['anonymous']; + yield 'authenticated' => ['authenticated']; + } + + /** + * Tests that a preview link needs no permission to open. + * + * The token is the only credential a recipient has, so granting either + * permission to a site-wide role would hand it to every visitor instead. + */ + #[DataProvider('dataProviderUnprivilegedRole')] + public function testUnprivilegedRoleCannotGenerate(string $role_id): void { + // Act. + $permissions = $this->loadConfig('user.role.' . $role_id . '.yml')['permissions']; + + // Assert. + $this->assertNotContains('generate preview links', $permissions); + } + + /** + * Data provider for testUnprivilegedRoleCannotGenerate. + */ + public static function dataProviderUnprivilegedRole(): \Iterator { + yield 'anonymous' => ['anonymous']; + yield 'authenticated' => ['authenticated']; + } + +} From 6c7472944e2e6023be170c0740377c0579cd24c8 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 07:43:44 +1000 Subject: [PATCH 04/11] Documented preview links and covered the editor flow with Behat. --- AGENTS.md | 1 + docs/preview-links.md | 44 +++++++++++++++++++++++ tests/behat/features/preview_link.feature | 40 +++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 docs/preview-links.md create mode 100644 tests/behat/features/preview_link.feature diff --git a/AGENTS.md b/AGENTS.md index ecc5d32a..412aa959 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ The `docs/` directory contains **what** applies to this project: - `docs/deployment.md` - Hosting provider and deployment rules - `docs/releasing.md` - Version scheme and release process - `docs/sitemap.md` - XML sitemap module, coverage and generation +- `docs/preview-links.md` - sharing unpublished content by link - `docs/faqs.md` - Project-specific FAQs **Always check these files first** to understand project-specific decisions. diff --git a/docs/preview-links.md b/docs/preview-links.md new file mode 100644 index 00000000..5515da23 --- /dev/null +++ b/docs/preview-links.md @@ -0,0 +1,44 @@ +# Preview links + +Editors share unpublished content with people who have no account on the site using [`drupal/preview_link`](https://www.drupal.org/project/preview_link). Generating a link produces a tokenised URL that anyone can open, so a reviewer needs neither an account nor a permission. + +## Why this module + +The editorial workflow keeps `draft` and `needs_review` off the default revision, so a page that is already live can carry a pending draft that no canonical URL will ever render. `preview_link` grants access to the **latest** revision, which covers both a page that has never been published and a draft sitting behind a published one. + +The obvious alternative, [`drupal/access_unpublished`](https://www.drupal.org/project/access_unpublished), only unlocks entities whose default revision is unpublished. It cannot reveal a pending draft, which is half the cases here. + +## How editors use it + +1. Open the content item and choose the **Preview Link** tab (`/node//generate-preview-link`). +2. Copy the generated URL and send it on. +3. **Save and regenerate preview link** mints a new token and immediately kills the old URL. **Reset lifetime** restarts the clock without changing the URL. + +Opening the link binds its token to the visitor's session, so from that point they can also follow ordinary links into the content. + +## What is configured + +| Setting | Value | Why | +|---|---|---| +| `enabled_entity_types` | `node`, no bundle list | An empty bundle list means every content type, so a type added later gets preview links without a config change. | +| `expiry_seconds` | `604800` (7 days) | Long enough for a review round, short enough to bound how long unpublished content stays reachable. | +| `multiple_entities` | `true` | A page is assembled from paragraphs and media, which have to travel with the node for the preview to render like the published page will. | +| `display_message` | `subsequent` | Tells the editor a link already exists rather than silently reusing it. | + +`generate preview links` is granted to Content Author, Content Approver and Site Administrator. `administer preview link settings` is granted to Site Administrator only. Recipients need no permission at all - the token is the entire credential, which is why neither permission belongs on the anonymous or authenticated role. + +## Keeping previews contained + +`_do_base_protect_preview_link_page()` acts on any route flagged `_preview_link_route` and does two things the module does not: + +- Emits `noindex, nofollow`, because a preview URL is meant to be pasted into mail and chat clients that follow links. +- Triggers the page cache kill switch. Rendering the page issues a session cookie and the response would otherwise be `max-age=900, public`, letting a shared cache serve the content after the link expired or was regenerated. + +## Expiry + +Expired links are deleted on cron. Expiry is enforced on access, so a link stops working the moment it lapses rather than when cron next runs. + +## Related + +- [Development agreements](development.md) - function visibility conventions the hook follows +- [Testing](testing.md) - PHPUnit and Behat conventions diff --git a/tests/behat/features/preview_link.feature b/tests/behat/features/preview_link.feature new file mode 100644 index 00000000..d7464ff3 --- /dev/null +++ b/tests/behat/features/preview_link.feature @@ -0,0 +1,40 @@ +@p1 @preview_link +Feature: Preview links for unpublished content + + As a content editor + I want to send someone a link to content that is not published yet + So that they can review it without needing an account on the site + + Background: + Given the following "civictheme_page" content: + | title | moderation_state | field_c_n_summary | + | [TEST] Preview Draft Page | draft | [TEST] Draft page summary | + + @api + Scenario: Content Author generates a preview link for a draft page + Given I am logged in as a user with the "Content Author" role + When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page" + And I click "Preview Link" + Then the response status code should be 200 + And I should see "Preview link" + And I should see the button "Save and regenerate preview link" + + @api + Scenario: Site Administrator generates a preview link for a draft page + Given I am logged in as a user with the "Site Administrator" role + When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page" + And I click "Preview Link" + Then the response status code should be 200 + And I should see the button "Save and regenerate preview link" + + @api + Scenario: Editor without the permission is not offered a preview link + Given I am logged in as a user with the "access content, access administration pages, access content overview, view any unpublished content" permissions + When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page" + Then I should not see the link "Preview Link" + + @api + Scenario: Site visitor cannot reach a draft page without a preview link + Given I am an anonymous user + When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page" + Then the response status code should be 403 From 58a68ff68ac43fa8480c3829c8451feb7664ee30 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 08:03:28 +1000 Subject: [PATCH 05/11] Covered preview link entity isolation and the administer permission grant. --- .../tests/src/Functional/PreviewLinkTest.php | 43 +++++++++++++++++++ .../tests/src/Unit/PreviewLinkConfigTest.php | 13 +++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php index a10f7e84..7cacec21 100644 --- a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php +++ b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php @@ -100,6 +100,10 @@ public function testPendingDraftIsPreviewedOverThePublishedRevision(): void { /** * Tests that a link stops working once it expires. + * + * The link is opened first so the refusal is known to come from the expiry + * rather than from a URL that never worked, and the canonical route is + * checked afterwards because by then the session is holding a stale token. */ public function testExpiredLinkIsRefused(): void { // Prepare. @@ -107,6 +111,13 @@ public function testExpiredLinkIsRefused(): void { $preview_link = $this->createPreviewLink($node); $url = $preview_link->getUrl($node); + // Act. + $this->drupalGet($url); + + // Assert. + $this->assertSession()->statusCodeEquals(200); + + // Prepare. $preview_link->setExpiry(new \DateTime('-1 minute')); $preview_link->save(); @@ -115,6 +126,38 @@ public function testExpiredLinkIsRefused(): void { // Assert. $this->assertSession()->statusCodeEquals(403); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Tests that a link unlocks only the content it was created for. + * + * Preview links carry several entities so a page's paragraphs and media + * travel with it, which makes it worth proving that the session a token + * opens does not reach unpublished content the link never named. + */ + public function testLinkDoesNotUnlockUnrelatedContent(): void { + // Prepare. + $linked = $this->createModeratedNode('[TEST] Linked Draft', 'draft'); + $unrelated = $this->createModeratedNode('[TEST] Unrelated Draft', 'draft'); + $preview_link = $this->createPreviewLink($linked); + + // Act. + $this->drupalGet($preview_link->getUrl($linked)); + + // Assert. + $this->assertSession()->statusCodeEquals(200); + + // Act. + $this->drupalGet($unrelated->toUrl()); + + // Assert. + $this->assertSession()->statusCodeEquals(403); } /** diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php index f11440e5..a25fb04a 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -104,7 +104,18 @@ public static function dataProviderRoleCanGenerate(): \Iterator { } /** - * Tests that only site administrators can change the preview link bounds. + * Tests that site administrators can change the preview link bounds. + */ + public function testSiteAdministratorCanAdminister(): void { + // Act. + $permissions = $this->loadConfig('user.role.civictheme_site_administrator.yml')['permissions']; + + // Assert. + $this->assertContains('administer preview link settings', $permissions); + } + + /** + * Tests that no other role can change the preview link bounds. */ #[DataProvider('dataProviderRoleCannotAdminister')] public function testRoleCannotAdminister(string $role_id): void { From 234ed484178a5c0b6dc665295700a82601d2602e Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 08:03:36 +1000 Subject: [PATCH 06/11] Corrected what the 'display_message' setting controls. --- docs/preview-links.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/preview-links.md b/docs/preview-links.md index 5515da23..dc99062a 100644 --- a/docs/preview-links.md +++ b/docs/preview-links.md @@ -14,7 +14,7 @@ The obvious alternative, [`drupal/access_unpublished`](https://www.drupal.org/pr 2. Copy the generated URL and send it on. 3. **Save and regenerate preview link** mints a new token and immediately kills the old URL. **Reset lifetime** restarts the clock without changing the URL. -Opening the link binds its token to the visitor's session, so from that point they can also follow ordinary links into the content. +Opening the link binds its token to the visitor's session, so from that point they can follow ordinary links into the content: a URL they could not otherwise see redirects them to its preview, carrying a notice that explains why and offers to drop the token. ## What is configured @@ -23,7 +23,7 @@ Opening the link binds its token to the visitor's session, so from that point th | `enabled_entity_types` | `node`, no bundle list | An empty bundle list means every content type, so a type added later gets preview links without a config change. | | `expiry_seconds` | `604800` (7 days) | Long enough for a review round, short enough to bound how long unpublished content stays reachable. | | `multiple_entities` | `true` | A page is assembled from paragraphs and media, which have to travel with the node for the preview to render like the published page will. | -| `display_message` | `subsequent` | Tells the editor a link already exists rather than silently reusing it. | +| `display_message` | `subsequent` | Shows the recipient why they can see the page, and offers to drop the token, but only when they arrive by being redirected from a normal URL. Landing on the preview link itself says nothing, which keeps the first thing they see the content rather than a notice. | `generate preview links` is granted to Content Author, Content Approver and Site Administrator. `administer preview link settings` is granted to Site Administrator only. Recipients need no permission at all - the token is the entire credential, which is why neither permission belongs on the anonymous or authenticated role. From d393cb47e6809b02bca79ed377a664a0112da6ce Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 08:22:04 +1000 Subject: [PATCH 07/11] Satisfied the data provider naming sniff and static analysis in the new tests. --- .../do_base/tests/src/Functional/PreviewLinkTest.php | 9 ++++++--- .../do_base/tests/src/Traits/ExportedConfigTrait.php | 2 +- .../do_base/tests/src/Unit/PreviewLinkConfigTest.php | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php index 7cacec21..955f3519 100644 --- a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php +++ b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php @@ -206,13 +206,11 @@ public function testPreviewPageIsNotStoredBySharedCaches(): void { * Creates a node in the given moderation state. */ protected function createModeratedNode(string $title, string $moderation_state): NodeInterface { - $node = $this->drupalCreateNode([ + return $this->drupalCreateNode([ 'type' => 'page', 'title' => $title, 'moderation_state' => $moderation_state, ]); - - return $node; } /** @@ -220,6 +218,11 @@ protected function createModeratedNode(string $title, string $moderation_state): */ protected function createPreviewLink(NodeInterface $node): PreviewLinkInterface { $preview_link = $this->container->get('entity_type.manager')->getStorage('preview_link')->create(['entities' => [$node]]); + + if (!$preview_link instanceof PreviewLinkInterface) { + throw new \UnexpectedValueException('Preview link storage returned an unexpected entity type.'); + } + $preview_link->save(); return $preview_link; diff --git a/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php b/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php index 78207017..c9fa9c6c 100644 --- a/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php +++ b/web/modules/custom/do_base/tests/src/Traits/ExportedConfigTrait.php @@ -29,7 +29,7 @@ protected function loadConfig(string $file_name): array { * Lists the bundle names of an entity type from its exported config files. */ protected function loadBundleNames(string $config_prefix): array { - $files = glob(dirname($this->root) . '/config/default/' . $config_prefix . '.*.yml'); + $files = glob(dirname($this->root) . '/config/default/' . $config_prefix . '.*.yml') ?: []; $this->assertNotEmpty($files, sprintf('No exported bundles found for "%s".', $config_prefix)); return array_map(static fn(string $file): string => substr(basename($file, '.yml'), strlen($config_prefix) + 1), $files); diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php index a25fb04a..2f49d00a 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -142,7 +142,7 @@ public static function dataProviderRoleCannotAdminister(): \Iterator { * The token is the only credential a recipient has, so granting either * permission to a site-wide role would hand it to every visitor instead. */ - #[DataProvider('dataProviderUnprivilegedRole')] + #[DataProvider('dataProviderUnprivilegedRoleCannotGenerate')] public function testUnprivilegedRoleCannotGenerate(string $role_id): void { // Act. $permissions = $this->loadConfig('user.role.' . $role_id . '.yml')['permissions']; @@ -154,7 +154,7 @@ public function testUnprivilegedRoleCannotGenerate(string $role_id): void { /** * Data provider for testUnprivilegedRoleCannotGenerate. */ - public static function dataProviderUnprivilegedRole(): \Iterator { + public static function dataProviderUnprivilegedRoleCannotGenerate(): \Iterator { yield 'anonymous' => ['anonymous']; yield 'authenticated' => ['authenticated']; } From 5ccaa1b2001f9cd4e00f3b47ab775e36b2ff2f05 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 08:49:11 +1000 Subject: [PATCH 08/11] Addressed code review: kept preview responses out of browser caches and widened role coverage. --- docs/preview-links.md | 6 +- tests/behat/features/preview_link.feature | 8 ++- web/modules/custom/do_base/do_base.module | 12 +--- .../custom/do_base/do_base.services.yml | 5 ++ .../PreviewLinkCacheSubscriber.php | 62 ++++++++++++++++ .../tests/src/Functional/PreviewLinkTest.php | 30 ++++++-- .../tests/src/Unit/PreviewLinkConfigTest.php | 70 ++++++++----------- 7 files changed, 131 insertions(+), 62 deletions(-) create mode 100644 web/modules/custom/do_base/src/EventSubscriber/PreviewLinkCacheSubscriber.php diff --git a/docs/preview-links.md b/docs/preview-links.md index dc99062a..314e9142 100644 --- a/docs/preview-links.md +++ b/docs/preview-links.md @@ -29,10 +29,10 @@ Opening the link binds its token to the visitor's session, so from that point th ## Keeping previews contained -`_do_base_protect_preview_link_page()` acts on any route flagged `_preview_link_route` and does two things the module does not: +Both act on any route flagged `_preview_link_route`, and both cover gaps the module leaves open: -- Emits `noindex, nofollow`, because a preview URL is meant to be pasted into mail and chat clients that follow links. -- Triggers the page cache kill switch. Rendering the page issues a session cookie and the response would otherwise be `max-age=900, public`, letting a shared cache serve the content after the link expired or was regenerated. +- `_do_base_attach_preview_link_robots()` emits `noindex, nofollow`, because a preview URL is meant to be pasted into mail and chat clients that follow links. +- `PreviewLinkCacheSubscriber` sends `Cache-Control: no-store` and trips the page cache kill switch. Left alone the response is `max-age=900, public`, and merely starting a session would only downgrade it to `private` - enough to bar shared caches, but the recipient's own browser could still surface the content from history once the link had expired or been regenerated. ## Expiry diff --git a/tests/behat/features/preview_link.feature b/tests/behat/features/preview_link.feature index d7464ff3..4fc2dce5 100644 --- a/tests/behat/features/preview_link.feature +++ b/tests/behat/features/preview_link.feature @@ -17,10 +17,14 @@ Feature: Preview links for unpublished content And I click "Preview Link" Then the response status code should be 200 And I should see "Preview link" - And I should see the button "Save and regenerate preview link" + + When I press "Save and regenerate preview link" + Then the response status code should be 200 + And I should see "Expiry:" + And the response should contain "/preview-link/node/" @api - Scenario: Site Administrator generates a preview link for a draft page + Scenario: Site Administrator can reach the preview link form for a draft page Given I am logged in as a user with the "Site Administrator" role When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page" And I click "Preview Link" diff --git a/web/modules/custom/do_base/do_base.module b/web/modules/custom/do_base/do_base.module index b7cdd514..cfd55278 100644 --- a/web/modules/custom/do_base/do_base.module +++ b/web/modules/custom/do_base/do_base.module @@ -26,14 +26,14 @@ function do_base_mail_alter(array &$message): void { * Implements hook_page_attachments(). */ function do_base_page_attachments(array &$attachments): void { - _do_base_protect_preview_link_page($attachments); + _do_base_attach_preview_link_robots($attachments); _do_base_attach_csp_nonce($attachments); } /** - * Confines a preview link page to the person holding the token. + * Keeps preview link pages out of search indexes. */ -function _do_base_protect_preview_link_page(array &$attachments): void { +function _do_base_attach_preview_link_robots(array &$attachments): void { $route = \Drupal::routeMatch()->getRouteObject(); if ($route === NULL || $route->getOption('_preview_link_route') !== TRUE) { @@ -53,12 +53,6 @@ function _do_base_protect_preview_link_page(array &$attachments): void { ], 'do_base_preview_link_robots', ]; - - // Rendering the page issues a session cookie and the response would - // otherwise be publicly cacheable, so a shared cache could keep serving the - // content after the link expires or is regenerated. Previews are rare - // enough that rendering each one costs nothing worth keeping. - \Drupal::service('page_cache_kill_switch')->trigger(); } /** diff --git a/web/modules/custom/do_base/do_base.services.yml b/web/modules/custom/do_base/do_base.services.yml index 51bf1663..19076cfb 100644 --- a/web/modules/custom/do_base/do_base.services.yml +++ b/web/modules/custom/do_base/do_base.services.yml @@ -9,3 +9,8 @@ services: arguments: ['@class_resolver', '@theme_handler'] tags: - { name: event_subscriber } + do_base.preview_link_cache_subscriber: + class: Drupal\do_base\EventSubscriber\PreviewLinkCacheSubscriber + arguments: ['@page_cache_kill_switch'] + tags: + - { name: event_subscriber } diff --git a/web/modules/custom/do_base/src/EventSubscriber/PreviewLinkCacheSubscriber.php b/web/modules/custom/do_base/src/EventSubscriber/PreviewLinkCacheSubscriber.php new file mode 100644 index 00000000..58bc3146 --- /dev/null +++ b/web/modules/custom/do_base/src/EventSubscriber/PreviewLinkCacheSubscriber.php @@ -0,0 +1,62 @@ +isMainRequest()) { + return; + } + + $route = $event->getRequest()->attributes->get(RouteObjectInterface::ROUTE_OBJECT); + + if (!$route instanceof Route || $route->getOption('_preview_link_route') !== TRUE) { + return; + } + + // The internal page cache decides from the response policy rather than + // from the header, so setting the header alone would not stop it storing + // the page. + $this->killSwitch->trigger(); + + // Rendering the page starts a session, which on its own only earns the + // response 'private'. That bars shared caches but still lets the + // recipient's own browser keep a copy, so ask for no storage at all. + $event->getResponse()->headers->set('Cache-Control', 'no-store'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // Later than FinishResponseSubscriber, which sets Cache-Control itself. + return [KernelEvents::RESPONSE => ['onResponse', -10]]; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php index 955f3519..df06f79c 100644 --- a/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php +++ b/web/modules/custom/do_base/tests/src/Functional/PreviewLinkTest.php @@ -176,12 +176,13 @@ public function testPreviewPageIsNotIndexable(): void { } /** - * Tests that a preview page is never held by a shared cache. + * Tests that a preview page is never stored by any cache. * - * A cached copy would outlive the link, so an expired or regenerated token - * would keep serving the content until the cache entry lapsed. + * A stored copy outlives the link, so an expired or regenerated token would + * keep serving the content until the entry lapsed. Only 'no-store' covers + * the recipient's own browser as well as shared caches. */ - public function testPreviewPageIsNotStoredBySharedCaches(): void { + public function testPreviewPageIsNotStoredByAnyCache(): void { // Prepare. $node = $this->createModeratedNode('[TEST] Uncached Draft', 'draft'); $preview_link = $this->createPreviewLink($node); @@ -197,9 +198,26 @@ public function testPreviewPageIsNotStoredBySharedCaches(): void { $this->drupalGet($preview_link->getUrl($node)); // Assert. + $this->assertPreviewIsNotStorable(); + + // Act. + $this->drupalGet($node->toUrl()); + + // Assert. + $this->assertPreviewIsNotStorable('The canonical route reroutes to the preview while the token is held, so it must not be stored either.'); + } + + /** + * Asserts the current response may not be stored by any cache. + * + * The directives are matched individually because their order and any + * companions Symfony merges in are incidental to the guarantee. + */ + protected function assertPreviewIsNotStorable(string $message = ''): void { $cache_control = (string) $this->getSession()->getResponseHeader('Cache-Control'); - $this->assertStringContainsString('private', $cache_control); - $this->assertStringNotContainsString('public', $cache_control); + + $this->assertStringContainsString('no-store', $cache_control, $message); + $this->assertStringNotContainsString('public', $cache_control, $message); } /** diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php index 2f49d00a..e1c98a64 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -104,59 +104,45 @@ public static function dataProviderRoleCanGenerate(): \Iterator { } /** - * Tests that site administrators can change the preview link bounds. + * Tests that no role beyond the editorial three can mint a preview link. + * + * Naming the roles that must not hold the permission would leave a role + * added later untested, so every exported role is weighed against the + * allowlist instead. A recipient needs no permission at all, so a site-wide + * role holding this one would hand link creation to every visitor. */ - public function testSiteAdministratorCanAdminister(): void { + #[DataProvider('dataProviderPermissionIsConfinedToItsRoles')] + public function testPermissionIsConfinedToItsRoles(string $permission, array $expected): void { // Act. - $permissions = $this->loadConfig('user.role.civictheme_site_administrator.yml')['permissions']; + $granted = []; - // Assert. - $this->assertContains('administer preview link settings', $permissions); - } + foreach ($this->loadBundleNames('user.role') as $role_id) { + if (in_array($permission, $this->loadConfig('user.role.' . $role_id . '.yml')['permissions'] ?? [], TRUE)) { + $granted[] = $role_id; + } + } - /** - * Tests that no other role can change the preview link bounds. - */ - #[DataProvider('dataProviderRoleCannotAdminister')] - public function testRoleCannotAdminister(string $role_id): void { - // Act. - $permissions = $this->loadConfig('user.role.' . $role_id . '.yml')['permissions']; + sort($granted); // Assert. - $this->assertNotContains('administer preview link settings', $permissions); - } - - /** - * Data provider for testRoleCannotAdminister. - */ - public static function dataProviderRoleCannotAdminister(): \Iterator { - yield 'content author' => ['civictheme_content_author']; - yield 'content approver' => ['civictheme_content_approver']; - yield 'anonymous' => ['anonymous']; - yield 'authenticated' => ['authenticated']; + $this->assertSame($expected, $granted); } /** - * Tests that a preview link needs no permission to open. + * Data provider for testPermissionIsConfinedToItsRoles. * - * The token is the only credential a recipient has, so granting either - * permission to a site-wide role would hand it to every visitor instead. - */ - #[DataProvider('dataProviderUnprivilegedRoleCannotGenerate')] - public function testUnprivilegedRoleCannotGenerate(string $role_id): void { - // Act. - $permissions = $this->loadConfig('user.role.' . $role_id . '.yml')['permissions']; - - // Assert. - $this->assertNotContains('generate preview links', $permissions); - } - - /** - * Data provider for testUnprivilegedRoleCannotGenerate. + * The administrator role carries 'is_admin', so it holds every permission + * without listing any, and is absent from these lists by design. */ - public static function dataProviderUnprivilegedRoleCannotGenerate(): \Iterator { - yield 'anonymous' => ['anonymous']; - yield 'authenticated' => ['authenticated']; + public static function dataProviderPermissionIsConfinedToItsRoles(): \Iterator { + yield 'generating links' => [ + 'generate preview links', + ['civictheme_content_approver', 'civictheme_content_author', 'civictheme_site_administrator'], + ]; + yield 'changing the bounds' => [ + 'administer preview link settings', + ['civictheme_site_administrator'], + ]; } } From 7180e3044be8cac88dc9e276ed3398aec4eabb0d Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 08:52:01 +1000 Subject: [PATCH 09/11] Described who the preview link notice is actually shown to. --- .../custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php index e1c98a64..66bb2a99 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -56,7 +56,9 @@ public static function dataProviderSetting(): \Iterator { // A page is assembled from paragraphs and media, which have to travel with // the node for the preview to render the way the published page will. yield 'a link can carry referenced entities' => ['multiple_entities', TRUE]; - yield 'the editor is told a link was created' => ['display_message', 'subsequent']; + // The notice reaches the recipient only when a normal URL redirected them + // here, so landing on the link itself opens on the content, not a banner. + yield 'a redirected recipient is told why they can see the page' => ['display_message', 'subsequent']; } /** From 9916009dcc560fe6d0736555a5eefda39a1aef01 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 11:04:06 +1000 Subject: [PATCH 10/11] Extended the preview link lifetime to two weeks. --- config/default/preview_link.settings.yml | 2 +- docs/preview-links.md | 2 +- .../custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/default/preview_link.settings.yml b/config/default/preview_link.settings.yml index 20d03660..535bfc42 100644 --- a/config/default/preview_link.settings.yml +++ b/config/default/preview_link.settings.yml @@ -4,4 +4,4 @@ display_message: subsequent enabled_entity_types: node: { } multiple_entities: true -expiry_seconds: 604800 +expiry_seconds: 1209600 diff --git a/docs/preview-links.md b/docs/preview-links.md index 314e9142..6428c895 100644 --- a/docs/preview-links.md +++ b/docs/preview-links.md @@ -21,7 +21,7 @@ Opening the link binds its token to the visitor's session, so from that point th | Setting | Value | Why | |---|---|---| | `enabled_entity_types` | `node`, no bundle list | An empty bundle list means every content type, so a type added later gets preview links without a config change. | -| `expiry_seconds` | `604800` (7 days) | Long enough for a review round, short enough to bound how long unpublished content stays reachable. | +| `expiry_seconds` | `1209600` (14 days) | Covers a review that spans a couple of weeks, including a reviewer who is away for one of them, while still bounding how long unpublished content stays reachable. | | `multiple_entities` | `true` | A page is assembled from paragraphs and media, which have to travel with the node for the preview to render like the published page will. | | `display_message` | `subsequent` | Shows the recipient why they can see the page, and offers to drop the token, but only when they arrive by being redirected from a normal URL. Landing on the preview link itself says nothing, which keeps the first thing they see the content rather than a notice. | diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php index 66bb2a99..08292164 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -52,7 +52,7 @@ public function testSetting(string $key, mixed $expected): void { * Data provider for testSetting. */ public static function dataProviderSetting(): \Iterator { - yield 'links expire after a week' => ['expiry_seconds', 604800]; + yield 'links expire after a fortnight' => ['expiry_seconds', 1209600]; // A page is assembled from paragraphs and media, which have to travel with // the node for the preview to render the way the published page will. yield 'a link can carry referenced entities' => ['multiple_entities', TRUE]; From 0f1a73b4469e99bab39095dc5cbfc720272c0e71 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 5 Aug 2026 11:18:53 +1000 Subject: [PATCH 11/11] Addressed code review: counted 'is_admin' roles as holding the preview link permissions. --- .../tests/src/Unit/PreviewLinkConfigTest.php | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php index 08292164..88f2fbb3 100644 --- a/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php +++ b/web/modules/custom/do_base/tests/src/Unit/PreviewLinkConfigTest.php @@ -106,12 +106,16 @@ public static function dataProviderRoleCanGenerate(): \Iterator { } /** - * Tests that no role beyond the editorial three can mint a preview link. + * Tests that no role beyond the expected ones holds each permission. * - * Naming the roles that must not hold the permission would leave a role - * added later untested, so every exported role is weighed against the - * allowlist instead. A recipient needs no permission at all, so a site-wide - * role holding this one would hand link creation to every visitor. + * Naming the roles that must not hold a permission would leave a role added + * later untested, so every exported role is weighed against the allowlist + * instead. A recipient needs no permission at all, so a site-wide role + * holding one of these would hand link creation to every visitor. + * + * A role flagged 'is_admin' holds every permission without listing any, so + * it counts as granted here. Reading only the explicit list would let a new + * administrative role pick both permissions up unnoticed. */ #[DataProvider('dataProviderPermissionIsConfinedToItsRoles')] public function testPermissionIsConfinedToItsRoles(string $permission, array $expected): void { @@ -119,7 +123,9 @@ public function testPermissionIsConfinedToItsRoles(string $permission, array $ex $granted = []; foreach ($this->loadBundleNames('user.role') as $role_id) { - if (in_array($permission, $this->loadConfig('user.role.' . $role_id . '.yml')['permissions'] ?? [], TRUE)) { + $role = $this->loadConfig('user.role.' . $role_id . '.yml'); + + if (($role['is_admin'] ?? FALSE) === TRUE || in_array($permission, $role['permissions'] ?? [], TRUE)) { $granted[] = $role_id; } } @@ -132,18 +138,15 @@ public function testPermissionIsConfinedToItsRoles(string $permission, array $ex /** * Data provider for testPermissionIsConfinedToItsRoles. - * - * The administrator role carries 'is_admin', so it holds every permission - * without listing any, and is absent from these lists by design. */ public static function dataProviderPermissionIsConfinedToItsRoles(): \Iterator { yield 'generating links' => [ 'generate preview links', - ['civictheme_content_approver', 'civictheme_content_author', 'civictheme_site_administrator'], + ['administrator', 'civictheme_content_approver', 'civictheme_content_author', 'civictheme_site_administrator'], ]; yield 'changing the bounds' => [ 'administer preview link settings', - ['civictheme_site_administrator'], + ['administrator', 'civictheme_site_administrator'], ]; }