packages/sql-catalog/tests/Unit/Core/Analysis/SinkMatcherTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Analysis;
6
7use ArrayObject;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Analysis\SinkMatcher;
12use SqlCatalog\Core\Evaluation\Domain;
13use SqlCatalog\Core\Evaluation\ObjectTerm;
14use SqlCatalog\Core\Evaluation\OpaqueTerm;
15use SqlCatalog\Core\Extension\ExtensionRegistry;
16use SqlCatalog\Core\Extension\SinkCallKind;
17use SqlCatalog\Core\Extension\SinkSpec;
18use SqlCatalog\Core\Php\ParsedFile;
19use SqlCatalog\Core\Php\ProgramIndex;
20use SqlCatalog\Core\Php\ProgramIndexBuilder;
21use SqlCatalog\Core\Php\SourceParser;
22use SqlCatalog\Core\Php\TypeReader;
23use SqlCatalog\Core\Type\TypeShape;
24use SqlCatalog\Extension\Mysqli\MysqliExtension;
25use SqlCatalog\Extension\Pdo\PdoExtension;
26
27#[CoversClass(SinkMatcher::class)]
28#[UsesClass(Domain::class)]
29#[UsesClass(ObjectTerm::class)]
30#[UsesClass(OpaqueTerm::class)]
31#[UsesClass(ExtensionRegistry::class)]
32#[UsesClass(MysqliExtension::class)]
33#[UsesClass(PdoExtension::class)]
34#[UsesClass(SinkSpec::class)]
35#[UsesClass(ParsedFile::class)]
36#[UsesClass(ProgramIndex::class)]
37#[UsesClass(ProgramIndexBuilder::class)]
38#[UsesClass(SourceParser::class)]
39#[UsesClass(TypeReader::class)]
40#[UsesClass(TypeShape::class)]
41#[UsesClass(\SqlCatalog\Core\Php\ClassShape::class)]
42#[UsesClass(\SqlCatalog\Extension\Laravel\LaravelExtension::class)]
43#[UsesClass(\SqlCatalog\Core\Extension\Model\ModelSet::class)]
44final class SinkMatcherTest extends TestCase
45{
46 public function testMatchMethodFindsTheCallOnAKnownReceiver(): void
47 {
48 $matcher = new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex());
49 self::assertSame('pdo.query', $matcher->matchMethod(Domain::of(new ObjectTerm('PDO')), 'query')?->id);
50 self::assertNull($matcher->matchMethod(Domain::of(new ObjectTerm('App\\Mailer')), 'query'));
51 }
52
53 public function testMatchMethodIgnoresAReceiverNothingIsKnownAbout(): void
54 {
55 $matcher = new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex());
56 self::assertNull($matcher->matchMethod(Domain::unknown(), 'query'));
57 }
58
59 public function testMatchStaticFindsTheCallOnAKnownClass(): void
60 {
61 $sink = new SinkSpec('db.select', SinkCallKind::StaticCall, 'App\\DB', 'select', \SqlCatalog\Core\Extension\SinkRole::Query, sqlParameter: 0);
62 $matcher = new SinkMatcher([$sink], new ProgramIndex());
63 self::assertSame('db.select', $matcher->matchStatic('App\\DB', 'select')?->id);
64 self::assertNull($matcher->matchStatic('App\\Other', 'select'));
65 }
66
67 public function testMatchFunctionFindsTheFreeFunction(): void
68 {
69 $matcher = new SinkMatcher((new MysqliExtension())->sinks(), new ProgramIndex());
70 self::assertSame('mysqli.fn.query', $matcher->matchFunction('mysqli_query')?->id);
71 self::assertNull($matcher->matchFunction('sprintf'));
72 }
73
74 public function testByNameCollectsEveryCallWrittenWithThatName(): void
75 {
76 $matcher = new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex());
77 self::assertCount(1, $matcher->byName(SinkCallKind::Method, 'prepare'));
78 self::assertSame([], $matcher->byName(SinkCallKind::FunctionCall, 'prepare'));
79 }
80
81 public function testByNameKeepsEveryCallOfThatName(): void
82 {
83 $matcher = new SinkMatcher([
84 new SinkSpec('a.run', SinkCallKind::Method, 'App\\A', 'run', \SqlCatalog\Core\Extension\SinkRole::Query, sqlParameter: 0),
85 new SinkSpec('b.run', SinkCallKind::Method, 'App\\B', 'run', \SqlCatalog\Core\Extension\SinkRole::Query, sqlParameter: 0),
86 ], new ProgramIndex());
87
88 self::assertSame(['a.run', 'b.run'], array_map(
89 static fn (SinkSpec $sink): string => $sink->id,
90 $matcher->byName(SinkCallKind::Method, 'run'),
91 ));
92 }
93
94 public function testClassMatchesIgnoresALeadingBackslashAndTheCaseOfTheName(): void
95 {
96 $matcher = new SinkMatcher([], new ProgramIndex());
97
98 self::assertTrue($matcher->classMatches('\\App\\Repository', 'App\\Repository'));
99 self::assertTrue($matcher->classMatches('App\\Repository', '\\App\\Repository'));
100 self::assertTrue($matcher->classMatches('App\\Repository', 'app\\repository'));
101 self::assertFalse($matcher->classMatches('App\\A', 'App\\B'));
102 self::assertFalse($matcher->classMatches('App\\B', 'App\\A'));
103 }
104
105 public function testClassMatchesReadsTheRunningProcessOnlyForAClassThatIsRelated(): void
106 {
107 $matcher = new SinkMatcher([], new ProgramIndex());
108
109 self::assertTrue($matcher->classMatches('ArrayIterator', 'Traversable'));
110 self::assertFalse($matcher->classMatches('PDOStatement', 'PDO'));
111 }
112
113 public function testClassMatchesNeverLoadsAClassOfTheAnalyzedSource(): void
114 {
115 $requested = new ArrayObject();
116 $loader = static function (string $class) use ($requested): void {
117 $requested->append($class);
118 };
119 spl_autoload_register($loader);
120
121 $matches = (new SinkMatcher([], new ProgramIndex()))->classMatches('App\\NeverDeclared', 'PDO');
122 spl_autoload_unregister($loader);
123
124 self::assertFalse($matches);
125 self::assertSame([], $requested->getArrayCopy());
126 }
127
128 public function testReceiverMatchesReadsTheClassOutOfTheDomain(): void
129 {
130 $matcher = new SinkMatcher([], new ProgramIndex());
131 self::assertTrue($matcher->receiverMatches(Domain::opaque(TypeShape::of(['PDO']), \SqlCatalog\Core\Text\Origin::Property), 'PDO'));
132 self::assertFalse($matcher->receiverMatches(Domain::opaque(TypeShape::of(['string']), \SqlCatalog\Core\Text\Origin::Property), 'PDO'));
133 }
134
135 public function testClassMatchesFollowsInheritanceDeclaredInTheAnalyzedSource(): void
136 {
137 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
138 'a.php',
139 '<?php namespace App; class ZtdPdo extends \\PDO {}',
140 )]);
141 $matcher = new SinkMatcher([], $index);
142 self::assertTrue($matcher->classMatches('App\\ZtdPdo', 'PDO'));
143 }
144
145 public function testClassMatchesFollowsInheritanceOfTheRunningProcess(): void
146 {
147 $matcher = new SinkMatcher([], new ProgramIndex());
148 self::assertTrue($matcher->classMatches('PDOStatement', 'PDOStatement'));
149 self::assertFalse($matcher->classMatches('App\\Unknown', 'PDO'));
150 }
151
152 public function testModelsSaysWhetherAnExtensionAlreadyStandsForTheReceiver(): void
153 {
154 $matcher = new SinkMatcher((new PdoExtension())->sinks(), new ProgramIndex());
155
156 self::assertTrue($matcher->models(Domain::of(new ObjectTerm('PDO'))));
157 self::assertFalse($matcher->models(Domain::of(new ObjectTerm('App\\Repository'))));
158 }
159
160 public function testClassMatchesUsesRegisteredTypeRelationsOnly(): void
161 {
162 $models = new \SqlCatalog\Core\Extension\Model\ModelSet(classRelations: [static fn (string $class, string $expected): bool => $class === 'VendorConnection' && $expected === 'Driver']);
163 self::assertTrue((new SinkMatcher([], new ProgramIndex(), $models))->classMatches('VendorConnection', 'Driver'));
164 self::assertFalse((new SinkMatcher([], new ProgramIndex()))->classMatches('VendorConnection', 'Driver'));
165 }
166}
167