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|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(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 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(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 $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(); } }