function EntityResourceTestBase::testPatch

Same name in other branches
  1. 8.9.x 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()
  3. 11.x 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 859

Class

EntityResourceTestBase
Defines a base class for testing all entity resources.

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->markTestSkipped('PATCHing config entities is not yet supported.');
    }
    // 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();
    [
        $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());
}

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.