refactor: AbilityRegistrar nimmt AbilityGuard-Liste und ToolRefusal statt DiviToolGate/DiviLocked; DiviToolGate implementiert AbilityGuard, DiviLocked ToolRefusal; Test mit Fake-Guard und Fake-Refusal ohne Divi

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 21:47:26 +02:00
co-authored by Claude Sonnet 5.5
parent a7a9c09de8
commit abeb4b0eed
8 changed files with 257 additions and 26 deletions
+146 -1
View File
@@ -6,8 +6,11 @@ namespace LIT\MCP\Tests\Unit\Abilities;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolRefusal;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
@@ -20,6 +23,7 @@ use LIT\MCP\Server\RequestContext;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use Throwable;
use WP_Error;
final class AbilityRegistrarTest extends TestCase
@@ -56,7 +60,7 @@ final class AbilityRegistrarTest extends TestCase
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
return new AbilityRegistrar($registry, null, [new DiviToolGate($registry->groupMap(...))]);
}
private function ability(bool $readonly = true): Mockery\MockInterface
@@ -247,6 +251,147 @@ final class AbilityRegistrarTest extends TestCase
self::assertToolError($this->executeLogged($ability), 'partial_write');
}
/** @param list<AbilityGuard> $guards */
private function guardedRegistrar(Ability $ability, array $guards): AbilityRegistrar
{
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('core');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
return new AbilityRegistrar(new IntegrationRegistry($integration), null, $guards);
}
/**
* A guard without any Divi knowledge: refuses with $refusal (or lets through), appends $tag to the result's
* "trail" and to the schema's "trail" property; $seen collects the inputs it was asked about.
*/
private function guard(string $tag, ?WP_Error $refusal = null, array &$seen = []): AbilityGuard
{
return new class ($tag, $refusal, $seen) implements AbilityGuard {
public function __construct(private string $tag, private ?WP_Error $refusal, private array &$seen)
{
}
public function refusal(Ability $ability, array $input): ?WP_Error
{
$this->seen[] = $input;
return $this->refusal;
}
public function annotate(Ability $ability, mixed $result): mixed
{
$result['trail'] = [...($result['trail'] ?? []), $this->tag];
return $result;
}
public function outputSchema(Ability $ability, ?array $schema): ?array
{
$schema['properties']['trail'][] = $this->tag;
return $schema;
}
};
}
private function refusalException(WP_Error $error, ?Throwable $previous = null): RuntimeException
{
return new class ($error, $previous) extends RuntimeException implements ToolRefusal {
public function __construct(private WP_Error $error, ?Throwable $previous)
{
parent::__construct('refused', 0, $previous);
}
public function toError(): WP_Error
{
return $this->error;
}
};
}
public function testWithoutGuardsNothingIsCheckedOrAdded(): void
{
$ability = $this->ability(false);
$ability->expects('execute')->with(['a' => 1])->andReturn(['ok' => true]);
$args = $this->registrarFor($ability)->argsFor($ability);
self::assertSame(['ok' => true], ($args['execute_callback'])(['a' => 1]));
self::assertSame(['type' => 'object'], $args['output_schema']);
}
public function testAGuardRefusesBeforeTheAbilityRuns(): void
{
$seen = [];
$ability = $this->ability(false);
$ability->expects('execute')->never();
$registrar = $this->guardedRegistrar($ability, [$this->guard('a', Errors::forbidden('Nein.'), $seen)]);
$result = ($registrar->argsFor($ability)['execute_callback'])(['x' => 1]);
self::assertToolError($result, 'forbidden');
self::assertSame([['x' => 1]], $seen);
}
public function testTheFirstRefusalWinsAndLaterGuardsAreNotAsked(): void
{
$seenSecond = [];
$ability = $this->ability(false);
$ability->expects('execute')->never();
$registrar = $this->guardedRegistrar($ability, [
$this->guard('a'),
$this->guard('b', Errors::notFound('Weg.')),
$this->guard('c', Errors::forbidden('Nein.'), $seenSecond),
]);
self::assertToolError(($registrar->argsFor($ability)['execute_callback'])([]), 'not_found');
self::assertSame([], $seenSecond);
}
public function testGuardsAnnotateTheResultAndExtendTheSchemaInOrder(): void
{
$ability = $this->ability(false);
$ability->allows('execute')->andReturn(['ok' => true]);
$registrar = $this->guardedRegistrar($ability, [$this->guard('a'), $this->guard('b')]);
$args = $registrar->argsFor($ability);
self::assertSame(['ok' => true, 'trail' => ['a', 'b']], ($args['execute_callback'])([]));
self::assertSame(['a', 'b'], $args['output_schema']['properties']['trail']);
}
public function testAToolRefusalThrownByAnAbilityIsItsErrorWithoutDivi(): void
{
$ability = $this->ability();
$ability->allows('execute')->andThrow($this->refusalException(Errors::forbidden('Gesperrt.')));
self::assertToolError($this->executeLogged($ability), 'forbidden');
}
public function testAToolRefusalInsideARolledBackWriteIsItsErrorButPartialStaysPartial(): void
{
$refusal = $this->refusalException(Errors::notFound('Weg.'));
$rolledBack = $this->ability();
$rolledBack->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $refusal));
$partial = $this->ability();
$partial->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $refusal));
self::assertToolError($this->executeLogged($rolledBack), 'not_found');
self::assertToolError($this->executeLogged($partial), 'partial_write');
}
public function testTheFirstRefusalInTheCauseChainWins(): void
{
$inner = $this->refusalException(Errors::notFound('Innen.'));
$outer = $this->refusalException(Errors::forbidden('Außen.'), $inner);
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $outer));
self::assertToolError($this->executeLogged($ability), 'forbidden');
}
/**
* Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above).
*/
@@ -47,7 +47,7 @@ final class DiviToolGateTest extends TestCase
{
$gate = $this->gate(FakeChangeLog::forbidden());
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false)));
self::assertNull($gate->refusal($this->ability('lit/divi-insert', false), []));
self::assertSame(['ok' => true], $gate->annotate($this->ability('lit/divi-outline', true), ['ok' => true]));
}
@@ -58,10 +58,10 @@ final class DiviToolGateTest extends TestCase
$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::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::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']);
}