Files
WP-LIT-MCP/tests/Unit/Abilities/AbilityRegistrarTest.php
T
2026-09-30 01:54:36 +02:00

334 lines
13 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\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;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
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');
}
/**
* 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();
}
}