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 }