fix(audit L1): Schleifen in module.advanced.loop brauchen list_users (Benutzer) bzw. read_private_posts (any, attachment, leere subTypes), auch beim Kopieren

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-20 00:00:59 +02:00
co-authored by Claude Sonnet 5.5
parent 9c1d5fad22
commit 4a8a889f93
10 changed files with 299 additions and 9 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\ValueKind;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
@@ -13,6 +14,87 @@ use PHPUnit\Framework\TestCase;
/** The choke point (ValueCleaner): tests moved from AttrWriterTest with clean()/kindOf() (H1). */
final class ValueCleanerTest extends TestCase
{
private static function loopCleaner(bool $listUsers, bool $readPrivate): ValueCleaner
{
return new ValueCleaner(
DiviFixtures::catalog(),
ComposeFixture::references(),
ComposeFixture::sanitizer(false, $listUsers, $readPrivate)
);
}
/** @return array<string, array{array<string, mixed>}> Loops that read private posts or user data (audit L1) */
public static function riskyLoops(): array
{
$types = static fn (string ...$values): array => array_map(
static fn (string $value): array => ['value' => $value, 'label' => $value],
$values
);
$post = ['enable' => 'on', 'queryType' => 'post_types'];
return [
'no subTypes' => [$post],
'empty subTypes' => [$post + ['subTypes' => []]],
'queryType missing, empty subTypes' => [['enable' => 'on', 'subTypes' => []]],
'any' => [$post + ['subTypes' => $types('any')]],
'attachment among others' => [$post + ['subTypes' => $types('post', 'attachment')]],
'only unknown types (Divi falls back to any)' => [$post + ['subTypes' => $types('gibtesnicht')]],
'comma string with any' => [$post + ['subTypes' => 'post, ANY']],
'entries without value' => [$post + ['subTypes' => [['label' => 'x']]]],
];
}
/**
* @dataProvider riskyLoops
* @param array<string, mixed> $loop
*/
public function testPostLoopsThatMayShowPrivatePostsNeedReadPrivatePosts(array $loop): void
{
try {
self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop);
self::fail('expected a refusal without read_private_posts');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('read_private_posts', $e->getMessage());
}
self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop);
$this->addToAssertionCount(1);
}
public function testUserLoopsNeedListUsersAlsoUnderDiviAliasAndSanitizeKey(): void
{
foreach (['user_roles', 'users', ' User_Roles '] as $queryType) {
$loop = ['enable' => 'on', 'queryType' => $queryType];
try {
self::loopCleaner(false, true)->guardLoop('module.advanced.loop', $loop);
self::fail('expected a refusal without list_users: ' . $queryType);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('list_users', $e->getMessage());
}
self::loopCleaner(true, false)->guardLoop('module.advanced.loop', $loop);
}
$this->addToAssertionCount(3);
}
public function testSafeLoopsPassWithoutTheExtraRights(): void
{
$cleaner = self::loopCleaner(false, false);
$on = ['enable' => 'on'];
foreach ([
$on + ['queryType' => 'post_types', 'subTypes' => [['value' => 'post'], ['value' => 'page']]],
$on + ['queryType' => 'post_types', 'subTypes' => 'post'],
$on + ['queryType' => 'post_taxonomies', 'subTypes' => []],
$on + ['queryType' => 'menus'],
$on + ['queryType' => 'current_page'],
['enable' => 'off', 'queryType' => 'user_roles'],
['queryType' => 'post_types'],
] as $loop) {
$cleaner->guardLoop('module.advanced.loop', $loop);
}
$cleaner->guardLoop('module.advanced.link', 'irrelevant');
$this->addToAssertionCount(1);
}
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
{