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