From c6a1996be610de900cf306837ce90ba644929414 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Wed, 30 Sep 2026 02:04:49 +0200 Subject: [PATCH] feat: adapter-level Divi release lock, content-create and site status Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Integrations/Core/EnvironmentProbe.php | 9 +- src/Integrations/Core/SiteStatusAbility.php | 3 +- .../Divi/Library/DiviLibraryStore.php | 4 + .../Divi/Pages/DiviContentComposer.php | 8 ++ src/Integrations/Divi/Pages/DiviSaver.php | 6 ++ .../Divi/ThemeBuilder/DiviThemeBuilder.php | 29 ++++++- .../WordPress/Content/ContentComposer.php | 6 ++ .../Content/ContentCreateAbility.php | 5 ++ src/Plugin.php | 6 ++ .../Core/EnvironmentProbeTest.php | 18 ++++ .../Divi/Release/ReleaseGuardsTest.php | 83 +++++++++++++++++++ .../Content/ContentCreateDiviTest.php | 21 ++++- 12 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php diff --git a/src/Integrations/Core/EnvironmentProbe.php b/src/Integrations/Core/EnvironmentProbe.php index c5b5e23..edc6141 100644 --- a/src/Integrations/Core/EnvironmentProbe.php +++ b/src/Integrations/Core/EnvironmentProbe.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Core; use LIT\MCP\Integrations\Divi\DiviCompatibility; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; defined('ABSPATH') || exit; @@ -19,7 +20,10 @@ class EnvironmentProbe { } - /** @return list}> */ + /** + * @return list, + * release: ?array}> + */ public function components(): array { $diviVersion = DiviEnvironment::version(); @@ -33,18 +37,21 @@ class EnvironmentProbe 'active' => $isDivi, 'version' => $diviVersion, 'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [], + 'release' => $isDivi ? ReleaseGate::status()->toArray() : null, ], [ 'id' => 'pods', 'active' => defined('PODS_VERSION'), 'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null, 'problems' => [], + 'release' => null, ], [ 'id' => 'the-events-calendar', 'active' => $tecActive, 'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null, 'problems' => [], + 'release' => null, ], ]; } diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php index e1212fb..29eadaa 100644 --- a/src/Integrations/Core/SiteStatusAbility.php +++ b/src/Integrations/Core/SiteStatusAbility.php @@ -31,7 +31,8 @@ final class SiteStatusAbility implements Ability { return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, ' . 'erkannte Integrationen (Divi, Pods, The Events Calendar). ' - . 'Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.'; + . 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und ' + . 'release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).'; } public function inputSchema(): array diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index d39c65d..c2abfa8 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -7,8 +7,10 @@ namespace LIT\MCP\Integrations\Divi\Library; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\Release\Mirror; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\WordPress\Content\NewDraft; use RuntimeException; use Throwable; @@ -211,9 +213,11 @@ class DiviLibraryStore implements DiviAdapter * when that delete fails; a throwing insert hook is a partial write without ID. * * @throws WriteFailed + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function create(LibraryItemSpec $spec, NewDraft $draft): int { + ReleaseGate::assertWritable(); $missing = $this->missingTaxonomies(); if ($missing !== []) { throw WriteFailed::rolledBack( diff --git a/src/Integrations/Divi/Pages/DiviContentComposer.php b/src/Integrations/Divi/Pages/DiviContentComposer.php index b19726e..acfe0b6 100644 --- a/src/Integrations/Divi/Pages/DiviContentComposer.php +++ b/src/Integrations/Divi/Pages/DiviContentComposer.php @@ -10,6 +10,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex; use LIT\MCP\Integrations\Divi\BlockTree\Outline; use LIT\MCP\Integrations\Divi\BlockTree\Serializer; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -59,6 +60,13 @@ final class DiviContentComposer implements ContentComposer return ($this->writable)(); } + public function refusal(): ?WP_Error + { + $status = ReleaseGate::status(); + + return $status->writable() ? null : $status->refusal(); + } + public function check(string $postType, array $sections): ?WP_Error { if (! $this->schema->diviEnabled($postType)) { diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index f87b5f4..79b5600 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -8,6 +8,8 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\FollowUp; use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\Divi\Release\DiviLocked; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\RevisionPoint; @@ -69,10 +71,12 @@ class DiviSaver /** * @return array{revision_id: ?int, warnings: list} * @throws WriteFailed + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array { $postId = (int) $post->ID; + ReleaseGate::assertWritable($postId); $revisionId = null; try { @@ -138,9 +142,11 @@ class DiviSaver * * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array { + ReleaseGate::assertWritable($postId); $refusal = self::refusal($this->writeContent($postId, $content)); if ($refusal !== null) { throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index eda601c..c2a9871 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviLocked; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\Release\Mirror; +use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use RuntimeException; use Throwable; use WP_Error; @@ -229,9 +231,13 @@ class DiviThemeBuilder implements DiviAdapter return $found !== null; } - /** @throws RuntimeException */ + /** + * @throws RuntimeException + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ public function insertLayout(string $area, string $title): int { + ReleaseGate::assertWritable(); $type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area); $this->assertCanWrite('et_theme_builder_insert_layout'); $id = $this->guarded( @@ -253,9 +259,11 @@ class DiviThemeBuilder implements DiviAdapter * @param array{title: string, layouts: array, use_on: list, * exclude_from: list} $template * @throws RuntimeException + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function storeTemplate(int $themeBuilderId, array $template): int { + ReleaseGate::assertWritable(); $this->assertCanWrite('et_theme_builder_store_template', 'et_'); $payload = [ 'title' => $template['title'], @@ -285,9 +293,11 @@ class DiviThemeBuilder implements DiviAdapter * template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach. * * @throws RuntimeException + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function attach(int $themeBuilderId, int $templateId): void { + ReleaseGate::assertWritable(); $existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false); if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) { return; @@ -304,8 +314,10 @@ class DiviThemeBuilder implements DiviAdapter } } + /** @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function detach(int $themeBuilderId, int $templateId): void { + ReleaseGate::assertWritable(); delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId); } @@ -316,9 +328,11 @@ class DiviThemeBuilder implements DiviAdapter * @param list $useOn * @param list $excludeFrom * @throws RuntimeException + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ public function setConditions(int $templateId, array $useOn, array $excludeFrom): void { + ReleaseGate::assertWritable(); foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) { delete_post_meta($templateId, $key); foreach (array_values(array_unique($conditions)) as $condition) { @@ -329,9 +343,13 @@ class DiviThemeBuilder implements DiviAdapter } } - /** @throws RuntimeException */ + /** + * @throws RuntimeException + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ public function setEnabled(int $templateId, bool $enabled): void { + ReleaseGate::assertWritable(); $value = $enabled ? '1' : '0'; update_post_meta($templateId, self::ENABLED, $value); if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) { @@ -339,9 +357,14 @@ class DiviThemeBuilder implements DiviAdapter } } - /** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */ + /** + * Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. + * + * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + */ public function unmarkUnused(int $postId): void { + ReleaseGate::assertWritable(); delete_post_meta($postId, self::UNUSED); } diff --git a/src/Integrations/WordPress/Content/ContentComposer.php b/src/Integrations/WordPress/Content/ContentComposer.php index ca6c435..eec00f0 100644 --- a/src/Integrations/WordPress/Content/ContentComposer.php +++ b/src/Integrations/WordPress/Content/ContentComposer.php @@ -29,6 +29,12 @@ interface ContentComposer */ public function writable(): bool; + /** + * Why writing such content is refused right now although it is enabled (e.g. the installed Divi version is not + * released for LIT MCP, spec §5.5); null = allowed. + */ + public function refusal(): ?WP_Error; + /** * Validates the description for a new entry of $postType. Nothing is written. * diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index 05b022e..8693f12 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -133,6 +133,11 @@ final class ContentCreateAbility implements Ability ); } + $locked = $withDivi ? $this->divi->refusal() : null; + if ($locked !== null) { + return $locked; + } + $prepared = $this->writer->prepare($postType, $input, true); $problem = self::combined($prepared, $withDivi ? $this->checkDivi($postType, $input) : null); if ($problem !== null) { diff --git a/src/Plugin.php b/src/Plugin.php index 557ac64..fdb5e34 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -25,6 +25,7 @@ use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Release\DiviToolGate; +use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; @@ -73,6 +74,11 @@ final class Plugin (new ChangeLogInstaller($changeLog))->maybeInstall(); (new ChangeLogCleanup($changeLog, $limits))->register(); + // Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too). + add_action('upgrader_process_complete', static function (): void { + delete_transient(ReleaseResolver::CACHE); + }); + $schema = self::contentSchema(); $registry = null; $policy = null; diff --git a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php index 2dfb442..0ebad51 100644 --- a/tests/Unit/Integrations/Core/EnvironmentProbeTest.php +++ b/tests/Unit/Integrations/Core/EnvironmentProbeTest.php @@ -26,4 +26,22 @@ final class EnvironmentProbeTest extends TestCase self::assertSame(['GlobalLayout'], $components['divi']['problems']); self::assertSame([], $components['pods']['problems']); } + + public function testDiviComponentCarriesTheReleaseStatus(): void + { + $theme = Mockery::mock(); + $theme->allows('get_template')->andReturn('Divi'); + $theme->allows('parent')->andReturn(false); + $theme->allows('get')->with('Version')->andReturn('5.2.1'); + Functions\when('wp_get_theme')->justReturn($theme); + + $components = array_column( + (new EnvironmentProbe(new DiviCompatibility([])))->components(), + null, + 'id' + ); + + self::assertSame('released', $components['divi']['release']['state'] ?? null); + self::assertNull($components['pods']['release'] ?? null); + } } diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php new file mode 100644 index 0000000..3accd9a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php @@ -0,0 +1,83 @@ + new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + // Nothing may be written: every write function WordPress offers fails the test. + foreach (['wp_update_post', 'wp_insert_post', 'update_post_meta', 'add_post_meta', 'delete_post_meta', + 'update_option', 'delete_option', 'wp_set_object_terms'] as $write) { + Functions\when($write)->alias(static function () use ($write): never { + self::fail($write . ' ran although Divi is locked'); + }); + } + Functions\when('current_user_can')->justReturn(true); + } + + /** @return iterable */ + public static function writes(): iterable + { + $post = new WP_Post(['ID' => 7, 'post_content' => '', 'post_type' => 'page']); + $spec = new LibraryItemSpec('Hero', 'section', false, 'regular', null, 'page'); + + yield 'save' => [static fn () => (new DiviSaver())->save($post, '')]; + yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '')]; + yield 'library create' => [static fn () => (new DiviLibraryStore())->create( + $spec, + new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag') + )]; + yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf')]; + yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [ + 'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], + ])]; + yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9)]; + yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9)]; + yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])]; + yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)]; + yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)]; + } + + /** @dataProvider writes */ + public function testRefusesBeforeTheFirstWrite(callable $write): void + { + $this->expectException(DiviLocked::class); + + $write(); + } + + public function testTheCheckPageMayBeWrittenWhileProbing(): void + { + Functions\when('wp_update_post')->justReturn(41); + Functions\when('update_post_meta')->justReturn(true); + Functions\when('get_post_meta')->alias( + static fn (int $id, string $key): string => $key === '_et_pb_show_page_creation' ? 'off' : 'on' + ); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 41, 'post_content' => 'x'])); + Functions\when('has_filter')->justReturn(false); + + $warnings = ReleaseGate::whileProbing(41, static fn () => (new DiviSaver())->saveNew(41, 'x')); + + self::assertSame([], $warnings); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php index 7ff7694..691e2a6 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -11,6 +11,8 @@ use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Release\ReleaseState; +use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; use LIT\MCP\Integrations\WordPress\Content\NewDraft; @@ -56,11 +58,12 @@ final class ContentCreateDiviTest extends TestCase } /** @return ContentComposer&MockInterface */ - private function composer(bool $available = true, bool $writable = true): ContentComposer + private function composer(bool $available = true, bool $writable = true, ?WP_Error $refusal = null): ContentComposer { $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn($available); $composer->allows('writable')->andReturn($writable); + $composer->allows('refusal')->andReturn($refusal); $composer->allows('check')->with('page', self::SECTIONS)->andReturn(null); return $composer; @@ -194,6 +197,16 @@ final class ContentCreateDiviTest extends TestCase * Security M2: with the Divi group disabled or "nur lesen", or lit/divi-insert disabled, the * divi input is refused before anything is created; content-create without divi keeps working. */ + public function testLockedDiviRefusesBeforeTheDraftExists(): void + { + $refusal = (new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal(); + Functions\expect('wp_insert_post')->never(); + + $result = $this->ability($this->composer(true, true, $refusal))->execute(self::INPUT); + + self::assertToolError($result, 'divi_untested'); + } + public function testDiviIsRefusedWhenDiviWritesAreSwitchedOff(): void { Functions\expect('wp_insert_post')->never(); @@ -224,6 +237,7 @@ final class ContentCreateDiviTest extends TestCase $composer = Mockery::mock(ContentComposer::class); $composer->allows('available')->andReturn(true); $composer->allows('writable')->andReturn(true); + $composer->allows('refusal')->andReturn(null); $composer->allows('check')->andReturn(Errors::invalidAttrPath(['divi[0].attrs.x' => 'unbekannt'])); $writer = Mockery::mock(PostWriter::class); $writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.'])); @@ -323,6 +337,11 @@ final class ContentCreateDiviTest extends TestCase return true; } + public function refusal(): ?WP_Error + { + return null; + } + public function check(string $postType, array $sections): ?WP_Error { return $this->real->check($postType, $sections);