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->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 <token>', $broken);
274 self::assertStringContainsString('<?php function { <script>', $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