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