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