annotate core/modules/jsonapi/tests/src/Functional/FileUploadTest.php @ 19:fa3358dc1485 tip

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