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