Files
WP-LIT-MCP/tests/Unit/Abilities/AbilityRegistrarTest.php
T

479 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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;
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;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use Throwable;
use WP_Error;
final class AbilityRegistrarTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Default to "inside the OAuth server request" so tests unrelated to this
// gate keep exercising the ability's own permission() as before.
RequestContext::reset(true);
}
protected function tearDown(): void
{
RequestContext::reset();
parent::tearDown();
}
private function registrarFor(Ability $ability): 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));
}
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');
$ability->allows('label')->andReturn('Test');
$ability->allows('description')->andReturn('Test ability');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(['type' => 'object']);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$ability->allows('isDirect')->andReturn(true);
return $ability;
}
public function testArgsExposeAbilityPubliclyToMcpWithAnnotations(): void
{
$args = $this->registrarFor($this->ability())->argsFor($this->ability());
self::assertSame('Test', $args['label']);
self::assertSame(AbilityRegistrar::CATEGORY, $args['category']);
self::assertSame(['type' => 'object'], $args['output_schema']);
self::assertTrue($args['meta']['mcp']['public']);
self::assertSame(['readonly' => true], $args['meta']['annotations']);
self::assertFalse($args['meta']['show_in_rest']);
}
public function testDirectOnlyAbilitiesAreNotPublicForTheMetaTools(): void
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn('lit/media-download');
$ability->allows('label')->andReturn('Download');
$ability->allows('description')->andReturn('Download');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(null);
$ability->allows('annotations')->andReturn(['readonly' => true]);
$ability->allows('isDirect')->andReturn(true);
$args = $this->registrarFor($ability)->argsFor($ability);
self::assertFalse($args['meta']['mcp']['public']);
self::assertSame('tool', $args['meta']['mcp']['type']);
self::assertIsCallable($args['execute_callback']);
}
public function testPermissionDeniedWithoutLitCapability(): void
{
Functions\when('current_user_can')->justReturn(false);
$ability = $this->ability();
$ability->shouldNotReceive('permission');
$result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]);
self::assertInstanceOf(WP_Error::class, $result);
self::assertToolError($result, 'forbidden');
}
public function testPermissionDeniedOutsideOAuthServerRequest(): void
{
Functions\when('current_user_can')->justReturn(true);
RequestContext::reset(false);
$ability = $this->ability();
$ability->shouldNotReceive('permission');
$result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]);
self::assertInstanceOf(WP_Error::class, $result);
self::assertToolError($result, 'forbidden');
}
public function testPermissionDelegatesToAbilityWhenGatePasses(): void
{
Functions\when('current_user_can')->justReturn(true);
$ability = $this->ability();
$ability->expects('permission')->with(['x' => 1])->andReturn(true);
$result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])(['x' => 1]);
self::assertTrue($result);
}
public function testNullInputIsPassedAsEmptyArray(): void
{
$ability = $this->ability();
$ability->expects('execute')->with([])->andReturn(['ok' => true]);
$result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])(null);
self::assertSame(['ok' => true], $result);
}
public function testPermissionExceptionBecomesNeutralInternalError(): void
{
Functions\when('current_user_can')->justReturn(true);
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$ability = $this->ability();
$ability->expects('permission')->andThrow(new RuntimeException('secret path /var/www/x.php'));
try {
$result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertInstanceOf(WP_Error::class, $result);
self::assertToolError($result, 'internal_error');
self::assertStringNotContainsString('/var/www', $result->get_error_message());
self::assertStringContainsString('lit/test failed', (string) file_get_contents($log));
unlink($log);
}
public function testExceptionBecomesNeutralInternalError(): void
{
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$ability = $this->ability();
$ability->expects('execute')->andThrow(new RuntimeException('secret path /var/www/x.php'));
try {
$result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertInstanceOf(WP_Error::class, $result);
self::assertToolError($result, 'internal_error');
self::assertStringNotContainsString('/var/www', $result->get_error_message());
self::assertStringContainsString('lit/test failed', (string) file_get_contents($log));
unlink($log);
}
public function testWriteFailedBecomesItsOwnErrorAndTheDetailIsLogged(): void
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
$ability = $this->ability();
$ability->expects('execute')->andThrow(
WriteFailed::partial(
'Felder konnten nicht gespeichert werden.',
new RuntimeException('meta /var/www'),
postId: 5,
revisionId: 811
)
);
try {
$result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertToolError($result, 'partial_write');
self::assertStringNotContainsString('/var/www', $result->get_error_message());
self::assertStringContainsString('meta /var/www', (string) file_get_contents($log));
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');
}
/** @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).
*/
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();
Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array'));
$this->registrarFor($ability)->registerAbilities();
}
public function testDisabledAbilitiesAreNotRegistered(): void
{
$ability = $this->ability();
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('core');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
Functions\expect('wp_register_ability')->never();
(new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled'])))
->registerAbilities();
}
public function testGroupDisabledAbilitiesAreNotRegistered(): void
{
$ability = $this->ability();
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('divi');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
Functions\expect('wp_register_ability')->never();
(new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap())))
->registerAbilities();
}
/**
* Security M2 follow-up: a write tool of a "nur lesen" group is never registered, so
* mcp-adapter-execute-ability cannot reach it either (it resolves abilities via
* wp_get_ability()); the group's read tools stay.
*/
public function testReadonlyGroupWriteAbilitiesAreNotRegisteredForExecuteAbilityEither(): void
{
$write = Mockery::mock(Ability::class);
$write->allows('name')->andReturn('lit/divi-insert');
$write->allows('isDirect')->andReturn(true);
$write->allows('annotations')->andReturn(['readonly' => false]);
$read = $this->ability();
$read->allows('name')->andReturn('lit/test');
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('divi');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$write, $read]);
$registry = new IntegrationRegistry($integration);
Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array'));
(new AbilityRegistrar($registry, (new ExposurePolicy(['@readonly:divi' => true]))->withGroups($registry->groupMap())))
->registerAbilities();
}
}