packages/requirements/tests/Unit/Source/DomSourceTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Source;
6
7use League\CommonMark\Exception\CommonMarkException;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\DataProvider;
10use PHPUnit\Framework\Attributes\Small;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use Requirements\Model\Source;
14use Requirements\Source\DomSource;
15use Requirements\Source\LocalFile;
16use Requirements\Source\ResourceLoader;
17use Requirements\Source\ResourceLocation;
18use Requirements\Source\TextFragment;
19use Requirements\Source\Unit;
20use RuntimeException;
21use Tests\Fake\ProjectDirectory;
22use Tests\Fake\SourceDocuments;
23use Tests\Fake\SourceFailure;
24
25#[CoversClass(DomSource::class)]
26#[UsesClass(LocalFile::class)]
27#[UsesClass(ResourceLoader::class)]
28#[UsesClass(ResourceLocation::class)]
29#[UsesClass(Source::class)]
30#[UsesClass(TextFragment::class)]
31#[UsesClass(Unit::class)]
32#[Small]
33final class DomSourceTest extends TestCase
34{
35 /**
36 * @throws CommonMarkException
37 */
38 #[DataProvider('providerSelectStableDistinctUnits')]
39 public function testSelectStableDistinctUnits(string $format, string $file, string $document, string $selector, int $count): void
40 {
41 $project = new ProjectDirectory();
42 $project->put($file, $document);
43 $source = new Source('test', $file, $format, $selector);
44 $extension = new DomSource();
45 $units = $extension->select($source, $selector, $project->directory, false);
46
47 self::assertCount($count, $units);
48 self::assertCount($count, array_unique(array_map(static fn (Unit $unit): string => $unit->location, $units)));
49 self::assertSame($units[0]->location, $extension->select($source, $selector, $project->directory, false)[0]->location);
50 }
51
52 /**
53 * @return array<string, array{string, string, string, string, int}>
54 */
55 public static function providerSelectStableDistinctUnits(): array
56 {
57 return [
58 'html' => ['html', 'source.html', SourceDocuments::HTML, 'main p', 3],
59 'xml' => ['xml', 'source.xml', SourceDocuments::XML, 'section t', 2],
60 'ietf' => ['ietf', 'source.xml', SourceDocuments::XML, 'section t', 2],
61 'markdown' => ['markdown', 'source.md', SourceDocuments::MARKDOWN, 'p', 2],
62 ];
63 }
64
65 /**
66 * @param list<Unit> $expected
67 * @throws CommonMarkException
68 */
69 #[DataProvider('providerSelectUnits')]
70 public function testSelectLocatesUnitsByNodePathWithNormalizedText(string $format, string $file, string $document, string $selector, array $expected): void
71 {
72 $project = new ProjectDirectory();
73 $project->put($file, $document);
74
75 self::assertEquals($expected, (new DomSource())->select(new Source('test', $file, $format, $selector), $selector, $project->directory, false));
76 }
77
78 /**
79 * @return array<string, array{string, string, string, string, list<Unit>}>
80 */
81 public static function providerSelectUnits(): array
82 {
83 return [
84 'html' => ['html', 'source.html', SourceDocuments::HTML, 'main p', [
85 new Unit('/html/body/main/p[1]', 'Names shall start with a letter.'),
86 new Unit('/html/body/main/p[2]', 'Names may contain digits.'),
87 new Unit('/html/body/main/p[3]', 'The generator shall produce C code.'),
88 ]],
89 'xml' => ['xml', 'source.xml', SourceDocuments::XML, 'section t', [
90 new Unit('/rfc/section/t[1]', 'First rule.'),
91 new Unit('/rfc/section/t[2]', 'Second rule.'),
92 ]],
93 'ietf' => ['ietf', 'source.xml', SourceDocuments::XML, 'section[anchor="rules"] t', [
94 new Unit('/rfc/section/t[1]', 'First rule.'),
95 new Unit('/rfc/section/t[2]', 'Second rule.'),
96 ]],
97 'markdown' => ['markdown', 'source.md', SourceDocuments::MARKDOWN, 'p', [
98 new Unit('/html/body/p[1]', 'First rule.'),
99 new Unit('/html/body/p[2]', 'Second rule.'),
100 ]],
101 'markdown heading' => ['markdown', 'source.md', SourceDocuments::MARKDOWN, 'h1', [
102 new Unit('/html/body/h1', 'Rules'),
103 ]],
104 'whitespace' => ['html', 'spaced.html', "<main><p> Names\n\tshall\u{00a0}start. </p></main>", 'p', [
105 new Unit('/html/body/main/p', 'Names shall start.'),
106 ]],
107 'nothing selected' => ['html', 'source.html', SourceDocuments::HTML, 'table', []],
108 'html is not read as xml' => ['html', 'tags.html', '<main><P>Upper.</P></main>', 'p', [
109 new Unit('/html/body/main/p', 'Upper.'),
110 ]],
111 ];
112 }
113
114 /**
115 * @throws CommonMarkException
116 */
117 #[DataProvider('providerSelectMarkdownIsRenderedSafely')]
118 public function testSelectMarkdownIsRenderedSafely(string $selector): void
119 {
120 $project = new ProjectDirectory();
121 $project->put('unsafe.md', "<script>alert(1)</script>\n\n[Link](javascript:alert(1))\n");
122
123 self::assertSame([], (new DomSource())->select(new Source('test', 'unsafe.md', 'markdown', $selector), $selector, $project->directory, false));
124 }
125
126 /**
127 * @return array<string, array{string}>
128 */
129 public static function providerSelectMarkdownIsRenderedSafely(): array
130 {
131 return [
132 'raw html stripped' => ['script'],
133 'unsafe link removed' => ['a[href]'],
134 ];
135 }
136
137 /**
138 * @throws CommonMarkException
139 */
140 public function testSelectRejectsNestedCoverageUnits(): void
141 {
142 $source = new Source('test', 'source.html', 'html', 'main, main p');
143 $project = new ProjectDirectory();
144
145 $this->expectException(RuntimeException::class);
146 $this->expectExceptionMessage('A source selection cannot contain both ancestor and descendant units. Select atomic elements.');
147 (new DomSource())->select($source, $source->selector, $project->directory, false);
148 }
149
150 /**
151 * @throws CommonMarkException
152 */
153 public function testSelectAcceptsSiblingsSharingALocationPrefix(): void
154 {
155 $project = new ProjectDirectory();
156 $project->put('prefix.html', '<main><p>Rule.</p><pre>Code.</pre></main>');
157
158 self::assertEquals(
159 [new Unit('/html/body/main/p', 'Rule.'), new Unit('/html/body/main/pre', 'Code.')],
160 (new DomSource())->select(new Source('test', 'prefix.html', 'html', 'p, pre'), 'p, pre', $project->directory, false),
161 );
162 }
163
164 /**
165 * @throws CommonMarkException
166 */
167 public function testSelectAcceptsSiblingsSharingAnIndexPrefix(): void
168 {
169 $project = new ProjectDirectory();
170 $project->put('many.html', '<main>' . str_repeat('<p>Rule.</p>', 10) . '</main>');
171 $units = (new DomSource())->select(new Source('test', 'many.html', 'html', 'p'), 'p', $project->directory, false);
172
173 self::assertCount(10, $units);
174 self::assertSame('/html/body/main/p[10]', $units[9]->location);
175 }
176
177 /**
178 * @throws CommonMarkException
179 */
180 public function testSelectVerifiesSnapshotHash(): void
181 {
182 $source = new Source('test', 'https://example.invalid/manual', 'html', 'p', 'source.html', str_repeat('0', 64));
183 $project = new ProjectDirectory();
184
185 $this->expectException(RuntimeException::class);
186 $this->expectExceptionMessage('SHA-256');
187 (new DomSource())->select($source, 'p', $project->directory, false);
188 }
189
190 /**
191 * @throws CommonMarkException
192 */
193 public function testSelectReadsThroughTheGivenLoader(): void
194 {
195 $project = new ProjectDirectory();
196 $loader = new ResourceLoader();
197 $loader->fetch($project->path('source.html'));
198 $project->put('source.html', '<main><p>Changed.</p></main>');
199
200 self::assertCount(3, (new DomSource($loader))->select(new Source('test', 'source.html', 'html', 'main p'), 'main p', $project->directory, false));
201 }
202
203 /**
204 * @throws CommonMarkException
205 */
206 public function testSelectExactDirectiveLocatesTheSameUnitAsCssWithoutChangingTheScope(): void
207 {
208 $source = new Source('manual', 'source.html', 'html', 'main p');
209 $extension = new DomSource();
210 $project = new ProjectDirectory();
211 $directory = $project->directory;
212 $css = $extension->select($source, '#a', $directory, false);
213 $fragment = $extension->select($source, '#:~:text=Names%20shall%20start%20with%20a%20letter.', $directory, false);
214
215 self::assertEquals($css, $fragment);
216 self::assertCount(3, $extension->select($source, $source->selector, $directory, false));
217 self::assertSame([], $extension->select($source, '#:~:text=Names%20shall', $directory, false));
218 self::assertSame([], $extension->select(new Source('manual', 'source.html', 'html', '#b'), '#:~:text=Names%20shall%20start%20with%20a%20letter.', $directory, false));
219 }
220
221 /**
222 * @throws CommonMarkException
223 */
224 public function testSelectDirectiveSelectsEveryUnitWithTheText(): void
225 {
226 $project = new ProjectDirectory();
227 $project->put('repeated.html', '<main><p>Repeated text.</p><p>Other.</p><p> Repeated text. </p></main>');
228
229 self::assertEquals(
230 [new Unit('/html/body/main/p[1]', 'Repeated text.'), new Unit('/html/body/main/p[3]', 'Repeated text.')],
231 (new DomSource())->select(new Source('manual', 'repeated.html', 'html', 'main p'), '#:~:text=Repeated%20text.', $project->directory, false),
232 );
233 }
234
235 /**
236 * @throws CommonMarkException
237 */
238 public function testSelectDirectiveStillRejectsNestedScopes(): void
239 {
240 $project = new ProjectDirectory();
241
242 $this->expectException(RuntimeException::class);
243 $this->expectExceptionMessage('ancestor and descendant');
244 (new DomSource())->select(new Source('manual', 'source.html', 'html', 'main, main p'), '#:~:text=Names%20shall%20start%20with%20a%20letter.', $project->directory, false);
245 }
246
247 /**
248 * @throws CommonMarkException
249 */
250 public function testSelectTextDirectivesCannotReplaceTheCoverageScope(): void
251 {
252 $source = new Source('manual', 'source.html', 'html', '#:~:text=Names');
253 $project = new ProjectDirectory();
254
255 $this->expectException(RuntimeException::class);
256 $this->expectExceptionMessage('coverage scope');
257 (new DomSource())->select($source, $source->selector, $project->directory, false);
258 }
259
260 /**
261 * @throws CommonMarkException
262 */
263 #[DataProvider('providerSelectDirectiveOutsideHtml')]
264 public function testSelectRejectsDirectivesOutsideHtml(string $format): void
265 {
266 $project = new ProjectDirectory();
267 $project->put('source.xml', SourceDocuments::XML);
268
269 $this->expectException(RuntimeException::class);
270 $this->expectExceptionMessage('Text Fragments select evidence in an HTML CSS scope; they cannot define the coverage scope.');
271 (new DomSource())->select(new Source('manual', 'source.xml', $format, 'section t'), '#:~:text=First%20rule.', $project->directory, false);
272 }
273
274 /**
275 * @return array<string, array{string}>
276 */
277 public static function providerSelectDirectiveOutsideHtml(): array
278 {
279 return [
280 'xml' => ['xml'],
281 'ietf' => ['ietf'],
282 'markdown' => ['markdown'],
283 ];
284 }
285
286 /**
287 * @throws CommonMarkException
288 */
289 public function testSelectRejectsDirectivesBeforeReadingTheSource(): void
290 {
291 $project = new ProjectDirectory();
292
293 $this->expectException(RuntimeException::class);
294 $this->expectExceptionMessage('coverage scope');
295 (new DomSource())->select(new Source('manual', 'missing.xml', 'xml', 't'), '#:~:text=Rule.', $project->directory, false);
296 }
297
298 /**
299 * @throws CommonMarkException
300 */
301 public function testSelectXmlEntitiesAreRejected(): void
302 {
303 $project = new ProjectDirectory();
304 $project->put('unsafe.xml', '<!DOCTYPE x [<!ENTITY secret SYSTEM "file:///etc/passwd">]><x>&secret;</x>');
305 $source = new Source('test', 'unsafe.xml', 'xml', 'x');
306
307 $this->expectException(RuntimeException::class);
308 (new DomSource())->select($source, 'x', $project->directory, false);
309 }
310
311 /**
312 * @throws CommonMarkException
313 */
314 #[DataProvider('providerSelectUnsafeXml')]
315 public function testSelectRejectsDtdAndEntityDeclarations(string $format, string $document): void
316 {
317 $project = new ProjectDirectory();
318 $project->put('unsafe.xml', $document);
319
320 $this->expectException(RuntimeException::class);
321 $this->expectExceptionMessage('XML sources cannot contain DTD or entity declarations.');
322 (new DomSource())->select(new Source('test', 'unsafe.xml', $format, 'x'), 'x', $project->directory, false);
323 }
324
325 /**
326 * @return array<string, array{string, string}>
327 */
328 public static function providerSelectUnsafeXml(): array
329 {
330 return [
331 'entity' => ['xml', '<!DOCTYPE x [<!ENTITY secret SYSTEM "file:///etc/passwd">]><x>&secret;</x>'],
332 'lowercase doctype' => ['xml', '<!doctype x><x>Text.</x>'],
333 'entity without doctype' => ['ietf', '<x><!entity a "b"></x>'],
334 ];
335 }
336
337 /**
338 * @throws CommonMarkException
339 */
340 public function testSelectAcceptsHtmlDoctype(): void
341 {
342 $project = new ProjectDirectory();
343
344 self::assertCount(1, (new DomSource())->select(new Source('test', 'source.html', 'html', 'aside p'), 'aside p', $project->directory, false));
345 }
346
347 /**
348 * @throws CommonMarkException
349 */
350 #[DataProvider('providerSelectMalformedXml')]
351 public function testSelectRejectsMalformedXml(string $format): void
352 {
353 $project = new ProjectDirectory();
354 $project->put('broken.xml', '<rfc><t>Unclosed.</rfc>');
355
356 $this->expectException(RuntimeException::class);
357 $this->expectExceptionMessage('Malformed XML source.');
358 (new DomSource())->select(new Source('test', 'broken.xml', $format, 't'), 't', $project->directory, false);
359 }
360
361 /**
362 * @return array<string, array{string}>
363 */
364 public static function providerSelectMalformedXml(): array
365 {
366 return [
367 'xml' => ['xml'],
368 'ietf' => ['ietf'],
369 ];
370 }
371
372 public function testSelectLeavesNoXmlErrorsBehind(): void
373 {
374 $project = new ProjectDirectory();
375 $project->put('broken.xml', '<rfc><t>Unclosed.</rfc>');
376
377 self::assertSame('Malformed XML source.', SourceFailure::message(static fn (): array => (new DomSource())->select(new Source('test', 'broken.xml', 'xml', 't'), 't', $project->directory, false)));
378 self::assertSame([], libxml_get_errors());
379 self::assertFalse(libxml_use_internal_errors(false));
380 }
381
382 /**
383 * @throws CommonMarkException
384 */
385 public function testSelectRestoresInternalXmlErrorHandling(): void
386 {
387 $project = new ProjectDirectory();
388 $project->put('source.xml', SourceDocuments::XML);
389 (new DomSource())->select(new Source('test', 'source.xml', 'xml', 't'), 't', $project->directory, false);
390
391 self::assertFalse(libxml_use_internal_errors(false));
392 }
393}
394