You are here

public function EntityResourceTestBase::testPatch in Drupal 9

Same name and namespace in other branches
  1. 8 core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
  2. 10 core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()

Tests a PATCH request for an entity, plus edge cases to ensure good DX.

2 methods override EntityResourceTestBase::testPatch()
EntityTestComputedFieldNormalizerTest::testPatch in core/modules/system/tests/modules/entity_test/tests/src/Functional/Rest/EntityTestComputedFieldNormalizerTest.php
Tests a PATCH request for an entity, plus edge cases to ensure good DX.
MessageResourceTestBase::testPatch in core/modules/contact/tests/src/Functional/Rest/MessageResourceTestBase.php
Tests a PATCH request for an entity, plus edge cases to ensure good DX.

File

core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php, line 866

Class

EntityResourceTestBase
Even though there is the generic EntityResource, it's necessary for every entity type to have its own test, because they each have different fields, validation constraints, et cetera. It's not because the generic case works, that every case…

Namespace

Drupal\Tests\rest\Functional\EntityResource

Code

public function testPatch() {

  // @todo Remove this in https://www.drupal.org/node/2300677.
  if ($this->entity instanceof ConfigEntityInterface) {
    $this
      ->assertTrue(TRUE, 'PATCHing config entities is not yet supported.');
    return;
  }

  // Patch testing requires that another entity of the same type exists.
  $this->anotherEntity = $this
    ->createAnotherEntity();
  $this
    ->initAuthentication();
  $has_canonical_url = $this->entity
    ->hasLinkTemplate('canonical');

  // Try with all of the following request bodies.
  $unparseable_request_body = '!{>}<';
  $parseable_valid_request_body = $this->serializer
    ->encode($this
    ->getNormalizedPatchEntity(), static::$format);
  $parseable_invalid_request_body = $this->serializer
    ->encode($this
    ->makeNormalizationInvalid($this
    ->getNormalizedPatchEntity(), 'label'), static::$format);
  $parseable_invalid_request_body_2 = $this->serializer
    ->encode($this
    ->getNormalizedPatchEntity() + [
    'field_rest_test' => [
      [
        'value' => $this
          ->randomString(),
      ],
    ],
  ], static::$format);

  // The 'field_rest_test' field does not allow 'view' access, so does not end
  // up in the normalization. Even when we explicitly add it the normalization
  // that we send in the body of a PATCH request, it is considered invalid.
  $parseable_invalid_request_body_3 = $this->serializer
    ->encode($this
    ->getNormalizedPatchEntity() + [
    'field_rest_test' => $this->entity
      ->get('field_rest_test')
      ->getValue(),
  ], static::$format);

  // The URL and Guzzle request options that will be used in this test. The
  // request options will be modified/expanded throughout this test:
  // - to first test all mistakes a developer might make, and assert that the
  //   error responses provide a good DX
  // - to eventually result in a well-formed request that succeeds.
  $url = $this
    ->getEntityResourceUrl();
  $request_options = [];

  // DX: 404 when resource not provisioned, 405 if canonical route. Plain text
  // or HTML response because missing ?_format query string.
  $response = $this
    ->request('PATCH', $url, $request_options);
  if ($has_canonical_url) {
    $this
      ->assertSame(405, $response
      ->getStatusCode());
    $this
      ->assertSame([
      'GET, POST, HEAD',
    ], $response
      ->getHeader('Allow'));
    $this
      ->assertSame([
      'text/html; charset=UTF-8',
    ], $response
      ->getHeader('Content-Type'));
    $this
      ->assertStringContainsString('A client error happened', (string) $response
      ->getBody());
  }
  else {
    $this
      ->assertSame(404, $response
      ->getStatusCode());
    $this
      ->assertSame([
      'text/html; charset=UTF-8',
    ], $response
      ->getHeader('Content-Type'));
  }
  $url
    ->setOption('query', [
    '_format' => static::$format,
  ]);

  // DX: 404 when resource not provisioned, 405 if canonical route.
  $response = $this
    ->request('PATCH', $url, $request_options);
  if ($has_canonical_url) {
    $this
      ->assertResourceErrorResponse(405, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this
      ->getEntityResourceUrl()
      ->setAbsolute()
      ->toString()) . '": Method Not Allowed (Allow: GET, POST, HEAD)', $response);
  }
  else {
    $this
      ->assertResourceErrorResponse(404, 'No route found for "PATCH ' . str_replace($this->baseUrl, '', $this
      ->getEntityResourceUrl()
      ->setAbsolute()
      ->toString()) . '"', $response);
  }
  $this
    ->provisionEntityResource();

  // Simulate the developer again forgetting the ?_format query string.
  $url
    ->setOption('query', []);

  // DX: 415 when no Content-Type request header.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertSame(415, $response
    ->getStatusCode());
  $this
    ->assertSame([
    'text/html; charset=UTF-8',
  ], $response
    ->getHeader('Content-Type'));
  $this
    ->assertStringContainsString('A client error happened', (string) $response
    ->getBody());
  $url
    ->setOption('query', [
    '_format' => static::$format,
  ]);

  // DX: 415 when no Content-Type request header.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(415, 'No "Content-Type" request header specified', $response);
  $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
  if (static::$auth) {

    // DX: forgetting authentication: authentication provider-specific error
    // response.
    $response = $this
      ->request('PATCH', $url, $request_options);
    $this
      ->assertResponseWhenMissingAuthentication('PATCH', $response);
  }
  $request_options = NestedArray::mergeDeep($request_options, $this
    ->getAuthenticationRequestOptions('PATCH'));

  // DX: 403 when unauthorized.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, $this
    ->getExpectedUnauthorizedAccessMessage('PATCH'), $response);
  $this
    ->setUpAuthorization('PATCH');

  // DX: 400 when no request body.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(400, 'No entity content received.', $response);
  $request_options[RequestOptions::BODY] = $unparseable_request_body;

  // DX: 400 when unparseable request body.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(400, 'Syntax error', $response);
  $request_options[RequestOptions::BODY] = $parseable_invalid_request_body;

  // DX: 422 when invalid entity: multiple values sent for single-value field.
  $response = $this
    ->request('PATCH', $url, $request_options);
  if ($label_field = $this->entity
    ->getEntityType()
    ->hasKey('label') ? $this->entity
    ->getEntityType()
    ->getKey('label') : static::$labelFieldName) {
    $label_field_capitalized = $this->entity
      ->getFieldDefinition($label_field)
      ->getLabel();
    $this
      ->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n{$label_field}: {$label_field_capitalized}: this field cannot hold more than 1 values.\n", $response);
  }
  $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;

  // DX: 403 when entity contains field without 'edit' access.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);

  // DX: 403 when entity trying to update an entity's ID field.
  $request_options[RequestOptions::BODY] = $this->serializer
    ->encode($this
    ->makeNormalizationInvalid($this
    ->getNormalizedPatchEntity(), 'id'), static::$format);
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('id')}'. The entity ID cannot be changed.", $response);
  if ($this->entity
    ->getEntityType()
    ->hasKey('uuid')) {

    // DX: 403 when entity trying to update an entity's UUID field.
    $request_options[RequestOptions::BODY] = $this->serializer
      ->encode($this
      ->makeNormalizationInvalid($this
      ->getNormalizedPatchEntity(), 'uuid'), static::$format);
    $response = $this
      ->request('PATCH', $url, $request_options);
    $this
      ->assertResourceErrorResponse(403, "Access denied on updating field '{$this->entity->getEntityType()->getKey('uuid')}'. The entity UUID cannot be changed.", $response);
  }
  $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;

  // DX: 403 when entity contains field without 'edit' nor 'view' access, even
  // when the value for that field matches the current value. This is allowed
  // in principle, but leads to information disclosure.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(403, "Access denied on updating field 'field_rest_test'.", $response);

  // DX: 403 when sending PATCH request with updated read-only fields.
  $this
    ->assertPatchProtectedFieldNamesStructure();
  list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity);

  // Send PATCH request by serializing the modified entity, assert the error
  // response, change the modified entity field that caused the error response
  // back to its original value, repeat.
  foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) {
    $request_options[RequestOptions::BODY] = $this->serializer
      ->serialize($modified_entity, static::$format);
    $response = $this
      ->request('PATCH', $url, $request_options);
    $this
      ->assertResourceErrorResponse(403, "Access denied on updating field '" . $patch_protected_field_name . "'." . ($reason !== NULL ? ' ' . $reason : ''), $response);
    $modified_entity
      ->get($patch_protected_field_name)
      ->setValue($original_values[$patch_protected_field_name]);
  }
  if ($this->entity instanceof FieldableEntityInterface) {

    // Change the rest_test_validation field to prove that then its validation
    // does run.
    $override = [
      'rest_test_validation' => [
        [
          'value' => 'ALWAYS_FAIL',
        ],
      ],
    ];
    $valid_request_body = $override + $this
      ->getNormalizedPatchEntity() + $this->serializer
      ->normalize($modified_entity, static::$format);
    $request_options[RequestOptions::BODY] = $this->serializer
      ->serialize($valid_request_body, static::$format);
    $response = $this
      ->request('PATCH', $url, $request_options);
    $this
      ->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nrest_test_validation: REST test validation failed\n", $response);

    // Set the rest_test_validation field to always fail validation, which
    // allows asserting that not modifying that field does not trigger
    // validation errors.
    $this->entity
      ->set('rest_test_validation', 'ALWAYS_FAIL');
    $this->entity
      ->save();

    // Information disclosure prevented: when a malicious user correctly
    // guesses the current invalid value of a field, ensure a 200 is not sent
    // because this would disclose to the attacker what the current value is.
    // @see rest_test_entity_field_access()
    $response = $this
      ->request('PATCH', $url, $request_options);
    $this
      ->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nrest_test_validation: REST test validation failed\n", $response);

    // All requests after the above one will not include this field (neither
    // its current value nor any other), and therefore all subsequent test
    // assertions should not trigger a validation error.
  }

  // 200 for well-formed PATCH request that sends all fields (even including
  // read-only ones, but with unchanged values).
  $valid_request_body = $this
    ->getNormalizedPatchEntity() + $this->serializer
    ->normalize($this->entity, static::$format);
  $request_options[RequestOptions::BODY] = $this->serializer
    ->serialize($valid_request_body, static::$format);
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceResponse(200, FALSE, $response);
  $request_options[RequestOptions::BODY] = $parseable_valid_request_body;

  // Before sending a well-formed request, allow the normalization and
  // authentication provider edge cases to also be tested.
  $this
    ->assertNormalizationEdgeCases('PATCH', $url, $request_options);
  $this
    ->assertAuthenticationEdgeCases('PATCH', $url, $request_options);
  $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml';

  // DX: 415 when request body in existing but not allowed format.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $response);
  $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;

  // 200 for well-formed request.
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceResponse(200, FALSE, $response);
  $this
    ->assertFalse($response
    ->hasHeader('X-Drupal-Cache'));

  // Assert that the entity was indeed updated, and that the response body
  // contains the serialized updated entity.
  $updated_entity = $this->entityStorage
    ->loadUnchanged($this->entity
    ->id());
  $updated_entity_normalization = $this->serializer
    ->normalize($updated_entity, static::$format, [
    'account' => $this->account,
  ]);
  $this
    ->assertSame($updated_entity_normalization, $this->serializer
    ->decode((string) $response
    ->getBody(), static::$format));
  $this
    ->assertStoredEntityMatchesSentNormalization($this
    ->getNormalizedPatchEntity(), $updated_entity);

  // Ensure that fields do not get deleted if they're not present in the PATCH
  // request. Test this using the configurable field that we added, but which
  // is not sent in the PATCH request.
  $this
    ->assertSame('All the faith they had had had had no effect on the outcome of their life.', $updated_entity
    ->get('field_rest_test')->value);

  // Multi-value field: remove item 0. Then item 1 becomes item 0.
  $normalization_multi_value_tests = $this
    ->getNormalizedPatchEntity();
  $normalization_multi_value_tests['field_rest_test_multivalue'] = $this->entity
    ->get('field_rest_test_multivalue')
    ->getValue();
  $normalization_remove_item = $normalization_multi_value_tests;
  unset($normalization_remove_item['field_rest_test_multivalue'][0]);
  $request_options[RequestOptions::BODY] = $this->serializer
    ->encode($normalization_remove_item, static::$format);
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceResponse(200, FALSE, $response);
  $this
    ->assertSame([
    0 => [
      'value' => 'Two',
    ],
  ], $this->entityStorage
    ->loadUnchanged($this->entity
    ->id())
    ->get('field_rest_test_multivalue')
    ->getValue());

  // Multi-value field: add one item before the existing one, and one after.
  $normalization_add_items = $normalization_multi_value_tests;
  $normalization_add_items['field_rest_test_multivalue'][2] = [
    'value' => 'Three',
  ];
  $request_options[RequestOptions::BODY] = $this->serializer
    ->encode($normalization_add_items, static::$format);
  $response = $this
    ->request('PATCH', $url, $request_options);
  $this
    ->assertResourceResponse(200, FALSE, $response);
  $this
    ->assertSame([
    0 => [
      'value' => 'One',
    ],
    1 => [
      'value' => 'Two',
    ],
    2 => [
      'value' => 'Three',
    ],
  ], $this->entityStorage
    ->loadUnchanged($this->entity
    ->id())
    ->get('field_rest_test_multivalue')
    ->getValue());
}