['innerContent' => ['desktop' => ['value' => 'Müller GmbH'], 'phone' => ['value' => 'Müller']]], 'content' => ['innerContent' => ['desktop' => ['value' => '

Seit 1987: Müller & Söhne. Anruf bei MÜLLER.

']]], '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 $attrs */ private static function text(string $value): array { return ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]]; } /** @param array $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('

Seit 1987: Schmidt & Söhne. Anruf bei MÜLLER.

', $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 = '

Kontakt & ' . 'kontakt@kunde.test ' . self::REF . ' amp

'; $result = (new TextReplacer('kontakt', 'Anfrage', false))->apply(self::text($value), self::same()); self::assertSame( '

Anfrage & ' . 'Anfrage@kunde.test ' . self::REF . ' amp

', 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

', (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 = '

Müller

' . ' Müller'; $result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same()); self::assertSame( '

Schmidt

' . ' Schmidt', self::valueOf($result['attrs']) ); self::assertSame(2, $result['count']); $unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('' . ''; $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same()); self::assertSame(1, $result['count']); self::assertStringEndsWith('', (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', 'Müller'))->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 */ public static function s45(): array { return [ 'double-quoted ">" then handler' => ['Link', ' B', '" onmouseover="alert(1)'], 'single-quoted ">" then handler' => ["A > B", ' B', "' onerror='alert(1)"], 'href rewrite after quoted ">"' => ['Link', 'kontakt', 'javascript:alert(1)'], 'comment with ">"' => ['

x

', ' B', '-->'], 'comment closed by --!>' => ['

x

', ' B', 'x'], 'unterminated comment' => ['

x B

', ' 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 */ public static function longInputs(): array { $n = 200000; return [ 'many "<"' => [str_repeat('<', $n) . ' Kontakt'], 'many " [str_repeat(' [str_repeat(' ['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({"type":"content","value":{"name":"post_title"}})$', 'replace'], 'nested tag-split marker' => ['Müller', '$vari<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) $')); } }