feat: central Divi release lock for the Divi tools
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,11 @@ use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\AbilityRegistrar;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
use LIT\MCP\Server\RequestContext;
|
||||
@@ -43,7 +48,18 @@ final class AbilityRegistrarTest extends TestCase
|
||||
return new AbilityRegistrar(new IntegrationRegistry($integration));
|
||||
}
|
||||
|
||||
private function ability(): Mockery\MockInterface
|
||||
private function diviRegistrar(Ability $ability): AbilityRegistrar
|
||||
{
|
||||
$integration = Mockery::mock(Integration::class);
|
||||
$integration->allows('id')->andReturn('divi');
|
||||
$integration->allows('isAvailable')->andReturn(true);
|
||||
$integration->allows('abilities')->andReturn([$ability]);
|
||||
$registry = new IntegrationRegistry($integration);
|
||||
|
||||
return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
|
||||
}
|
||||
|
||||
private function ability(bool $readonly = true): Mockery\MockInterface
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability->allows('name')->andReturn('lit/test');
|
||||
@@ -51,7 +67,7 @@ final class AbilityRegistrarTest extends TestCase
|
||||
$ability->allows('description')->andReturn('Test ability');
|
||||
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
|
||||
$ability->allows('outputSchema')->andReturn(['type' => 'object']);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => true]);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
||||
$ability->allows('isDirect')->andReturn(true);
|
||||
|
||||
return $ability;
|
||||
@@ -202,6 +218,60 @@ final class AbilityRegistrarTest extends TestCase
|
||||
unlink($log);
|
||||
}
|
||||
|
||||
public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
|
||||
{
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
|
||||
$ability = $this->ability(false);
|
||||
$ability->expects('execute')->never();
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
|
||||
self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
|
||||
}
|
||||
|
||||
public function testALockThrownInsideAWriteBecomesTheLockError(): void
|
||||
{
|
||||
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']));
|
||||
$ability = $this->ability();
|
||||
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
|
||||
|
||||
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
|
||||
}
|
||||
|
||||
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
|
||||
{
|
||||
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
|
||||
$ability = $this->ability();
|
||||
$ability->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $locked));
|
||||
|
||||
self::assertToolError($this->executeLogged($ability), 'partial_write');
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above).
|
||||
*/
|
||||
private function executeLogged(Ability $ability): mixed
|
||||
{
|
||||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||
$previous = ini_set('error_log', $log);
|
||||
|
||||
try {
|
||||
return ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
|
||||
} finally {
|
||||
ini_set('error_log', (string) $previous);
|
||||
unlink($log);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDiviReadToolsGetWarningsInTheirSchema(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
|
||||
$args = $this->diviRegistrar($ability)->argsFor($ability);
|
||||
|
||||
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
|
||||
}
|
||||
|
||||
public function testRegisterAbilitiesCallsWordPressForEachAbility(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
|
||||
@@ -141,6 +141,7 @@ final class LogListTableTest extends TestCase
|
||||
{
|
||||
return [
|
||||
['partial', 'warning', 'teilweise'],
|
||||
['refused', 'warning', 'abgelehnt'],
|
||||
['failed', 'error', 'fehlgeschlagen'],
|
||||
['<b>kaputt', 'error', 'unbekannt'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Error;
|
||||
|
||||
final class DiviToolGateTest extends TestCase
|
||||
{
|
||||
private const GROUPS = [
|
||||
'lit/divi-insert' => 'divi',
|
||||
'lit/divi-outline' => 'divi',
|
||||
'lit/library-save' => 'divi-library',
|
||||
'lit/tb-create-template' => 'divi-theme-builder',
|
||||
'lit/content-create' => 'wordpress',
|
||||
];
|
||||
|
||||
private function ability(string $name, bool $readonly): Ability
|
||||
{
|
||||
$ability = Mockery::mock(Ability::class);
|
||||
$ability->allows('name')->andReturn($name);
|
||||
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
|
||||
|
||||
return $ability;
|
||||
}
|
||||
|
||||
private function gate(?FakeChangeLog $log = null): DiviToolGate
|
||||
{
|
||||
return new DiviToolGate(static fn (): array => self::GROUPS, $log);
|
||||
}
|
||||
|
||||
private function untested(): void
|
||||
{
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
|
||||
}
|
||||
|
||||
public function testReleasedDiviRefusesNothingAndAddsNothing(): void
|
||||
{
|
||||
$gate = $this->gate(FakeChangeLog::forbidden());
|
||||
|
||||
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
|
||||
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
|
||||
}
|
||||
|
||||
public function testWriteToolOfEveryDiviGroupIsRefused(): void
|
||||
{
|
||||
$this->untested();
|
||||
$log = new FakeChangeLog();
|
||||
$gate = $this->gate($log);
|
||||
|
||||
foreach (['lit/divi-insert', 'lit/library-save', 'lit/tb-create-template'] as $name) {
|
||||
self::assertToolError($gate->refusal($this->ability($name, false)), 'divi_untested', $name);
|
||||
}
|
||||
self::assertNull($gate->refusal($this->ability('lit/content-create', false)), 'not a Divi tool (adapter lock)');
|
||||
self::assertNull($gate->refusal($this->ability('lit/divi-outline', true)), 'reading stays allowed');
|
||||
self::assertSame(['refused', 'refused', 'refused'], array_column($log->records, 'result'));
|
||||
self::assertSame('Abgelehnt: Divi 5.3.0 ist nicht freigegeben.', $log->records[0]['summary']);
|
||||
}
|
||||
|
||||
public function testReadResultsKeepTheirShapeAndGetTheNotice(): void
|
||||
{
|
||||
$this->untested();
|
||||
$gate = $this->gate();
|
||||
$read = $this->ability('lit/divi-outline', true);
|
||||
$notice = 'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.';
|
||||
|
||||
self::assertSame(['post_id' => 7, 'warnings' => [$notice]], $gate->annotate($read, ['post_id' => 7]));
|
||||
self::assertSame(
|
||||
['warnings' => ['alt', $notice]],
|
||||
$gate->annotate($read, ['warnings' => ['alt']])
|
||||
);
|
||||
self::assertSame([1, 2], $gate->annotate($read, [1, 2]), 'a list stays a list');
|
||||
$error = new WP_Error('lit_not_found', 'x');
|
||||
self::assertSame($error, $gate->annotate($read, $error));
|
||||
$other = $this->ability('lit/content-create', true);
|
||||
self::assertSame(['post_id' => 7], $gate->annotate($other, ['post_id' => 7]));
|
||||
}
|
||||
|
||||
public function testOutputSchemaAllowsWarnings(): void
|
||||
{
|
||||
$gate = $this->gate();
|
||||
$read = $this->ability('lit/divi-outline', true);
|
||||
|
||||
$properties = ['post_id' => ['type' => 'integer']];
|
||||
$schema = $gate->outputSchema($read, ['type' => 'object', 'properties' => $properties]);
|
||||
$warnings = ['type' => 'array', 'items' => ['type' => 'string']];
|
||||
self::assertSame($warnings, $schema['properties']['warnings'] ?? null);
|
||||
|
||||
$own = ['type' => 'object', 'properties' => ['warnings' => ['type' => 'array']]];
|
||||
self::assertSame($own, $gate->outputSchema($read, $own), 'an existing warnings property stays');
|
||||
self::assertNull($gate->outputSchema($read, null));
|
||||
self::assertSame(
|
||||
['type' => 'object'],
|
||||
$gate->outputSchema($this->ability('lit/content-create', true), ['type' => 'object'])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user