packages/sql-catalog/tests/Unit/Reporter/Html/HtmlReporterTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Medium;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use SqlCatalog\Core\Catalog\AnalysisProblem;
13use SqlCatalog\Core\Catalog\CallSite;
14use SqlCatalog\Core\Catalog\Catalog;
15use SqlCatalog\Core\Catalog\CatalogEntry;
16use SqlCatalog\Core\Catalog\Finding;
17use SqlCatalog\Core\Catalog\FindingRule;
18use SqlCatalog\Core\Catalog\Resolution;
19use SqlCatalog\Core\Catalog\Severity;
20use SqlCatalog\Core\Catalog\StatementPart;
21use SqlCatalog\Core\Reporter\CatalogArtifacts;
22use SqlCatalog\Core\Sql\StatementKind;
23use SqlCatalog\Core\Text\LiteralText;
24use SqlCatalog\Core\Text\Origin;
25use SqlCatalog\Core\Text\TextHole;
26use SqlCatalog\Core\Text\TextPattern;
27use SqlCatalog\Core\Type\TypeShape;
28use SqlCatalog\Reporter\Html\CatalogIndex;
29use SqlCatalog\Reporter\Html\CatalogStatistics;
30use SqlCatalog\Reporter\Html\HtmlReporter;
31use SqlCatalog\Reporter\Html\HtmlText;
32use SqlCatalog\Reporter\Html\Page\ClassPage;
33use SqlCatalog\Reporter\Html\Page\FileIndexPage;
34use SqlCatalog\Reporter\Html\Page\FilePage;
35use SqlCatalog\Reporter\Html\Page\FindingPage;
36use SqlCatalog\Reporter\Html\Page\NamespacePage;
37use SqlCatalog\Reporter\Html\Page\OverviewPage;
38use SqlCatalog\Reporter\Html\Page\StatementIndexPage;
39use SqlCatalog\Reporter\Html\Page\StatementPage;
40use SqlCatalog\Reporter\Html\Page\TableIndexPage;
41use SqlCatalog\Reporter\Html\Page\TablePage;
42use SqlCatalog\Reporter\Html\PageShell;
43use SqlCatalog\Reporter\Html\Palette;
44use SqlCatalog\Reporter\Html\ReportAssets;
45use SqlCatalog\Reporter\Html\ReportSite;
46use SqlCatalog\Reporter\Html\Scope;
47use SqlCatalog\Reporter\Html\SearchIndex;
48use SqlCatalog\Reporter\Html\Source\SourceCode;
49use SqlCatalog\Reporter\Html\SqlFormatter;
50use SqlCatalog\Reporter\Html\SqlHighlighter;
51use SqlCatalog\Reporter\Html\StatementList;
52use SqlCatalog\Reporter\Html\StatementRow;
53use SqlCatalog\Reporter\Html\TableName;
54
55#[CoversClass(HtmlReporter::class)]
56#[UsesClass(AnalysisProblem::class)]
57#[UsesClass(CallSite::class)]
58#[UsesClass(Catalog::class)]
59#[UsesClass(CatalogArtifacts::class)]
60#[UsesClass(CatalogEntry::class)]
61#[UsesClass(CatalogIndex::class)]
62#[UsesClass(CatalogStatistics::class)]
63#[UsesClass(ClassPage::class)]
64#[UsesClass(FileIndexPage::class)]
65#[UsesClass(FilePage::class)]
66#[UsesClass(Finding::class)]
67#[UsesClass(FindingRule::class)]
68#[UsesClass(FindingPage::class)]
69#[UsesClass(HtmlText::class)]
70#[UsesClass(LiteralText::class)]
71#[UsesClass(NamespacePage::class)]
72#[UsesClass(Origin::class)]
73#[UsesClass(OverviewPage::class)]
74#[UsesClass(PageShell::class)]
75#[UsesClass(Palette::class)]
76#[UsesClass(ReportAssets::class)]
77#[UsesClass(ReportSite::class)]
78#[UsesClass(Resolution::class)]
79#[UsesClass(Scope::class)]
80#[UsesClass(SourceCode::class)]
81#[UsesClass(SearchIndex::class)]
82#[UsesClass(Severity::class)]
83#[UsesClass(SqlFormatter::class)]
84#[UsesClass(SqlHighlighter::class)]
85#[UsesClass(StatementIndexPage::class)]
86#[UsesClass(StatementList::class)]
87#[UsesClass(StatementPage::class)]
88#[UsesClass(StatementPart::class)]
89#[UsesClass(StatementRow::class)]
90#[UsesClass(TableIndexPage::class)]
91#[UsesClass(TableName::class)]
92#[UsesClass(TablePage::class)]
93#[UsesClass(TextHole::class)]
94#[UsesClass(TextPattern::class)]
95#[UsesClass(TypeShape::class)]
96#[UsesClass(StatementKind::class)]
97#[Medium]
98final class HtmlReporterTest extends TestCase
99{
100    public function testNameIsHowTheCommandLineSelectsIt(): void
101    {
102        self::assertSame('html', (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->name());
103    }
104
105    public function testDescriptionMentionsWhatItProduces(): void
106    {
107        self::assertStringContainsString('HTML', (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->description());
108    }
109
110    public function testRenderWritesTheWholeSiteOfPages(): void
111    {
112        $catalog = new Catalog([
113            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts'), ['posts'], [], new CallSite('src/a.php', 1, 'App\\R::find', 'pdo.query'), [
114                Finding::of(FindingRule::ExternalInput, 'spliced'),
115            ]),
116        ]);
117        $artifacts = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->render($catalog);
118
119        self::assertSame(
120            [
121                'assets/document-design-LICENSE.txt',
122                'assets/document-design-v1.0.0.css',
123                'assets/document-design-v1.0.0.js',
124                'assets/report.js',
125                'assets/search-index.js',
126                'classes/app-r.html',
127                'files.html',
128                'files/src-a-php.html',
129                'findings.html',
130                'index.html',
131                'namespaces.html',
132                'statements.html',
133                'statements/a1.html',
134                'tables.html',
135                'tables/posts.html',
136            ],
137            $artifacts->names(),
138        );
139    }
140
141    public function testRenderNamesThePageAReaderOpensFirst(): void
142    {
143        $artifacts = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->render(new Catalog());
144
145        self::assertSame($artifacts->get(HtmlReporter::FILE), $artifacts->primary());
146        self::assertStringContainsString('<title>Overview</title>', (string) $artifacts->primary());
147    }
148
149    public function testRenderLinksEveryRouteToTheStatementsPage(): void
150    {
151        $catalog = new Catalog([
152            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts'), ['posts'], [], new CallSite('src/a.php', 1, 'App\\R::find', 'pdo.query'), [
153                Finding::of(FindingRule::ExternalInput, 'spliced'),
154            ]),
155        ]);
156        $artifacts = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->render($catalog);
157
158        self::assertStringContainsString('href="../statements/a1.html"', (string) $artifacts->get('tables/posts.html'));
159        self::assertStringContainsString('href="../statements/a1.html"', (string) $artifacts->get('classes/app-r.html'));
160        self::assertStringContainsString('href="../statements/a1.html"', (string) $artifacts->get('files/src-a-php.html'));
161        self::assertStringContainsString('href="statements/a1.html"', (string) $artifacts->get('findings.html'));
162        self::assertStringContainsString('"u":"statements/a1.html"', (string) $artifacts->get('assets/search-index.js'));
163    }
164
165    public function testRenderKeepsACallNoStatementWasReadFromApartFromAStatement(): void
166    {
167        $catalog = new Catalog([
168            new CatalogEntry('a1', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
169        ]);
170        $page = (string) (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->render($catalog)->get('statements/a1.html');
171
172        self::assertStringContainsString('no statement was read from this call', $page);
173        self::assertStringContainsString('<span class="chip tone-neutral">not-analyzed</span>', $page);
174    }
175
176    public function testRenderCarriesTheFilesThatCouldNotBeRead(): void
177    {
178        $overview = (string) (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->render(new Catalog([], [new AnalysisProblem('b.php', 'broken')]))->get('index.html');
179
180        self::assertStringContainsString('<tr><td><code>b.php</code></td><td>broken</td></tr>', $overview);
181    }
182
183    public function testTablePagesWriteOneDocumentPerTable(): void
184    {
185        $catalog = new Catalog([
186            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users', 'posts'], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
187        ]);
188        $pages = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->tablePages(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), new PageShell());
189
190        self::assertSame(['tables/posts.html', 'tables/users.html'], array_keys($pages));
191        self::assertStringContainsString('<title>posts</title>', $pages['tables/posts.html']);
192    }
193
194    public function testClassPagesWriteOneDocumentPerClass(): void
195    {
196        $catalog = new Catalog([
197            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'App\\R::find', 'pdo.query'), []),
198            new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 2, 'helper', 'pdo.query'), []),
199        ]);
200        $pages = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->classPages(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), new PageShell());
201
202        self::assertSame(['classes/app-r.html'], array_keys($pages));
203        self::assertStringContainsString('<span class="breadcrumb-current">R</span>', $pages['classes/app-r.html']);
204    }
205
206    public function testFilePagesWriteOneDocumentPerFile(): void
207    {
208        $catalog = new Catalog([
209            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('src/a.php', 1, 'f', 'pdo.query'), []),
210        ]);
211        $pages = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->filePages(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), new PageShell());
212
213        self::assertSame(['files/src-a-php.html'], array_keys($pages));
214        self::assertStringContainsString('<title>src/a.php</title>', $pages['files/src-a-php.html']);
215    }
216
217    public function testStatementPagesWriteOneDocumentPerStatement(): void
218    {
219        $catalog = new Catalog([
220            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 'pdo.query'), []),
221            new CatalogEntry('b1', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('b.php', 1, 'f', 'pdo.query'), []),
222        ]);
223        $pages = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->statementPages(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), new PageShell());
224
225        self::assertSame(['statements/a1.html', 'statements/b1.html'], array_keys($pages));
226        self::assertStringContainsString('<title>SELECT at b.php:1</title>', $pages['statements/b1.html']);
227    }
228
229    public function testRenderWritesTheTrailToEveryPage(): void
230    {
231        $catalog = new Catalog([
232            new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts'), ['posts'], [], new CallSite('src/a.php', 1, 'App\\R::find', 'pdo.query'), []),
233        ]);
234        $artifacts = (new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter()))->render($catalog);
235        $home = '<a href="../index.html">Overview</a><span class="breadcrumb-sep">/</span>';
236
237        self::assertStringContainsString('<nav class="breadcrumbs" aria-label="Breadcrumb"><span class="breadcrumb-current">Overview</span></nav>', (string) $artifacts->get('index.html'));
238        self::assertStringContainsString('<a href="index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Statements</span>', (string) $artifacts->get('statements.html'));
239        self::assertStringContainsString('<a href="index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Tables</span>', (string) $artifacts->get('tables.html'));
240        self::assertStringContainsString('<a href="index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Namespaces</span>', (string) $artifacts->get('namespaces.html'));
241        self::assertStringContainsString('<a href="index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Files</span>', (string) $artifacts->get('files.html'));
242        self::assertStringContainsString('<a href="index.html">Overview</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">Findings</span>', (string) $artifacts->get('findings.html'));
243        self::assertStringContainsString($home . '<a href="../tables.html">Tables</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">posts</span>', (string) $artifacts->get('tables/posts.html'));
244        self::assertStringContainsString($home . '<a href="../namespaces.html">Namespaces</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">R</span>', (string) $artifacts->get('classes/app-r.html'));
245        self::assertStringContainsString($home . '<a href="../files.html">Files</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">src/a.php</span>', (string) $artifacts->get('files/src-a-php.html'));
246        self::assertStringContainsString($home . '<a href="../statements.html">Statements</a><span class="breadcrumb-sep">/</span><span class="breadcrumb-current">a1</span>', (string) $artifacts->get('statements/a1.html'));
247        self::assertStringContainsString('<a href="#attention" title="Needs attention">Needs attention</a></li><li><a href="#coverage"', (string) $artifacts->get('index.html'));
248        self::assertStringContainsString('<p class="sidebar-title">Tables</p>', (string) $artifacts->get('tables/posts.html'));
249        self::assertStringContainsString('<p class="sidebar-title">Classes in App</p>', (string) $artifacts->get('classes/app-r.html'));
250        self::assertStringContainsString('<p class="sidebar-title">Files in src/</p>', (string) $artifacts->get('files/src-a-php.html'));
251        self::assertStringContainsString('<p class="sidebar-title">Belongs to</p>', (string) $artifacts->get('statements/a1.html'));
252    }
253
254    #[DataProvider('providerOpenOrigins')]
255    public function testRenderIncludesSourceForUnresolvedAndUnanalyzedCallsAndParseFailures(Origin $origin): void
256    {
257        $entry = new CatalogEntry($origin->value, StatementKind::Unknown, TextPattern::fromHole(new TextHole($origin, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('src/query.php', 3, 'f', 'unmatched'), []);
258        $catalog = new Catalog([$entry], [new AnalysisProblem('src/broken.php', 'Unexpected <token>')], [
259            'src/query.php' => "<?php\n\$sql = buildQuery();\n\$db->query(\$sql);",
260            'src/broken.php' => '<?php function { <script>alert(1)</script>',
261        ]);
262        $reporter = new HtmlReporter(\SqlCatalog\Facade\Builtins::sqlFormatter());
263        $artifacts = $reporter->render($catalog->filter(static fn (CatalogEntry $entry): bool => true));
264        $page = (string) $artifacts->get('statements/' . $entry->id . '.html');
265        self::assertStringContainsString('$sql = buildQuery();', $page);
266        self::assertStringContainsString('$db-&gt;query($sql);', $page);
267        self::assertStringContainsString('class="code-line is-target" id="L3"', $page);
268        self::assertStringContainsString('href="../files/src-query-php.html#L3">View full source</a>', $page);
269        $file = (string) $artifacts->get('files/src-query-php.html');
270        self::assertStringContainsString('href="#source" title="Source code">Source code</a>', $file);
271        self::assertStringContainsString('id="L3"', $file);
272        $broken = (string) $artifacts->get('files/src-broken-php.html');
273        self::assertStringContainsString('Unexpected &lt;token&gt;', $broken);
274        self::assertStringContainsString('&lt;?php function { &lt;script&gt;', $broken);
275        self::assertStringNotContainsString('<script>alert(1)</script>', $broken);
276        self::assertStringContainsString('href="files/src-broken-php.html#source"', (string) $artifacts->get('index.html'));
277        self::assertStringContainsString('href="files/src-broken-php.html"', (string) $artifacts->get('files.html'));
278        self::assertEquals($artifacts, $reporter->render($catalog));
279    }
280
281
282    /**
283     * @return iterable<string, array{Origin}>
284     */
285    public static function providerOpenOrigins(): iterable
286    {
287        yield 'unresolved' => [Origin::Call];
288        yield 'not analyzed' => [Origin::Unreached];
289    }
290
291}
292