refactor: ToolAvailability ersetzt die sechs Referenz-Closures in Plugin (Frage vor der Verdrahtung wirft statt still false); DiviIntegration bekommt den Tools-Tab-Zustand als Objekt; Plugin::integrations() mit Verdrahtungstest für Reihenfolge, einmaliges Booten und spätes Binden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6c59934caa
commit
e6e0f914ba
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Abilities;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tools-tab state for integrations that gate their own tools on another group or tool (rulings P9, E9, audit
|
||||||
|
* M2/L5). Integrations get this object before the registry and the policy exist – both need the integrations –
|
||||||
|
* and ask it only at call time; Plugin binds it right after building them. A question before bind() is a wiring
|
||||||
|
* error and throws, instead of quietly answering false.
|
||||||
|
*/
|
||||||
|
final class ToolAvailability
|
||||||
|
{
|
||||||
|
private ?IntegrationRegistry $registry = null;
|
||||||
|
private ?ExposurePolicy $policy = null;
|
||||||
|
|
||||||
|
/** Called once by Plugin after the registry and the policy are built. */
|
||||||
|
public function bind(IntegrationRegistry $registry, ExposurePolicy $policy): void
|
||||||
|
{
|
||||||
|
if ($this->policy !== null) {
|
||||||
|
throw new LogicException('ToolAvailability is already bound.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->registry = $registry;
|
||||||
|
$this->policy = $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether $group is switched on at all (ExposurePolicy::groupEnabled()). */
|
||||||
|
public function groupEnabled(string $group): bool
|
||||||
|
{
|
||||||
|
return $this->policy()->groupEnabled($group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether abilities of $group may write (ExposurePolicy::groupWritable()). */
|
||||||
|
public function groupWritable(string $group): bool
|
||||||
|
{
|
||||||
|
return $this->policy()->groupWritable($group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the ability named $ability is registered and not disabled (ExposurePolicy::allows()). */
|
||||||
|
public function allows(string $ability): bool
|
||||||
|
{
|
||||||
|
$policy = $this->policy();
|
||||||
|
|
||||||
|
return $policy->allows($ability, $this->registry()->abilities());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function policy(): ExposurePolicy
|
||||||
|
{
|
||||||
|
return $this->policy ?? throw new LogicException('ToolAvailability asked before Plugin bound it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registry(): IntegrationRegistry
|
||||||
|
{
|
||||||
|
return $this->registry ?? throw new LogicException('ToolAvailability asked before Plugin bound it.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Integrations\Divi;
|
namespace LIT\MCP\Integrations\Divi;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
use LIT\MCP\Admin\DiviTab;
|
use LIT\MCP\Admin\DiviTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
@@ -83,40 +84,42 @@ final class DiviIntegration implements Integration, Bootable
|
|||||||
private ThemeBuilderAccess $themeBuilderAccess;
|
private ThemeBuilderAccess $themeBuilderAccess;
|
||||||
private DesignSystemWriter $designWriter;
|
private DesignSystemWriter $designWriter;
|
||||||
|
|
||||||
|
/** @var (Closure(): bool)|null Whether lit/divi-insert is enabled (ruling P9, E9, audit M2); null = always. */
|
||||||
|
private ?Closure $diviWritable;
|
||||||
|
|
||||||
|
/** @var (Closure(): bool)|null Whether lit/library-insert is enabled (audit L5); null = always. */
|
||||||
|
private ?Closure $libraryInsertable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
|
* @param ToolAvailability|null $tools The Tools-tab state, asked at call time: the groups "Divi-Bibliothek" and
|
||||||
* (group "Divi-Bibliothek" writable); null = always.
|
* "Divi Theme Builder" gate their entries in the Divi tools (ruling P9, E9), lit/divi-insert gates
|
||||||
* @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group
|
* lit/divi-node-copy, lit-library-insert and content-create's divi input (audit M2), lit/library-insert
|
||||||
* "Divi-Bibliothek" not disabled, ruling P9); null = always.
|
* gates lit/divi-node-copy from a library entry (audit L5). null = no Tools tab: everything allowed.
|
||||||
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy
|
|
||||||
* and lit-library-insert follow it (ruling P9, E9, audit M2); null = always.
|
|
||||||
* @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled –
|
|
||||||
* lit/divi-node-copy follows it for library entries as source (audit L5); null = always.
|
|
||||||
* @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in
|
|
||||||
* the Divi tools (group "Divi Theme Builder" writable, E9); null = always.
|
|
||||||
* @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi
|
|
||||||
* tools at all (group "Divi Theme Builder" not disabled); null = always.
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ContentSchema $schema,
|
private ContentSchema $schema,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
?Closure $libraryWritable = null,
|
private ?ToolAvailability $tools = null
|
||||||
?Closure $libraryVisible = null,
|
|
||||||
private ?Closure $diviWritable = null,
|
|
||||||
private ?Closure $libraryInsertable = null,
|
|
||||||
?Closure $themeBuilderWritable = null,
|
|
||||||
?Closure $themeBuilderVisible = null
|
|
||||||
) {
|
) {
|
||||||
$this->modules = new DiviModules();
|
$this->modules = new DiviModules();
|
||||||
$this->design = new DesignSystemReader();
|
$this->design = new DesignSystemReader();
|
||||||
$this->designWriter = new DesignSystemWriter();
|
$this->designWriter = new DesignSystemWriter();
|
||||||
|
$this->diviWritable = $this->gate(static fn (ToolAvailability $t): bool => $t->allows('lit/divi-insert'));
|
||||||
|
$this->libraryInsertable = $this->gate(
|
||||||
|
static fn (ToolAvailability $t): bool => $t->allows('lit/library-insert')
|
||||||
|
);
|
||||||
$roles = new DiviRoles();
|
$roles = new DiviRoles();
|
||||||
$this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
|
$this->access = new LibraryAccess(
|
||||||
|
new DiviLibraryStore(),
|
||||||
|
$roles,
|
||||||
|
$this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(LibraryAccess::GROUP)),
|
||||||
|
$this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(LibraryAccess::GROUP))
|
||||||
|
);
|
||||||
$this->themeBuilderAccess = new ThemeBuilderAccess(
|
$this->themeBuilderAccess = new ThemeBuilderAccess(
|
||||||
new DiviThemeBuilder(),
|
new DiviThemeBuilder(),
|
||||||
$roles,
|
$roles,
|
||||||
$themeBuilderWritable,
|
$this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(ThemeBuilderAccess::GROUP)),
|
||||||
$themeBuilderVisible
|
$this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(ThemeBuilderAccess::GROUP))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,20 +184,12 @@ final class DiviIntegration implements Integration, Bootable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Divi short form for lit/content-create; available() reports whether Divi 5 is loaded,
|
* Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, writable() follows
|
||||||
* writable() asks $writable (the Tools-tab gate, security M2).
|
* lit/divi-insert's Tools-tab state (security M2).
|
||||||
*
|
|
||||||
* @param (Closure(): bool)|null $writable
|
|
||||||
*/
|
*/
|
||||||
public function contentComposer(?Closure $writable = null): ContentComposer
|
public function contentComposer(): ContentComposer
|
||||||
{
|
{
|
||||||
return new DiviContentComposer(
|
return $this->shortForm($this->diviWritable);
|
||||||
$this->schema,
|
|
||||||
$this->composition(),
|
|
||||||
new DiviSaver(),
|
|
||||||
$this->design,
|
|
||||||
$writable
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,7 +206,7 @@ final class DiviIntegration implements Integration, Bootable
|
|||||||
|
|
||||||
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
|
// Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no
|
||||||
// Tools-tab gate: the check tests Divi, not this site's tool switches.
|
// Tools-tab gate: the check tests Divi, not this site's tool switches.
|
||||||
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer());
|
$check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null));
|
||||||
(new DiviCheckRoute($check))->register();
|
(new DiviCheckRoute($check))->register();
|
||||||
DiviCheckCommand::register($check);
|
DiviCheckCommand::register($check);
|
||||||
|
|
||||||
@@ -318,6 +313,26 @@ final class DiviIntegration implements Integration, Bootable
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A gate on the Tools-tab state for the classes that take a (Closure(): bool)|null: asked at call time, null
|
||||||
|
* without $tools (always allowed).
|
||||||
|
*
|
||||||
|
* @param Closure(ToolAvailability): bool $ask
|
||||||
|
* @return (Closure(): bool)|null
|
||||||
|
*/
|
||||||
|
private function gate(Closure $ask): ?Closure
|
||||||
|
{
|
||||||
|
$tools = $this->tools;
|
||||||
|
|
||||||
|
return $tools === null ? null : static fn (): bool => $ask($tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param (Closure(): bool)|null $writable */
|
||||||
|
private function shortForm(?Closure $writable): DiviContentComposer
|
||||||
|
{
|
||||||
|
return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one wiring of the composition for every tool that composes (spec D7): presets and Divi's option groups come
|
* The one wiring of the composition for every tool that composes (spec D7): presets and Divi's option groups come
|
||||||
* from the design system writer, so a stack or group preset in any write tool resolves against the live book.
|
* from the design system writer, so a stack or group preset in any write tool resolves against the live book.
|
||||||
|
|||||||
+35
-58
@@ -6,6 +6,7 @@ namespace LIT\MCP;
|
|||||||
|
|
||||||
use LIT\MCP\Abilities\AbilityRegistrar;
|
use LIT\MCP\Abilities\AbilityRegistrar;
|
||||||
use LIT\MCP\Abilities\ExposurePolicy;
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
use LIT\MCP\Admin\AccessTab;
|
use LIT\MCP\Admin\AccessTab;
|
||||||
use LIT\MCP\Admin\AdminNotices;
|
use LIT\MCP\Admin\AdminNotices;
|
||||||
use LIT\MCP\Admin\ConnectionRepository;
|
use LIT\MCP\Admin\ConnectionRepository;
|
||||||
@@ -23,9 +24,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
|||||||
use LIT\MCP\Guides\InstructionsBuilder;
|
use LIT\MCP\Guides\InstructionsBuilder;
|
||||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
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\DiviToolGate;
|
||||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
|
||||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||||
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||||
@@ -74,66 +73,14 @@ final class Plugin
|
|||||||
(new ChangeLogCleanup($changeLog, $limits))->register();
|
(new ChangeLogCleanup($changeLog, $limits))->register();
|
||||||
|
|
||||||
$schema = self::contentSchema();
|
$schema = self::contentSchema();
|
||||||
$registry = null;
|
$tools = new ToolAvailability();
|
||||||
$policy = null;
|
$registry = self::integrations($schema, $changeLog, $limits, $tools);
|
||||||
// Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries.
|
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||||
$libraryVisible = static function () use (&$policy): bool {
|
$tools->bind($registry, $policy);
|
||||||
return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP);
|
|
||||||
};
|
|
||||||
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
|
|
||||||
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
|
|
||||||
$diviWritable = static function () use (&$registry, &$policy): bool {
|
|
||||||
return $registry instanceof IntegrationRegistry
|
|
||||||
&& $policy instanceof ExposurePolicy
|
|
||||||
&& $policy->allows('lit/divi-insert', $registry->abilities());
|
|
||||||
};
|
|
||||||
// The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
|
|
||||||
$libraryWritable = static function () use (&$policy): bool {
|
|
||||||
return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
|
|
||||||
};
|
|
||||||
// Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state.
|
|
||||||
$libraryInsertable = static function () use (&$registry, &$policy): bool {
|
|
||||||
return $registry instanceof IntegrationRegistry
|
|
||||||
&& $policy instanceof ExposurePolicy
|
|
||||||
&& $policy->allows('lit/library-insert', $registry->abilities());
|
|
||||||
};
|
|
||||||
// Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9).
|
|
||||||
$themeBuilderVisible = static function () use (&$policy): bool {
|
|
||||||
return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP);
|
|
||||||
};
|
|
||||||
$themeBuilderWritable = static function () use (&$policy): bool {
|
|
||||||
return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP);
|
|
||||||
};
|
|
||||||
$divi = new DiviIntegration(
|
|
||||||
$schema,
|
|
||||||
$changeLog,
|
|
||||||
$libraryWritable,
|
|
||||||
libraryVisible: $libraryVisible,
|
|
||||||
diviWritable: $diviWritable,
|
|
||||||
libraryInsertable: $libraryInsertable,
|
|
||||||
themeBuilderWritable: $themeBuilderWritable,
|
|
||||||
themeBuilderVisible: $themeBuilderVisible
|
|
||||||
);
|
|
||||||
$registry = new IntegrationRegistry(
|
|
||||||
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
|
|
||||||
new WordPressIntegration(
|
|
||||||
$schema,
|
|
||||||
$changeLog,
|
|
||||||
$divi->contentComposer($diviWritable),
|
|
||||||
$divi->libraryIdsVisible(...),
|
|
||||||
$divi->builderDetector()
|
|
||||||
),
|
|
||||||
new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
|
|
||||||
$divi,
|
|
||||||
$divi->library(),
|
|
||||||
$divi->themeBuilder()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
|
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
|
||||||
// page reads its tabs.
|
// page reads its tabs.
|
||||||
$registry->boot();
|
$registry->boot();
|
||||||
|
|
||||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
|
||||||
(new AbilityRegistrar(
|
(new AbilityRegistrar(
|
||||||
$registry,
|
$registry,
|
||||||
$policy,
|
$policy,
|
||||||
@@ -172,6 +119,36 @@ final class Plugin
|
|||||||
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
\WPMedia\MCP\OAuth\Bootstrap::instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The integrations in registry order – the order of the Tools tab, the guide and the server instructions. The
|
||||||
|
* Divi integration and its groups share $tools, which Plugin binds once registry and policy exist.
|
||||||
|
*
|
||||||
|
* @internal Public for PluginTest only.
|
||||||
|
*/
|
||||||
|
public static function integrations(
|
||||||
|
ContentSchema $schema,
|
||||||
|
ChangeLog $changeLog,
|
||||||
|
Limits $limits,
|
||||||
|
ToolAvailability $tools
|
||||||
|
): IntegrationRegistry {
|
||||||
|
$divi = new DiviIntegration($schema, $changeLog, $tools);
|
||||||
|
|
||||||
|
return new IntegrationRegistry(
|
||||||
|
new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]),
|
||||||
|
new WordPressIntegration(
|
||||||
|
$schema,
|
||||||
|
$changeLog,
|
||||||
|
$divi->contentComposer(),
|
||||||
|
$divi->libraryIdsVisible(...),
|
||||||
|
$divi->builderDetector()
|
||||||
|
),
|
||||||
|
new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()),
|
||||||
|
$divi,
|
||||||
|
$divi->library(),
|
||||||
|
$divi->themeBuilder()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function settingsPage(): ?SettingsPage
|
public static function settingsPage(): ?SettingsPage
|
||||||
{
|
{
|
||||||
return self::$settingsPage;
|
return self::$settingsPage;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
use LogicException;
|
||||||
|
use Mockery;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ToolAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function registry(string ...$names): IntegrationRegistry
|
||||||
|
{
|
||||||
|
$abilities = array_map(static function (string $name): Ability {
|
||||||
|
$ability = Mockery::mock(Ability::class);
|
||||||
|
$ability->allows('name')->andReturn($name);
|
||||||
|
$ability->allows('isDirect')->andReturn(false);
|
||||||
|
|
||||||
|
return $ability;
|
||||||
|
}, $names);
|
||||||
|
$integration = Mockery::mock(Integration::class);
|
||||||
|
$integration->allows('id')->andReturn('divi');
|
||||||
|
$integration->allows('isAvailable')->andReturn(true);
|
||||||
|
$integration->allows('abilities')->andReturn($abilities);
|
||||||
|
|
||||||
|
return new IntegrationRegistry($integration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The temporal coupling of the former use-by-reference closures: they answered false until Plugin set $policy. */
|
||||||
|
public function testAQuestionBeforeBindIsAWiringErrorNotFalse(): void
|
||||||
|
{
|
||||||
|
$tools = new ToolAvailability();
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
static fn (): bool => $tools->groupEnabled('divi-library'),
|
||||||
|
static fn (): bool => $tools->groupWritable('divi-library'),
|
||||||
|
static fn (): bool => $tools->allows('lit/divi-insert'),
|
||||||
|
] as $ask) {
|
||||||
|
try {
|
||||||
|
$ask();
|
||||||
|
self::fail('answered before bind()');
|
||||||
|
} catch (LogicException $e) {
|
||||||
|
self::assertSame('ToolAvailability asked before Plugin bound it.', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A gate built before bind() (as DiviIntegration builds its gates) answers from the policy bound later. */
|
||||||
|
public function testAGateBuiltBeforeBindResolvesLazily(): void
|
||||||
|
{
|
||||||
|
$tools = new ToolAvailability();
|
||||||
|
$libraryVisible = static fn (): bool => $tools->groupEnabled('divi-library');
|
||||||
|
$libraryWritable = static fn (): bool => $tools->groupWritable('divi-library');
|
||||||
|
$diviWritable = static fn (): bool => $tools->allows('lit/divi-insert');
|
||||||
|
|
||||||
|
$tools->bind(self::registry('lit/divi-insert'), new ExposurePolicy(['@readonly:divi-library' => true]));
|
||||||
|
|
||||||
|
self::assertTrue($libraryVisible());
|
||||||
|
self::assertFalse($libraryWritable());
|
||||||
|
self::assertTrue($diviWritable());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFollowsTheBoundPolicy(): void
|
||||||
|
{
|
||||||
|
$tools = new ToolAvailability();
|
||||||
|
$tools->bind(
|
||||||
|
self::registry('lit/divi-insert', 'lit/library-insert'),
|
||||||
|
new ExposurePolicy([
|
||||||
|
'@group:divi-theme-builder' => ExposurePolicy::DISABLED,
|
||||||
|
'lit/library-insert' => ExposurePolicy::DISABLED,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($tools->groupEnabled('divi-theme-builder'));
|
||||||
|
self::assertFalse($tools->groupWritable('divi-theme-builder'));
|
||||||
|
self::assertTrue($tools->groupEnabled('divi-library'));
|
||||||
|
self::assertTrue($tools->groupWritable('divi-library'));
|
||||||
|
self::assertTrue($tools->allows('lit/divi-insert'));
|
||||||
|
self::assertFalse($tools->allows('lit/library-insert'));
|
||||||
|
self::assertFalse($tools->allows('lit/unknown'), 'not registered = not allowed (ExposurePolicy::allows)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBindsOnlyOnce(): void
|
||||||
|
{
|
||||||
|
$tools = new ToolAvailability();
|
||||||
|
$tools->bind(self::registry(), new ExposurePolicy());
|
||||||
|
|
||||||
|
$this->expectException(LogicException::class);
|
||||||
|
$this->expectExceptionMessage('ToolAvailability is already bound.');
|
||||||
|
$tools->bind(self::registry(), new ExposurePolicy());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use Brain\Monkey\Actions;
|
|||||||
use Brain\Monkey\Filters;
|
use Brain\Monkey\Filters;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
use LIT\MCP\Admin\AdminTab;
|
use LIT\MCP\Admin\AdminTab;
|
||||||
use LIT\MCP\Admin\DiviTab;
|
use LIT\MCP\Admin\DiviTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
@@ -19,6 +21,8 @@ use LIT\MCP\Integrations\Divi\DiviLogoUsage;
|
|||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||||
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
use LIT\MCP\Integrations\Media\MediaUsageSource;
|
use LIT\MCP\Integrations\Media\MediaUsageSource;
|
||||||
use LIT\MCP\Integrations\WordPress\BuilderDetector;
|
use LIT\MCP\Integrations\WordPress\BuilderDetector;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
@@ -183,13 +187,29 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertSame(1, $report->blockedHidden);
|
self::assertSame(1, $report->blockedHidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A Tools-tab state bound to $policy, with lit/divi-insert registered (the Divi group available). */
|
||||||
|
private static function tools(ExposurePolicy $policy = new ExposurePolicy()): ToolAvailability
|
||||||
|
{
|
||||||
|
$insert = Mockery::mock(Ability::class);
|
||||||
|
$insert->allows('name')->andReturn('lit/divi-insert');
|
||||||
|
$insert->allows('isDirect')->andReturn(true);
|
||||||
|
$divi = Mockery::mock(Integration::class);
|
||||||
|
$divi->allows('id')->andReturn('divi');
|
||||||
|
$divi->allows('isAvailable')->andReturn(true);
|
||||||
|
$divi->allows('abilities')->andReturn([$insert]);
|
||||||
|
$tools = new ToolAvailability();
|
||||||
|
$tools->bind(new IntegrationRegistry($divi), $policy);
|
||||||
|
|
||||||
|
return $tools;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
|
/** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
|
||||||
public function testNodeCopyFollowsLitDiviInsert(): void
|
public function testNodeCopyFollowsLitDiviInsert(): void
|
||||||
{
|
{
|
||||||
$integration = new DiviIntegration(
|
$integration = new DiviIntegration(
|
||||||
Mockery::mock(ContentSchema::class),
|
Mockery::mock(ContentSchema::class),
|
||||||
FakeChangeLog::forbidden(),
|
FakeChangeLog::forbidden(),
|
||||||
diviWritable: static fn (): bool => false
|
self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
|
||||||
);
|
);
|
||||||
$copy = null;
|
$copy = null;
|
||||||
foreach ($integration->abilities() as $ability) {
|
foreach ($integration->abilities() as $ability) {
|
||||||
@@ -202,6 +222,22 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported');
|
self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Security M2: content-create's divi input follows lit/divi-insert, asked when content-create asks. */
|
||||||
|
public function testTheShortFormFollowsLitDiviInsert(): void
|
||||||
|
{
|
||||||
|
$schema = Mockery::mock(ContentSchema::class);
|
||||||
|
$open = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
|
||||||
|
$closed = new DiviIntegration(
|
||||||
|
$schema,
|
||||||
|
FakeChangeLog::forbidden(),
|
||||||
|
self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED]))
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($open->contentComposer()->writable());
|
||||||
|
self::assertFalse($closed->contentComposer()->writable());
|
||||||
|
self::assertTrue((new DiviIntegration($schema, FakeChangeLog::forbidden()))->contentComposer()->writable());
|
||||||
|
}
|
||||||
|
|
||||||
public function testGivesContentCreateTheDiviShortForm(): void
|
public function testGivesContentCreateTheDiviShortForm(): void
|
||||||
{
|
{
|
||||||
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
|
||||||
@@ -221,21 +257,22 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
$outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]);
|
$outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]);
|
||||||
|
|
||||||
// No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12);
|
// No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12);
|
||||||
// the wiring shows in which gate the integration asks.
|
// the wiring shows in which gate the integration asks: an unbound ToolAvailability throws when asked.
|
||||||
$asked = 0;
|
$visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools());
|
||||||
$visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool {
|
|
||||||
++$asked;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
self::assertToolError($outline($visible), 'not_found');
|
self::assertToolError($outline($visible), 'not_found');
|
||||||
self::assertSame(1, $asked);
|
|
||||||
self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found');
|
self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found');
|
||||||
|
|
||||||
$hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false);
|
$hidden = new DiviIntegration(
|
||||||
|
$schema,
|
||||||
|
FakeChangeLog::forbidden(),
|
||||||
|
self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
|
||||||
|
);
|
||||||
$refusal = $outline($hidden);
|
$refusal = $outline($hidden);
|
||||||
self::assertInstanceOf(WP_Error::class, $refusal);
|
self::assertInstanceOf(WP_Error::class, $refusal);
|
||||||
self::assertToolError($refusal, 'not_found');
|
self::assertToolError($refusal, 'not_found');
|
||||||
|
|
||||||
|
$this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.');
|
||||||
|
$outline(new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -247,16 +284,16 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
{
|
{
|
||||||
LibraryFixtures::given([]);
|
LibraryFixtures::given([]);
|
||||||
$schema = Mockery::mock(ContentSchema::class);
|
$schema = Mockery::mock(ContentSchema::class);
|
||||||
$asked = 0;
|
$hidden = new DiviIntegration(
|
||||||
$hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static function () use (&$asked): bool {
|
$schema,
|
||||||
++$asked;
|
FakeChangeLog::forbidden(),
|
||||||
|
self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]))
|
||||||
return false;
|
);
|
||||||
});
|
|
||||||
|
|
||||||
self::assertFalse($hidden->libraryIdsVisible());
|
self::assertFalse($hidden->libraryIdsVisible());
|
||||||
self::assertSame(1, $asked);
|
|
||||||
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
|
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
|
||||||
|
$this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.');
|
||||||
|
(new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()))->libraryIdsVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */
|
/** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit;
|
||||||
|
|
||||||
|
use Brain\Monkey\Actions;
|
||||||
|
use Brain\Monkey\Filters;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Abilities\ToolAvailability;
|
||||||
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
use LIT\MCP\Limits;
|
||||||
|
use LIT\MCP\Plugin;
|
||||||
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
/** The wiring of the composition root: Plugin::integrations() and the steps Plugin::boot() takes with it. */
|
||||||
|
final class PluginTest extends TestCase
|
||||||
|
{
|
||||||
|
private static function registry(ToolAvailability $tools = new ToolAvailability()): IntegrationRegistry
|
||||||
|
{
|
||||||
|
return Plugin::integrations(
|
||||||
|
Mockery::mock(ContentSchema::class),
|
||||||
|
FakeChangeLog::forbidden(),
|
||||||
|
new Limits(),
|
||||||
|
$tools
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry order = order of the Tools tab, the guide and the server instructions. */
|
||||||
|
public function testIntegrationsInRegistryOrder(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder'],
|
||||||
|
self::registry()->ids()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the Divi integration has boot steps; a second boot registers nothing twice. */
|
||||||
|
public function testTheBootableIntegrationsBootExactlyOnce(): void
|
||||||
|
{
|
||||||
|
Actions\expectAdded('upgrader_process_complete')->once();
|
||||||
|
Actions\expectAdded('rest_api_init')->once();
|
||||||
|
Filters\expectAdded(SettingsPage::TABS_FILTER)->once();
|
||||||
|
$registry = self::registry();
|
||||||
|
|
||||||
|
$registry->boot();
|
||||||
|
$registry->boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No temporal coupling: building and booting the integrations never asks the Tools-tab state (an unbound
|
||||||
|
* ToolAvailability throws when asked); bound afterwards, it answers from the bound policy.
|
||||||
|
*/
|
||||||
|
public function testTheToolsTabStateIsAskedOnlyAfterBind(): void
|
||||||
|
{
|
||||||
|
Functions\stubTranslationFunctions();
|
||||||
|
$tools = new ToolAvailability();
|
||||||
|
$registry = self::registry($tools);
|
||||||
|
$registry->boot();
|
||||||
|
|
||||||
|
$tools->bind($registry, new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED]));
|
||||||
|
|
||||||
|
self::assertFalse($tools->groupEnabled('divi-library'));
|
||||||
|
self::assertTrue($tools->groupWritable('divi-theme-builder'));
|
||||||
|
self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user