packages/sql-catalog/tests/Unit/Reporter/Html/Page/ClassPageTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Html\Page;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Catalog\AnalysisProblem;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\Catalog;
13use SqlCatalog\Core\Catalog\CatalogEntry;
14use SqlCatalog\Core\Catalog\Finding;
15use SqlCatalog\Core\Catalog\FindingRule;
16use SqlCatalog\Core\Catalog\Placeholder;
17use SqlCatalog\Core\Catalog\Resolution;
18use SqlCatalog\Core\Catalog\Severity;
19use SqlCatalog\Core\Catalog\StatementPart;
20use SqlCatalog\Core\Catalog\ValueDomain;
21use SqlCatalog\Core\Sql\StatementKind;
22use SqlCatalog\Core\Text\LiteralText;
23use SqlCatalog\Core\Text\Origin;
24use SqlCatalog\Core\Text\TextHole;
25use SqlCatalog\Core\Text\TextPattern;
26use SqlCatalog\Core\Type\TypeShape;
27use SqlCatalog\Reporter\Html\CatalogIndex;
28use SqlCatalog\Reporter\Html\CatalogStatistics;
29use SqlCatalog\Reporter\Html\HtmlText;
30use SqlCatalog\Reporter\Html\Page\ClassPage;
31use SqlCatalog\Reporter\Html\Palette;
32use SqlCatalog\Reporter\Html\ReportSite;
33use SqlCatalog\Reporter\Html\Scope;
34use SqlCatalog\Reporter\Html\SqlFormatter;
35use SqlCatalog\Reporter\Html\SqlHighlighter;
36use SqlCatalog\Reporter\Html\StatementList;
37use SqlCatalog\Reporter\Html\StatementRow;
38use SqlCatalog\Reporter\Html\TableName;
39
40#[CoversClass(ClassPage::class)]
41#[UsesClass(CallSite::class)]
42#[UsesClass(Catalog::class)]
43#[UsesClass(CatalogEntry::class)]
44#[UsesClass(CatalogIndex::class)]
45#[UsesClass(CatalogStatistics::class)]
46#[UsesClass(HtmlText::class)]
47#[UsesClass(Palette::class)]
48#[UsesClass(ReportSite::class)]
49#[UsesClass(Resolution::class)]
50#[UsesClass(Scope::class)]
51#[UsesClass(Severity::class)]
52#[UsesClass(SqlHighlighter::class)]
53#[UsesClass(StatementKind::class)]
54#[UsesClass(StatementList::class)]
55#[UsesClass(StatementPart::class)]
56#[UsesClass(StatementRow::class)]
57#[UsesClass(TableName::class)]
58#[UsesClass(LiteralText::class)]
59#[UsesClass(TextPattern::class)]
60#[UsesClass(AnalysisProblem::class)]
61#[UsesClass(Finding::class)]
62#[UsesClass(FindingRule::class)]
63#[UsesClass(Placeholder::class)]
64#[UsesClass(ValueDomain::class)]
65#[UsesClass(SqlFormatter::class)]
66#[UsesClass(Origin::class)]
67#[UsesClass(TextHole::class)]
68#[UsesClass(TypeShape::class)]
69final class ClassPageTest extends TestCase
70{
71 public function testRenderListsTheStatementsMethodByMethod(): void
72 {
73 $catalog = new Catalog([
74 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), ['users'], [], new CallSite('src/a.php', 9, 'App\\R::find', 'pdo.query'), []),
75 new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT'), ['users'], [], new CallSite('src/a.php', 3, 'App\\R::add', 'pdo.query'), []),
76 ]);
77 $page = (new ClassPage())->render(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'App\\R');
78
79 self::assertStringContainsString('<h1><code>R</code><span class="count">2 statements</span></h1>', $page);
80 self::assertStringContainsString('2 statements in 2 methods of <code>App\\R</code>, written in <a href="../files/src-a-php.html">src/a.php</a>.', $page);
81 self::assertStringContainsString('<a class="chip chip-ghost" href="../tables/users.html">users<span class="facet-count">2</span></a>', $page);
82 self::assertMatchesRegularExpression('/id="fn-app-r-add".*id="fn-app-r-find"/s', $page);
83 }
84
85 public function testTablesSaySoWhenNoneIsNamed(): void
86 {
87 self::assertSame('<h2 id="tables">Tables</h2><p class="empty-inline">No statement here names a table.</p>', (new ClassPage())->tables(new ReportSite(new Catalog(), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), []));
88 }
89
90 public function testMethodsAreInTheOrderTheyAreWritten(): void
91 {
92 $entries = [
93 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 9, 'R::find', 'pdo.query'), []),
94 new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 3, 'R::add', 'pdo.query'), []),
95 ];
96
97 self::assertSame(['R::add', 'R::find'], array_keys((new ClassPage())->methods($entries)));
98 }
99
100 public function testSectionsHeadEachMethodWithWhereItStarts(): void
101 {
102 $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 9, 'R::find', 'pdo.query'), []);
103 $site = new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
104
105 self::assertStringStartsWith(
106 '<section class="group" id="fn-r-find"><h3><code>R::find</code><span class="count">1</span><span class="muted">a.php:9</span><a class="anchor" href="#fn-r-find">#</a></h3><ol class="rows">',
107 (new ClassPage())->sections($site, 'classes/r.html', ['R::find' => [$entry]]),
108 );
109 }
110
111 public function testAnchorsNameTheTablesAndEveryMethod(): void
112 {
113 $entry = new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 9, 'R::find', 'pdo.query'), []);
114
115 self::assertSame([['Tables', 'tables'], ['find', 'fn-r-find']], (new ClassPage())->anchors(new ReportSite(new Catalog([$entry]), formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), [$entry]));
116 }
117
118
119 public function testContextListsTheMethodsAndTheClassesOfTheSameNamespace(): void
120 {
121 $catalog = new Catalog([
122 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'App\\R::find', 'pdo.query'), []),
123 new CatalogEntry('a2', StatementKind::Select, TextPattern::fromText('SELECT 2'), [], [], new CallSite('a.php', 2, 'App\\S::go', 'pdo.query'), []),
124 new CatalogEntry('a3', StatementKind::Select, TextPattern::fromText('SELECT 3'), [], [], new CallSite('a.php', 3, 'Other\\T::go', 'pdo.query'), []),
125 ]);
126
127 self::assertSame(
128 [
129 ['On this page', [['Tables', '#tables', null, false], ['find', '#fn-app-r-find', null, false]], null],
130 ['Classes in App', [['R', 'classes/app-r.html', 1, true], ['S', 'classes/app-s.html', 1, false]], 'namespaces.html'],
131 ],
132 (new ClassPage())->context(new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter()), 'App\\R'),
133 );
134 }
135
136 public function testRenderIsWrittenExactly(): void
137 {
138 $entries = [
139 new CatalogEntry('a1', StatementKind::Select, TextPattern::fromSegments([
140 new LiteralText('SELECT id FROM posts WHERE slug = '),
141 new TextHole(Origin::External, TypeShape::unknown(), '$_GET["s"]'),
142 ]), ['posts'], [new Placeholder('?', 0, null, new ValueDomain('int', [7], true, []))], new CallSite('src/a.php', 4, 'App\\R::find', 'pdo.query'), [
143 Finding::of(FindingRule::ExternalInput, 'spliced'),
144 ], false, ['App\\R::run', 'App\\R::find']),
145 new CatalogEntry('a2', StatementKind::Insert, TextPattern::fromText('INSERT INTO posts (id) VALUES (1)'), ['posts'], [], new CallSite('src/a.php', 9, 'App\\R::add', 'pdo.query'), []),
146 new CatalogEntry('a3', StatementKind::Update, TextPattern::fromText('UPDATE posts SET title = ? WHERE id = ?'), ['posts'], [], new CallSite('src/a.php', 14, 'App\\R::add', 'pdo.prepare'), [
147 Finding::of(FindingRule::PlaceholderCountMismatch, 'one bound'),
148 ]),
149 new CatalogEntry('b1', StatementKind::Delete, TextPattern::fromText('DELETE FROM users WHERE id = 1'), ['users'], [], new CallSite('src/b.php', 3, 'App\\Admin\\U::drop', 'pdo.query'), []),
150 new CatalogEntry('b2', StatementKind::Alter, TextPattern::fromText('ALTER TABLE users ADD x INT'), ['users'], [], new CallSite('src/b.php', 8, 'App\\Admin\\U::migrate', 'pdo.exec'), []),
151 new CatalogEntry('c1', StatementKind::Select, TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), [], [], new CallSite('lib/c.php', 2, 'helper', 'mysqli.query'), [
152 Finding::of(FindingRule::AnalysisIncomplete, 'stopped'),
153 ], true, [], true),
154 new CatalogEntry('c2', StatementKind::Unknown, TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')), [], [], new CallSite('lib/c.php', 6, '{main}', 'unmatched'), [
155 Finding::of(FindingRule::CallNotAnalyzed, 'unseen'),
156 ]),
157 new CatalogEntry('d1', StatementKind::Select, TextPattern::fromText('SELECT * FROM posts p JOIN users u ON u.id = p.author'), ['posts', 'users'], [], new CallSite('src/d.php', 1, 'App\\R::find', 'pdo.query'), [
158 Finding::of(FindingRule::DynamicSql, 'dyn'),
159 ]),
160 new CatalogEntry('e1', StatementKind::Show, TextPattern::fromText('SHOW TABLES'), [], [], new CallSite('src/e.php', 1, 'App\\R::find', 'pdo.query'), []),
161 new CatalogEntry('f1', StatementKind::Select, TextPattern::fromText('SELECT 1 FROM t1'), ['t1'], [], new CallSite('src/f.php', 1, 'App\\F::a', 'pdo.query'), []),
162 new CatalogEntry('f2', StatementKind::Select, TextPattern::fromText('SELECT 2 FROM t2'), ['t2'], [], new CallSite('src/f.php', 2, 'App\\F::b', 'pdo.query'), []),
163 new CatalogEntry('f3', StatementKind::Select, TextPattern::fromText('SELECT 3 FROM t3'), ['t3'], [], new CallSite('src/f.php', 3, 'App\\G::a', 'pdo.query'), []),
164 new CatalogEntry('f4', StatementKind::Select, TextPattern::fromText('SELECT 4 FROM t4'), ['t4'], [], new CallSite('src/f.php', 4, 'App\\H::a', 'pdo.query'), []),
165 new CatalogEntry('f5', StatementKind::Select, TextPattern::fromText('SELECT 5 FROM t5'), ['t5'], [], new CallSite('src/g.php', 1, 'App\\I::a', 'pdo.query'), []),
166 new CatalogEntry('f6', StatementKind::Select, TextPattern::fromText('SELECT 6 FROM t6'), ['t6'], [], new CallSite('src/h.php', 1, 'App\\J::a', 'pdo.query'), []),
167 new CatalogEntry('f7', StatementKind::Select, TextPattern::fromText('SELECT 7 FROM t7'), ['t7'], [], new CallSite('src/i.php', 1, 'App\\K::a', 'pdo.query'), [
168 Finding::of(FindingRule::DynamicSql, 'dyn'),
169 ]),
170 new CatalogEntry('f8', StatementKind::Select, TextPattern::fromText('SELECT 8 FROM t8'), ['t8'], [], new CallSite('src/j.php', 1, 'App\\L::a', 'pdo.query'), [
171 Finding::of(FindingRule::DynamicSql, 'dyn'),
172 ]),
173 new CatalogEntry('f9', StatementKind::Select, TextPattern::fromText('SELECT 9 FROM t9'), ['t9'], [], new CallSite('src/k.php', 1, 'App\\M::a', 'pdo.query'), [
174 Finding::of(FindingRule::DynamicSql, 'dyn'),
175 ]),
176 ];
177 $catalog = new Catalog($entries, [new AnalysisProblem('src/broken.php', 'broken')]);
178 $site = new ReportSite($catalog, formatter: \SqlCatalog\Facade\Builtins::sqlFormatter());
179
180 self::assertSame(
181 '<h1><code>R</code><span class="count">5 statements</span></h1><p class="lede">5 statements in 2 methods of <code>App\\R</code>, written in <a'
182 . ' href="../files/src-a-php.html">src/a.php</a>, <a href="../files/src-d-php.html">src/d.php</a>, <a href="../files/src-e-php.html">src/e.php<'
183 . '/a>.</p><h2 id="tables">Tables</h2><div class="chips"><a class="chip chip-ghost" href="../tables/posts.html">posts<span class="facet-count">'
184 . '4</span></a><a class="chip chip-ghost" href="../tables/users.html">users<span class="facet-count">1</span></a></div><h2 id="methods">Methods'
185 . '</h2><div data-narrowable><div class="facets" role="group" aria-label="Narrow the listing"><input type="search" name="narrow" class="input f'
186 . 'acet-search" placeholder="Narrow by text…" aria-label="Narrow by text" autocomplete="off" spellcheck="false"><span class="facet-group"><bu'
187 . 'tton type="button" class="chip facet tone-blue" data-facet="kind" data-value="select" aria-pressed="false">SELECT<span class="facet-count">2'
188 . '</span></button><button type="button" class="chip facet tone-teal" data-facet="kind" data-value="insert" aria-pressed="false">INSERT<span cl'
189 . 'ass="facet-count">1</span></button><button type="button" class="chip facet tone-violet" data-facet="kind" data-value="update" aria-pressed="'
190 . 'false">UPDATE<span class="facet-count">1</span></button><button type="button" class="chip facet tone-slate" data-facet="kind" data-value="sh'
191 . 'ow" aria-pressed="false">SHOW<span class="facet-count">1</span></button></span><span class="facet-group"><button type="button" class="chip f'
192 . 'acet tone-ok" data-facet="resolution" data-value="resolved" aria-pressed="false">resolved<span class="facet-count">4</span></button><button '
193 . 'type="button" class="chip facet tone-danger" data-facet="resolution" data-value="external-input" aria-pressed="false">external-input<span cl'
194 . 'ass="facet-count">1</span></button></span><span class="facet-group"><button type="button" class="chip facet tone-warn" data-facet="severity"'
195 . ' data-value="medium" aria-pressed="false">medium<span class="facet-count">2</span></button><button type="button" class="chip facet tone-dang'
196 . 'er" data-facet="severity" data-value="high" aria-pressed="false">high<span class="facet-count">1</span></button></span><span class="facet-sh'
197 . 'own" aria-live="polite"></span><button type="button" class="btn facet-clear" hidden>Clear</button></div><section class="group" id="fn-app-r-'
198 . 'find"><h3><code>R::find</code><span class="count">3</span><span class="muted">src/a.php:4</span><a class="anchor" href="#fn-app-r-find">#</a'
199 . '></h3><ol class="rows"><li class="row" data-kind="select" data-resolution="external-input" data-severity="high" data-rule="external-input" d'
200 . 'ata-sink="pdo.query" data-open="" data-table="posts" data-namespace="App" data-class="App\\R" data-function="App\\R::find" data-file="src/a.ph'
201 . 'p"><a class="row-main" href="../statements/a1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class="tok-kw">SELECT<'
202 . '/span>
203 id
204<span class="tok-kw">FROM</span>
205 posts
206<span class="tok-kw">WHERE</span>
207 slug = <span class="hole tone-danger" title="'
208 . 'This is a gap: external input fills it. Written as $_GET["s"].">{$}</span></pre></a><p class="row-meta"><a href="../files/src-a-ph'
209 . 'p.html">src/a.php:4</a><a class="chip chip-ghost" href="../tables/posts.html">posts</a><span class="chip tone-danger" title="The values were'
210 . ' followed to runtime input, so the text cannot be fixed.">external-input</span><span class="chip tone-danger" title="The most serious findin'
211 . 'g on this statement">high</span></p></li><li class="row" data-kind="select" data-resolution="resolved" data-severity="medium" data-rule="dyn'
212 . 'amic-sql" data-sink="pdo.query" data-open="" data-table="posts users" data-namespace="App" data-class="App\\R" data-function="App\\R::find" da'
213 . 'ta-file="src/d.php"><a class="row-main" href="../statements/d1.html"><span class="chip tone-blue">SELECT</span><pre class="code"><span class'
214 . '="tok-kw">SELECT</span>
215 *
216<span class="tok-kw">FROM</span>
217 posts p
218<span class="tok-kw">JOIN</span>
219 users u
220<span class="tok-kw"'
221 . '>ON</span>
222 u.id = p.author</pre></a><p class="row-meta"><a href="../files/src-d-php.html">src/d.php:1</a><a class="chip chip-ghost" href'
223 . '="../tables/posts.html">posts</a><a class="chip chip-ghost" href="../tables/users.html">users</a><span class="chip tone-warn" title="The mos'
224 . 't serious finding on this statement">medium</span></p></li><li class="row" data-kind="show" data-resolution="resolved" data-severity="" data'
225 . '-rule="" data-sink="pdo.query" data-open="" data-table="" data-namespace="App" data-class="App\\R" data-function="App\\R::find" data-file="src'
226 . '/e.php"><a class="row-main" href="../statements/e1.html"><span class="chip tone-slate">SHOW</span><pre class="code"><span class="tok-kw">SHO'
227 . 'W</span> TABLES</pre></a><p class="row-meta"><a href="../files/src-e-php.html">src/e.php:1</a></p></li></ol></section><section class="group"'
228 . ' id="fn-app-r-add"><h3><code>R::add</code><span class="count">2</span><span class="muted">src/a.php:9</span><a class="anchor" href="#fn-app-'
229 . 'r-add">#</a></h3><ol class="rows"><li class="row" data-kind="insert" data-resolution="resolved" data-severity="" data-rule="" data-sink="pdo'
230 . '.query" data-open="" data-table="posts" data-namespace="App" data-class="App\\R" data-function="App\\R::add" data-file="src/a.php"><a class="r'
231 . 'ow-main" href="../statements/a2.html"><span class="chip tone-teal">INSERT</span><pre class="code"><span class="tok-kw">INSERT</span> <span c'
232 . 'lass="tok-kw">INTO</span>
233 posts (id)
234<span class="tok-kw">VALUES</span>
235 (<span class="tok-num">1</span>)</pre></a><p class="row-meta'
236 . '"><a href="../files/src-a-php.html">src/a.php:9</a><a class="chip chip-ghost" href="../tables/posts.html">posts</a></p></li><li class="row" '
237 . 'data-kind="update" data-resolution="resolved" data-severity="medium" data-rule="placeholder-count-mismatch" data-sink="pdo.prepare" data-ope'
238 . 'n="" data-table="posts" data-namespace="App" data-class="App\\R" data-function="App\\R::add" data-file="src/a.php"><a class="row-main" href=".'
239 . './statements/a3.html"><span class="chip tone-violet">UPDATE</span><pre class="code"><span class="tok-kw">UPDATE</span>
240 posts
241<span class'
242 . '="tok-kw">SET</span>
243 title = <span class="tok-var">?</span>
244<span class="tok-kw">WHERE</span>
245 id = <span class="tok-var">?</span></p'
246 . 're></a><p class="row-meta"><a href="../files/src-a-php.html">src/a.php:14</a><a class="chip chip-ghost" href="../tables/posts.html">posts</a'
247 . '><span class="chip tone-warn" title="The most serious finding on this statement">medium</span></p></li></ol></section></div>',
248 (new ClassPage())->render($site, 'App\\R'),
249 );
250 }
251}
252