|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * Tests that a GlotPress translation is held to its own field's markup boundary. |
| 4 | + * |
| 5 | + * @package WordPressdotorg\Plugin_Directory\Tests |
| 6 | + */ |
| 7 | + |
| 8 | +declare( strict_types = 1 ); |
| 9 | + |
| 10 | +use PHPUnit\Framework\Attributes\DataProvider; |
| 11 | +use PHPUnit\Framework\Attributes\Group; |
| 12 | +use PHPUnit\Framework\TestCase; |
| 13 | +use WordPressdotorg\Plugin_Directory\Plugin_I18n; |
| 14 | + |
| 15 | +/** |
| 16 | + * A translation replaces the value the readme parser sanitized at import, and is |
| 17 | + * then displayed wherever that value was. |
| 18 | + * |
| 19 | + * @group i18n |
| 20 | + */ |
| 21 | +#[Group( 'i18n' )] |
| 22 | +class Plugin_I18n_Sanitization_Test extends TestCase { |
| 23 | + |
| 24 | + /** |
| 25 | + * The translator under test. |
| 26 | + * |
| 27 | + * @var Plugin_I18n |
| 28 | + */ |
| 29 | + protected $i18n; |
| 30 | + |
| 31 | + /** |
| 32 | + * Builds the translator without its constructor, which needs cache globals. |
| 33 | + */ |
| 34 | + protected function setUp(): void { |
| 35 | + parent::setUp(); |
| 36 | + |
| 37 | + $reflection = new \ReflectionClass( Plugin_I18n::class ); |
| 38 | + $this->i18n = $reflection->newInstanceWithoutConstructor(); |
| 39 | + } |
| 40 | + |
| 41 | + /** |
| 42 | + * Data provider for {@see test_markup_fields_keep_section_markup_only()}. |
| 43 | + * |
| 44 | + * @return array<string, array{0: string, 1: string, 2: string}> |
| 45 | + */ |
| 46 | + public static function markup_field_provider(): array { |
| 47 | + return array( |
| 48 | + 'script in a section' => array( |
| 49 | + 'description', |
| 50 | + 'Bonjour <script>alert(1)</script>', |
| 51 | + 'Bonjour alert(1)', |
| 52 | + ), |
| 53 | + 'directives in a section' => array( |
| 54 | + 'faq', |
| 55 | + '<a data-wp-interactive="core/image" data-wp-context=\'{"t":"javascript:alert(1)"}\' data-wp-bind--href="context.t" href="#">Détails</a>', |
| 56 | + '<a href="#">Détails</a>', |
| 57 | + ), |
| 58 | + 'directives in a caption' => array( |
| 59 | + 'screenshot-1', |
| 60 | + '<a data-wp-bind--href="context.t" href="#">Légende</a>', |
| 61 | + '<a href="#">Légende</a>', |
| 62 | + ), |
| 63 | + 'event attribute in a caption' => array( |
| 64 | + 'screenshot-2', |
| 65 | + '<strong onmouseover="alert(1)">Légende</strong>', |
| 66 | + '<strong>Légende</strong>', |
| 67 | + ), |
| 68 | + 'comment syntax in a caption' => array( |
| 69 | + 'screenshot-3', |
| 70 | + 'Légende <!-- /wp:image --><!-- wp:image -->', |
| 71 | + 'Légende', |
| 72 | + ), |
| 73 | + 'section markup survives unchanged' => array( |
| 74 | + 'changelog', |
| 75 | + '<h4>1.0</h4><ul><li>Correction d\'un <code>bug</code>, voir la <a href="https://example.org/" rel="nofollow">doc</a>.</li></ul>', |
| 76 | + '<h4>1.0</h4><ul><li>Correction d\'un <code>bug</code>, voir la <a href="https://example.org/" rel="nofollow">doc</a>.</li></ul>', |
| 77 | + ), |
| 78 | + ); |
| 79 | + } |
| 80 | + |
| 81 | + /** |
| 82 | + * Sections and screenshot captions carry readme markup and nothing else. |
| 83 | + * |
| 84 | + * @param string $key The translation key the field translates under. |
| 85 | + * @param string $translation The translation as GlotPress stored it. |
| 86 | + * @param string $expected The value the field may display. |
| 87 | + */ |
| 88 | + #[DataProvider( 'markup_field_provider' )] |
| 89 | + public function test_markup_fields_keep_section_markup_only( string $key, string $translation, string $expected ): void { |
| 90 | + $this->assertSame( $expected, $this->i18n->sanitize_translation( $key, $translation ) ); |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * Data provider for {@see test_plain_text_fields_drop_all_markup()}. |
| 95 | + * |
| 96 | + * @return array<string, array{0: string}> |
| 97 | + */ |
| 98 | + public static function plain_text_field_provider(): array { |
| 99 | + return array( |
| 100 | + 'title' => array( 'title' ), |
| 101 | + 'excerpt' => array( 'excerpt' ), |
| 102 | + 'block title' => array( 'block_title:' . md5( 'Block' ) ), |
| 103 | + ); |
| 104 | + } |
| 105 | + |
| 106 | + /** |
| 107 | + * Title, short description and block titles are plain text in every consumer. |
| 108 | + * |
| 109 | + * @param string $key The translation key the field translates under. |
| 110 | + */ |
| 111 | + #[DataProvider( 'plain_text_field_provider' )] |
| 112 | + public function test_plain_text_fields_drop_all_markup( string $key ): void { |
| 113 | + $translation = 'Mon Plugin <script>alert(1)</script><a href="#" data-wp-bind--href="context.t">x</a>'; |
| 114 | + |
| 115 | + $this->assertSame( 'Mon Plugin x', $this->i18n->sanitize_translation( $key, $translation ) ); |
| 116 | + $this->assertStringNotContainsString( 'alert', $this->i18n->sanitize_translation( $key, $translation ) ); |
| 117 | + } |
| 118 | + |
| 119 | + /** |
| 120 | + * The title and short description are stored entity-encoded, so a translation is too. |
| 121 | + * |
| 122 | + * A block title is not: it is stored as the block declared it. |
| 123 | + */ |
| 124 | + public function test_plain_text_fields_match_their_stored_encoding(): void { |
| 125 | + $this->assertSame( 'Tom & Jerry < Co', $this->i18n->sanitize_translation( 'title', 'Tom & Jerry < Co' ) ); |
| 126 | + $this->assertSame( 'Tom & Jerry', $this->i18n->sanitize_translation( 'excerpt', 'Tom & Jerry' ) ); |
| 127 | + $this->assertSame( 'Tom & Jerry', $this->i18n->sanitize_translation( 'block_title:' . md5( 'B' ), 'Tom & Jerry' ) ); |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * A stored marker cannot pick its own substitution site. |
| 132 | + * |
| 133 | + * `title` is on the readme's allow-list, so a readme can store marker syntax |
| 134 | + * inside an attribute and aim a translation at a context it would survive. |
| 135 | + */ |
| 136 | + public function test_a_stored_marker_is_not_a_substitution_site(): void { |
| 137 | + $stored = '<a href="https://example.org/" title="___TRANSLATION_7___">Docs</a>'; |
| 138 | + |
| 139 | + $this->assertSame( |
| 140 | + '<a href="https://example.org/" title=" ">Docs</a>', |
| 141 | + $this->i18n->translate_marked_gp_originals( |
| 142 | + Plugin_I18n::remove_translation_markers( $stored ), |
| 143 | + array( 7 => '" onmouseover="alert(1)' ), |
| 144 | + array( 7 => 'Docs' ) |
| 145 | + ) |
| 146 | + ); |
| 147 | + } |
| 148 | + |
| 149 | + /** |
| 150 | + * A marker split across a marker does not survive the one that wraps it. |
| 151 | + */ |
| 152 | + public function test_a_marker_cannot_be_spliced_from_the_text_around_one(): void { |
| 153 | + $crafted = '___TRAN___TRANSLATION_1___SLATION_7___'; |
| 154 | + |
| 155 | + $this->assertDoesNotMatchRegularExpression( |
| 156 | + '/___TRANSLATION_\d+___/', |
| 157 | + Plugin_I18n::remove_translation_markers( $crafted ) |
| 158 | + ); |
| 159 | + } |
| 160 | + |
| 161 | + /** |
| 162 | + * Sanitizing the assembled field catches what a substitution opened up. |
| 163 | + */ |
| 164 | + public function test_assembled_field_drops_an_attribute_a_substitution_opened(): void { |
| 165 | + $assembled = '<a href="https://example.org/" title="" onmouseover="alert(1)">Docs</a>'; |
| 166 | + |
| 167 | + $this->assertSame( |
| 168 | + '<a href="https://example.org/" title="">Docs</a>', |
| 169 | + $this->i18n->sanitize_translation( 'description', $assembled ) |
| 170 | + ); |
| 171 | + } |
| 172 | +} |
0 commit comments