packages/sql-catalog/tests/Unit/Reporter/Json/JsonReporterTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Reporter\Json;
6
7use JsonException;
8use Opis\JsonSchema\Validator;
9use PHPUnit\Framework\Attributes\CoversClass;
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\Placeholder;
17use SqlCatalog\Core\Catalog\ValueDomain;
18use SqlCatalog\Core\Reporter\CatalogArtifacts;
19use SqlCatalog\Core\Text\LiteralText;
20use SqlCatalog\Core\Text\TextPattern;
21use SqlCatalog\Facade\AnalysisOptions;
22use SqlCatalog\Facade\Analyzer;
23use SqlCatalog\Reporter\Json\JsonReporter;
24
25#[CoversClass(JsonReporter::class)]
26#[UsesClass(AnalysisOptions::class)]
27#[UsesClass(Analyzer::class)]
28#[UsesClass(AnalysisProblem::class)]
29#[UsesClass(CallSite::class)]
30#[UsesClass(Catalog::class)]
31#[UsesClass(CatalogEntry::class)]
32#[UsesClass(Placeholder::class)]
33#[UsesClass(ValueDomain::class)]
34#[UsesClass(CatalogArtifacts::class)]
35#[UsesClass(LiteralText::class)]
36#[UsesClass(TextPattern::class)]
37#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
38#[UsesClass(\SqlCatalog\Core\Analysis\EntryFactory::class)]
39#[UsesClass(\SqlCatalog\Core\Analysis\EvaluationBudget::class)]
40#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
41#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
42#[UsesClass(\SqlCatalog\Core\Analysis\FunctionScope::class)]
43#[UsesClass(\SqlCatalog\Core\Analysis\Interpreter::class)]
44#[UsesClass(\SqlCatalog\Core\Analysis\QueryRecord::class)]
45#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
46#[UsesClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
47#[UsesClass(\SqlCatalog\Core\Analysis\StatementRecorder::class)]
48#[UsesClass(\SqlCatalog\Core\Analysis\ValueBinder::class)]
49#[UsesClass(\SqlCatalog\Core\Catalog\EntryIdentity::class)]
50#[UsesClass(\SqlCatalog\Core\Catalog\Finding::class)]
51#[UsesClass(\SqlCatalog\Core\Catalog\FindingRule::class)]
52#[UsesClass(\SqlCatalog\Core\Evaluation\Domain::class)]
53#[UsesClass(\SqlCatalog\Core\Evaluation\Environment::class)]
54#[UsesClass(\SqlCatalog\Core\Evaluation\LiteralTerm::class)]
55#[UsesClass(\SqlCatalog\Core\Evaluation\OpaqueTerm::class)]
56#[UsesClass(\SqlCatalog\Core\Evaluation\PatternTerm::class)]
57#[UsesClass(\SqlCatalog\Extension\Doctrine\DoctrineExtension::class)]
58#[UsesClass(\SqlCatalog\Core\Extension\ExtensionRegistry::class)]
59#[UsesClass(\SqlCatalog\Extension\Laravel\LaravelExtension::class)]
60#[UsesClass(\SqlCatalog\Extension\Mysqli\MysqliExtension::class)]
61#[UsesClass(\SqlCatalog\Extension\Pdo\PdoExtension::class)]
62#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
63#[UsesClass(\SqlCatalog\Core\Php\FunctionShape::class)]
64#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
65#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
66#[UsesClass(\SqlCatalog\Core\Php\ParsedFile::class)]
67#[UsesClass(\SqlCatalog\Core\Php\ProgramIndex::class)]
68#[UsesClass(\SqlCatalog\Core\Php\ProgramIndexBuilder::class)]
69#[UsesClass(\SqlCatalog\Core\Php\SourceParser::class)]
70#[UsesClass(\SqlCatalog\Core\Php\TypeReader::class)]
71#[UsesClass(\SqlCatalog\Core\Source\SourceFile::class)]
72#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderScanner::class)]
73#[UsesClass(\SqlCatalog\Core\Sql\SqlLexer::class)]
74#[UsesClass(\SqlCatalog\Core\Sql\SqlToken::class)]
75#[UsesClass(\SqlCatalog\Core\Sql\StatementKindReader::class)]
76#[UsesClass(\SqlCatalog\Core\Sql\TableReader::class)]
77#[UsesClass(\SqlCatalog\Core\Text\Origin::class)]
78#[UsesClass(\SqlCatalog\Core\Text\TextHole::class)]
79#[UsesClass(\SqlCatalog\Core\Type\TypeShape::class)]
80#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayEntry::class)]
81#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayTerm::class)]
82#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
83#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderRef::class)]
84#[UsesClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
85#[UsesClass(\SqlCatalog\Core\Catalog\Resolution::class)]
86#[UsesClass(\SqlCatalog\Extension\WordPress\WordPressExtension::class)]
87#[UsesClass(\SqlCatalog\Core\Php\SyntaxException::class)]
88#[UsesClass(\SqlCatalog\Core\Php\DeclaredGlobals::class)]
89#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
90#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Binding::class)]
91#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
92#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
93#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
94#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
95#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
96#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\FreeNames::class)]
97#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\ModifiedNames::class)]
98#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
99#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SliceExecutor::class)]
100#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Arrival::class)]
101#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
102#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer::class)]
103#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
104#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
105#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\SliceStep::class)]
106#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Solution::class)]
107#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SourceTree::class)]
108#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerSet::class)]
109#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
110#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
111#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
112#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
113#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
114final class JsonReporterTest extends TestCase
115{
116    public function testNameIsHowTheCommandLineSelectsIt(): void
117    {
118        self::assertSame('json', (new JsonReporter())->name());
119    }
120
121    public function testDescriptionMentionsWhatItProduces(): void
122    {
123        self::assertStringContainsString('JSON', (new JsonReporter())->description());
124    }
125
126    /**
127     * @throws JsonException
128     */
129    public function testTheDocumentMatchesTheSchemaItDeclares(): void
130    {
131        $catalog = (new Analyzer())->analyzeSource([
132            'a.php' => '<?php function f(PDO $d, string $t): void {'
133                . ' $s = $d->prepare("SELECT id FROM users WHERE id = ?"); $s->execute([7]);'
134                . ' $d->query("SELECT " . $_GET["x"]); $d->query("SELECT * FROM " . $t); }',
135            'broken.php' => '<?php function {',
136        ]);
137        $reporter = new JsonReporter();
138        $document = json_decode((string) $reporter->render($catalog)->get(JsonReporter::FILE), false, 64, JSON_THROW_ON_ERROR);
139
140        $result = (new Validator())->validate($document, $reporter->schema());
141
142        self::assertNull($result->error());
143    }
144
145    public function testTheSchemaIsWrittenBesideTheDocument(): void
146    {
147        $artifacts = (new JsonReporter())->render(new Catalog());
148
149        self::assertSame([JsonReporter::SCHEMA_FILE, JsonReporter::FILE], $artifacts->names());
150        self::assertSame($artifacts->get(JsonReporter::FILE), $artifacts->primary());
151    }
152
153    /**
154     * @throws JsonException
155     */
156    public function testSchemaIsTheShippedDocumentSchema(): void
157    {
158        $schema = json_decode((new JsonReporter())->schema(), true, 64, JSON_THROW_ON_ERROR);
159
160        self::assertIsArray($schema);
161        self::assertSame('https://json-schema.org/draft/2020-12/schema', $schema['$schema']);
162    }
163
164    public function testRenderWritesTheJsonDocument(): void
165    {
166        $artifacts = (new JsonReporter())->render(new Catalog());
167
168        self::assertStringEndsWith("\n", (string) $artifacts->get(JsonReporter::FILE));
169    }
170
171    public function testRenderIsTheSameForTheSameCatalog(): void
172    {
173        $catalog = (new Analyzer())->analyzeSource([
174            'a.php' => '<?php function f(PDO $d) { $d->query("SELECT 1"); }',
175        ]);
176        $reporter = new JsonReporter();
177
178        self::assertSame($reporter->render($catalog)->all(), $reporter->render($catalog)->all());
179    }
180
181    public function testRenderWritesTheWholeDocumentExactly(): void
182    {
183        $catalog = (new Analyzer())->analyzeSource([
184            'a.php' => '<?php function f(PDO $d) { $s = $d->prepare("SELECT id FROM users WHERE id = ?"); $s->execute([7]); }',
185        ]);
186        $identifier = $catalog->entries()[0]->id;
187
188        self::assertSame(
189            '{' . "\n"
190            . '    "$schema": "catalog-schema.json",' . "\n"
191            . '    "version": 2,' . "\n"
192            . '    "analysis": {' . "\n"
193            . '        "conditions": "not-evaluated",' . "\n"
194            . '        "reachability": "not-assessed"' . "\n"
195            . '    },' . "\n"
196            . '    "summary": {' . "\n"
197            . '        "statements": 1,' . "\n"
198            . '        "resolved": 1,' . "\n"
199            . '        "undetermined": 0,' . "\n"
200            . '        "findings": 0' . "\n"
201            . '    },' . "\n"
202            . '    "statements": [' . "\n"
203            . '        {' . "\n"
204            . '            "id": "' . $identifier . '",' . "\n"
205            . '            "kind": "select",' . "\n"
206            . '            "sql": "SELECT id FROM users WHERE id = ?",' . "\n"
207            . '            "exact": true,' . "\n"
208            . '            "resolution": "resolved",' . "\n"
209            . '            "searchClosed": true,' . "\n"
210            . '            "correlated": true,' . "\n"
211            . '            "tables": [' . "\n"
212            . '                "users"' . "\n"
213            . '            ],' . "\n"
214            . '            "site": {' . "\n"
215            . '                "file": "a.php",' . "\n"
216            . '                "line": 1,' . "\n"
217            . '                "function": "f",' . "\n"
218            . '                "sink": "pdo.prepare"' . "\n"
219            . '            },' . "\n"
220            . '            "through": [' . "\n"
221            . '                "f"' . "\n"
222            . '            ],' . "\n"
223            . '            "placeholders": [' . "\n"
224            . '                {' . "\n"
225            . '                    "token": "?",' . "\n"
226            . '                    "position": 0,' . "\n"
227            . '                    "name": null,' . "\n"
228            . '                    "value": {' . "\n"
229            . '                        "type": "int",' . "\n"
230            . '                        "values": [' . "\n"
231            . '                            7' . "\n"
232            . '                        ],' . "\n"
233            . '                        "exhaustive": true,' . "\n"
234            . '                        "origins": []' . "\n"
235            . '                    }' . "\n"
236            . '                }' . "\n"
237            . '            ],' . "\n"
238            . '            "findings": []' . "\n"
239            . '        }' . "\n"
240            . '    ],' . "\n"
241            . '    "problems": []' . "\n"
242            . '}' . "\n",
243            (string) (new JsonReporter())->render($catalog)->get(JsonReporter::FILE),
244        );
245    }
246
247    public function testToArrayCarriesTheVersionAndTheStatements(): void
248    {
249        $catalog = (new Analyzer())->analyzeSource([
250            'a.php' => '<?php function f(PDO $d) { $d->query("SELECT id FROM users"); }',
251        ]);
252        $document = (new JsonReporter())->toArray($catalog);
253        self::assertSame(JsonReporter::VERSION, $document['version']);
254        self::assertSame('SELECT id FROM users', $document['statements'][0]['sql']);
255        self::assertSame(['users'], $document['statements'][0]['tables']);
256        self::assertSame('a.php', $document['statements'][0]['site']['file']);
257    }
258
259    public function testToArrayCarriesTheFilesThatCouldNotBeRead(): void
260    {
261        $document = (new JsonReporter())->toArray(new Catalog([], [new AnalysisProblem('a.php', 'broken')]));
262        self::assertSame([['file' => 'a.php', 'message' => 'broken']], $document['problems']);
263    }
264
265    public function testSummaryCountsResolvedAndDynamicStatements(): void
266    {
267        $catalog = (new Analyzer())->analyzeSource([
268            'a.php' => '<?php function f(PDO $d, string $n) { $d->query("SELECT 1"); $d->query("SELECT " . $n); }',
269        ]);
270        $summary = (new JsonReporter())->summary($catalog);
271        self::assertSame(2, $summary['statements']);
272        self::assertSame(1, $summary['resolved']);
273        self::assertSame(1, $summary['undetermined']);
274        self::assertGreaterThan(0, $summary['findings']);
275    }
276
277    public function testEntryToArrayCarriesTheFindings(): void
278    {
279        $catalog = (new Analyzer())->analyzeSource([
280            'a.php' => '<?php function f(PDO $d) { $d->query("SELECT " . $_GET["x"]); }',
281        ]);
282        $node = (new JsonReporter())->entryToArray($catalog->entries()[0]);
283        self::assertFalse($node['exact']);
284        self::assertNotSame([], $node['findings']);
285        self::assertSame('high', $node['findings'][count($node['findings']) - 1]['severity']);
286    }
287
288    public function testPlaceholderToArrayCarriesTheBoundValue(): void
289    {
290        $placeholder = new Placeholder(':id', 0, 'id', new ValueDomain('int', [1], true, []));
291        $node = (new JsonReporter())->placeholderToArray($placeholder);
292        self::assertSame(':id', $node['token']);
293        self::assertSame(['type' => 'int', 'values' => [1], 'exhaustive' => true, 'origins' => []], $node['value']);
294    }
295
296    public function testPlaceholderToArrayReportsAnUnboundParameter(): void
297    {
298        self::assertNull((new JsonReporter())->placeholderToArray(new Placeholder('?', 0, null, null))['value']);
299    }
300}
301