Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
370 lines
18 KiB
PHP
370 lines
18 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class TextReplacerTest extends TestCase
|
||
{
|
||
private const ATTRS = [
|
||
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller GmbH'], 'phone' => ['value' => 'Müller']]],
|
||
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Seit 1987: Müller & Söhne. Anruf bei MÜLLER.</p>']]],
|
||
'button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller anrufen', 'linkUrl' => '/mueller-kontakt']]]],
|
||
'module' => ['meta' => ['adminLabel' => ['desktop' => ['value' => 'Müller-Hero']]]],
|
||
'dyn' => ['innerContent' => ['desktop' => ['value' => '$variable({"type":"content","value":{"name":"Müller","settings":{}}})$']]],
|
||
];
|
||
|
||
private const REF = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
|
||
|
||
/** @return callable(string, ?string, string): string */
|
||
private static function same(): callable
|
||
{
|
||
return static fn (string $p, ?string $k, string $v): string => $v;
|
||
}
|
||
|
||
/** @param array<string, mixed> $attrs */
|
||
private static function text(string $value): array
|
||
{
|
||
return ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]];
|
||
}
|
||
|
||
/** @param array<string, mixed> $attrs */
|
||
private static function valueOf(array $attrs): mixed
|
||
{
|
||
return $attrs['content']['innerContent']['desktop']['value'];
|
||
}
|
||
|
||
public function testReplacesVisibleTextOnly(): void
|
||
{
|
||
$seen = [];
|
||
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::ATTRS, static function (string $path, ?string $key, string $value) use (&$seen): string {
|
||
$seen[] = $path . '|' . ($key ?? '');
|
||
|
||
return $value;
|
||
});
|
||
|
||
self::assertSame(4, $result['count']);
|
||
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
|
||
self::assertSame('Schmidt', $result['attrs']['title']['innerContent']['phone']['value']);
|
||
self::assertSame('<p>Seit 1987: Schmidt & Söhne. Anruf bei MÜLLER.</p>', $result['attrs']['content']['innerContent']['desktop']['value']);
|
||
self::assertSame(['text' => 'Schmidt anrufen', 'linkUrl' => '/mueller-kontakt'], $result['attrs']['button']['innerContent']['desktop']['value']);
|
||
self::assertSame('Müller-Hero', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
|
||
self::assertSame(self::ATTRS['dyn'], $result['attrs']['dyn']);
|
||
self::assertContains('button.innerContent|text', $seen);
|
||
self::assertSame(['path' => 'button.innerContent.text', 'before' => 'Müller anrufen', 'after' => 'Schmidt anrufen'], $result['samples'][0]);
|
||
}
|
||
|
||
public function testCaseInsensitiveIncludingUmlauts(): void
|
||
{
|
||
$result = (new TextReplacer('müller', 'Schmidt', false))->apply(self::ATTRS, self::same());
|
||
|
||
self::assertSame(5, $result['count']);
|
||
self::assertStringContainsString('Anruf bei Schmidt.', $result['attrs']['content']['innerContent']['desktop']['value']);
|
||
}
|
||
|
||
public function testNothingFound(): void
|
||
{
|
||
$result = (new TextReplacer('Meier', 'X'))->apply(self::ATTRS, self::same());
|
||
|
||
self::assertSame(0, $result['count']);
|
||
self::assertSame(self::ATTRS, $result['attrs']);
|
||
}
|
||
|
||
/** Ruling S8: tags with their attributes, entities and references are never touched. */
|
||
public function testTagsEntitiesAndReferencesStayUntouched(): void
|
||
{
|
||
$value = '<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Kontakt</a> & '
|
||
. 'kontakt@kunde.test ' . self::REF . ' amp</p>';
|
||
|
||
$result = (new TextReplacer('kontakt', 'Anfrage', false))->apply(self::text($value), self::same());
|
||
|
||
self::assertSame(
|
||
'<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Anfrage</a> & '
|
||
. 'Anfrage@kunde.test ' . self::REF . ' amp</p>',
|
||
self::valueOf($result['attrs'])
|
||
);
|
||
self::assertSame(2, $result['count']);
|
||
|
||
$entity = (new TextReplacer('amp', 'X'))->apply(self::text($value), self::same());
|
||
self::assertStringContainsString('& ', (string) self::valueOf($entity['attrs']));
|
||
self::assertStringEndsWith(self::REF . ' X</p>', (string) self::valueOf($entity['attrs']));
|
||
self::assertSame(1, $entity['count']);
|
||
|
||
$inside = (new TextReplacer('post_title', 'post_date'))->apply(self::text('Titel: ' . self::REF), self::same());
|
||
self::assertSame(0, $inside['count'], 'text inside an embedded reference is not replaced');
|
||
}
|
||
|
||
/** Script and style bodies are code, not visible text (D10: never styles). */
|
||
public function testScriptAndStyleBodiesAreNotText(): void
|
||
{
|
||
$value = '<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Müller</p>'
|
||
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Müller';
|
||
|
||
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same());
|
||
|
||
self::assertSame(
|
||
'<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Schmidt</p>'
|
||
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Schmidt',
|
||
self::valueOf($result['attrs'])
|
||
);
|
||
self::assertSame(2, $result['count']);
|
||
|
||
$unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('<script>x("Müller")'), self::same());
|
||
self::assertSame(0, $unclosed['count'], 'an unclosed script runs to the end of the value');
|
||
}
|
||
|
||
/** The clean callback gets the replacement for each place; its result is what is inserted. */
|
||
public function testInsertsTheCleanedReplacementPerPlace(): void
|
||
{
|
||
$calls = [];
|
||
$result = (new TextReplacer('Müller', '<b>Schmidt</b>'))->apply(
|
||
self::ATTRS,
|
||
static function (string $path, ?string $key, string $value) use (&$calls): string {
|
||
$calls[] = $path . '|' . ($key ?? '');
|
||
|
||
return $path === 'title.innerContent' ? strip_tags($value) : $value;
|
||
}
|
||
);
|
||
|
||
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
|
||
self::assertSame('<p>Seit 1987: <b>Schmidt</b> & Söhne. Anruf bei MÜLLER.</p>', self::valueOf($result['attrs']));
|
||
self::assertSame(['button.innerContent|text', 'content.innerContent|', 'title.innerContent|'], $calls, 'once per place');
|
||
}
|
||
|
||
/** A place the callback refuses (null: URL or code field) stays as it is. */
|
||
public function testNullFromTheCallbackLeavesThePlace(): void
|
||
{
|
||
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(
|
||
self::ATTRS,
|
||
static fn (string $p, ?string $k, string $v): ?string => $p === 'content.innerContent' ? null : $v
|
||
);
|
||
|
||
self::assertSame(3, $result['count']);
|
||
self::assertSame(self::ATTRS['content'], $result['attrs']['content']);
|
||
}
|
||
|
||
/** Forward risk (Task 1): unchanged strings are never handed to the callback and stay byte for byte. */
|
||
public function testUnchangedStringsAreNeverCleaned(): void
|
||
{
|
||
$odd = '$variable({"type":"content","value":{"name":"not_in_the_allow_list","settings":{"x":"<b>"}}})$';
|
||
$attrs = [
|
||
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller']]],
|
||
'content' => ['innerContent' => ['desktop' => ['value' => $odd], 'tablet' => ['value' => " a\t<br>b %41 "]]],
|
||
];
|
||
$seen = [];
|
||
$result = (new TextReplacer('Müller', 'Schmidt'))->apply($attrs, static function (string $p, ?string $k, string $v) use (&$seen): string {
|
||
$seen[] = $p;
|
||
|
||
return $v;
|
||
});
|
||
|
||
self::assertSame(['title.innerContent'], $seen);
|
||
self::assertSame($attrs['content'], $result['attrs']['content']);
|
||
}
|
||
|
||
/**
|
||
* Ruling S8 hardening: a result whose tags, entities or references differ from the original
|
||
* ones plus the inserted replacement's own is refused – nothing is changed.
|
||
*
|
||
* @return array<string, array{string, string, string}>
|
||
*/
|
||
public static function crossBoundary(): array
|
||
{
|
||
return [
|
||
'replacement closes a lone "<" into a tag' => ['a < b Müller c', 'Müller', 'x onmouseover=alert(1)>'],
|
||
'replacement opens a tag closed by a lone ">"' => ['Müller c>', 'Müller', '<img src=x onerror=alert(1) '],
|
||
'replacement completes an entity' => ['Müller;', 'Müller', '&'],
|
||
'removal glues an entity together' => ['&--; text', '--', ''],
|
||
'replacement closes a partial reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{}}} END', 'END', ')$'],
|
||
'nested reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$', 'Müller', 'x'],
|
||
'reference across a line break' => ["\$variable({\"type\":\"content\",\n\"value\":{\"name\":\"post_title\"}})$ Müller", 'Müller', 'x'],
|
||
'removal glues a marker' => ['$varia--ble( Müller', '--', ''],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider crossBoundary */
|
||
public function testRefusesResultsThatChangeStructure(string $value, string $search, string $replace): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('content.innerContent');
|
||
|
||
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
|
||
}
|
||
|
||
/**
|
||
* Ruling S45: a ">" inside a quoted attribute value does not end the tag, so nothing after it
|
||
* in the tag is text. With the old "<[^>]*>" split these tests fail (the rest of the tag
|
||
* was searched), which is how a reverted tokenizer shows.
|
||
*/
|
||
public function testQuotedGreaterThanDoesNotEndTheTag(): void
|
||
{
|
||
$double = '<a title="A > Kontakt" href="/kontakt">Kontakt</a> und Kontakt';
|
||
$single = "<img alt='x > Kontakt' src='/kontakt.jpg'> Kontakt";
|
||
|
||
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($double), self::same());
|
||
$image = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($single), self::same());
|
||
|
||
self::assertSame('<a title="A > Kontakt" href="/kontakt">Anfrage</a> und Anfrage', self::valueOf($result['attrs']));
|
||
self::assertSame(2, $result['count']);
|
||
self::assertSame("<img alt='x > Kontakt' src='/kontakt.jpg'> Anfrage", self::valueOf($image['attrs']));
|
||
}
|
||
|
||
public function testCommentsAreNoText(): void
|
||
{
|
||
$value = "<!-- Kontakt > Kontakt\n<b> --><p>Kontakt</p><!---->Kontakt";
|
||
|
||
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
|
||
|
||
self::assertSame("<!-- Kontakt > Kontakt\n<b> --><p>Anfrage</p><!---->Anfrage", self::valueOf($result['attrs']));
|
||
}
|
||
|
||
/** More raw-text elements; a closing tag must really close ("</script-x>" does not). */
|
||
public function testRawTextBodiesOfAllKinds(): void
|
||
{
|
||
$value = '<iframe>Kontakt</iframe><noscript>Kontakt</noscript><script>a</script-x> Kontakt</script>'
|
||
. '<textarea>Kontakt</textarea>';
|
||
|
||
$result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
|
||
|
||
self::assertSame(1, $result['count']);
|
||
self::assertStringEndsWith('<textarea>Anfrage</textarea>', (string) self::valueOf($result['attrs']));
|
||
}
|
||
|
||
/** Search == replacement (or a cleaned replacement equal to the match) is no change and no hit. */
|
||
public function testNoEffectiveChangeIsNoHit(): void
|
||
{
|
||
$same = (new TextReplacer('Müller', 'Müller'))->apply(self::ATTRS, self::same());
|
||
$cleanedAway = (new TextReplacer('Müller', '<b>Müller</b>'))->apply(
|
||
self::ATTRS,
|
||
static fn (string $p, ?string $k, string $v): string => strip_tags($v)
|
||
);
|
||
$partly = (new TextReplacer('müller', 'Müller', false))->apply(self::ATTRS, self::same());
|
||
|
||
self::assertSame(0, $same['count']);
|
||
self::assertSame(self::ATTRS, $same['attrs']);
|
||
self::assertSame(0, $cleanedAway['count']);
|
||
self::assertSame(self::ATTRS, $cleanedAway['attrs']);
|
||
self::assertSame(1, $partly['count'], 'only MÜLLER changes');
|
||
}
|
||
|
||
/**
|
||
* Ruling S45 malicious rows: either refused (exception) or no change at all.
|
||
*
|
||
* @return array<string, array{string, string, string}>
|
||
*/
|
||
public static function s45(): array
|
||
{
|
||
return [
|
||
'double-quoted ">" then handler' => ['<a title="A > B" href="/kontakt">Link</a>', ' B', '" onmouseover="alert(1)'],
|
||
'single-quoted ">" then handler' => ["<img alt='A > B' src='/b.jpg'>", ' B', "' onerror='alert(1)"],
|
||
'href rewrite after quoted ">"' => ['<a title="x > y" href="/kontakt">Link</a>', 'kontakt', 'javascript:alert(1)'],
|
||
'comment with ">"' => ['<!-- a > B --><p>x</p>', ' B', '--><img src=x onerror=alert(1)>'],
|
||
'comment closed by --!>' => ['<!-- B --!><p>x</p>', ' B', 'x'],
|
||
'unterminated comment' => ['<p>x B</p><!-- B', ' B', 'y'],
|
||
'unterminated tag' => ['<p>ok B</p><a title="x B', ' B', 'y'],
|
||
'unbalanced quote' => ['<a title="x>B B</a>', ' B', 'y'],
|
||
'unquoted "<" in a tag' => ['<a <b>x B</b>', ' B', 'y'],
|
||
'lone "<" before text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'],
|
||
'"<" + letter in text' => ['a<B c', ' c', 'y'],
|
||
'raw body' => ['<iframe>x B</iframe>', ' B', '<img src=x onerror=alert(1)>'],
|
||
'fake script end' => ['<script>a</script-x> B</script>', ' B', 'y'],
|
||
'double-escaped script' => ['<script><!--<script></script> B--></script>', ' B', 'y'],
|
||
'unterminated reference' => ['$variable({"type":"content"} B', ' B', ')$'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider s45 */
|
||
public function testS45RowsAreRefusedOrUnchanged(string $value, string $search, string $replace): void
|
||
{
|
||
try {
|
||
$result = (new TextReplacer($search, $replace))->apply(self::text($value), self::same());
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('content.innerContent', $e->getMessage());
|
||
|
||
return;
|
||
}
|
||
|
||
self::assertSame(0, $result['count']);
|
||
self::assertSame($value, self::valueOf($result['attrs']));
|
||
}
|
||
|
||
/** @return array<string, array{string}> */
|
||
public static function longInputs(): array
|
||
{
|
||
$n = 200000;
|
||
|
||
return [
|
||
'many "<"' => [str_repeat('<', $n) . ' Kontakt'],
|
||
'many "<a"' => [str_repeat('<a ', intdiv($n, 3)) . ' Kontakt'],
|
||
'many open quotes' => [str_repeat('<a title="', intdiv($n, 10)) . ' Kontakt'],
|
||
'many comment openers' => [str_repeat('<!--', intdiv($n, 4)) . ' Kontakt'],
|
||
'comments then a closer' => [str_repeat('<!--', intdiv($n, 4)) . ' --> Kontakt'],
|
||
'many markers' => [str_repeat('$variable(', intdiv($n, 10)) . ' Kontakt'],
|
||
'many markers then a closer' => [str_repeat('$variable(', intdiv($n, 10)) . ')$ Kontakt'],
|
||
'many entities' => [str_repeat('&', intdiv($n, 4)) . ' Kontakt'],
|
||
'long text' => [str_repeat('Kontakt ', intdiv($n, 8))],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider longInputs */
|
||
public function testLongInputsStayLinear(string $value): void
|
||
{
|
||
$start = microtime(true);
|
||
try {
|
||
(new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same());
|
||
} catch (InvalidArgumentException) {
|
||
// refused is fine
|
||
}
|
||
|
||
self::assertLessThan(2.0, microtime(true) - $start);
|
||
}
|
||
|
||
public function testRefusesAMarkerFromTheCallback(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
(new TextReplacer('Müller', 'x'))->apply(self::text('Müller'), static fn (): string => self::REF);
|
||
}
|
||
|
||
/** @return array<string, array{string, string, string}> */
|
||
public static function badInput(): array
|
||
{
|
||
return [
|
||
'short search' => ['M', 'x', 'search'],
|
||
'empty search' => ['', 'x', 'search'],
|
||
'search with marker' => ['$variable(', 'x', 'search'],
|
||
'invalid UTF-8 search' => ["M\xC3", 'x', 'search'],
|
||
'invalid UTF-8 replacement' => ['Müller', "x\xC3", 'replace'],
|
||
'replacement is a reference' => ['Müller', self::REF, 'replace'],
|
||
'replacement embeds a reference' => ['Müller', 'Hallo ' . self::REF . ' Welt', 'replace'],
|
||
'tag-split marker (S28)' => ['Müller', '$variable<x>({"type":"content","value":{"name":"post_title"}})$', 'replace'],
|
||
'nested tag-split marker' => ['Müller', '$vari<<x>x>able(', 'replace'],
|
||
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
|
||
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
|
||
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
|
||
'NUL in search' => ["Mül\0ler", 'x', 'search'],
|
||
'VT in search' => ["Mül\x0Bler", 'x', 'search'],
|
||
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider badInput */
|
||
public function testProblemsNameTheField(string $search, string $replace, string $field): void
|
||
{
|
||
self::assertArrayHasKey($field, TextReplacer::problems($search, $replace));
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
new TextReplacer($search, $replace);
|
||
}
|
||
|
||
public function testFineInputHasNoProblems(): void
|
||
{
|
||
self::assertSame([], TextReplacer::problems('Müller', ''));
|
||
self::assertSame([], TextReplacer::problems('$5', 'Preis (netto) $'));
|
||
}
|
||
}
|