Tools tab shows one block per integration with a group mode (inherited by its tools) and a read-only switch that disables all non-readonly abilities of the group. ExposurePolicy resolves tool override > group override > tool default; lit/guide keeps its default, direct-only tools fall back to direct under a group "indirekt". Group entries live in lit_mcp_exposure under "@group:<id>" and "@readonly:<id>"; the old flat format stays valid. The ability->group map comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
272 lines
11 KiB
PHP
272 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Abilities;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Abilities\Ability;
|
|
use LIT\MCP\Abilities\ExposurePolicy;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
|
|
final class ExposurePolicyTest extends TestCase
|
|
{
|
|
/** @param array<string, bool>|null $annotations */
|
|
private function ability(string $name, bool $direct, ?array $annotations = null): Ability
|
|
{
|
|
$ability = Mockery::mock(Ability::class);
|
|
$ability->allows('name')->andReturn($name);
|
|
$ability->allows('isDirect')->andReturn($direct);
|
|
if ($annotations !== null) {
|
|
$ability->allows('annotations')->andReturn($annotations);
|
|
}
|
|
|
|
return $ability;
|
|
}
|
|
|
|
public function testDefaultFollowsIsDirect(): void
|
|
{
|
|
$policy = new ExposurePolicy();
|
|
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true)));
|
|
self::assertSame('indirect', $policy->mode($this->ability('lit/menu-list', false)));
|
|
}
|
|
|
|
public function testOverridesChangeTheMode(): void
|
|
{
|
|
$policy = new ExposurePolicy(['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled']);
|
|
|
|
self::assertSame('indirect', $policy->mode($this->ability('lit/content-get', true)));
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/menu-list', false)));
|
|
}
|
|
|
|
public function testImpossibleOrBrokenOverridesFallBackToTheDefault(): void
|
|
{
|
|
$policy = new ExposurePolicy([
|
|
'lit/media-preview' => 'indirect',
|
|
'lit/media-download' => 'indirect',
|
|
'lit/guide' => 'disabled',
|
|
'lit/content-get' => 'sometimes',
|
|
]);
|
|
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/media-preview', true)));
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/media-download', true)));
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/guide', true)));
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true)));
|
|
}
|
|
|
|
public function testGuideCanNeitherBeDisabledNorMadeIndirect(): void
|
|
{
|
|
$policy = new ExposurePolicy(['lit/guide' => 'indirect']);
|
|
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/guide', true)));
|
|
self::assertFalse(ExposurePolicy::isAllowed('lit/guide', 'indirect'));
|
|
self::assertFalse(ExposurePolicy::isAllowed('lit/guide', 'disabled'));
|
|
self::assertTrue(ExposurePolicy::isAllowed('lit/guide', 'direct'));
|
|
}
|
|
|
|
public function testMediaPreviewAndDownloadMayStillBeDisabled(): void
|
|
{
|
|
$policy = new ExposurePolicy(['lit/media-preview' => 'disabled', 'lit/media-download' => 'disabled']);
|
|
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/media-preview', true)));
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true)));
|
|
}
|
|
|
|
public function testEnabledAndDirectNamesKeepTheRegistryOrder(): void
|
|
{
|
|
$a = $this->ability('lit/a', true);
|
|
$b = $this->ability('lit/b', false);
|
|
$c = $this->ability('lit/c', true);
|
|
$policy = new ExposurePolicy(['lit/b' => 'direct', 'lit/c' => 'disabled']);
|
|
|
|
self::assertSame([$a, $b], $policy->enabled([$a, $b, $c]));
|
|
self::assertSame(['lit/a', 'lit/b'], $policy->directNames([$a, $b, $c]));
|
|
}
|
|
|
|
public function testSanitizeKeepsOnlyValidEntries(): void
|
|
{
|
|
self::assertSame(
|
|
['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled'],
|
|
ExposurePolicy::sanitize([
|
|
'lit/menu-list' => 'disabled',
|
|
'lit/content-get' => 'indirect',
|
|
'lit/guide' => 'disabled',
|
|
'<script>' => 'direct',
|
|
'lit/x' => ['direct'],
|
|
])
|
|
);
|
|
self::assertSame([], ExposurePolicy::sanitize('garbage'));
|
|
}
|
|
|
|
public function testSanitizeRejectsIndirectForDirectOnlyAbilities(): void
|
|
{
|
|
self::assertSame(
|
|
['lit/media-preview' => 'disabled'],
|
|
ExposurePolicy::sanitize([
|
|
'lit/media-preview' => 'disabled',
|
|
'lit/media-download' => 'indirect',
|
|
])
|
|
);
|
|
}
|
|
|
|
public function testFromOptionToleratesACorruptOption(): void
|
|
{
|
|
Functions\when('get_option')->justReturn('garbage');
|
|
|
|
self::assertSame('direct', ExposurePolicy::fromOption()->mode($this->ability('lit/a', true)));
|
|
}
|
|
|
|
private const GROUPS = [
|
|
'lit/guide' => 'core',
|
|
'lit/site-status' => 'core',
|
|
'lit/divi-outline' => 'divi',
|
|
'lit/divi-update' => 'divi',
|
|
'lit/divi-delete' => 'divi',
|
|
'lit/media-preview' => 'media',
|
|
'lit/media-download' => 'media',
|
|
'lit/media-get' => 'media',
|
|
];
|
|
|
|
public function testToolOverrideBeatsGroupOverrideBeatsToolDefault(): void
|
|
{
|
|
$policy = (new ExposurePolicy([
|
|
'@group:divi' => 'indirect',
|
|
'lit/divi-update' => 'direct',
|
|
]))->withGroups(self::GROUPS);
|
|
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/divi-update', false)));
|
|
self::assertSame('indirect', $policy->mode($this->ability('lit/divi-outline', true)));
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true)));
|
|
self::assertTrue($policy->hasToolOverride('lit/divi-update'));
|
|
self::assertFalse($policy->hasToolOverride('lit/divi-outline'));
|
|
self::assertSame('indirect', $policy->groupMode('divi'));
|
|
self::assertNull($policy->groupMode('core'));
|
|
self::assertSame('indirect', $policy->inheritedMode($this->ability('lit/divi-update', false)));
|
|
}
|
|
|
|
public function testGroupSettingsAreIgnoredWithoutAGroupMap(): void
|
|
{
|
|
$policy = new ExposurePolicy(['@group:divi' => 'disabled', '@readonly:divi' => true]);
|
|
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/divi-outline', true)));
|
|
}
|
|
|
|
public function testGuideKeepsItsDefaultWhateverTheGroupSays(): void
|
|
{
|
|
$policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:core' => true]))
|
|
->withGroups(self::GROUPS);
|
|
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/guide', true, [])));
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/site-status', true, ['readonly' => true])));
|
|
}
|
|
|
|
public function testDirectOnlyToolsTakeTheNearestAllowedModeWhenTheGroupSaysIndirect(): void
|
|
{
|
|
$policy = (new ExposurePolicy(['@group:media' => 'indirect', 'lit/media-download' => 'disabled']))
|
|
->withGroups(self::GROUPS);
|
|
|
|
$preview = $this->ability('lit/media-preview', true);
|
|
self::assertSame('direct', $policy->mode($preview));
|
|
self::assertTrue($policy->groupModeAdjusted($preview));
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true)));
|
|
self::assertSame('indirect', $policy->mode($this->ability('lit/media-get', true)));
|
|
self::assertFalse($policy->groupModeAdjusted($this->ability('lit/media-get', true)));
|
|
}
|
|
|
|
public function testReadonlyGroupDisablesWriteToolsEvenAgainstAToolOverride(): void
|
|
{
|
|
$policy = (new ExposurePolicy([
|
|
'@readonly:divi' => true,
|
|
'@group:divi' => 'direct',
|
|
'lit/divi-update' => 'direct',
|
|
'lit/divi-outline' => 'indirect',
|
|
]))->withGroups(self::GROUPS);
|
|
|
|
$update = $this->ability('lit/divi-update', false, ['readonly' => false, 'destructive' => false]);
|
|
$delete = $this->ability('lit/divi-delete', false, ['destructive' => true]);
|
|
$outline = $this->ability('lit/divi-outline', true, ['readonly' => true]);
|
|
|
|
self::assertSame('disabled', $policy->mode($update));
|
|
self::assertSame('disabled', $policy->mode($delete));
|
|
self::assertTrue($policy->disabledByReadonly($update));
|
|
self::assertSame('indirect', $policy->mode($outline));
|
|
self::assertFalse($policy->disabledByReadonly($outline));
|
|
self::assertTrue($policy->isReadonly('divi'));
|
|
self::assertFalse($policy->isReadonly('media'));
|
|
self::assertSame([$outline], $policy->enabled([$update, $delete, $outline]));
|
|
self::assertSame([], $policy->directNames([$update, $delete, $outline]));
|
|
}
|
|
|
|
public function testSanitizeValidatesGroupEntries(): void
|
|
{
|
|
self::assertSame(
|
|
[
|
|
'@group:divi' => 'indirect',
|
|
'@readonly:divi' => true,
|
|
'@readonly:media' => true,
|
|
'lit/menu-list' => 'disabled',
|
|
],
|
|
ExposurePolicy::sanitize([
|
|
'lit/menu-list' => 'disabled',
|
|
'@group:divi' => 'indirect',
|
|
'@group:media' => 'sometimes',
|
|
'@group:core' => '',
|
|
'@group:unknown' => 'direct',
|
|
'@group:' => 'direct',
|
|
'@group:<b>' => 'direct',
|
|
'@readonly:divi' => '1',
|
|
'@readonly:media' => true,
|
|
'@readonly:core' => 'yes',
|
|
'@readonly:pods' => ['1'],
|
|
'@readonly:unknown' => '1',
|
|
'@other:divi' => 'direct',
|
|
'@group:divi/x' => 'direct',
|
|
0 => 'direct',
|
|
], ['core', 'divi', 'media', 'pods'])
|
|
);
|
|
}
|
|
|
|
public function testSanitizeWithoutKnownGroupsStillChecksTheIdSyntax(): void
|
|
{
|
|
self::assertSame(
|
|
['@group:divi' => 'disabled', '@readonly:divi' => true],
|
|
ExposurePolicy::sanitize(['@group:divi' => 'disabled', '@readonly:divi' => true, '@group:a b' => 'direct'])
|
|
);
|
|
}
|
|
|
|
public function testOldFlatOptionStaysValid(): void
|
|
{
|
|
$old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct'];
|
|
|
|
self::assertSame($old, ExposurePolicy::sanitize($old, ['core', 'wordpress']));
|
|
}
|
|
|
|
public function testFromOptionReadsGroupEntries(): void
|
|
{
|
|
Functions\when('get_option')->justReturn(['@group:divi' => 'disabled', 'lit/divi-update' => 'indirect']);
|
|
|
|
$policy = ExposurePolicy::fromOption()->withGroups(self::GROUPS);
|
|
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true)));
|
|
self::assertSame('indirect', $policy->mode($this->ability('lit/divi-update', false)));
|
|
}
|
|
|
|
public function testGroupMapCanBeResolvedLazily(): void
|
|
{
|
|
$calls = 0;
|
|
$policy = (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups(
|
|
static function () use (&$calls): array {
|
|
$calls++;
|
|
return self::GROUPS;
|
|
}
|
|
);
|
|
|
|
self::assertSame(0, $calls, 'The map must not be built while booting (Divi is not loaded yet).');
|
|
self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true)));
|
|
self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true)));
|
|
self::assertSame(1, $calls);
|
|
}
|
|
}
|