Files
WP-LIT-MCP/tests/Unit/Abilities/ExposurePolicyTest.php
T
janniclas.loosenandClaude Opus 5.5 e420145cbe fix: anchored input patterns end at the last character (/D)
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
2026-09-26 18:02:51 +02:00

286 lines
11 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/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);
}
}