From c54ff6f0cf211c86b3191abe226820406267bb51 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 1 Jul 2026 20:13:25 +1000 Subject: [PATCH 1/4] Scoped the content authoring API moderation policy to entity creation. The 'ModerationPolicyHook' ran on every entity save, so it also clobbered a reviewer's later publish: any account holding 'use content authoring api' could create a draft but never publish it - the moderation state was silently coerced back to 'draft' on the update. The policy governs authoring, which is creation, so it now only applies when the entity is new; a subsequent publish (an update) is left untouched. Added a kernel test covering publish-after-authoring and a Behat scenario that publishes a draft through the editorial UI. The kernel test setup now installs the 'node_access' schema so a second (update) save can run. --- tests/behat/bootstrap/FeatureContext.php | 41 +++++++++++++++++++ .../content_moderation_publish.feature | 16 ++++++++ .../src/Hook/ModerationPolicyHook.php | 9 +++- .../Kernel/Hook/ModerationPolicyHookTest.php | 26 ++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/behat/features/content_moderation_publish.feature diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php index 63639c70..84f0a371 100644 --- a/tests/behat/bootstrap/FeatureContext.php +++ b/tests/behat/bootstrap/FeatureContext.php @@ -37,6 +37,7 @@ use DrevOps\BehatSteps\ResponseTrait; use DrevOps\BehatSteps\WaitTrait; use Behat\Step\Given; +use Behat\Step\Then; use Drupal\DrupalExtension\Context\DrupalContext; use Drupal\node\NodeInterface; use Drupal\pathauto\PathautoState; @@ -109,4 +110,44 @@ public function contentSetPathAlias(string $content_type, string $title, string $node->save(); } + /** + * Assert that a content item is published. + * + * Checks both the published flag and the moderation state. The content is + * reloaded from storage because the UI action that published it ran in a + * separate web request, leaving this process's entity cache stale. + * + * @code + * Then the "civictheme_page" content "[TEST] Article" should be published + * @endcode + */ + #[Then('the :content_type content :title should be published')] + public function contentShouldBePublished(string $content_type, string $title): void { + $storage = \Drupal::entityTypeManager()->getStorage('node'); + + $nids = $storage->getQuery() + ->accessCheck(FALSE) + ->condition('type', $content_type) + ->condition('title', $title) + ->execute(); + + if (count($nids) !== 1) { + throw new \RuntimeException(sprintf('Expected exactly one "%s" content item with the title "%s", but found %d.', $content_type, $title, count($nids))); + } + + $nid = (int) reset($nids); + $storage->resetCache([$nid]); + $node = $storage->load($nid); + + if (!$node instanceof NodeInterface) { + throw new \RuntimeException(sprintf('Unable to load "%s" content with the title "%s".', $content_type, $title)); + } + + $state = $node->get('moderation_state')->value; + + if ($state !== 'published' || !$node->isPublished()) { + throw new \RuntimeException(sprintf('Expected "%s" to be published, but its moderation state is "%s" and its published flag is %s.', $title, $state, $node->isPublished() ? 'true' : 'false')); + } + } + } diff --git a/tests/behat/features/content_moderation_publish.feature b/tests/behat/features/content_moderation_publish.feature new file mode 100644 index 00000000..6477c247 --- /dev/null +++ b/tests/behat/features/content_moderation_publish.feature @@ -0,0 +1,16 @@ +@api +Feature: Publishing API-authored content through the editorial UI + + As a reviewer who also holds the content authoring API permission + I want to publish a draft page through the editorial UI + So that content authored through the API can be reviewed and go live + + Scenario: An account with the content authoring API permission publishes a draft page + Given I am logged in as a user with the "access content, access administration pages, access content overview, edit any civictheme_page content, view any unpublished content, use content authoring api, use civictheme_editorial transition create_new_draft, use civictheme_editorial transition publish" permissions + And the following "civictheme_page" content: + | title | moderation_state | field_c_n_banner_type | field_c_n_banner_theme | field_c_n_banner_blend_mode | field_c_n_vertical_spacing | + | API draft to publish | draft | large | inherit | normal | both | + When I visit the "civictheme_page" content edit page with the title "API draft to publish" + And I select "Published" from "moderation_state[0][state]" + And I press "Save" + Then the "civictheme_page" content "API draft to publish" should be published diff --git a/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php b/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php index e301b5fb..c3da2b6c 100644 --- a/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php +++ b/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php @@ -46,8 +46,15 @@ public function entityPresave(EntityInterface $entity): void { return; } + // The policy governs authoring, which is entity creation. A later moderation + // change - a reviewer publishing the draft - is an update and must be left + // untouched, otherwise authored content could never be published. + if (!$entity->isNew()) { + return; + } + // Pages authored through the API never go live directly; a human reviews - // and publishes them, so they are always forced back to a draft. + // and publishes them, so they are forced to draft on creation. if ($entity->getEntityTypeId() === 'node') { $entity->set('moderation_state', 'draft'); diff --git a/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php b/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php index 7a8ad913..16f0dfad 100644 --- a/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php +++ b/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php @@ -64,6 +64,7 @@ protected function setUp(): void { $this->installEntitySchema('file'); $this->installEntitySchema('content_moderation_state'); $this->installSchema('file', ['file_usage']); + $this->installSchema('node', ['node_access']); $this->installConfig(['filter']); NodeType::create(['type' => 'civictheme_page', 'name' => 'Page'])->save(); @@ -176,6 +177,31 @@ public function testNonApiPageUnaffected(): void { $this->assertTrue($node->isPublished()); } + /** + * Tests that an API actor can publish a page it previously authored. + */ + public function testApiPagePublishAfterAuthoringIsHonoured(): void { + $api_user = $this->createUser(['use content authoring api']); + $this->assertNotFalse($api_user); + $this->setCurrentUser($api_user); + + // Authoring (create) is forced to draft by the policy. + $node = Node::create([ + 'type' => 'civictheme_page', + 'title' => '[TEST] Authored then published', + 'moderation_state' => 'draft', + ]); + $node->save(); + $this->assertSame('draft', $node->get('moderation_state')->value); + + // A later publish is an update, not authoring, so it must be honoured. + $node->set('moderation_state', 'published'); + $node->save(); + + $this->assertSame('published', $node->get('moderation_state')->value); + $this->assertTrue($node->isPublished()); + } + /** * Tests that the superuser is treated as an ordinary administrator. */ From 172b002cff93d6116724e6c86cf0c412039e8f18 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 1 Jul 2026 20:41:05 +1000 Subject: [PATCH 2/4] Fixed line-length warning in 'ModerationPolicyHook' comment. --- .../do_content_api/src/Hook/ModerationPolicyHook.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php b/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php index c3da2b6c..05135184 100644 --- a/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php +++ b/web/modules/custom/do_content_api/src/Hook/ModerationPolicyHook.php @@ -46,9 +46,10 @@ public function entityPresave(EntityInterface $entity): void { return; } - // The policy governs authoring, which is entity creation. A later moderation - // change - a reviewer publishing the draft - is an update and must be left - // untouched, otherwise authored content could never be published. + // The policy governs authoring, which is entity creation. A later + // moderation change - a reviewer publishing the draft - is an update and + // must be left untouched, otherwise authored content could never be + // published. if (!$entity->isNew()) { return; } From f216a6eb502cde5312ad650fb4bcb754c1c2b1cd Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 1 Jul 2026 20:41:43 +1000 Subject: [PATCH 3/4] Added phpstan-ignore annotation to 'contentShouldBePublished' step. --- tests/behat/bootstrap/FeatureContext.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php index 84f0a371..70cb6576 100644 --- a/tests/behat/bootstrap/FeatureContext.php +++ b/tests/behat/bootstrap/FeatureContext.php @@ -123,6 +123,7 @@ public function contentSetPathAlias(string $content_type, string $title, string */ #[Then('the :content_type content :title should be published')] public function contentShouldBePublished(string $content_type, string $title): void { + // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection $storage = \Drupal::entityTypeManager()->getStorage('node'); $nids = $storage->getQuery() From d5ff521d6da855c289f50dfca1083dc9b02a68bf Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Wed, 1 Jul 2026 20:56:08 +1000 Subject: [PATCH 4/4] Addressed code review: added a companion media update-after-authoring test. --- .../Kernel/Hook/ModerationPolicyHookTest.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php b/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php index 16f0dfad..03bbc3c5 100644 --- a/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php +++ b/web/modules/custom/do_content_api/tests/src/Kernel/Hook/ModerationPolicyHookTest.php @@ -202,6 +202,30 @@ public function testApiPagePublishAfterAuthoringIsHonoured(): void { $this->assertTrue($node->isPublished()); } + /** + * Tests that a later moderation change to an API-authored media is honoured. + */ + public function testApiMediaUpdateAfterAuthoringIsHonoured(): void { + $api_user = $this->createUser(['use content authoring api']); + $this->assertNotFalse($api_user); + $this->setCurrentUser($api_user); + + // Authoring (create) forces media to published. + $media = Media::create([ + 'bundle' => 'civictheme_image', + 'name' => '[TEST] Authored then updated', + 'moderation_state' => 'draft', + ]); + $media->save(); + $this->assertSame('published', $media->get('moderation_state')->value); + + // A later state change is an update, not authoring, so it is honoured. + $media->set('moderation_state', 'draft'); + $media->save(); + + $this->assertSame('draft', $media->get('moderation_state')->value); + } + /** * Tests that the superuser is treated as an ordinary administrator. */