Files

370 lines
18 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\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 &amp; 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 &amp; 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> &amp; '
. '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> &amp; '
. '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('&amp; ', (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> &amp; 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', '&amp'],
'removal glues an entity together' => ['&amp--; 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('&amp', 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) $'));
}
}