packages/sql-catalog/tests/Unit/Core/Catalog/CatalogEntryTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Catalog;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Catalog\CallSite;
12use SqlCatalog\Core\Catalog\CatalogEntry;
13use SqlCatalog\Core\Catalog\Finding;
14use SqlCatalog\Core\Catalog\FindingRule;
15use SqlCatalog\Core\Catalog\Resolution;
16use SqlCatalog\Core\Catalog\Severity;
17use SqlCatalog\Core\Catalog\StatementPart;
18use SqlCatalog\Core\Sql\StatementKind;
19use SqlCatalog\Core\Text\LiteralText;
20use SqlCatalog\Core\Text\Origin;
21use SqlCatalog\Core\Text\TextHole;
22use SqlCatalog\Core\Text\TextPattern;
23use SqlCatalog\Core\Type\TypeShape;
24
25#[CoversClass(CatalogEntry::class)]
26#[UsesClass(CallSite::class)]
27#[UsesClass(Finding::class)]
28#[UsesClass(FindingRule::class)]
29#[UsesClass(Resolution::class)]
30#[UsesClass(Severity::class)]
31#[UsesClass(StatementPart::class)]
32#[UsesClass(LiteralText::class)]
33#[UsesClass(TextHole::class)]
34#[UsesClass(TextPattern::class)]
35#[UsesClass(TypeShape::class)]
36#[UsesClass(Origin::class)]
37final class CatalogEntryTest extends TestCase
38{
39    public function testSqlWritesThePatternWithItsGaps(): void
40    {
41        $pattern = TextPattern::fromText('SELECT * FROM ')
42            ->concat(TextPattern::fromHole(new TextHole(Origin::Property, TypeShape::unknown())));
43        $entry = new CatalogEntry(
44            'id',
45            StatementKind::Select,
46            $pattern,
47            [],
48            [],
49            new CallSite('a.php', 1, 'f', 'pdo.query'),
50            [],
51        );
52        self::assertSame('SELECT * FROM {$}', $entry->sql());
53        self::assertFalse($entry->isExact());
54    }
55
56    public function testIsExactWhenNothingIsLeftOpen(): void
57    {
58        $entry = new CatalogEntry(
59            'id',
60            StatementKind::Select,
61            TextPattern::fromText('SELECT 1'),
62            [],
63            [],
64            new CallSite('a.php', 1, 'f', 'pdo.query'),
65            [],
66        );
67        self::assertTrue($entry->isExact());
68    }
69
70    public function testSeverityIsTheHighestOfTheFindings(): void
71    {
72        $entry = new CatalogEntry(
73            'id',
74            StatementKind::Select,
75            TextPattern::fromText('SELECT 1'),
76            [],
77            [],
78            new CallSite('a.php', 1, 'f', 'pdo.query'),
79            [Finding::of(FindingRule::UnresolvedSql, 'a'), Finding::of(FindingRule::ExternalInput, 'b')],
80        );
81        self::assertSame(Severity::High, $entry->severity());
82    }
83
84    public function testSeverityIsInformationalWithoutFindings(): void
85    {
86        $entry = new CatalogEntry(
87            'id',
88            StatementKind::Select,
89            TextPattern::fromText('SELECT 1'),
90            [],
91            [],
92            new CallSite('a.php', 1, 'f', 'pdo.query'),
93            [],
94        );
95        self::assertSame(Severity::Info, $entry->severity());
96    }
97
98    public function testResolutionReadsHowFarTheAnalyzerGot(): void
99    {
100        $resolved = new CatalogEntry('id', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 's'), []);
101        $open = new CatalogEntry(
102            'id',
103            StatementKind::Select,
104            TextPattern::fromHole(new TextHole(Origin::External, TypeShape::unknown())),
105            [],
106            [],
107            new CallSite('a.php', 1, 'f', 's'),
108            [],
109        );
110
111        self::assertSame(Resolution::Resolved, $resolved->resolution());
112        self::assertSame(Resolution::ExternalInput, $open->resolution());
113    }
114
115    #[DataProvider('providerSearchClosed')]
116    public function testSearchClosedOnlyWhenTheResolutionIsClosedAndNoBoundCutItShort(TextPattern $pattern, bool $truncated, bool $expected): void
117    {
118        $entry = new CatalogEntry('id', StatementKind::Select, $pattern, [], [], new CallSite('a.php', 1, 'f', 's'), [], truncated: $truncated);
119
120        self::assertSame($expected, $entry->searchClosed());
121    }
122
123    /**
124     * @return array<string, array{TextPattern, bool, bool}>
125     */
126    public static function providerSearchClosed(): array
127    {
128        return [
129            'resolved' => [TextPattern::fromText('SELECT 1'), false, true],
130            'resolved but truncated' => [TextPattern::fromText('SELECT 1'), true, false],
131            'external input' => [TextPattern::fromHole(new TextHole(Origin::External, TypeShape::unknown())), false, true],
132            'external input but truncated' => [TextPattern::fromHole(new TextHole(Origin::External, TypeShape::unknown())), true, false],
133            'incomplete model' => [TextPattern::fromHole(new TextHole(Origin::Property, TypeShape::unknown())), false, false],
134            'budget' => [TextPattern::fromHole(new TextHole(Origin::Budget, TypeShape::unknown())), false, false],
135            'not analyzed' => [TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown())), false, false],
136        ];
137    }
138
139    public function testTheAlternativesAreMarkedAsReachableOrNot(): void
140    {
141        $entry = new CatalogEntry('id', StatementKind::Select, TextPattern::fromText('SELECT 1'), [], [], new CallSite('a.php', 1, 'f', 's'), [], false, ['App\\R::find']);
142
143        self::assertFalse($entry->correlated);
144        self::assertSame(['App\\R::find'], $entry->through);
145    }
146
147    public function testHasFindingLooksForOneRule(): void
148    {
149        $entry = new CatalogEntry(
150            'id',
151            StatementKind::Select,
152            TextPattern::fromText('SELECT 1'),
153            [],
154            [],
155            new CallSite('a.php', 1, 'f', 'pdo.query'),
156            [Finding::of(FindingRule::DynamicSql, 'a')],
157        );
158        self::assertTrue($entry->hasFinding(FindingRule::DynamicSql));
159        self::assertFalse($entry->hasFinding(FindingRule::ExternalInput));
160    }
161
162    public function testPartsAreTheRunsAndGapsTheStatementIsKnownIn(): void
163    {
164        $entry = new CatalogEntry(
165            'id',
166            StatementKind::Select,
167            TextPattern::fromSegments([
168                new LiteralText('SELECT * FROM t WHERE id = '),
169                new TextHole(Origin::External, TypeShape::unknown()),
170            ]),
171            ['t'],
172            [],
173            new CallSite('a.php', 1, 'f', 'pdo.query'),
174            [],
175        );
176
177        self::assertSame(
178            [false, true],
179            array_map(static fn (StatementPart $part): bool => $part->isGap, $entry->parts()),
180        );
181    }
182
183    public function testFirstGapIsTheFirstOneOrNothingWhenThereIsNone(): void
184    {
185        $open = new CatalogEntry(
186            'id',
187            StatementKind::Select,
188            TextPattern::fromHole(new TextHole(Origin::Unreached, TypeShape::unknown(), '$db->query($sql)')),
189            [],
190            [],
191            new CallSite('a.php', 1, 'f', 'pdo.query'),
192            [],
193        );
194        $closed = new CatalogEntry(
195            'id',
196            StatementKind::Select,
197            TextPattern::fromText('SELECT 1'),
198            [],
199            [],
200            new CallSite('a.php', 1, 'f', 'pdo.query'),
201            [],
202        );
203
204        self::assertSame('$db->query($sql)', $open->firstGap()?->expression);
205        self::assertNull($closed->firstGap());
206    }
207}
208