feat: group tools by integration with inherited group mode and "nur lesen" switch
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>
This commit is contained in:
@@ -217,4 +217,18 @@ final class AbilityRegistrarTest extends TestCase
|
||||
(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ use Mockery;
|
||||
|
||||
final class ExposurePolicyTest extends TestCase
|
||||
{
|
||||
private function ability(string $name, bool $direct): Ability
|
||||
/** @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;
|
||||
}
|
||||
@@ -113,4 +117,155 @@ final class ExposurePolicyTest extends TestCase
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user