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