packages/sql-catalog/tests/Unit/Cli/CatalogCommandTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Cli;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Cli\ArtifactWriter;
11use SqlCatalog\Cli\CatalogCommand;
12use SqlCatalog\Cli\CommandLine;
13use SqlCatalog\Cli\CommandLineParser;
14use SqlCatalog\Cli\CommandResult;
15use SqlCatalog\Cli\ExitCode;
16use SqlCatalog\Cli\InvalidCommandLineException;
17use SqlCatalog\Cli\UsageText;
18use SqlCatalog\Core\Catalog\Catalog;
19use SqlCatalog\Core\Catalog\Severity;
20use SqlCatalog\Core\Filter\CatalogFilter;
21use SqlCatalog\Core\Reporter\CatalogArtifacts;
22use SqlCatalog\Core\Reporter\ReporterRegistry;
23use SqlCatalog\Facade\AnalysisOptions;
24use SqlCatalog\Facade\Analyzer;
25use SqlCatalog\Reporter\Json\JsonReporter;
26use SqlCatalog\Reporter\Text\TextReporter;
27
28#[CoversClass(CatalogCommand::class)]
29#[UsesClass(AnalysisOptions::class)]
30#[UsesClass(Analyzer::class)]
31#[UsesClass(Catalog::class)]
32#[UsesClass(ArtifactWriter::class)]
33#[UsesClass(CommandLine::class)]
34#[UsesClass(CommandLineParser::class)]
35#[UsesClass(CommandResult::class)]
36#[UsesClass(UsageText::class)]
37#[UsesClass(CatalogFilter::class)]
38#[UsesClass(CatalogArtifacts::class)]
39#[UsesClass(JsonReporter::class)]
40#[UsesClass(ReporterRegistry::class)]
41#[UsesClass(TextReporter::class)]
42#[UsesClass(\SqlCatalog\Core\Analysis\CallEvaluator::class)]
43#[UsesClass(\SqlCatalog\Core\Analysis\EntryFactory::class)]
44#[UsesClass(\SqlCatalog\Core\Analysis\EvaluationBudget::class)]
45#[UsesClass(\SqlCatalog\Core\Analysis\ExpressionEvaluator::class)]
46#[UsesClass(\SqlCatalog\Core\Analysis\ExternalInput::class)]
47#[UsesClass(\SqlCatalog\Core\Analysis\FunctionScope::class)]
48#[UsesClass(\SqlCatalog\Core\Analysis\Interpreter::class)]
49#[UsesClass(\SqlCatalog\Core\Analysis\QueryRecord::class)]
50#[UsesClass(\SqlCatalog\Core\Analysis\ReferenceEvaluator::class)]
51#[UsesClass(\SqlCatalog\Core\Analysis\SinkMatcher::class)]
52#[UsesClass(\SqlCatalog\Core\Analysis\StatementRecorder::class)]
53#[UsesClass(\SqlCatalog\Core\Analysis\ValueBinder::class)]
54#[UsesClass(\SqlCatalog\Core\Catalog\CallSite::class)]
55#[UsesClass(\SqlCatalog\Core\Catalog\CatalogEntry::class)]
56#[UsesClass(\SqlCatalog\Core\Catalog\EntryIdentity::class)]
57#[UsesClass(\SqlCatalog\Core\Catalog\Finding::class)]
58#[UsesClass(\SqlCatalog\Core\Catalog\FindingRule::class)]
59#[UsesClass(\SqlCatalog\Core\Catalog\Placeholder::class)]
60#[UsesClass(Severity::class)]
61#[UsesClass(\SqlCatalog\Core\Catalog\ValueDomain::class)]
62#[UsesClass(InvalidCommandLineException::class)]
63#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayEntry::class)]
64#[UsesClass(\SqlCatalog\Core\Evaluation\ArrayTerm::class)]
65#[UsesClass(\SqlCatalog\Core\Evaluation\Domain::class)]
66#[UsesClass(\SqlCatalog\Core\Evaluation\Environment::class)]
67#[UsesClass(\SqlCatalog\Core\Evaluation\LiteralTerm::class)]
68#[UsesClass(\SqlCatalog\Core\Evaluation\ObjectTerm::class)]
69#[UsesClass(\SqlCatalog\Core\Evaluation\OpaqueTerm::class)]
70#[UsesClass(\SqlCatalog\Core\Evaluation\PatternTerm::class)]
71#[UsesClass(\SqlCatalog\Extension\Doctrine\DoctrineExtension::class)]
72#[UsesClass(\SqlCatalog\Core\Extension\ExtensionRegistry::class)]
73#[UsesClass(\SqlCatalog\Extension\Laravel\LaravelExtension::class)]
74#[UsesClass(\SqlCatalog\Extension\Mysqli\MysqliExtension::class)]
75#[UsesClass(\SqlCatalog\Extension\Pdo\PdoExtension::class)]
76#[UsesClass(\SqlCatalog\Core\Extension\SinkSpec::class)]
77#[UsesClass(\SqlCatalog\Core\Php\FunctionShape::class)]
78#[UsesClass(\SqlCatalog\Core\Php\NodeText::class)]
79#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
80#[UsesClass(\SqlCatalog\Core\Php\ParsedFile::class)]
81#[UsesClass(\SqlCatalog\Core\Php\ProgramIndex::class)]
82#[UsesClass(\SqlCatalog\Core\Php\ProgramIndexBuilder::class)]
83#[UsesClass(\SqlCatalog\Core\Php\SourceParser::class)]
84#[UsesClass(\SqlCatalog\Core\Php\TypeReader::class)]
85#[UsesClass(\SqlCatalog\Reporter\Html\HtmlReporter::class)]
86#[UsesClass(\SqlCatalog\Core\Source\SourceFile::class)]
87#[UsesClass(\SqlCatalog\Core\Source\SourceScanner::class)]
88#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderRef::class)]
89#[UsesClass(\SqlCatalog\Core\Sql\PlaceholderScanner::class)]
90#[UsesClass(\SqlCatalog\Core\Sql\SqlLexer::class)]
91#[UsesClass(\SqlCatalog\Core\Sql\SqlToken::class)]
92#[UsesClass(\SqlCatalog\Core\Sql\StatementKindReader::class)]
93#[UsesClass(\SqlCatalog\Core\Sql\TableReader::class)]
94#[UsesClass(\SqlCatalog\Core\Text\LiteralText::class)]
95#[UsesClass(\SqlCatalog\Core\Text\Origin::class)]
96#[UsesClass(\SqlCatalog\Core\Text\TextHole::class)]
97#[UsesClass(\SqlCatalog\Core\Text\TextPattern::class)]
98#[UsesClass(\SqlCatalog\Core\Type\TypeShape::class)]
99#[UsesClass(\SqlCatalog\Core\Source\SourceScanException::class)]
100#[UsesClass(\SqlCatalog\Core\Analysis\SinkFinder::class)]
101#[UsesClass(\SqlCatalog\Core\Catalog\Resolution::class)]
102#[UsesClass(\SqlCatalog\Extension\WordPress\WordPressExtension::class)]
103#[UsesClass(\SqlCatalog\Core\Php\DeclaredGlobals::class)]
104#[UsesClass(\SqlCatalog\Core\Analysis\ConstantReader::class)]
105#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Binding::class)]
106#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CalleeReturns::class)]
107#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerIndex::class)]
108#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Callers::class)]
109#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Deriver::class)]
110#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\EntryBinder::class)]
111#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\FreeNames::class)]
112#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\ModifiedNames::class)]
113#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\PropertyWrites::class)]
114#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SliceExecutor::class)]
115#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Arrival::class)]
116#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\AssignmentSteps::class)]
117#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\BackwardSlicer::class)]
118#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\LoopPasses::class)]
119#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Slice\Pending::class)]
120#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\Solution::class)]
121#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\SourceTree::class)]
122#[UsesClass(\SqlCatalog\Core\Analysis\Derivation\CallerSet::class)]
123#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\Registry::class)]
124#[UsesClass(\SqlCatalog\Facade\Configuration::class)]
125#[UsesClass(\SqlCatalog\Facade\InvalidConfigurationException::class)]
126#[UsesClass(\SqlCatalog\Core\Analysis\BuiltinCallModel::class)]
127#[UsesClass(\SqlCatalog\Core\Analysis\FunctionModel\NamedModel::class)]
128#[UsesClass(\SqlCatalog\Facade\ConfigurationSchema::class)]
129#[UsesClass(\SqlCatalog\Core\Analysis\Effect\WriteEffects::class)]
130#[UsesClass(\SqlCatalog\Core\Analysis\Effect\ReferenceEffects::class)]
131#[UsesClass(\SqlCatalog\Core\Extension\Model\CallContext::class)]
132final class CatalogCommandTest extends TestCase
133{
134    public function testRunAnswersTheHelp(): void
135    {
136        $result = (new CatalogCommand())->run(['--help']);
137        self::assertSame(ExitCode::Success, $result->exitCode);
138        self::assertStringContainsString('sql-catalog', $result->output);
139    }
140
141    public function testRunReportsAnInvalidCommandLine(): void
142    {
143        $result = (new CatalogCommand())->run(['--nope']);
144        self::assertSame(ExitCode::InvalidCommandLine, $result->exitCode);
145        self::assertStringContainsString('Unknown option', $result->error);
146    }
147
148    public function testRunReportsAPathItCannotRead(): void
149    {
150        $result = (new CatalogCommand())->run(['/definitely/not/here']);
151        self::assertSame(ExitCode::SourceUnreadable, $result->exitCode);
152        self::assertStringContainsString('Cannot read', $result->error);
153    }
154
155    public function testRunCatalogsTheNamedPathOntoStandardOutput(): void
156    {
157        $directory = sys_get_temp_dir() . '/sql-catalog-test-' . bin2hex(random_bytes(6));
158        mkdir($directory);
159        file_put_contents($directory . '/Repo.php', '<?php function f(PDO $d) { $d->exec("INSERT INTO users (id) VALUES (1)"); }');
160
161        $result = (new CatalogCommand())->run(['--root', $directory, $directory]);
162
163        self::assertSame(ExitCode::Success, $result->exitCode);
164        self::assertStringContainsString('Repo.php', $result->output);
165        self::assertStringContainsString('INSERT INTO users', $result->output);
166        unlink($directory . '/Repo.php');
167        rmdir($directory);
168    }
169
170    public function testExecuteRefusesToRunWithoutAPath(): void
171    {
172        $this->expectException(InvalidCommandLineException::class);
173        $this->expectExceptionMessage('Name at least one file or directory');
174        (new CatalogCommand())->execute(new CommandLine());
175    }
176
177    public function testExecuteAppliesTheFilter(): void
178    {
179        $directory = sys_get_temp_dir() . '/sql-catalog-test-' . bin2hex(random_bytes(6));
180        mkdir($directory);
181        file_put_contents(
182            $directory . '/Repo.php',
183            '<?php function f(PDO $d) { $d->query("SELECT 1"); $d->exec("DELETE FROM users"); }',
184        );
185        $command = new CommandLine(
186            [$directory],
187            null,
188            'text',
189            ['pdo'],
190            new CatalogFilter(kinds: [\SqlCatalog\Core\Sql\StatementKind::Delete]),
191            [],
192            $directory,
193        );
194
195        $result = (new CatalogCommand())->execute($command);
196
197        self::assertStringContainsString('1 statement(s)', $result->output);
198        unlink($directory . '/Repo.php');
199        rmdir($directory);
200    }
201
202    public function testAnswerQueryListsTheExtensionsAndTheReporters(): void
203    {
204        $command = new CatalogCommand();
205        self::assertStringContainsString('pdo', (string) $command->answerQuery(new CommandLine(listExtensions: true))?->output);
206        self::assertStringContainsString('json', (string) $command->answerQuery(new CommandLine(listReporters: true))?->output);
207        self::assertNull($command->answerQuery(new CommandLine(['src'])));
208    }
209
210    public function testReportWritesIntoTheDirectoryItIsGiven(): void
211    {
212        $directory = sys_get_temp_dir() . '/sql-catalog-test-' . bin2hex(random_bytes(6));
213        $result = (new CatalogCommand())->report(new CommandLine(['src'], $directory, 'json'), new Catalog());
214        self::assertStringContainsString('catalog.json', $result->output);
215        self::assertFileExists($directory . '/catalog.json');
216        unlink($directory . '/catalog.json');
217        rmdir($directory);
218    }
219
220    public function testStatusReportsFindingsOnlyWhenAskedTo(): void
221    {
222        $catalog = (new Analyzer())->analyzeSource([
223            'a.php' => '<?php function f(PDO $d) { $d->query("SELECT " . $_GET["x"]); }',
224        ]);
225        $command = new CatalogCommand();
226        self::assertSame(ExitCode::Success, $command->status(new CommandLine(['src']), $catalog));
227        self::assertSame(
228            ExitCode::FindingsReported,
229            $command->status(new CommandLine(['src'], failOn: Severity::High), $catalog),
230        );
231        self::assertSame(
232            ExitCode::Success,
233            $command->status(new CommandLine(['src'], failOn: Severity::High), new Catalog()),
234        );
235    }
236    public function testConfiguredAnalyzerOverridesModelsWithoutLeakingThem(): void
237    {
238        $command = new CatalogCommand();
239        $configured = $command->configuredAnalyzer(new \SqlCatalog\Facade\Configuration(functionModels: ['array_fill' => \Tests\Fake\PairModel::class]));
240        $source = '<?php function f(PDO $db, array $ids) { $db->prepare("SELECT * FROM users WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"); }';
241        self::assertSame('SELECT * FROM users WHERE id IN (?,?)', $configured->analyzeSource(['query.php' => $source])->entries()[0]->sql());
242        self::assertSame('SELECT * FROM users WHERE id IN (?)', $command->configuredAnalyzer(new \SqlCatalog\Facade\Configuration())->analyzeSource(['query.php' => $source])->entries()[0]->sql());
243    }
244
245    public function testRunLoadsCatalogSettingsAndAllowsCliOverrides(): void
246    {
247        $directory = sys_get_temp_dir() . '/catalog-' . bin2hex(random_bytes(6));
248        mkdir($directory);
249        file_put_contents($directory . '/query.php', '<?php function f(PDO $db, array $ids) { $db->prepare("SELECT * FROM users WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"); }');
250        file_put_contents($directory . '/.catalog.yaml', "paths: [query.php]\nextensions: [pdo]\nreporter: json\nfunction-models:\n  array_fill: Tests\\Fake\\PairModel\n");
251        try {
252            $command = new CatalogCommand();
253            $result = $command->run(['--config', $directory . '/.catalog.yaml', '--reporter=text']);
254            self::assertSame(ExitCode::Success, $result->exitCode);
255            self::assertStringContainsString('SELECT * FROM users WHERE id IN (?,?)', $result->output);
256            self::assertStringContainsString('query.php:', $result->output);
257            $withoutConfig = $command->run([$directory . '/query.php']);
258            self::assertStringContainsString('SELECT * FROM users WHERE id IN (?)', $withoutConfig->output);
259        } finally {
260            unlink($directory . '/query.php');
261            unlink($directory . '/.catalog.yaml');
262            rmdir($directory);
263        }
264    }
265
266    public function testRunReportsMissingConfigurationAsAnInvalidCommandLine(): void
267    {
268        $result = (new CatalogCommand())->run(['--config=/definitely/missing/.catalog.yaml', 'src']);
269        self::assertSame(ExitCode::InvalidCommandLine, $result->exitCode);
270        self::assertStringContainsString('Cannot read configuration', $result->error);
271    }
272
273    #[\PHPUnit\Framework\Attributes\DataProvider('providerInvalidConfiguration')]
274    public function testRunReportsConfigurationErrors(string $source, string $message): void
275    {
276        $path = sys_get_temp_dir() . '/sql-catalog-config-' . bin2hex(random_bytes(6)) . '.yaml';
277        file_put_contents($path, $source);
278        try {
279            $result = (new CatalogCommand())->run(['--config', $path, 'src']);
280            self::assertSame(ExitCode::InvalidCommandLine, $result->exitCode);
281            self::assertStringContainsString($path, $result->error);
282            self::assertStringContainsString($message, $result->error);
283        } finally {
284            unlink($path);
285        }
286    }
287
288    /**
289     * @return array<string, array{string, string}>
290     */
291    public static function providerInvalidConfiguration(): array
292    {
293        return [
294            'not a mapping' => ['[]', 'must contain a YAML mapping'],
295            'syntax error' => ['paths: [', 'Invalid configuration'],
296            'unknown model' => ["function-models:\n  array_fill: Missing\\Model\n", 'must be an autoloadable callable'],
297        ];
298    }
299
300    public function testRunDoesNotLoadConfigurationWhenPrintingHelp(): void
301    {
302        $result = (new CatalogCommand())->run(['--config=/definitely/missing/.catalog.yaml', '--help']);
303        self::assertSame(ExitCode::Success, $result->exitCode);
304        self::assertStringContainsString('--config=FILE', $result->output);
305    }
306
307}
308