Mercurial > hg > cmmr2012-drupal-site
comparison core/modules/jsonapi/tests/src/Functional/FileUploadTest.php @ 5:12f9dff5fda9 tip
Update to Drupal core 8.7.1
author | Chris Cannam |
---|---|
date | Thu, 09 May 2019 15:34:47 +0100 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
4:a9cd425dd02b | 5:12f9dff5fda9 |
---|---|
1 <?php | |
2 | |
3 namespace Drupal\Tests\jsonapi\Functional; | |
4 | |
5 use Drupal\Component\Render\PlainTextOutput; | |
6 use Drupal\Component\Serialization\Json; | |
7 use Drupal\Component\Utility\NestedArray; | |
8 use Drupal\Core\Field\FieldStorageDefinitionInterface; | |
9 use Drupal\Core\Url; | |
10 use Drupal\entity_test\Entity\EntityTest; | |
11 use Drupal\field\Entity\FieldConfig; | |
12 use Drupal\field\Entity\FieldStorageConfig; | |
13 use Drupal\file\Entity\File; | |
14 use Drupal\user\Entity\User; | |
15 use GuzzleHttp\RequestOptions; | |
16 use Psr\Http\Message\ResponseInterface; | |
17 | |
18 /** | |
19 * Tests binary data file upload route. | |
20 * | |
21 * @group jsonapi | |
22 */ | |
23 class FileUploadTest extends ResourceTestBase { | |
24 | |
25 /** | |
26 * {@inheritdoc} | |
27 */ | |
28 public static $modules = ['entity_test', 'file']; | |
29 | |
30 /** | |
31 * {@inheritdoc} | |
32 * | |
33 * @see $entity | |
34 */ | |
35 protected static $entityTypeId = 'entity_test'; | |
36 | |
37 /** | |
38 * {@inheritdoc} | |
39 * | |
40 * @see $entity | |
41 */ | |
42 protected static $resourceTypeName = 'entity_test--entity_test'; | |
43 | |
44 /** | |
45 * The POST URI. | |
46 * | |
47 * @var string | |
48 */ | |
49 protected static $postUri = '/jsonapi/entity_test/entity_test/field_rest_file_test'; | |
50 | |
51 /** | |
52 * Test file data. | |
53 * | |
54 * @var string | |
55 */ | |
56 protected $testFileData = 'Hares sit on chairs, and mules sit on stools.'; | |
57 | |
58 /** | |
59 * The test field storage config. | |
60 * | |
61 * @var \Drupal\field\Entity\FieldStorageConfig | |
62 */ | |
63 protected $fieldStorage; | |
64 | |
65 /** | |
66 * The field config. | |
67 * | |
68 * @var \Drupal\field\Entity\FieldConfig | |
69 */ | |
70 protected $field; | |
71 | |
72 /** | |
73 * The parent entity. | |
74 * | |
75 * @var \Drupal\Core\Entity\EntityInterface | |
76 */ | |
77 protected $entity; | |
78 | |
79 /** | |
80 * Created file entity. | |
81 * | |
82 * @var \Drupal\file\Entity\File | |
83 */ | |
84 protected $file; | |
85 | |
86 /** | |
87 * An authenticated user. | |
88 * | |
89 * @var \Drupal\user\UserInterface | |
90 */ | |
91 protected $user; | |
92 | |
93 /** | |
94 * The entity storage for the 'file' entity type. | |
95 * | |
96 * @var \Drupal\Core\Entity\EntityStorageInterface | |
97 */ | |
98 protected $fileStorage; | |
99 | |
100 /** | |
101 * {@inheritdoc} | |
102 */ | |
103 public function setUp() { | |
104 parent::setUp(); | |
105 | |
106 $this->fileStorage = $this->container->get('entity_type.manager') | |
107 ->getStorage('file'); | |
108 | |
109 // Add a file field. | |
110 $this->fieldStorage = FieldStorageConfig::create([ | |
111 'entity_type' => 'entity_test', | |
112 'field_name' => 'field_rest_file_test', | |
113 'type' => 'file', | |
114 'settings' => [ | |
115 'uri_scheme' => 'public', | |
116 ], | |
117 ]) | |
118 ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); | |
119 $this->fieldStorage->save(); | |
120 | |
121 $this->field = FieldConfig::create([ | |
122 'entity_type' => 'entity_test', | |
123 'field_name' => 'field_rest_file_test', | |
124 'bundle' => 'entity_test', | |
125 'settings' => [ | |
126 'file_directory' => 'foobar', | |
127 'file_extensions' => 'txt', | |
128 'max_filesize' => '', | |
129 ], | |
130 ]) | |
131 ->setLabel('Test file field') | |
132 ->setTranslatable(FALSE); | |
133 $this->field->save(); | |
134 | |
135 // Reload entity so that it has the new field. | |
136 $this->entity = $this->entityStorage->loadUnchanged($this->entity->id()); | |
137 | |
138 $this->rebuildAll(); | |
139 } | |
140 | |
141 /** | |
142 * {@inheritdoc} | |
143 * | |
144 * @requires module irrelevant_for_this_test | |
145 */ | |
146 public function testGetIndividual() {} | |
147 | |
148 /** | |
149 * {@inheritdoc} | |
150 * | |
151 * @requires module irrelevant_for_this_test | |
152 */ | |
153 public function testPostIndividual() {} | |
154 | |
155 /** | |
156 * {@inheritdoc} | |
157 * | |
158 * @requires module irrelevant_for_this_test | |
159 */ | |
160 public function testPatchIndividual() {} | |
161 | |
162 /** | |
163 * {@inheritdoc} | |
164 * | |
165 * @requires module irrelevant_for_this_test | |
166 */ | |
167 public function testDeleteIndividual() {} | |
168 | |
169 /** | |
170 * {@inheritdoc} | |
171 * | |
172 * @requires module irrelevant_for_this_test | |
173 */ | |
174 public function testCollection() {} | |
175 | |
176 /** | |
177 * {@inheritdoc} | |
178 * | |
179 * @requires module irrelevant_for_this_test | |
180 */ | |
181 public function testRelationships() {} | |
182 | |
183 /** | |
184 * {@inheritdoc} | |
185 */ | |
186 protected function createEntity() { | |
187 // Create an entity that a file can be attached to. | |
188 $entity_test = EntityTest::create([ | |
189 'name' => 'Llama', | |
190 'type' => 'entity_test', | |
191 ]); | |
192 $entity_test->setOwnerId($this->account->id()); | |
193 $entity_test->save(); | |
194 | |
195 return $entity_test; | |
196 } | |
197 | |
198 /** | |
199 * Tests using the file upload POST route; needs second request to "use" file. | |
200 */ | |
201 public function testPostFileUpload() { | |
202 $uri = Url::fromUri('base:' . static::$postUri); | |
203 | |
204 // DX: 405 when read-only mode is enabled. | |
205 $response = $this->fileRequest($uri, $this->testFileData); | |
206 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $uri, $response); | |
207 $this->assertSame(['GET'], $response->getHeader('Allow')); | |
208 | |
209 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
210 | |
211 // DX: 403 when unauthorized. | |
212 $response = $this->fileRequest($uri, $this->testFileData); | |
213 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $uri, $response); | |
214 | |
215 $this->setUpAuthorization('POST'); | |
216 | |
217 // 404 when the field name is invalid. | |
218 $invalid_uri = Url::fromUri('base:' . static::$postUri . '_invalid'); | |
219 $response = $this->fileRequest($invalid_uri, $this->testFileData); | |
220 $this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist.', $invalid_uri, $response); | |
221 | |
222 // This request will have the default 'application/octet-stream' content | |
223 // type header. | |
224 $response = $this->fileRequest($uri, $this->testFileData); | |
225 $this->assertSame(201, $response->getStatusCode()); | |
226 $expected = $this->getExpectedDocument(); | |
227 $this->assertResponseData($expected, $response); | |
228 | |
229 // Check the actual file data. | |
230 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); | |
231 | |
232 // Test the file again but using 'filename' in the Content-Disposition | |
233 // header with no 'file' prefix. | |
234 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']); | |
235 $this->assertSame(201, $response->getStatusCode()); | |
236 $expected = $this->getExpectedDocument(2, 'example_0.txt'); | |
237 $this->assertResponseData($expected, $response); | |
238 | |
239 // Check the actual file data. | |
240 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); | |
241 $this->assertTrue($this->fileStorage->loadUnchanged(1)->isTemporary()); | |
242 | |
243 // Verify that we can create an entity that references the uploaded file. | |
244 $entity_test_post_url = Url::fromRoute('jsonapi.entity_test--entity_test.collection.post'); | |
245 $request_options = []; | |
246 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
247 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
248 | |
249 $request_options[RequestOptions::BODY] = Json::encode($this->getPostDocument()); | |
250 $response = $this->request('POST', $entity_test_post_url, $request_options); | |
251 $this->assertResourceResponse(201, FALSE, $response); | |
252 $this->assertTrue($this->fileStorage->loadUnchanged(1)->isPermanent()); | |
253 $this->assertSame([ | |
254 [ | |
255 'target_id' => '1', | |
256 'display' => NULL, | |
257 'description' => "The most fascinating file ever!", | |
258 ], | |
259 ], EntityTest::load(2)->get('field_rest_file_test')->getValue()); | |
260 } | |
261 | |
262 /** | |
263 * Tests using the 'file upload and "use" file in single request" POST route. | |
264 */ | |
265 public function testPostFileUploadAndUseInSingleRequest() { | |
266 // Update the test entity so it already has a file. This allows verifying | |
267 // that this route appends files, and does not replace them. | |
268 mkdir('public://foobar'); | |
269 file_put_contents('public://foobar/existing.txt', $this->testFileData); | |
270 $existing_file = File::create([ | |
271 'uri' => 'public://foobar/existing.txt', | |
272 ]); | |
273 $existing_file->setOwnerId($this->account->id()); | |
274 $existing_file->setPermanent(); | |
275 $existing_file->save(); | |
276 $this->entity | |
277 ->set('field_rest_file_test', ['target_id' => $existing_file->id()]) | |
278 ->save(); | |
279 | |
280 $uri = Url::fromUri('base:' . '/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test'); | |
281 | |
282 // DX: 405 when read-only mode is enabled. | |
283 $response = $this->fileRequest($uri, $this->testFileData); | |
284 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $uri, $response); | |
285 $this->assertSame(['GET'], $response->getHeader('Allow')); | |
286 | |
287 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
288 | |
289 // DX: 403 when unauthorized. | |
290 $response = $this->fileRequest($uri, $this->testFileData); | |
291 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('PATCH'), $uri, $response); | |
292 | |
293 $this->setUpAuthorization('PATCH'); | |
294 | |
295 // 404 when the field name is invalid. | |
296 $invalid_uri = Url::fromUri($uri->getUri() . '_invalid'); | |
297 $response = $this->fileRequest($invalid_uri, $this->testFileData); | |
298 $this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist.', $invalid_uri, $response); | |
299 | |
300 // This request fails despite the upload succeeding, because we're not | |
301 // allowed to view the entity we're uploading to. | |
302 $response = $this->fileRequest($uri, $this->testFileData); | |
303 $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.site', 'user.permissions']); | |
304 | |
305 $this->setUpAuthorization('GET'); | |
306 | |
307 // Reuploading the same file will result in the file being uploaded twice | |
308 // and referenced twice. | |
309 $response = $this->fileRequest($uri, $this->testFileData); | |
310 $this->assertSame(200, $response->getStatusCode()); | |
311 $expected = [ | |
312 'jsonapi' => [ | |
313 'meta' => [ | |
314 'links' => [ | |
315 'self' => ['href' => 'http://jsonapi.org/format/1.0/'], | |
316 ], | |
317 ], | |
318 'version' => '1.0', | |
319 ], | |
320 'links' => [ | |
321 'self' => ['href' => Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test')->setAbsolute(TRUE)->toString()], | |
322 ], | |
323 'data' => [ | |
324 0 => $this->getExpectedDocument(1, 'existing.txt', TRUE, TRUE)['data'], | |
325 1 => $this->getExpectedDocument(2, 'example.txt', TRUE, TRUE)['data'], | |
326 2 => $this->getExpectedDocument(3, 'example_0.txt', FALSE, TRUE)['data'], | |
327 ], | |
328 ]; | |
329 $this->assertResponseData($expected, $response); | |
330 | |
331 // The response document received for the POST request is identical to the | |
332 // response document received by GETting the same URL. | |
333 $request_options = []; | |
334 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
335 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
336 $response = $this->request('GET', $uri, $request_options); | |
337 $this->assertSame(200, $response->getStatusCode()); | |
338 $this->assertResponseData($expected, $response); | |
339 | |
340 // Check the actual file data. | |
341 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); | |
342 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); | |
343 } | |
344 | |
345 /** | |
346 * Returns the JSON:API POST document referencing the uploaded file. | |
347 * | |
348 * @return array | |
349 * A JSON:API request document. | |
350 * | |
351 * @see ::testPostFileUpload() | |
352 * @see \Drupal\Tests\jsonapi\Functional\EntityTestTest::getPostDocument() | |
353 */ | |
354 protected function getPostDocument() { | |
355 return [ | |
356 'data' => [ | |
357 'type' => 'entity_test--entity_test', | |
358 'attributes' => [ | |
359 'name' => 'Dramallama', | |
360 ], | |
361 'relationships' => [ | |
362 'field_rest_file_test' => [ | |
363 'data' => [ | |
364 'id' => File::load(1)->uuid(), | |
365 'meta' => [ | |
366 'description' => 'The most fascinating file ever!', | |
367 ], | |
368 'type' => 'file--file', | |
369 ], | |
370 ], | |
371 ], | |
372 ], | |
373 ]; | |
374 } | |
375 | |
376 /** | |
377 * Tests using the file upload POST route with invalid headers. | |
378 */ | |
379 public function testPostFileUploadInvalidHeaders() { | |
380 $this->setUpAuthorization('POST'); | |
381 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
382 | |
383 $uri = Url::fromUri('base:' . static::$postUri); | |
384 | |
385 // The wrong content type header should return a 415 code. | |
386 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => 'application/vnd.api+json']); | |
387 $this->assertSame(415, $response->getStatusCode()); | |
388 | |
389 // An empty Content-Disposition header should return a 400. | |
390 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => FALSE]); | |
391 $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.', $uri, $response); | |
392 | |
393 // An empty filename with a context in the Content-Disposition header should | |
394 // return a 400. | |
395 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']); | |
396 $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $uri, $response); | |
397 | |
398 // An empty filename without a context in the Content-Disposition header | |
399 // should return a 400. | |
400 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']); | |
401 $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $uri, $response); | |
402 | |
403 // An invalid key-value pair in the Content-Disposition header should return | |
404 // a 400. | |
405 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']); | |
406 $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $uri, $response); | |
407 | |
408 // Using filename* extended format is not currently supported. | |
409 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']); | |
410 $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header.', $uri, $response); | |
411 } | |
412 | |
413 /** | |
414 * Tests using the file upload POST route with a duplicate file name. | |
415 * | |
416 * A new file should be created with a suffixed name. | |
417 */ | |
418 public function testPostFileUploadDuplicateFile() { | |
419 $this->setUpAuthorization('POST'); | |
420 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
421 | |
422 $uri = Url::fromUri('base:' . static::$postUri); | |
423 | |
424 // This request will have the default 'application/octet-stream' content | |
425 // type header. | |
426 $response = $this->fileRequest($uri, $this->testFileData); | |
427 | |
428 $this->assertSame(201, $response->getStatusCode()); | |
429 | |
430 // Make the same request again. The file should be saved as a new file | |
431 // entity that has the same file name but a suffixed file URI. | |
432 $response = $this->fileRequest($uri, $this->testFileData); | |
433 $this->assertSame(201, $response->getStatusCode()); | |
434 | |
435 // Loading expected normalized data for file 2, the duplicate file. | |
436 $expected = $this->getExpectedDocument(2, 'example_0.txt'); | |
437 $this->assertResponseData($expected, $response); | |
438 | |
439 // Check the actual file data. | |
440 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt')); | |
441 } | |
442 | |
443 /** | |
444 * Tests using the file upload route with any path prefixes being stripped. | |
445 * | |
446 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Directives | |
447 */ | |
448 public function testFileUploadStrippedFilePath() { | |
449 $this->setUpAuthorization('POST'); | |
450 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
451 | |
452 $uri = Url::fromUri('base:' . static::$postUri); | |
453 | |
454 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']); | |
455 $this->assertSame(201, $response->getStatusCode()); | |
456 $expected = $this->getExpectedDocument(); | |
457 $this->assertResponseData($expected, $response); | |
458 | |
459 // Check the actual file data. It should have been written to the configured | |
460 // directory, not /foobar/directory/example.txt. | |
461 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt')); | |
462 | |
463 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="../../example_2.txt"']); | |
464 $this->assertSame(201, $response->getStatusCode()); | |
465 $expected = $this->getExpectedDocument(2, 'example_2.txt', TRUE); | |
466 $this->assertResponseData($expected, $response); | |
467 | |
468 // Check the actual file data. It should have been written to the configured | |
469 // directory, not /foobar/directory/example.txt. | |
470 $this->assertSame($this->testFileData, file_get_contents('public://foobar/example_2.txt')); | |
471 $this->assertFalse(file_exists('../../example_2.txt')); | |
472 | |
473 // Check a path from the root. Extensions have to be empty to allow a file | |
474 // with no extension to pass validation. | |
475 $this->field->setSetting('file_extensions', '') | |
476 ->save(); | |
477 $this->rebuildAll(); | |
478 | |
479 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="/etc/passwd"']); | |
480 $this->assertSame(201, $response->getStatusCode()); | |
481 $expected = $this->getExpectedDocument(3, 'passwd', TRUE); | |
482 // This mime will be guessed as there is no extension. | |
483 $expected['data']['attributes']['filemime'] = 'application/octet-stream'; | |
484 $this->assertResponseData($expected, $response); | |
485 | |
486 // Check the actual file data. It should have been written to the configured | |
487 // directory, not /foobar/directory/example.txt. | |
488 $this->assertSame($this->testFileData, file_get_contents('public://foobar/passwd')); | |
489 } | |
490 | |
491 /** | |
492 * Tests using the file upload route with a unicode file name. | |
493 */ | |
494 public function testFileUploadUnicodeFilename() { | |
495 $this->setUpAuthorization('POST'); | |
496 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
497 | |
498 $uri = Url::fromUri('base:' . static::$postUri); | |
499 | |
500 // It is important that the filename starts with a unicode character. See | |
501 // https://bugs.php.net/bug.php?id=77239. | |
502 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="Èxample-✓.txt"']); | |
503 $this->assertSame(201, $response->getStatusCode()); | |
504 $expected = $this->getExpectedDocument(1, 'Èxample-✓.txt', TRUE); | |
505 $this->assertResponseData($expected, $response); | |
506 $this->assertSame($this->testFileData, file_get_contents('public://foobar/Èxample-✓.txt')); | |
507 } | |
508 | |
509 /** | |
510 * Tests using the file upload route with a zero byte file. | |
511 */ | |
512 public function testFileUploadZeroByteFile() { | |
513 $this->setUpAuthorization('POST'); | |
514 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
515 | |
516 $uri = Url::fromUri('base:' . static::$postUri); | |
517 | |
518 // Test with a zero byte file. | |
519 $response = $this->fileRequest($uri, NULL); | |
520 $this->assertSame(201, $response->getStatusCode()); | |
521 $expected = $this->getExpectedDocument(); | |
522 // Modify the default expected data to account for the 0 byte file. | |
523 $expected['data']['attributes']['filesize'] = 0; | |
524 $this->assertResponseData($expected, $response); | |
525 | |
526 // Check the actual file data. | |
527 $this->assertSame('', file_get_contents('public://foobar/example.txt')); | |
528 } | |
529 | |
530 /** | |
531 * Tests using the file upload route with an invalid file type. | |
532 */ | |
533 public function testFileUploadInvalidFileType() { | |
534 $this->setUpAuthorization('POST'); | |
535 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
536 | |
537 $uri = Url::fromUri('base:' . static::$postUri); | |
538 | |
539 // Test with a JSON file. | |
540 $response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']); | |
541 $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: <em class=\"placeholder\">txt</em>."), $uri, $response); | |
542 | |
543 // Make sure that no file was saved. | |
544 $this->assertEmpty(File::load(1)); | |
545 $this->assertFalse(file_exists('public://foobar/example.txt')); | |
546 } | |
547 | |
548 /** | |
549 * Tests using the file upload route with a file size larger than allowed. | |
550 */ | |
551 public function testFileUploadLargerFileSize() { | |
552 // Set a limit of 50 bytes. | |
553 $this->field->setSetting('max_filesize', 50) | |
554 ->save(); | |
555 $this->rebuildAll(); | |
556 | |
557 $this->setUpAuthorization('POST'); | |
558 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
559 | |
560 $uri = Url::fromUri('base:' . static::$postUri); | |
561 | |
562 // Generate a string larger than the 50 byte limit set. | |
563 $response = $this->fileRequest($uri, $this->randomString(100)); | |
564 $this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file is <em class=\"placeholder\">100 bytes</em> exceeding the maximum file size of <em class=\"placeholder\">50 bytes</em>."), $uri, $response); | |
565 | |
566 // Make sure that no file was saved. | |
567 $this->assertEmpty(File::load(1)); | |
568 $this->assertFalse(file_exists('public://foobar/example.txt')); | |
569 } | |
570 | |
571 /** | |
572 * Tests using the file upload POST route with malicious extensions. | |
573 */ | |
574 public function testFileUploadMaliciousExtension() { | |
575 // Allow all file uploads but system.file::allow_insecure_uploads is set to | |
576 // FALSE. | |
577 $this->field->setSetting('file_extensions', '')->save(); | |
578 $this->rebuildAll(); | |
579 | |
580 $this->setUpAuthorization('POST'); | |
581 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
582 | |
583 $uri = Url::fromUri('base:' . static::$postUri); | |
584 | |
585 $php_string = '<?php print "Drupal"; ?>'; | |
586 | |
587 // Test using a masked exploit file. | |
588 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']); | |
589 // The filename is not munged because .txt is added and it is a known | |
590 // extension to apache. | |
591 $expected = $this->getExpectedDocument(1, 'example.php.txt', TRUE); | |
592 // Override the expected filesize. | |
593 $expected['data']['attributes']['filesize'] = strlen($php_string); | |
594 $this->assertResponseData($expected, $response); | |
595 $this->assertTrue(file_exists('public://foobar/example.php.txt')); | |
596 | |
597 // Add php as an allowed format. Allow insecure uploads still being FALSE | |
598 // should still not allow this. So it should still have a .txt extension | |
599 // appended even though it is not in the list of allowed extensions. | |
600 $this->field->setSetting('file_extensions', 'php') | |
601 ->save(); | |
602 $this->rebuildAll(); | |
603 | |
604 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_2.php"']); | |
605 $expected = $this->getExpectedDocument(2, 'example_2.php.txt', TRUE); | |
606 // Override the expected filesize. | |
607 $expected['data']['attributes']['filesize'] = strlen($php_string); | |
608 $this->assertResponseData($expected, $response); | |
609 $this->assertTrue(file_exists('public://foobar/example_2.php.txt')); | |
610 $this->assertFalse(file_exists('public://foobar/example_2.php')); | |
611 | |
612 // Allow .doc file uploads and ensure even a mis-configured apache will not | |
613 // fallback to php because the filename will be munged. | |
614 $this->field->setSetting('file_extensions', 'doc')->save(); | |
615 $this->rebuildAll(); | |
616 | |
617 // Test using a masked exploit file. | |
618 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_3.php.doc"']); | |
619 // The filename is munged. | |
620 $expected = $this->getExpectedDocument(3, 'example_3.php_.doc', TRUE); | |
621 // Override the expected filesize. | |
622 $expected['data']['attributes']['filesize'] = strlen($php_string); | |
623 // The file mime should be 'application/msword'. | |
624 $expected['data']['attributes']['filemime'] = 'application/msword'; | |
625 $this->assertResponseData($expected, $response); | |
626 $this->assertTrue(file_exists('public://foobar/example_3.php_.doc')); | |
627 $this->assertFalse(file_exists('public://foobar/example_3.php.doc')); | |
628 | |
629 // Now allow insecure uploads. | |
630 \Drupal::configFactory() | |
631 ->getEditable('system.file') | |
632 ->set('allow_insecure_uploads', TRUE) | |
633 ->save(); | |
634 // Allow all file uploads. This is very insecure. | |
635 $this->field->setSetting('file_extensions', '')->save(); | |
636 $this->rebuildAll(); | |
637 | |
638 $response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_4.php"']); | |
639 $expected = $this->getExpectedDocument(4, 'example_4.php', TRUE); | |
640 // Override the expected filesize. | |
641 $expected['data']['attributes']['filesize'] = strlen($php_string); | |
642 // The file mime should also now be PHP. | |
643 $expected['data']['attributes']['filemime'] = 'application/x-httpd-php'; | |
644 $this->assertResponseData($expected, $response); | |
645 $this->assertTrue(file_exists('public://foobar/example_4.php')); | |
646 } | |
647 | |
648 /** | |
649 * Tests using the file upload POST route no extension configured. | |
650 */ | |
651 public function testFileUploadNoExtensionSetting() { | |
652 $this->setUpAuthorization('POST'); | |
653 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
654 | |
655 $uri = Url::fromUri('base:' . static::$postUri); | |
656 | |
657 $this->field->setSetting('file_extensions', '') | |
658 ->save(); | |
659 $this->rebuildAll(); | |
660 | |
661 $response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']); | |
662 $expected = $this->getExpectedDocument(1, 'example.txt', TRUE); | |
663 | |
664 $this->assertResponseData($expected, $response); | |
665 $this->assertTrue(file_exists('public://foobar/example.txt')); | |
666 } | |
667 | |
668 /** | |
669 * {@inheritdoc} | |
670 */ | |
671 protected function getExpectedUnauthorizedAccessMessage($method) { | |
672 switch ($method) { | |
673 case 'GET': | |
674 return "The current user is not allowed to view this relationship. The 'view test entity' permission is required."; | |
675 | |
676 case 'POST': | |
677 return "The current user is not permitted to upload a file for this field. The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'."; | |
678 | |
679 case 'PATCH': | |
680 return "The current user is not permitted to upload a file for this field. The 'administer entity_test content' permission is required."; | |
681 } | |
682 } | |
683 | |
684 /** | |
685 * Returns the expected JSON:API document for the expected file entity. | |
686 * | |
687 * @param int $fid | |
688 * The file ID to load and create a JSON:API document for. | |
689 * @param string $expected_filename | |
690 * The expected filename for the stored file. | |
691 * @param bool $expected_as_filename | |
692 * Whether the expected filename should be the filename property too. | |
693 * @param bool $expected_status | |
694 * The expected file status. Defaults to FALSE. | |
695 * | |
696 * @return array | |
697 * A JSON:API response document. | |
698 */ | |
699 protected function getExpectedDocument($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE, $expected_status = FALSE) { | |
700 $author = User::load($this->account->id()); | |
701 $file = File::load($fid); | |
702 $self_url = Url::fromUri('base:/jsonapi/file/file/' . $file->uuid())->setAbsolute()->toString(TRUE)->getGeneratedUrl(); | |
703 | |
704 return [ | |
705 'jsonapi' => [ | |
706 'meta' => [ | |
707 'links' => [ | |
708 'self' => ['href' => 'http://jsonapi.org/format/1.0/'], | |
709 ], | |
710 ], | |
711 'version' => '1.0', | |
712 ], | |
713 'links' => [ | |
714 'self' => ['href' => $self_url], | |
715 ], | |
716 'data' => [ | |
717 'id' => $file->uuid(), | |
718 'type' => 'file--file', | |
719 'links' => [ | |
720 'self' => ['href' => $self_url], | |
721 ], | |
722 'attributes' => [ | |
723 'created' => (new \DateTime())->setTimestamp($file->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339), | |
724 'changed' => (new \DateTime())->setTimestamp($file->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339), | |
725 'filemime' => 'text/plain', | |
726 'filename' => $expected_as_filename ? $expected_filename : 'example.txt', | |
727 'filesize' => strlen($this->testFileData), | |
728 'langcode' => 'en', | |
729 'status' => $expected_status, | |
730 'uri' => [ | |
731 'value' => 'public://foobar/' . $expected_filename, | |
732 'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename), | |
733 ], | |
734 'drupal_internal__fid' => (int) $file->id(), | |
735 ], | |
736 'relationships' => [ | |
737 'uid' => [ | |
738 'data' => [ | |
739 'id' => $author->uuid(), | |
740 'type' => 'user--user', | |
741 ], | |
742 'links' => [ | |
743 'related' => ['href' => $self_url . '/uid'], | |
744 'self' => ['href' => $self_url . '/relationships/uid'], | |
745 ], | |
746 ], | |
747 ], | |
748 ], | |
749 ]; | |
750 } | |
751 | |
752 /** | |
753 * Performs a file upload request. Wraps the Guzzle HTTP client. | |
754 * | |
755 * @param \Drupal\Core\Url $url | |
756 * URL to request. | |
757 * @param string $file_contents | |
758 * The file contents to send as the request body. | |
759 * @param array $headers | |
760 * Additional headers to send with the request. Defaults will be added for | |
761 * Content-Type and Content-Disposition. In order to remove the defaults set | |
762 * the header value to FALSE. | |
763 * | |
764 * @return \Psr\Http\Message\ResponseInterface | |
765 * The received response. | |
766 * | |
767 * @see \GuzzleHttp\ClientInterface::request() | |
768 */ | |
769 protected function fileRequest(Url $url, $file_contents, array $headers = []) { | |
770 $request_options = []; | |
771 $headers = $headers + [ | |
772 // Set the required (and only accepted) content type for the request. | |
773 'Content-Type' => 'application/octet-stream', | |
774 // Set the required Content-Disposition header for the file name. | |
775 'Content-Disposition' => 'file; filename="example.txt"', | |
776 // Set the required JSON:API Accept header. | |
777 'Accept' => 'application/vnd.api+json', | |
778 ]; | |
779 $request_options[RequestOptions::HEADERS] = array_filter($headers, function ($value) { | |
780 return $value !== FALSE; | |
781 }); | |
782 $request_options[RequestOptions::BODY] = $file_contents; | |
783 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
784 | |
785 return $this->request('POST', $url, $request_options); | |
786 } | |
787 | |
788 /** | |
789 * {@inheritdoc} | |
790 */ | |
791 protected function setUpAuthorization($method) { | |
792 switch ($method) { | |
793 case 'GET': | |
794 $this->grantPermissionsToTestedRole(['view test entity']); | |
795 break; | |
796 | |
797 case 'POST': | |
798 $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities', 'access content']); | |
799 break; | |
800 | |
801 case 'PATCH': | |
802 $this->grantPermissionsToTestedRole(['administer entity_test content', 'access content']); | |
803 break; | |
804 } | |
805 } | |
806 | |
807 /** | |
808 * Asserts expected normalized data matches response data. | |
809 * | |
810 * @param array $expected | |
811 * The expected data. | |
812 * @param \Psr\Http\Message\ResponseInterface $response | |
813 * The file upload response. | |
814 */ | |
815 protected function assertResponseData(array $expected, ResponseInterface $response) { | |
816 static::recursiveKSort($expected); | |
817 $actual = Json::decode((string) $response->getBody()); | |
818 static::recursiveKSort($actual); | |
819 | |
820 $this->assertSame($expected, $actual); | |
821 } | |
822 | |
823 /** | |
824 * {@inheritdoc} | |
825 */ | |
826 protected function getExpectedUnauthorizedAccessCacheability() { | |
827 // There is cacheability metadata to check as file uploads only allows POST | |
828 // requests, which will not return cacheable responses. | |
829 } | |
830 | |
831 } |