Files
WP-LIT-MCP/tests/Unit/AccessTest.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 165e61d7fe fix(security): headingLevel prüfen, überschreibende Tools kennzeichnen, Meta-Tools auf lit/ beschränken
- Divi-Vollformat: headingLevel nur h1 bis h6 (ValueCleaner, StoredNodeGuard, ShortformWriter);
  sonst ließ sich für Konten ohne unfiltered_html ein Skript-Element erzeugen.
- content-update, divi-update und wp-settings-update sind destructive, damit MCP-Clients nachfragen.
- mcp-adapter/execute-ability und get-ability-info nehmen auf dem OAuth-Server nur lit/-Abilities an.
- release-publish.sh übergibt den Gitea-Token per Konfigurationsdatei statt als curl-Argument.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 16:36:16 +01:00

168 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Access;
use LIT\MCP\Server\RequestContext;
use WP_REST_Request;
use WP_User;
final class AccessTest extends TestCase
{
public function testDefaultsToAdministratorOnly(): void
{
Functions\expect('get_option')
->once()
->with(Access::OPTION, ['administrator'])
->andReturn(['administrator']);
self::assertSame(['administrator'], (new Access())->allowedRoles());
}
public function testRoleListIsFilterable(): void
{
Functions\when('get_option')->justReturn(['administrator']);
Filters\expectApplied('lit_mcp_allowed_roles')
->once()
->andReturn(['administrator', 'editor']);
self::assertSame(['administrator', 'editor'], (new Access())->allowedRoles());
}
public function testCorruptFilteredResultFallsBackToAdministrator(): void
{
Functions\when('get_option')->justReturn(['administrator']);
Filters\expectApplied('lit_mcp_allowed_roles')
->once()
->andReturn('editor');
self::assertSame(['administrator'], (new Access())->allowedRoles());
}
public function testGrantsCapabilityToAllowedRole(): void
{
Functions\when('get_option')->justReturn(['editor']);
$caps = (new Access())->grantCapability(
[],
[Access::CAPABILITY],
[],
new WP_User(5, ['editor'])
);
self::assertTrue($caps[Access::CAPABILITY]);
}
public function testDoesNotGrantCapabilityToOtherRoles(): void
{
Functions\when('get_option')->justReturn(['administrator']);
$caps = (new Access())->grantCapability(
[],
[Access::CAPABILITY],
[],
new WP_User(7, ['subscriber'])
);
self::assertArrayNotHasKey(Access::CAPABILITY, $caps);
}
public function testCorruptOptionFallsBackToAdministrator(): void
{
Functions\when('get_option')->justReturn('not-an-array');
self::assertSame(['administrator'], (new Access())->allowedRoles());
}
public function testRegisterRoutesAdapterMetaToolCapabilitiesToOwnCapability(): void
{
// Brain Monkey records add_filter() but never runs callbacks, so we
// capture each registered callback and invoke it ourselves.
foreach (['discover_abilities', 'get_ability_info', 'execute_ability'] as $tool) {
Filters\expectAdded("mcp_adapter_{$tool}_capability")
->once()
->whenHappen(static function (callable $callback): void {
self::assertSame(Access::CAPABILITY, $callback('read'));
});
}
$access = new Access();
$access->register();
self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability']));
}
protected function tearDown(): void
{
RequestContext::reset();
parent::tearDown();
}
/** @return array<string, mixed> The args of a meta tool as the adapter registers them (permission always granted). */
private function metaToolArgs(): array
{
return ['permission_callback' => static fn (mixed $input = null): bool => true];
}
private function asOAuthServerRequest(): void
{
RequestContext::recordRoute(null, [], new WP_REST_Request('/mcp/mcp-oauth-server'));
}
public function testRegistersTheFilterThatLimitsTheAdapterMetaTools(): void
{
$access = new Access();
$access->register();
self::assertNotFalse(has_filter('wp_register_ability_args', [$access, 'limitMetaToolsToOwnAbilities']));
}
/** Security audit 2026-11, B3: execute-ability and get-ability-info may only name lit/ abilities. */
public function testMetaToolsRefuseForeignAbilitiesOnTheOAuthServer(): void
{
$this->asOAuthServerRequest();
foreach (['mcp-adapter/execute-ability', 'mcp-adapter/get-ability-info'] as $meta) {
$permission = (new Access())->limitMetaToolsToOwnAbilities($this->metaToolArgs(), $meta)['permission_callback'];
self::assertTrue($permission(['ability_name' => 'lit/guide']), $meta);
foreach (['akismet/comment-check', 'elementor/delete-page', 'litx/guide', '', 'LIT/guide'] as $foreign) {
$result = $permission(['ability_name' => $foreign]);
self::assertInstanceOf(\WP_Error::class, $result, $meta . ' ' . $foreign);
self::assertSame('lit_forbidden', $result->get_error_code());
}
self::assertInstanceOf(\WP_Error::class, $permission(null), 'no ability_name');
self::assertInstanceOf(\WP_Error::class, $permission(['ability_name' => ['lit/guide']]), 'not a string');
}
}
public function testMetaToolsKeepTheirOwnCheckOutsideTheOAuthServer(): void
{
$permission = (new Access())
->limitMetaToolsToOwnAbilities($this->metaToolArgs(), 'mcp-adapter/execute-ability')['permission_callback'];
self::assertTrue($permission(['ability_name' => 'akismet/comment-check']));
}
public function testOtherAbilitiesAreLeftAlone(): void
{
$args = $this->metaToolArgs();
self::assertSame($args, (new Access())->limitMetaToolsToOwnAbilities($args, 'lit/guide'));
self::assertSame($args, (new Access())->limitMetaToolsToOwnAbilities($args, 'mcp-adapter/discover-abilities'));
}
public function testAMetaToolWithoutAPermissionCallbackFailsClosed(): void
{
$this->asOAuthServerRequest();
$permission = (new Access())->limitMetaToolsToOwnAbilities([], 'mcp-adapter/execute-ability')['permission_callback'];
self::assertFalse($permission(['ability_name' => 'lit/guide']));
}
}