Without /D, "$" also matches before a trailing "\n", so "custom_meta_telefon\n", a color setting "5\n" (stored as sent) or a whole reference followed by "\n" passed VariableRef. Added /D to every ^...$ pattern that validates client or admin input: - VariableRef META_KEY, NUMBER, WHOLE (canonical/typeOf; Placeholders::field shares META_KEY) - Cursor::decode, GuideRepository topic names (MCP input and filter keys) - ExposurePolicy::sanitize ability keys and group ids, LogTab list filters Not changed: Placeholders::PATTERN (runs on trim()), Warnings hex checks (hints only), NodeSummary/AttrPathIndex/LogListTable (stored or Divi data, no client input). Each fixed pattern has a test with a trailing "\n" that is rejected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
286 lines
11 KiB
PHP
286 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'])
|
||
);
|
||
}
|
||
|
||
/** "$" 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);
|
||
}
|
||
}
|