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

656 lines
26 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\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
use LIT\MCP\Tests\Support\ReleaseMatrices;
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 DiviFeatureGate()]);
}
/** @param list<DiviFeature>|null $features null = a tool that declares no Divi features */
private function ability(bool $readonly = true, ?array $features = null): Mockery\MockInterface
{
$ability = $features === null
? Mockery::mock(Ability::class)
: Mockery::mock(Ability::class, RequiresDiviFeatures::class);
if ($features !== null) {
$ability->allows('diviFeatures')->andReturn($features);
}
$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 testALockedFeatureRefusesAWritingDiviToolBeforeItRuns(): void
{
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
$ability = $this->ability(false, [DiviFeature::PageRead, DiviFeature::PageWrite]);
$ability->expects('execute')->never();
$args = $this->diviRegistrar($ability)->argsFor($ability);
$payload = self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
self::assertSame(['page-write'], array_column($payload['features'], 'id'));
}
public function testALockedFeatureRefusesAReadingDiviToolToo(): void
{
DiviFeatureFixtures::lock(DiviFeature::DesignRead, ReleaseState::Incompatible);
$ability = $this->ability(true, [DiviFeature::PageRead, DiviFeature::DesignRead]);
$ability->expects('execute')->never();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertToolError(($args['execute_callback'])([]), 'divi_incompatible');
}
public function testAToolRunsAndKeepsItsResultWhileOnlyOtherFeaturesAreLocked(): void
{
DiviFeatureFixtures::lock(DiviFeature::PresetWrite);
$ability = $this->ability(false, [DiviFeature::PageRead, DiviFeature::PageWrite]);
$ability->expects('execute')->once()->with(['post_id' => 3])->andReturn(['ok' => true]);
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertSame(['ok' => true], ($args['execute_callback'])(['post_id' => 3]), 'no notice is added (E2)');
}
public function testAToolThatDeclaresNoFeaturesIsNotGated(): void
{
DiviFeatureFixtures::useStates(array_fill_keys(
array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()),
ReleaseState::Incompatible
));
$ability = $this->ability(false);
$ability->expects('execute')->once()->andReturn(['ok' => true]);
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertSame(['ok' => true], ($args['execute_callback'])([]));
}
public function testALockThrownInsideAWriteBecomesTheLockError(): void
{
$locked = new DiviLocked(
ReleaseMatrices::uniform(ReleaseState::Incompatible, '5.3.0', ['x']),
[DiviFeature::PageWrite]
);
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
}
public function testAFeatureLockThrownInsideAWriteBecomesTheFeatureError(): void
{
DiviFeatureFixtures::lock(DiviFeature::LibraryWrite);
$locked = new DiviLocked(ReleaseGate::status(), [DiviFeature::LibraryWrite]);
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
$payload = self::assertToolError($this->executeLogged($ability), 'divi_untested');
self::assertSame(['library-write'], array_column($payload['features'], 'id'));
}
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
{
$locked = new DiviLocked(ReleaseMatrices::uniform(ReleaseState::Untested, '5.3.0'), [DiviFeature::PageWrite]);
$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);
}
private function throwingGuard(): AbilityGuard
{
return new class implements AbilityGuard {
public function refusal(Ability $ability, array $input): ?WP_Error
{
throw new RuntimeException('secret path /var/www/x.php');
}
public function annotate(Ability $ability, mixed $result): mixed
{
return $result;
}
public function outputSchema(Ability $ability, ?array $schema): ?array
{
return $schema;
}
};
}
/** @param callable(): mixed $call */
private function withErrorLog(callable $call): mixed
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
return $call();
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
}
public function testAGuardRefusalInThePermissionCallbackSkipsPermissionAndExecute(): void
{
Functions\when('current_user_can')->justReturn(true);
$seen = [];
$ability = $this->ability(false);
$ability->shouldNotReceive('permission');
$ability->expects('execute')->never();
$registrar = $this->guardedRegistrar($ability, [$this->guard('a', Errors::forbidden('Nein.'), $seen)]);
$result = ($registrar->argsFor($ability)['permission_callback'])(['x' => 1]);
self::assertToolError($result, 'forbidden');
self::assertSame([['x' => 1]], $seen);
}
public function testAReadToolWhosePermissionWouldThrowGetsTheGuardRefusalInstead(): void
{
Functions\when('current_user_can')->justReturn(true);
$ability = $this->ability(true);
$ability->shouldNotReceive('permission');
$registrar = $this->guardedRegistrar($ability, [$this->guard('a', Errors::forbidden('Gesperrt.'))]);
self::assertToolError(($registrar->argsFor($ability)['permission_callback'])([]), 'forbidden');
}
public function testAPassingGuardLeavesThePermissionToTheAbility(): void
{
Functions\when('current_user_can')->justReturn(true);
$ability = $this->ability();
$ability->expects('permission')->with(['x' => 1])->andReturn(true);
$registrar = $this->guardedRegistrar($ability, [$this->guard('a')]);
self::assertTrue(($registrar->argsFor($ability)['permission_callback'])(['x' => 1]));
}
public function testAThrowingGuardIsAnInternalErrorInThePermissionCallback(): void
{
Functions\when('current_user_can')->justReturn(true);
$ability = $this->ability(false);
$ability->shouldNotReceive('permission');
$ability->expects('execute')->never();
$registrar = $this->guardedRegistrar($ability, [$this->throwingGuard()]);
$result = $this->withErrorLog(
static fn (): mixed => ($registrar->argsFor($ability)['permission_callback'])([])
);
$payload = self::assertToolError($result, 'internal_error');
self::assertStringNotContainsString('/var/www', $result->get_error_message());
self::assertIsArray($payload);
}
public function testAThrowingGuardIsAnInternalErrorInTheExecuteCallbackAndNothingRuns(): void
{
$ability = $this->ability(false);
$ability->expects('execute')->never();
$registrar = $this->guardedRegistrar($ability, [$this->throwingGuard()]);
$result = $this->withErrorLog(
static fn (): mixed => ($registrar->argsFor($ability)['execute_callback'])([])
);
self::assertToolError($result, 'internal_error');
}
public function testARealFeatureGateRefusesAReadToolInThePermissionCallback(): void
{
Functions\when('current_user_can')->justReturn(true);
DiviFeatureFixtures::lock(DiviFeature::PageRead);
$ability = $this->ability(true, [DiviFeature::PageRead]);
$ability->shouldNotReceive('permission');
$ability->expects('execute')->never();
$result = ($this->diviRegistrar($ability)->argsFor($ability)['permission_callback'])(['post_id' => 7]);
$payload = self::assertToolError($result, 'divi_untested');
self::assertSame(['page-read'], array_column($payload['features'], 'id'));
}
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 testDiviToolsGetWarningsInTheirSchemaOthersDoNot(): void
{
$divi = $this->ability(true, [DiviFeature::PageRead]);
$core = $this->ability();
$args = $this->diviRegistrar($divi)->argsFor($divi);
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
$coreArgs = $this->diviRegistrar($core)->argsFor($core);
self::assertSame(['type' => 'object'], $coreArgs['output_schema']);
}
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();
}
}