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