Files
WP-LIT-MCP/tests/Unit/Abilities/ExposurePolicyTest.php
T
janniclas.loosenandClaude Opus 5.5 5f596b8a17 fix(security): content-create divi follows the Divi group switches (audit M2)
The divi input of lit/content-create is refused with unsupported when
lit/divi-insert is disabled or the Divi group is disabled or read-only
(ExposurePolicy::allows as a cross-group gate, asked at execute time).
content-create without divi keeps working. Adds a registrar test that
read-only write tools are never registered, so execute-ability can't
reach them either.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:16:35 +02:00

315 lines
13 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\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/divi-insert' => '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'])
);
}
/** "$" without /D also matches before a trailing newline – ability names and group ids end with their last character. */
public function testSanitizeRejectsKeysWithTrailingNewline(): void
{
self::assertSame(
[],
ExposurePolicy::sanitize([
"lit/menu-list\n" => 'disabled',
"@group:divi\n" => 'indirect',
"@readonly:divi\n" => true,
])
);
self::assertSame([], ExposurePolicy::sanitize(["@group:divi\n" => 'indirect'], ['core', 'divi']));
}
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);
}
/**
* Cross-group gate (security M2): lit/content-create's divi input follows lit/divi-insert – off
* when that tool is disabled, its group is disabled or "nur lesen", or it does not exist.
*/
public function testAllowsFollowsTheNamedAbilitysMode(): void
{
$insert = $this->ability('lit/divi-insert', true, ['readonly' => false]);
$others = [$this->ability('lit/divi-outline', true, ['readonly' => true]), $insert];
self::assertTrue((new ExposurePolicy())->withGroups(self::GROUPS)->allows('lit/divi-insert', $others));
self::assertTrue(
(new ExposurePolicy(['lit/divi-insert' => 'indirect']))->allows('lit/divi-insert', $others),
'indirect still writes'
);
foreach (
[
['lit/divi-insert' => 'disabled'],
['@group:divi' => 'disabled'],
['@readonly:divi' => true, 'lit/divi-insert' => 'direct'],
] as $option
) {
$policy = (new ExposurePolicy($option))->withGroups(self::GROUPS);
self::assertFalse($policy->allows('lit/divi-insert', $others), (string) json_encode($option));
}
self::assertFalse((new ExposurePolicy())->allows('lit/divi-insert', []), 'Divi not available');
}
}