Files
WP-LIT-MCP/tests/Unit/Abilities/ExposurePolicyTest.php
T

333 lines
14 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]));
}
/** 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'));
}
}