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