packages/requirements/tests/Unit/Source/ResourceLoaderTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Source;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\Small;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use Requirements\Model\Source;
12use Requirements\Source\Download;
13use Requirements\Source\LocalFile;
14use Requirements\Source\ResourceLoader;
15use Requirements\Source\ResourceLocation;
16use Requirements\Source\SnapshotCache;
17use RuntimeException;
18use Symfony\Component\HttpClient\MockHttpClient;
19use Symfony\Component\HttpClient\Response\MockResponse;
20use Tests\Fake\ProjectDirectory;
21use Tests\Fake\SourceFailure;
22
23#[CoversClass(ResourceLoader::class)]
24#[UsesClass(Download::class)]
25#[UsesClass(LocalFile::class)]
26#[UsesClass(ResourceLocation::class)]
27#[UsesClass(SnapshotCache::class)]
28#[UsesClass(Source::class)]
29#[Small]
30final class ResourceLoaderTest extends TestCase
31{
32    public function testReadResolvesPathsAgainstTheDirectory(): void
33    {
34        $project = new ProjectDirectory();
35        $project->put('docs/notes.txt', 'Names start with a letter.');
36
37        self::assertSame('Names start with a letter.', (new ResourceLoader())->read(new Source('notes', 'docs/notes.txt', 'text', 'lines:1'), $project->directory, false));
38    }
39
40    public function testReadAcceptsAMatchingDigest(): void
41    {
42        $project = new ProjectDirectory();
43        $project->put('notes.txt', 'Names start with a letter.');
44        $source = new Source('notes', 'notes.txt', 'text', 'lines:1', sha256: hash('sha256', 'Names start with a letter.'));
45
46        self::assertSame('Names start with a letter.', (new ResourceLoader())->read($source, $project->directory, false));
47    }
48
49    public function testReadRejectsADifferentDigest(): void
50    {
51        $project = new ProjectDirectory();
52        $project->put('notes.txt', 'Names start with a digit.');
53        $source = new Source('notes', 'notes.txt', 'text', 'lines:1', sha256: hash('sha256', 'Names start with a letter.'));
54
55        $this->expectException(RuntimeException::class);
56        $this->expectExceptionMessage('notes: source SHA-256 mismatch.');
57        (new ResourceLoader())->read($source, $project->directory, false);
58    }
59
60    public function testReadLiveStillVerifiesASourceWithoutSnapshot(): void
61    {
62        $project = new ProjectDirectory();
63        $project->put('notes.txt', 'Names start with a digit.');
64        $source = new Source('notes', 'notes.txt', 'text', 'lines:1', sha256: hash('sha256', 'Names start with a letter.'));
65
66        $this->expectException(RuntimeException::class);
67        $this->expectExceptionMessage('notes: source SHA-256 mismatch.');
68        (new ResourceLoader())->read($source, $project->directory, true);
69    }
70
71    public function testReadLiveModeBypassesPinnedSnapshotAndDetectsCurrentText(): void
72    {
73        $project = new ProjectDirectory();
74        $project->put('current.txt', 'New text.');
75        $project->put('snapshot.txt', 'Old text.');
76        $source = new Source('test', 'current.txt', 'text', 'lines:1', 'snapshot.txt', hash('sha256', 'Old text.'));
77        $loader = new ResourceLoader();
78
79        self::assertSame('Old text.', $loader->read($source, $project->directory, false));
80        self::assertSame('New text.', $loader->read($source, $project->directory, true));
81    }
82
83    public function testReadPrefersAnExistingSnapshotOverTheUri(): void
84    {
85        $project = new ProjectDirectory();
86        $project->put('snapshot.txt', 'Pinned text.');
87        $source = new Source('test', 'missing.txt', 'text', 'lines:1', 'snapshot.txt', hash('sha256', 'Pinned text.'));
88
89        self::assertSame('Pinned text.', (new ResourceLoader())->read($source, $project->directory, false));
90    }
91
92    public function testReadRejectsAChangedSnapshot(): void
93    {
94        $project = new ProjectDirectory();
95        $project->put('current.txt', 'Pinned text.');
96        $project->put('snapshot.txt', 'Changed text.');
97        $source = new Source('test', 'current.txt', 'text', 'lines:1', 'snapshot.txt', hash('sha256', 'Pinned text.'));
98
99        $this->expectException(RuntimeException::class);
100        $this->expectExceptionMessage('test: source SHA-256 mismatch.');
101        (new ResourceLoader())->read($source, $project->directory, false);
102    }
103
104    public function testReadMissingSnapshotIsFetchedAndVerifiedBeforeCaching(): void
105    {
106        $project = new ProjectDirectory();
107        $content = 'Original source text.';
108        $project->put('upstream.txt', $content);
109        $source = new Source('test', 'upstream.txt', 'text', 'lines:1', '.requirements-cache/manual.txt', hash('sha256', $content));
110
111        self::assertSame($content, (new ResourceLoader())->read($source, $project->directory, false));
112        unlink($project->path('upstream.txt'));
113        self::assertSame($content, (new ResourceLoader())->read($source, $project->directory, false));
114        $project->put('.requirements-cache/manual.txt', 'Changed text.');
115        $this->expectException(RuntimeException::class);
116        $this->expectExceptionMessage('SHA-256');
117        (new ResourceLoader())->read($source, $project->directory, false);
118    }
119
120    public function testReadCachesAMissingSnapshotVerbatim(): void
121    {
122        $project = new ProjectDirectory();
123        $project->put('upstream.txt', "Original source text.\n");
124        $source = new Source('test', 'upstream.txt', 'text', 'lines:1', '.requirements-cache/manual.txt', hash('sha256', "Original source text.\n"));
125        (new ResourceLoader())->read($source, $project->directory, false);
126
127        self::assertSame("Original source text.\n", $project->read('.requirements-cache/manual.txt'));
128    }
129
130    public function testReadDownloadsAMissingSnapshotOnce(): void
131    {
132        $project = new ProjectDirectory();
133        $client = new MockHttpClient([new MockResponse('Remote text.')]);
134        $source = new Source('test', 'https://example.org/manual', 'text', 'lines:1', 'cache/manual.txt', hash('sha256', 'Remote text.'));
135
136        self::assertSame('Remote text.', (new ResourceLoader($client))->read($source, $project->directory, false));
137        self::assertSame('Remote text.', (new ResourceLoader($client))->read($source, $project->directory, false));
138        self::assertSame(1, $client->getRequestsCount());
139        self::assertSame('Remote text.', $project->read('cache/manual.txt'));
140    }
141
142    public function testReadRejectsAnUnverifiedDownload(): void
143    {
144        $project = new ProjectDirectory();
145        $project->put('upstream.txt', 'Changed text.');
146        $source = new Source('test', 'upstream.txt', 'text', 'lines:1', '.requirements-cache/manual.txt', hash('sha256', 'Original text.'));
147
148        $this->expectException(RuntimeException::class);
149        $this->expectExceptionMessage('SHA-256');
150        (new ResourceLoader())->read($source, $project->directory, false);
151    }
152
153    public function testReadUnverifiedDownloadIsNeverCached(): void
154    {
155        $project = new ProjectDirectory();
156        $project->put('upstream.txt', 'Changed text.');
157        $source = new Source('test', 'upstream.txt', 'text', 'lines:1', '.requirements-cache/manual.txt', hash('sha256', 'Original text.'));
158
159        self::assertStringContainsString('SHA-256', SourceFailure::message(static fn (): string => (new ResourceLoader())->read($source, $project->directory, false)));
160        self::assertFileDoesNotExist($project->path('.requirements-cache/manual.txt'));
161    }
162
163    public function testReadRemoteSnapshotIsNeverCachedLocally(): void
164    {
165        $project = new ProjectDirectory();
166        $snapshot = new MockResponse('Pinned text.');
167        $source = new Source('test', 'https://example.org/manual', 'text', 'lines:1', 'https://example.org/snapshot', hash('sha256', 'Pinned text.'));
168
169        self::assertSame('Pinned text.', (new ResourceLoader(new MockHttpClient([$snapshot])))->read($source, $project->directory, false));
170        self::assertSame('https://example.org/snapshot', $snapshot->getRequestUrl());
171    }
172
173    public function testReadHttpContentIsBoundedAndFailuresAreNotRepeated(): void
174    {
175        $client = new MockHttpClient([new MockResponse(str_repeat('x', 16777217))]);
176        $loader = new ResourceLoader($client);
177        $source = new Source('test', 'https://example.org/manual', 'text', 'lines:1');
178
179        self::assertStringContainsString('16 MiB', SourceFailure::message(static fn (): string => $loader->read($source, '.', false)));
180        self::assertStringContainsString('16 MiB', SourceFailure::message(static fn (): string => $loader->read($source, '.', false)));
181        self::assertSame(1, $client->getRequestsCount());
182    }
183
184    public function testReadHttpErrorCannotBecomeValidEvidence(): void
185    {
186        $loader = new ResourceLoader(new MockHttpClient(new MockResponse('Names shall start with a letter.', ['http_code' => 404])));
187
188        $this->expectException(RuntimeException::class);
189        $this->expectExceptionMessage('HTTP 404');
190        $loader->read(new Source('test', 'https://example.org/missing', 'text', 'lines:1'), '.', false);
191    }
192
193    public function testReadHttpContentIsCachedWithinTheCommand(): void
194    {
195        $client = new MockHttpClient(new MockResponse('Source text.'));
196        $loader = new ResourceLoader($client);
197        $source = new Source('test', 'https://example.org/manual', 'text', 'lines:1');
198
199        self::assertSame('Source text.', $loader->read($source, '.', false));
200        self::assertSame('Source text.', $loader->read($source, '.', false));
201        self::assertSame(1, $client->getRequestsCount());
202    }
203
204    public function testReadRejectsOtherSchemes(): void
205    {
206        $this->expectException(RuntimeException::class);
207        $this->expectExceptionMessage('Built-in sources accept HTTP(S) URLs or local paths only.');
208        (new ResourceLoader())->read(new Source('test', 'file:///etc/hostname', 'text', 'lines:1'), '.', false);
209    }
210
211    public function testFetchRemembersTheDocument(): void
212    {
213        $project = new ProjectDirectory();
214        $path = $project->put('notes.txt', 'First text.');
215        $loader = new ResourceLoader();
216
217        self::assertSame('First text.', $loader->fetch($path));
218        $project->put('notes.txt', 'Second text.');
219        self::assertSame('First text.', $loader->fetch($path));
220        self::assertSame('Second text.', (new ResourceLoader())->fetch($path));
221    }
222
223    public function testFetchNamesTheUnreadableResource(): void
224    {
225        $project = new ProjectDirectory();
226        $path = $project->path('missing.txt');
227
228        $this->expectException(RuntimeException::class);
229        $this->expectExceptionMessage("Cannot read source $path: Unreadable resource or 16 MiB size limit exceeded.");
230        $this->expectExceptionCode(0);
231        (new ResourceLoader())->fetch($path);
232    }
233
234    public function testFetchRemembersTheFailure(): void
235    {
236        $project = new ProjectDirectory();
237        $path = $project->path('late.txt');
238        $loader = new ResourceLoader();
239
240        self::assertSame("Cannot read source $path: Unreadable resource or 16 MiB size limit exceeded.", SourceFailure::message(static fn (): string => $loader->fetch($path)));
241        $project->put('late.txt', 'Too late.');
242        $this->expectException(RuntimeException::class);
243        $this->expectExceptionMessage("Cannot read source $path: Unreadable resource or 16 MiB size limit exceeded.");
244        $loader->fetch($path);
245    }
246
247    public function testFetchWrapsTransportFailures(): void
248    {
249        $loader = new ResourceLoader(new MockHttpClient(new MockResponse('', ['error' => 'Connection refused.'])));
250
251        $this->expectException(RuntimeException::class);
252        $this->expectExceptionMessage('Cannot read source https://example.org/manual: Connection refused.');
253        $loader->fetch('https://example.org/manual');
254    }
255
256    public function testFetchWrapsClientFailures(): void
257    {
258        $loader = new ResourceLoader(new MockHttpClient(new MockResponse('Source text.')));
259
260        $this->expectException(RuntimeException::class);
261        $this->expectExceptionMessage('Cannot read source https://: Malformed URL "https://".');
262        $loader->fetch('https://');
263    }
264}
265