Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
333 lines
14 KiB
PHP
333 lines
14 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/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]));
|
||
}
|
||
|
||
/** Ruling P9: only a group disabled as a whole is switched off; "nur lesen" still lets it read. */
|
||
public function testGroupEnabledUnlessTheWholeGroupIsDisabled(): void
|
||
{
|
||
self::assertTrue((new ExposurePolicy())->groupEnabled('divi-library'));
|
||
self::assertTrue((new ExposurePolicy(['@readonly:divi-library' => true]))->groupEnabled('divi-library'));
|
||
self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupEnabled('divi-library'));
|
||
self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupEnabled('divi-library'));
|
||
self::assertTrue((new ExposurePolicy(['@group:divi' => 'disabled']))->groupEnabled('divi-library'));
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
public function testGroupWritable(): void
|
||
{
|
||
self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library'));
|
||
self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library'));
|
||
self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library'));
|
||
self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library'));
|
||
}
|
||
}
|