Files

606 lines
28 KiB
PHP
Raw Permalink 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\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
/** Ruling S1/S2/S12/S21/S22: every $variable(…)$ value is parsed, strictly checked, cleaned and re-serialized. */
final class VariableRefTest extends TestCase
{
/** @param array<string, array<string, string>>|null $dynamicContent */
private static function ref(?array $dynamicContent = null, ?LoopScope $loop = null): VariableRef
{
return new VariableRef(DiviFixtures::designSystem(), $dynamicContent, $loop);
}
private static function postLoop(): LoopScope
{
return LoopScope::ofQueryType('post_types');
}
private static function content(string $name, string $settings): string
{
return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
}
/** Divi's one post meta loop option (DynamicContentOptionLoopPostMetaKey.php:218-227). */
private static function loopMeta(string $settings): string
{
return self::content('loop_post_meta_key_manual_custom_field', $settings);
}
/** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
private static function escape(string $hex): string
{
return chr(92) . 'u' . $hex;
}
/** @return array<string, array{string, string}> */
public static function valid(): array
{
return [
'reordered keys' => [
'$variable({"value":{"settings":{},"name":"gcid-a1b2"},"type":"color"})$',
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
],
'empty list settings, no settings' => [
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":[]}})$',
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
],
'missing settings' => [
'$variable({"type":"content","value":{"name":"gvid-r1"}})$',
'$variable({"type":"content","value":{"name":"gvid-r1","settings":{}}})$',
],
'color settings numeric' => [
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{"opacity":"50","hue":-10,"lightness":1.5}}})$',
],
'post date with format and html before' => [
self::content('post_date', '{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'),
self::content('post_date', '{"before":"<b>Am</b> ","after":"","date_format":"custom","custom_date_format":"d.m.Y"}'),
],
'custom meta' => [
self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'),
self::content('custom_meta_telefon-mobil', '{"before":"","after":""}'),
],
'theme font' => [
self::content('--et_global_body_font', '{}'),
self::content('--et_global_body_font', '{}'),
],
];
}
/** @dataProvider valid */
public function testReserializesValidReferences(string $input, string $expected): void
{
self::assertSame($expected, self::ref()->canonical($input, ComposeFixture::sanitizer()));
}
/**
* Ruling S21: string settings are cleaned and the cleaned value is re-serialized.
*
* @return array<string, array{string, string}>
*/
public static function cleaned(): array
{
$script = self::escape('003c') . 'script' . self::escape('003e') . 'alert(1)' . self::escape('003c') . '/script'
. self::escape('003e');
return [
'script in before' => [
self::content('post_title', '{"before":"<b>Hi</b><script>alert(1)</script>","after":""}'),
self::content('post_title', '{"before":"<b>Hi</b>","after":""}'),
],
'script in after' => [
self::content('custom_meta_telefon', '{"before":"","after":"<script>x()</script>!"}'),
self::content('custom_meta_telefon', '{"before":"","after":"!"}'),
],
'escaped script in before' => [
self::content('post_title', '{"before":"' . $script . 'Hallo","after":""}'),
self::content('post_title', '{"before":"Hallo","after":""}'),
],
// S32a: controls go before text(), so "<" + VT/FF is a tag text() removes.
'VT-split tag in date format' => [
self::content('post_date', '{"custom_date_format":"<' . self::escape('000b') . 'img src=x onerror=alert(1)>d.m"}'),
self::content('post_date', '{"custom_date_format":"d.m"}'),
],
'FF-split script in date format' => [
self::content('post_date', '{"custom_date_format":"<' . self::escape('000c') . 'script>alert(1)</script>d.m"}'),
self::content('post_date', '{"custom_date_format":"d.m"}'),
],
'tags in date format' => [
self::content('post_date', '{"custom_date_format":"<b>d</b>.m"}'),
self::content('post_date', '{"custom_date_format":"d.m"}'),
],
];
}
/** @dataProvider cleaned */
public function testCleansStringSettings(string $input, string $expected): void
{
$canonical = self::ref()->canonical($input, ComposeFixture::sanitizer());
self::assertSame($expected, $canonical);
self::assertStringNotContainsString('<script', $canonical);
}
/**
* Hand-crafted attacks (preflight §4 "Malicious $variable tests").
*
* @return array<string, array{string, string}> Value => part of the German error.
*/
public static function malicious(): array
{
return DiviFixtures::maliciousRefs();
}
/** @dataProvider malicious */
public function testRejectsMaliciousReferences(string $input, string $message): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage($message);
self::ref()->canonical($input, ComposeFixture::sanitizer());
}
/**
* "$" without /D also matches before a trailing newline: the whole-reference pattern, a meta
* key name and a color setting number must each end with their last character.
*/
public function testATrailingNewlineIsNeverAnAnchorMatch(): void
{
$color = VariableRef::serialize('color', 'gcid-a1b2', []);
$rejects = [
'whole reference' => [$color . "\n", 'Verweise nur allein im Wert'],
'meta key name' => [
VariableRef::serialize('content', "custom_meta_telefon\n", ['before' => '', 'after' => '']),
'Unbekannter Name',
],
'color number' => [VariableRef::serialize('color', 'gcid-a1b2', ['hue' => "5\n"]), 'nur Zahlen'],
];
foreach ($rejects as $label => [$input, $message]) {
try {
self::ref()->canonical($input, ComposeFixture::sanitizer());
self::fail($label . ': accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($message, $e->getMessage(), $label);
}
}
self::assertNull(VariableRef::typeOf($color . "\n"));
}
/** I-1: the escape really is in the input, and nothing with ")$" inside comes out. */
public function testEscapedEndMarkerIsBytesNotTheCharacter(): void
{
$input = self::content('post_title', '{"before":"x' . self::escape('0029') . '$","after":""}');
self::assertStringNotContainsString(')$ ', $input);
self::assertSame(1, substr_count($input, ')$'));
self::assertStringContainsString("\\u0029", $input);
$this->expectException(InvalidArgumentException::class);
self::ref()->canonical($input, ComposeFixture::sanitizer());
}
public function testUrlFieldsTakeContentReferencesWithoutBeforeOrAfter(): void
{
$ref = self::ref();
$link = '$variable({"type":"content","value":{"name":"post_link_url","settings":{}}})$';
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
foreach (
[
self::content('custom_meta_ziel', '{"before":"javascript:alert(1)//","after":""}'),
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
] as $value
) {
try {
$ref->canonical($value, ComposeFixture::sanitizer(), true);
self::fail('Accepted in a URL field: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('URL', $e->getMessage());
}
}
}
/** Ruling S22: an injected allow map (Divi's live registry in Task 5) decides names and setting types. */
public function testInjectedDynamicContentMap(): void
{
$ref = self::ref([
'site_title' => ['before' => 'text', 'after' => 'richtext'],
'post_date' => ['date_format' => 'select', 'offset' => 'number'],
'loop_post_title' => [],
'custom_meta_preis' => ['before' => 'text', 'enable_html' => 'yes_no_button'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('site_title', '{"before":"Site","after":"<em>!</em>"}'),
$ref->canonical(self::content('site_title', '{"before":"<b>Site</b>","after":"<em>!</em>"}'), $sanitizer)
);
self::assertSame(
self::content('post_date', '{"date_format":"d.m.Y","offset":3}'),
$ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer)
);
// Ruling X5: a loop_* name the map names still needs a loop of its kind.
try {
$ref->canonical(self::content('loop_post_title', '{}'), $sanitizer);
self::fail('loop_post_title accepted without a loop');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('braucht eine Divi-Schleife', $e->getMessage());
}
$inLoop = self::ref(['loop_post_title' => []], self::postLoop());
self::assertSame(
self::content('loop_post_title', '{}'),
$inLoop->canonical(self::content('loop_post_title', '{}'), $sanitizer)
);
// Names outside the map: custom_meta_ keeps its rule, the rest is unknown.
self::assertSame(
self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'),
$ref->canonical(self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'), $sanitizer)
);
$rejected = [
'post_title' => self::content('post_title', '{}'),
'enable_html' => self::content('custom_meta_preis', '{"enable_html":"on"}'),
'offset' => self::content('post_date', '{"offset":[1]}'),
];
foreach ($rejected as $fragment => $value) {
try {
$ref->canonical($value, $sanitizer);
self::fail('Accepted: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
/** Ruling X2/X5: loop content in a loop of its kind is cleaned like any other content reference. */
public function testLoopContentInALoopOfItsKind(): void
{
$meta = [
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
'select_loop_meta_key' => 'select', 'loop_meta_key' => 'text', 'enable_html' => 'yes_no_button',
];
$map = [
'loop_post_date' => [
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
'date_format' => 'select', 'custom_date_format' => 'text',
],
'loop_post_meta_key_manual_custom_field' => $meta,
'loop_term_meta_key_manual_custom_field' => $meta,
];
$sanitizer = ComposeFixture::sanitizer();
$date = self::content(
'loop_post_date',
'{"before":"Am ","after":"","loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}'
);
self::assertSame($date, self::ref($map, self::postLoop())->canonical($date, $sanitizer));
self::assertSame(
self::content('loop_post_date', '{"before":"Am ","after":""}'),
self::ref($map, self::postLoop())->canonical(
self::content('loop_post_date', '{"before":"<script>x()</script>Am ","after":""}'),
$sanitizer
)
);
$terms = self::ref($map, LoopScope::ofQueryType('post_taxonomies'));
$termMeta = self::content('loop_term_meta_key_manual_custom_field', '{"select_loop_meta_key":"%s"}');
$farbe = sprintf($termMeta, 'loop_term_meta_key_farbe');
self::assertSame($farbe, $terms->canonical($farbe, $sanitizer));
$rejected = [
'Typ: Terme' => [$terms, self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}')],
'Meta-Schlüssel' => [$terms, sprintf($termMeta, 'loop_term_meta_key__x')],
'HTML-Ausgabe' => [self::ref($map, self::postLoop()), self::loopMeta('{"enable_html":"on"}')],
'braucht eine Divi-Schleife' => [self::ref($map), $date],
];
foreach ($rejected as $fragment => [$ref, $value]) {
try {
$ref->canonical($value, $sanitizer);
self::fail('Accepted: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
/** S28: the split-marker rows really carry no marker before sanitizing. */
public function testSplitMarkerRowsHaveNoRawMarker(): void
{
$rows = self::malicious();
foreach (['tag-split marker in before', 'NUL-split marker in after', 'octet-split marker in date format'] as $row) {
$payload = substr($rows[$row][0], strlen('$variable('));
self::assertStringNotContainsString('$variable(', $payload, $row);
}
self::assertStringContainsString(chr(92) . 'u0000(y', $rows['NUL-split marker in after'][0]);
}
/** Ruling S29: custom_meta_ names and meta-key settings obey META_KEY, map or not. */
public function testMetaKeysObeyTheRuleEvenFromTheMap(): void
{
$ref = self::ref([
'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'],
'custom_meta__edit_lock' => ['before' => 'text'],
'loop_post_meta_key_manual_custom_field' => ['loop_meta_key' => 'text'],
], self::postLoop());
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'),
$ref->canonical(self::content('post_meta_key', '{"meta_key":"telefon","select_meta_key":"custom_meta_fax"}'), $sanitizer)
);
self::assertSame(
self::content('post_meta_key', '{"select_meta_key":""}'),
$ref->canonical(self::content('post_meta_key', '{"select_meta_key":""}'), $sanitizer)
);
$rejected = [
self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key',
self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key',
self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key',
self::loopMeta('{"loop_meta_key":"_secret"}') => 'loop_meta_key',
self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock',
];
foreach ($rejected as $value => $fragment) {
try {
$ref->canonical($value, $sanitizer);
self::fail('Accepted: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
/** @return array<string, array{string}> */
public static function derivedMetaKeyBypasses(): array
{
return [
'doubled prefix in the name' => [self::content('custom_meta_custom_meta__edit_lock', '{}')],
'tripled prefix in the name' => [self::content('custom_meta_custom_meta_custom_meta__edit_lock', '{}')],
'prefix rebuilt by the strip' => [self::content('custom_meta_custom_mcustom_meta_eta_telefon', '{}')],
'loop prefix in the name' => [self::content('custom_meta_loop_post_meta_key__edit_lock', '{}')],
'doubled prefix in select_meta_key' => [
self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'),
],
'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')],
'loop post prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
'loop user prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
'loop term prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
'doubled loop prefix' => [
self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
],
'mixed loop and meta prefix' => [
self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
],
'mixed meta and loop prefix' => [
self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'),
],
'two loop prefixes' => [
self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
],
];
}
/**
* Ruling S30: Divi derives the meta key with str_replace of every prefix occurrence
* (DynamicContentOptionPostMetaKey.php:232,275; DynamicContentOptionLoopPostMetaKey.php:366-373).
*
* @dataProvider derivedMetaKeyBypasses
*/
public function testMetaKeysAreCheckedAfterDivisDerivation(string $value): void
{
$ref = self::ref([
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
], self::postLoop());
$this->expectException(InvalidArgumentException::class);
// The meta key rule refuses it – not the loop check (the post loop fits the post meta option).
$this->expectExceptionMessageMatches('/Meta-Schlüssel|Unbekannter Name/');
$ref->canonical($value, ComposeFixture::sanitizer());
}
public function testDerivedMetaKeysThatAreFineStayAccepted(): void
{
$ref = self::ref([
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
], self::postLoop());
$sanitizer = ComposeFixture::sanitizer();
foreach ([
self::content('custom_meta_telefon', '{}'),
self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'),
self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
self::loopMeta(
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}'
),
] as $value) {
self::assertSame($value, $ref->canonical($value, $sanitizer));
}
}
/** @return array<string, array{array<array-key, mixed>}> */
public static function malformedMaps(): array
{
return [
'setting type is an array' => [['post_title' => ['before' => ['type' => 'text']]]],
'settings not an array' => [['post_title' => 'text']],
'numeric setting key' => [['post_title' => ['text']]],
'numeric name' => [[['before' => 'text']]],
];
}
/**
* Minor-2: a malformed map fails early and clearly.
*
* @dataProvider malformedMaps
* @param array<array-key, mixed> $map
*/
public function testRejectsMalformedMaps(array $map): void
{
$this->expectException(InvalidArgumentException::class);
self::ref($map); // @phpstan-ignore-line
}
/** Ruling S33c: the type of a whole reference, read from its payload (not from a string prefix). */
public function testTypeOfAReference(): void
{
self::assertSame('color', VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
self::assertSame('content', VariableRef::typeOf(self::content('post_title', '{}')));
self::assertSame('color', VariableRef::typeOf('$variable({"value":{"name":"gcid-a1b2"},"type":"color"})$'));
self::assertNull(VariableRef::typeOf('#1a1a1a'));
self::assertNull(VariableRef::typeOf('$variable(kaputt)$'));
self::assertNull(VariableRef::typeOf('$variable({"type":"evil","value":{"name":"x"}})$'));
self::assertNull(VariableRef::typeOf('#fff $variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'));
self::assertNull(VariableRef::typeOf('$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$ '));
}
public function testDetectsReferences(): void
{
self::assertTrue(VariableRef::contains('a $variable( b'));
self::assertFalse(VariableRef::contains('Preis: 5 $'));
}
/**
* Ruling S37 (M-1/M-3): map field types come from a filter – untrusted. url → url() (a URL the
* sanitizer empties is refused), select_post → a readable numeric post id, select /
* yes_no_button / number → plain values; every other type is refused.
*/
public function testMapFieldTypesAreRoutedStrictly(): void
{
$ref = self::ref([
'evil' => ['link' => 'url', 'color' => 'color', 'blank' => '', 'flag' => 'yes_no_button', 'n' => 'number'],
'post_link_url_page' => ['post_id' => 'select_post'],
]);
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
self::content('evil', '{"link":"https://kunde.test/a%20b","flag":"on","n":3}'),
$ref->canonical(self::content('evil', '{"link":" https://kunde.test/a b","flag":"on","n":3}'), $sanitizer)
);
self::assertSame(
self::content('post_link_url_page', '{"post_id":"12"}'),
$ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true)
);
self::assertSame(
self::content('post_link_url_page', '{"post_id":12}'),
$ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true)
);
$rejected = [
'javascript: in a url setting' => [self::content('evil', '{"link":"javascript:alert(1)"}'), 'URL'],
'url setting not a string' => [self::content('evil', '{"link":5}'), 'nur Text'],
'unknown field type' => [self::content('evil', '{"color":"red"}'), 'Feldtyp'],
'empty field type' => [self::content('evil', '{"blank":"x"}'), 'Feldtyp'],
'post slug' => [self::content('post_link_url_page', '{"post_id":"impressum"}'), 'Beitrags-ID'],
'zero post id' => [self::content('post_link_url_page', '{"post_id":0}'), 'Beitrags-ID'],
'negative post id' => [self::content('post_link_url_page', '{"post_id":"-3"}'), 'Beitrags-ID'],
'unreadable post' => [self::content('post_link_url_page', '{"post_id":"666"}'), 'Beitrags-ID'],
];
foreach ($rejected as $case => [$value, $fragment]) {
try {
// URL mode: internal links (post_link_url_*) are allowed only there (E13a).
$ref->canonical($value, $sanitizer, true);
self::fail('Accepted: ' . $case);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage(), $case);
}
}
}
/** 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
{
$ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]);
$link = self::content('post_link_url_page', '{"post_id":"12"}');
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
$ref->canonical($link, ComposeFixture::sanitizer());
}
public function testNameOfReadsTheContentOptionOnly(): void
{
self::assertSame('custom_meta_role', VariableRef::nameOf(self::content('custom_meta_role', '{}')));
self::assertNull(VariableRef::nameOf('$variable({"type":"color","value":{"name":"gcid-primary","settings":{}}})$'));
self::assertNull(VariableRef::nameOf('Text'));
self::assertNull(VariableRef::nameOf('$variable(kaputt)$'));
}
}