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:
co-authored by
Claude Sonnet 5.5
parent
a7a9c09de8
commit
abeb4b0eed
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user