fix: full-form internal links follow the {{link:}} target rules (ruling P10)

One PostIdParser for {{link:}} and select_post; post_link_url_<type> needs a
living, readable, non-attachment target of exactly that type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:54:51 +02:00
co-authored by Claude Opus 5.5
parent c615522cf6
commit 06f13799de
14 changed files with 229 additions and 28 deletions
+10
View File
@@ -105,6 +105,16 @@ final class ComposeFixture
{
return $id !== 666;
}
/** Ruling P10: 840 is a post, 666 no link target, 667 a readable attachment (none), every other ID a page. */
public function linkTargetType(int $id): ?string
{
return match ($id) {
666, 667 => null,
840 => 'post',
default => 'page',
};
}
};
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
use PHPUnit\Framework\TestCase;
final class PostIdParserTest extends TestCase
{
public function testAcceptsPositiveIdsAsIntOrDigits(): void
{
self::assertSame(839, PostIdParser::parse(839));
self::assertSame(839, PostIdParser::parse('839'));
self::assertSame(PHP_INT_MAX, PostIdParser::parse((string) PHP_INT_MAX));
}
/** Ruling P10: one rule for {{link:}} and post_link_url_* – no zero, sign, padding, slug or overflow. */
public function testRefusesEverythingElse(): void
{
$bad = [
0, -3, '0', '-3', '+3', '0839', '8 39', ' 839', '839 ', "839\n", '839.0', '0x1f', '1e3', 'impressum',
'99999999999999999999', '9223372036854775808', 1.5, 839.0, null, [], true, false,
];
foreach ($bad as $value) {
self::assertNull(PostIdParser::parse($value), var_export($value, true));
}
}
}
@@ -415,6 +415,11 @@ final class StoredNodeGuardTest extends TestCase
{
return $this->inner->canReadPost($id);
}
public function linkTargetType(int $id): ?string
{
return $this->inner->linkTargetType($id);
}
};
$references = new VariableRef(
DiviFixtures::designSystem(),
@@ -449,6 +449,64 @@ final class VariableRefTest extends TestCase
}
}
/** Ruling P10: a full-form internal link follows the {{link:}} target rules, its type included. */
public function testFullFormInternalLinksFollowTheLinkTargetRules(): void
{
$ref = self::ref([
'post_link_url_page' => ['post_id' => 'select_post'],
'post_link_url_post' => ['post_id' => 'select_post'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('post_link_url_post', '{"post_id":"840"}'),
$ref->canonical(self::content('post_link_url_post', '{"post_id":"840"}'), $sanitizer, true)
);
self::assertSame(
self::content('post_link_url_post', '{"post_id":840}'),
$ref->canonical(self::content('post_link_url_post', '{"post_id":840}'), $sanitizer, true)
);
$refused = [
'a post is no page' => self::content('post_link_url_page', '{"post_id":"840"}'),
'a post is no page (int)' => self::content('post_link_url_page', '{"post_id":840}'),
'a page is no post' => self::content('post_link_url_post', '{"post_id":"12"}'),
'no link target' => self::content('post_link_url_page', '{"post_id":"666"}'),
'attachment' => self::content('post_link_url_page', '{"post_id":"667"}'),
'overflowing id' => self::content('post_link_url_page', '{"post_id":"99999999999999999999"}'),
'padded id' => self::content('post_link_url_page', '{"post_id":"0840"}'),
'float id' => self::content('post_link_url_page', '{"post_id":12.0}'),
'bool id' => self::content('post_link_url_page', '{"post_id":true}'),
];
foreach ($refused as $case => $value) {
try {
$ref->canonical($value, $sanitizer, true);
self::fail($case . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $case);
}
}
}
/** Ruling S37/P10: select_post outside an internal link stays "a post the user may read", of any type. */
public function testOtherPostSettingsOnlyNeedAReadablePost(): void
{
$ref = self::ref(['evil' => ['p' => 'select_post']]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('evil', '{"p":"840"}'),
$ref->canonical(self::content('evil', '{"p":"840"}'), $sanitizer)
);
foreach (['"666"', '"0840"', '"99999999999999999999"'] as $id) {
try {
$ref->canonical(self::content('evil', '{"p":' . $id . '}'), $sanitizer);
self::fail($id . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $id);
}
}
}
/** E13a: an internal link (post_link_url_<type>) is a URL – refused in every other field. */
public function testInternalLinksOnlyInUrlFields(): void
{
@@ -19,6 +19,9 @@ final class WpLinkTargetsTest extends TestCase
842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']),
843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']),
844 => new WP_Post(['ID' => 844, 'post_type' => 'page', 'post_status' => 'auto-draft']),
845 => new WP_Post(['ID' => 845, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => 0]),
846 => new WP_Post(['ID' => 846, 'post_type' => 'attachment', 'post_status' => 'trash']),
847 => new WP_Post(['ID' => 847, 'post_type' => 'attachment', 'post_status' => 'private']),
];
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null);
Functions\when('current_user_can')->alias(
@@ -33,5 +36,8 @@ final class WpLinkTargetsTest extends TestCase
self::assertNull($links->postTypeOf(843), 'revision');
self::assertNull($links->postTypeOf(844), 'auto-draft');
self::assertNull($links->postTypeOf(900), 'missing');
self::assertNull($links->postTypeOf(845), 'unattached attachment');
self::assertNull($links->postTypeOf(846), 'trashed attachment');
self::assertNull($links->postTypeOf(847), 'attachment with an own status (ruling P10: no attachment)');
}
}
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class WpValueSanitizerTest extends TestCase
{
@@ -55,5 +56,32 @@ final class WpValueSanitizerTest extends TestCase
self::assertTrue($sanitizer->canReadPost(12));
self::assertFalse($sanitizer->canReadPost(13));
}
}
/** Ruling P10: the full form uses the {{link:}} targets – attachments, trash and unreadable entries are none. */
public function testLinkTargetTypeFollowsTheLinkTargets(): void
{
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) {
12 => new WP_Post(['ID' => 12, 'post_type' => 'page', 'post_status' => 'draft']),
13 => new WP_Post(['ID' => 13, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => 12]),
14 => new WP_Post(['ID' => 14, 'post_type' => 'page', 'post_status' => 'trash']),
16 => new WP_Post(['ID' => 16, 'post_type' => 'page', 'post_status' => 'private']),
17 => new WP_Post(['ID' => 17, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => 0]),
18 => new WP_Post(['ID' => 18, 'post_type' => 'attachment', 'post_status' => 'trash', 'post_parent' => 0]),
19 => new WP_Post(['ID' => 19, 'post_type' => 'attachment', 'post_status' => 'private', 'post_parent' => 0]),
20 => new WP_Post(['ID' => 20, 'post_type' => 'post', 'post_status' => 'publish']),
default => null,
});
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16);
$sanitizer = new WpValueSanitizer();
self::assertSame('page', $sanitizer->linkTargetType(12));
self::assertSame('post', $sanitizer->linkTargetType(20));
self::assertNull($sanitizer->linkTargetType(13), 'attached attachment');
self::assertNull($sanitizer->linkTargetType(14), 'trashed');
self::assertNull($sanitizer->linkTargetType(15), 'missing');
self::assertNull($sanitizer->linkTargetType(16), 'unreadable');
self::assertNull($sanitizer->linkTargetType(17), 'unattached attachment');
self::assertNull($sanitizer->linkTargetType(18), 'trashed attachment');
self::assertNull($sanitizer->linkTargetType(19), 'attachment with an own status');
}
}