packages/sql-catalog/tests/Unit/Reporter/Text/TextReporterTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Text;
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\Reporter\CatalogArtifacts;
15use SqlCatalog\Core\Sql\StatementKind;
16use SqlCatalog\Core\Text\Origin;
17use SqlCatalog\Core\Text\TextHole;
18use SqlCatalog\Core\Text\TextPattern;
19use SqlCatalog\Core\Type\TypeShape;
20use SqlCatalog\Facade\AnalysisOptions;
21use SqlCatalog\Facade\Analyzer;
22use SqlCatalog\Reporter\Text\TextReporter;
23
24#[CoversClass(TextReporter::class)]
25#[UsesClass(AnalysisOptions::class)]
26#[UsesClass(Analyzer::class)]
27#[UsesClass(AnalysisProblem::class)]
28#[UsesClass(Catalog::class)]
29#[UsesClass(CallSite::class)]
30#[UsesClass(CatalogEntry::class)]
31#[UsesClass(\SqlCatalog\Core\Catalog\Resolution::class)]
32#[UsesClass(TextHole::class)]
33#[UsesClass(TextPattern::class)]
34#[UsesClass(TypeShape::class)]
35#[UsesClass(CatalogArtifacts::class)]
36#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
37#[UsesClass(\SqlCatalog\Core\Analysis\EntryFactory::class)]
38#[UsesClass(\SqlCatalog\Core\Analysis\EvaluationBudget::class)]
39#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
40#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
41#[UsesClass(\SqlCatalog\Core\Analysis\FunctionScope::class)]
42#[UsesClass(\SqlCatalog\Core\Analysis\Interpreter::class)]
43#[UsesClass(\SqlCatalog\Core\Analysis\QueryRecord::class)]
44#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
45#[UsesClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
46#[UsesClass(\SqlCatalog\Core\Analysis\StatementRecorder::class)]
47#[UsesClass(\SqlCatalog\Core\Analysis\ValueBinder::class)]
48#[UsesClass(CallSite::class)]
49#[UsesClass(CatalogEntry::class)]
50#[UsesClass(\SqlCatalog\Core\Catalog\EntryIdentity::class)]
51#[UsesClass(\SqlCatalog\Core\Catalog\Placeholder::class)]
52#[UsesClass(\SqlCatalog\Core\Catalog\ValueDomain::class)]
53#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayEntry::class)]
54#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayTerm::class)]
55#[UsesClass(\SqlCatalog\Core\Evaluation\Domain::class)]
56#[UsesClass(\SqlCatalog\Core\Evaluation\Environment::class)]
57#[UsesClass(\SqlCatalog\Core\Evaluation\LiteralTerm::class)]
58#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
59#[UsesClass(\SqlCatalog\Core\Evaluation\OpaqueTerm::class)]
60#[UsesClass(\SqlCatalog\Extension\Doctrine\DoctrineExtension::class)]
61#[UsesClass(\SqlCatalog\Core\Extension\ExtensionRegistry::class)]
62#[UsesClass(\SqlCatalog\Extension\Laravel\LaravelExtension::class)]
63#[UsesClass(\SqlCatalog\Extension\Mysqli\MysqliExtension::class)]
64#[UsesClass(\SqlCatalog\Extension\Pdo\PdoExtension::class)]
65#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
66#[UsesClass(\SqlCatalog\Core\Php\FunctionShape::class)]
67#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
68#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
69#[UsesClass(\SqlCatalog\Core\Php\ParsedFile::class)]
70#[UsesClass(\SqlCatalog\Core\Php\ProgramIndex::class)]
71#[UsesClass(\SqlCatalog\Core\Php\ProgramIndexBuilder::class)]
72#[UsesClass(\SqlCatalog\Core\Php\SourceParser::class)]
73#[UsesClass(\SqlCatalog\Core\Php\TypeReader::class)]
74#[UsesClass(\SqlCatalog\Core\Source\SourceFile::class)]
75#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderRef::class)]
76#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderScanner::class)]
77#[UsesClass(\SqlCatalog\Core\Sql\SqlLexer::class)]
78#[UsesClass(\SqlCatalog\Core\Sql\SqlToken::class)]
79#[UsesClass(\SqlCatalog\Core\Sql\StatementKindReader::class)]
80#[UsesClass(\SqlCatalog\Core\Sql\TableReader::class)]
81#[UsesClass(\SqlCatalog\Core\Text\LiteralText::class)]
82#[UsesClass(TextPattern::class)]
83#[UsesClass(TypeShape::class)]
84#[UsesClass(\SqlCatalog\Core\Catalog\Finding::class)]
85#[UsesClass(\SqlCatalog\Core\Catalog\FindingRule::class)]
86#[UsesClass(\SqlCatalog\Core\Evaluation\PatternTerm::class)]
87#[UsesClass(Origin::class)]
88#[UsesClass(TextHole::class)]
89#[UsesClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
90#[UsesClass(\SqlCatalog\Extension\WordPress\WordPressExtension::class)]
91#[UsesClass(\SqlCatalog\Core\Php\DeclaredGlobals::class)]
92#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
93#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Binding::class)]
94#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
95#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
96#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
97#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
98#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
99#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\FreeNames::class)]
100#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\ModifiedNames::class)]
101#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
102#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SliceExecutor::class)]
103#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Arrival::class)]
104#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
105#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer::class)]
106#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
107#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
108#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\SliceStep::class)]
109#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Solution::class)]
110#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SourceTree::class)]
111#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerSet::class)]
112#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
113#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
114#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
115#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
116#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
117final class TextReporterTest extends TestCase
118{
119 public function testNameIsHowTheCommandLineSelectsIt(): void
120 {
121 self::assertSame('text', (new TextReporter())->name());
122 }
123
124 public function testDescriptionMentionsWhatItProduces(): void
125 {
126 self::assertStringContainsString('terminal', (new TextReporter())->description());
127 }
128
129 public function testRenderWritesOneTextFile(): void
130 {
131 $artifacts = (new TextReporter())->render(new Catalog());
132 self::assertSame([TextReporter::FILE], $artifacts->names());
133 self::assertStringContainsString('0 statement(s)', (string) $artifacts->sole());
134 }
135
136 public function testRenderReportsTheFilesThatCouldNotBeRead(): void
137 {
138 $rendered = (string) (new TextReporter())->render(new Catalog([], [new AnalysisProblem('a.php', 'broken')]))->sole();
139 self::assertStringContainsString('! a.php: broken', $rendered);
140 }
141
142 public function testEntryLinesShowTheStatementAndItsParameters(): void
143 {
144 $catalog = (new Analyzer())->analyzeSource([
145 'a.php' => '<?php function f(PDO $d) { $s = $d->prepare("SELECT id FROM users WHERE id = ?"); $s->execute([7]); }',
146 ]);
147 $lines = (new TextReporter())->entryLines($catalog->entries()[0]);
148 self::assertStringContainsString('a.php:1', $lines[0]);
149 self::assertStringContainsString('SELECT', $lines[2]);
150 self::assertStringContainsString('? = 7', $lines[4]);
151 }
152
153 public function testEntryLinesShowTheFindings(): void
154 {
155 $catalog = (new Analyzer())->analyzeSource([
156 'a.php' => '<?php function f(PDO $d) { $d->query("SELECT " . $_GET["x"]); }',
157 ]);
158 $lines = (new TextReporter())->entryLines($catalog->entries()[0]);
159 self::assertStringContainsString('[HIGH]', implode("\n", $lines));
160 }
161
162 public function testRenderWritesTheWholeReportExactly(): void
163 {
164 $catalog = (new Analyzer())->analyzeSource([
165 'a.php' => '<?php function f(PDO $d) { $s = $d->prepare("SELECT id FROM users WHERE id = ?"); $s->execute([7]); }',
166 ]);
167
168 self::assertSame(
169 'a.php:1 SELECT ' . $catalog->entries()[0]->id . "\n"
170 . ' in f via pdo.prepare' . "\n"
171 . ' SELECT id FROM users WHERE id = ?' . "\n"
172 . ' resolved' . "\n"
173 . ' ? = 7' . "\n"
174 . "\n"
175 . 'Conditions are not evaluated; runtime reachability is not assessed.' . "\n"
176 . '1 statement(s), 1 fully resolved, 0 finding(s), 0 unreadable file(s).' . "\n",
177 (string) (new TextReporter())->render($catalog)->sole(),
178 );
179 }
180
181 public function testEntryLinesAreWrittenExactly(): void
182 {
183 $catalog = (new Analyzer())->analyzeSource([
184 'a.php' => '<?php function f(PDO $d) { $d->query("SELECT " . $_GET["x"]); }',
185 ]);
186 $entry = $catalog->entries()[0];
187
188 self::assertSame(
189 [
190 'a.php:1 SELECT ' . $entry->id,
191 ' in f via pdo.query',
192 ' SELECT {$}',
193 ' external-input',
194 ' [MEDIUM] dynamic-sql 1 value(s) are spliced into the statement text rather than bound.',
195 ' [HIGH] external-input A value from external input reaches the statement text.',
196 ],
197 (new TextReporter())->entryLines($entry),
198 );
199 }
200
201 public function testStatusLineSaysHowFarTheAnalyzerGot(): void
202 {
203 $catalog = (new Analyzer())->analyzeSource([
204 'a.php' => '<?php function f(PDO $d, string $s): void { $d->query("SELECT " . $s); }',
205 ]);
206
207 self::assertSame('incomplete-model; search did not close', (new TextReporter())->statusLine($catalog->entries()[0]));
208 }
209
210 public function testStatusLineWarnsWhenTheSearchDidNotClose(): void
211 {
212 $entry = new CatalogEntry(
213 'id',
214 StatementKind::Select,
215 TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())),
216 [],
217 [],
218 new CallSite('a.php', 1, 'f', 'unreached'),
219 [],
220 false,
221 ['App\\R::find', 'App\\R::run'],
222 );
223
224 self::assertSame(
225 'incomplete; search did not close; alternatives may be unreachable; via App\\R::find -> App\\R::run',
226 (new TextReporter())->statusLine($entry),
227 );
228 }
229
230 public function testSummaryLineCountsWhatWasFound(): void
231 {
232 $catalog = (new Analyzer())->analyzeSource([
233 'a.php' => '<?php function f(PDO $d) { $d->query("SELECT 1"); }',
234 ]);
235 self::assertSame(
236 '1 statement(s), 1 fully resolved, 0 finding(s), 0 unreadable file(s).',
237 (new TextReporter())->summaryLine($catalog),
238 );
239 }
240}
241