fix: link targets never reveal library entries by type (ruling Q14)
WpLinkTargets answers the library, internal "_" types and every type without a Divi post_link_url option like a missing ID; replace-text strips library IDs only from global subtrees. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -464,6 +464,22 @@ final class DiviReplaceTextAbilityTest extends TestCase
|
||||
self::assertSame([], $byId['skipped_nodes']);
|
||||
}
|
||||
|
||||
/** Ruling P13/Q14: only global subtrees lose their IDs – an undecodable node is matched as stored, with or without access. */
|
||||
public function testUndecodableNodesAreMatchedAlikeWithAndWithoutLibraryAccess(): void
|
||||
{
|
||||
$content = str_replace(
|
||||
'<!-- /wp:divi/column -->',
|
||||
'<!-- wp:divi/text {"note": Müller} --><!-- /wp:divi/text --><!-- /wp:divi/column -->',
|
||||
self::page([self::text('<p>Meier</p>')])
|
||||
);
|
||||
|
||||
$hidden = $this->ability($content)->execute(['search' => 'Müller', 'replace' => 'x']);
|
||||
$shown = $this->ability($content, false, [7], true)->execute(['search' => 'Müller', 'replace' => 'x']);
|
||||
|
||||
self::assertSame(['s0.r0.c0.m1'], array_column($hidden['skipped_nodes'], 'id'));
|
||||
self::assertSame($shown['skipped_nodes'], $hidden['skipped_nodes']);
|
||||
}
|
||||
|
||||
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
|
||||
public function testNoEffectiveChangeSavesNothing(): void
|
||||
{
|
||||
|
||||
@@ -5,7 +5,12 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
|
||||
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
|
||||
@@ -28,7 +33,7 @@ final class WpLinkTargetsTest extends TestCase
|
||||
static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842
|
||||
);
|
||||
|
||||
$links = new WpLinkTargets();
|
||||
$links = new WpLinkTargets(self::linkable());
|
||||
|
||||
self::assertSame('page', $links->postTypeOf(839));
|
||||
self::assertNull($links->postTypeOf(841), 'trashed');
|
||||
@@ -40,4 +45,77 @@ final class WpLinkTargetsTest extends TestCase
|
||||
self::assertNull($links->postTypeOf(846), 'trashed attachment');
|
||||
self::assertNull($links->postTypeOf(847), 'attachment with an own status (ruling P10: no attachment)');
|
||||
}
|
||||
|
||||
/** Post types Divi offers post_link_url_<type> for in these tests (et_builder_get_public_post_types). */
|
||||
private static function linkable(): \Closure
|
||||
{
|
||||
return static fn (string $type): bool => in_array($type, ['page', 'post', 'attachment', 'lit_person'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q14: a type without a Divi link option (the library, internal "_" types, any other) is no
|
||||
* link target – answered like a missing ID, so {{link:}} tells nobody which IDs are library entries.
|
||||
*/
|
||||
public function testTypesWithoutADiviLinkOptionAreNotFound(): void
|
||||
{
|
||||
$posts = [
|
||||
1058 => new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout', 'post_status' => 'publish']),
|
||||
1070 => new WP_Post(['ID' => 1070, 'post_type' => '_internal', 'post_status' => 'publish']),
|
||||
1071 => new WP_Post(['ID' => 1071, 'post_type' => 'wp_template', 'post_status' => 'publish']),
|
||||
1072 => new WP_Post(['ID' => 1072, 'post_type' => 'lit_person', 'post_status' => 'publish']),
|
||||
];
|
||||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null);
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
$everything = new WpLinkTargets(static fn (string $type): bool => true);
|
||||
|
||||
$links = new WpLinkTargets(self::linkable());
|
||||
self::assertNull($links->postTypeOf(1058), 'library entry');
|
||||
self::assertNull($links->postTypeOf(1070), 'internal type');
|
||||
self::assertNull($links->postTypeOf(1071), 'type without link option');
|
||||
self::assertSame('lit_person', $links->postTypeOf(1072));
|
||||
self::assertNull($everything->postTypeOf(1058), 'the library never, whatever Divi lists');
|
||||
self::assertNull($everything->postTypeOf(1070), 'internal types never, whatever Divi lists');
|
||||
self::assertNull((new WpLinkTargets())->postTypeOf(1072), 'without Divi no type has a link option');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q14: {{link:}} and the full form answer a library ID exactly like a missing ID – with and
|
||||
* without the library right (read_post on the entry), so no guessed ID reveals a library entry.
|
||||
*/
|
||||
public function testALibraryIdIsAnsweredLikeAMissingId(): void
|
||||
{
|
||||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $id === 1058
|
||||
? new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout', 'post_status' => 'publish'])
|
||||
: null);
|
||||
$links = new WpLinkTargets(self::linkable());
|
||||
$placeholders = new Placeholders(DiviFixtures::designSystem(), $links);
|
||||
$ref = new VariableRef(DiviFixtures::designSystem(), ['post_link_url_page' => ['post_id' => 'select_post']]);
|
||||
$sanitizer = new WpValueSanitizer($links);
|
||||
$answer = static function (callable $call, string $id): string {
|
||||
try {
|
||||
$call($id);
|
||||
|
||||
return 'accepted';
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return str_replace($id, '<ID>', $e->getMessage());
|
||||
}
|
||||
};
|
||||
$short = static fn (string $id): string => $placeholders->resolve('{{link:' . $id . '}}');
|
||||
$full = static fn (string $id): string => $ref->canonical(
|
||||
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"' . $id . '"}}})$',
|
||||
$sanitizer,
|
||||
true
|
||||
);
|
||||
|
||||
foreach (['with the library right' => true, 'without it' => false] as $case => $right) {
|
||||
Functions\when('current_user_can')->justReturn($right);
|
||||
foreach (['{{link:}}' => $short, 'post_link_url_page' => $full] as $form => $call) {
|
||||
$library = $answer($call, '1058');
|
||||
self::assertNotSame('accepted', $library, $case . ' ' . $form);
|
||||
self::assertSame($answer($call, '1066'), $library, $case . ' ' . $form);
|
||||
self::assertStringNotContainsString('et_pb_layout', $library, $case . ' ' . $form);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
|
||||
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
@@ -72,7 +73,7 @@ final class WpValueSanitizerTest extends TestCase
|
||||
default => null,
|
||||
});
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16);
|
||||
$sanitizer = new WpValueSanitizer();
|
||||
$sanitizer = new WpValueSanitizer(new WpLinkTargets(static fn (string $type): bool => $type !== 'wp_block'));
|
||||
|
||||
self::assertSame('page', $sanitizer->linkTargetType(12));
|
||||
self::assertSame('post', $sanitizer->linkTargetType(20));
|
||||
|
||||
Reference in New Issue
Block a user