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