packages/sql-catalog/tests/Unit/Core/Analysis/SinkFinderTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Analysis;
6
7use PhpParser\Node\Expr;
8use PhpParser\Node\Name;
9use PhpParser\NodeFinder;
10use PHPUnit\Framework\Attributes\CoversClass;
11use PHPUnit\Framework\Attributes\UsesClass;
12use PHPUnit\Framework\TestCase;
13use SqlCatalog\Core\Analysis\SinkFinder;
14use SqlCatalog\Core\Extension\SinkCallKind;
15use SqlCatalog\Core\Extension\SinkRole;
16use SqlCatalog\Core\Extension\SinkSpec;
17use SqlCatalog\Core\Php\ParsedFile;
18use SqlCatalog\Core\Php\SourceParser;
19use SqlCatalog\Extension\Pdo\PdoExtension;
20
21#[CoversClass(SinkFinder::class)]
22#[UsesClass(PdoExtension::class)]
23#[UsesClass(SinkSpec::class)]
24#[UsesClass(ParsedFile::class)]
25#[UsesClass(SourceParser::class)]
26final class SinkFinderTest extends TestCase
27{
28 public function testFindInLooksAtTheNodesItIsGiven(): void
29 {
30 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query("SELECT 1"); }');
31 $finder = new SinkFinder();
32
33 self::assertCount(1, $finder->findIn($file->statements, ['query' => true]));
34 self::assertSame([], $finder->findIn($file->statements, ['prepare' => true]));
35 }
36
37 public function testFindCollectsTheCallsWrittenLikeDatabaseCalls(): void
38 {
39 $file = (new SourceParser())->parse(
40 't.php',
41 '<?php function f(PDO $d): void { $d->query("SELECT 1"); $d->prepare("SELECT 2"); $d->fetchAll(); }',
42 );
43
44 self::assertCount(2, (new SinkFinder())->find($file, (new PdoExtension())->sinks()));
45 }
46
47 public function testFindIgnoresACallWrittenWithoutAName(): void
48 {
49 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d, string $m): void { $d->$m("SELECT 1"); }');
50
51 self::assertSame([], (new SinkFinder())->find($file, (new PdoExtension())->sinks()));
52 }
53
54 public function testFindAllCollectsEveryDatabaseCallButTheOnesThatComposeAStatement(): void
55 {
56 $file = (new SourceParser())->parse(
57 't.php',
58 '<?php function f(PDO $d, PDOStatement $s): void { $d->query("a"); $s->execute(); $s->bindValue(1, 2); $d->prepare("b"); $d->interpolate("c"); \\mysqli_query($d, "d"); $d->fetchAll(); }',
59 );
60 $finder = new SinkFinder();
61 $sinks = [
62 new SinkSpec('t.query', SinkCallKind::Method, 'PDO', 'query', SinkRole::Query),
63 new SinkSpec('t.execute', SinkCallKind::Method, 'PDOStatement', 'execute', SinkRole::Execute),
64 new SinkSpec('t.bind', SinkCallKind::Method, 'PDOStatement', 'BindValue', SinkRole::Bind),
65 new SinkSpec('t.prepare', SinkCallKind::Method, 'PDO', 'prepare', SinkRole::Prepare),
66 new SinkSpec('t.compose', SinkCallKind::Method, 'PDO', 'interpolate', SinkRole::Compose),
67 new SinkSpec('t.mysqli', SinkCallKind::FunctionCall, null, '\\mysqli_query', SinkRole::Query),
68 ];
69
70 self::assertSame(
71 ['query', 'execute', 'bindValue', 'prepare', 'mysqli_query'],
72 array_map(static fn (Expr\CallLike $call): ?string => $finder->nameOf($call), $finder->findAll($file, $sinks)),
73 );
74 }
75
76 public function testFindAllFindsNothingWhenEverySinkComposesAStatement(): void
77 {
78 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->interpolate("a"); $d->query("b"); }');
79
80 self::assertSame([], (new SinkFinder())->findAll($file, [
81 new SinkSpec('t.compose', SinkCallKind::Method, 'PDO', 'interpolate', SinkRole::Compose),
82 ]));
83 }
84
85 public function testFindAllTakesInTheCallsThatOnlyBindValues(): void
86 {
87 $file = (new SourceParser())->parse(
88 't.php',
89 '<?php function f(PDO $d): void { $s = $d->prepare("SELECT ?"); $s->bindValue(1, 2); $s->execute(); }',
90 );
91 $finder = new SinkFinder();
92 $sinks = (new PdoExtension())->sinks();
93
94 self::assertCount(1, $finder->find($file, $sinks));
95 self::assertCount(3, $finder->findAll($file, $sinks));
96 }
97
98 public function testNamesOfKeepsOnlyTheCallsThatCarryAStatement(): void
99 {
100 $names = (new SinkFinder())->namesOf((new PdoExtension())->sinks());
101
102 self::assertArrayHasKey('query', $names);
103 self::assertArrayHasKey('prepare', $names);
104 self::assertArrayNotHasKey('bindvalue', $names);
105 self::assertArrayNotHasKey('execute', $names);
106 }
107
108 public function testNamesOfWritesEachNameInLowerCaseWithoutALeadingBackslash(): void
109 {
110 $names = (new SinkFinder())->namesOf([
111 new SinkSpec('t.query', SinkCallKind::Method, 'Db', 'Query', SinkRole::Query),
112 new SinkSpec('t.prepare', SinkCallKind::FunctionCall, null, '\\Db_Prepare', SinkRole::Prepare),
113 new SinkSpec('t.execute', SinkCallKind::Method, 'Db', 'execute', SinkRole::Execute),
114 ]);
115
116 self::assertSame(['query' => true, 'db_prepare' => true], $names);
117 }
118
119 public function testNameOfReadsEveryFormOfCall(): void
120 {
121 $file = (new SourceParser())->parse(
122 't.php',
123 '<?php function f(PDO $d): void { $d->query("a"); $d?->query("b"); PDO::query("c"); mysqli_query($d, "e"); new PDO("f"); }',
124 );
125 $finder = new SinkFinder();
126 $calls = (new NodeFinder())->findInstanceOf($file->statements, Expr\CallLike::class);
127
128 $names = array_map(
129 static fn (Expr\CallLike $call): ?string => $finder->nameOf($call),
130 $calls,
131 );
132
133 self::assertSame(['query', 'query', 'query', 'mysqli_query', null], $names);
134 }
135
136 public function testNameOfIgnoresAStaticCallWrittenWithoutAName(): void
137 {
138 $call = new Expr\StaticCall(new Name('PDO'), new Expr\Variable('m'));
139
140 self::assertNull((new SinkFinder())->nameOf($call));
141 }
142
143 public function testEnclosingBodyFindsTheFunctionACallIsWrittenIn(): void
144 {
145 $file = (new SourceParser())->parse('t.php', '<?php function f(PDO $d): void { $d->query("SELECT 1"); } $d->query("SELECT 2");');
146 $finder = new SinkFinder();
147 $calls = $finder->find($file, (new PdoExtension())->sinks());
148
149 self::assertInstanceOf(\PhpParser\Node\Stmt\Function_::class, $finder->enclosingBody($calls[0]));
150 self::assertNull($finder->enclosingBody($calls[1]));
151 }
152}
153