.../EntityTest/EntityTestHalJsonAnonTest.php | 13 ++ .../EntityResource/Node/NodeHalJsonAnonTest.php | 13 ++ .../EntityResource/Term/TermHalJsonAnonTest.php | 13 ++ .../EntityResource/Block/BlockResourceTestBase.php | 2 +- .../ConfigTest/ConfigTestResourceTestBase.php | 2 +- .../EntityResource/EntityResourceTestBase.php | 143 ++++++++++++++++++--- .../EntityTest/EntityTestResourceTestBase.php | 25 +++- .../EntityResource/Node/NodeResourceTestBase.php | 30 ++++- .../EntityResource/Role/RoleResourceTestBase.php | 2 +- .../EntityResource/Term/TermResourceTestBase.php | 30 ++++- .../Vocabulary/VocabularyResourceTestBase.php | 2 +- .../rest/tests/src/Functional/ResourceTestBase.php | 42 +++++- 12 files changed, 283 insertions(+), 34 deletions(-) diff --git a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php index 935a899..cb0d544 100644 --- a/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php +++ b/core/modules/hal/tests/src/Functional/EntityResource/EntityTest/EntityTestHalJsonAnonTest.php @@ -80,4 +80,17 @@ protected function getExpectedNormalizedEntity() { ]; } + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/entity_test/entity_test', + ], + ], + ]; + } + } diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php index c926da7..cb22d58 100644 --- a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonAnonTest.php @@ -101,4 +101,17 @@ protected function getExpectedNormalizedEntity() { ]; } + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/node/camelids', + ], + ], + ]; + } + } diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php index b2b2bee..6fd2279 100644 --- a/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php +++ b/core/modules/hal/tests/src/Functional/EntityResource/Term/TermHalJsonAnonTest.php @@ -54,4 +54,17 @@ protected function getExpectedNormalizedEntity() { ]; } + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return parent::getNormalizedEntityToCreate() + [ + '_links' => [ + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/taxonomy_term/camelids', + ], + ], + ]; + } + } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php index f77a522..8c692b6 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Block/BlockResourceTestBase.php @@ -22,7 +22,7 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { + protected function setUpAuthorization($method) { $this->grantPermissionsToTestedRole(['administer blocks']); } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php index 36315f3..be91643 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/ConfigTest/ConfigTestResourceTestBase.php @@ -19,7 +19,7 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { + protected function setUpAuthorization($method) { $this->grantPermissionsToTestedRole(['view config_test']); } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 80a21d2..ded166b 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -3,10 +3,12 @@ namespace Drupal\Tests\rest\Functional\EntityResource; use Drupal\Component\Serialization\Json; +use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\EntityType; use Drupal\Core\Url; use Drupal\Tests\rest\Functional\ResourceTestBase; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\RequestOptions; use Psr\Http\Message\ResponseInterface; /** @@ -64,6 +66,9 @@ public function setUp() { // Create an entity. $this->entity = $this->createEntity(); + + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); } /** @@ -86,7 +91,7 @@ public function setUp() { public function testGet() { // @todo test what happens before provisioning. Edge case: node. - $this->setUpAuthorization(); + $this->setUpAuthorization('GET'); // Provision REST resource. $this->provisionEntityResource(); @@ -210,29 +215,127 @@ public function testGet() { } } - // @todo try the following request bodies: - // 1. empty (only makes sense for DELETE and GET), assert 400 - // 2. not parseable, assert 400 - // 3. parseable but invalid (with various possible errors, possibly entity type specific ones?), assert 422 - // 4. parseable and valid, assert 201 - public function atestPost() { - // Attempt to use REST resource while not provisioned. - $this->performUnsafeOperation('POST'); - $this->assertSame(404, $this->getSession()->getStatusCode()); - $this->assertFalse(EntityType::loadMultiple(), 'No entity has been created in the database.'); + public function testPost() { + // @todo Remove this in https://www.drupal.org/node/2300677. + if ($this->entity instanceof ConfigEntityInterface) { + $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.'); + return; + } + + // Try with all of the following request bodies. + $unparseable_request_body = '!{>}<'; + $parseable_valid_request_body = $this->serializer->encode($this->getNormalizedEntityToCreate(), static::$format); + $parseable_invalid_request_body = $this->serializer->encode($this->getInvalidNormalizedEntityToCreate(), 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->getPostUrl()->setOption('query', ['_format' => static::$format]); + $request_options = []; + + + // DX: 404 when resource not provisioned. + $response = $this->request('POST', $url, $request_options); + // @todo Open issue to improve this message: it should list '/relative/url?query-string', not just '/relative/url'. + $this->assertResourceErrorResponse(404, 'No route found for "POST ' . $this->getPostUrl()->toString() . '"', $response); - // Provision REST resource. $this->provisionEntityResource(); - // Try as user without 'create' access for this entity type. - $this->performUnsafeOperation('POST'); - $this->assertSame(403, $this->getSession()->getStatusCode()); - $this->assertSession()->responseContains('not allowed …'); - // Try as user with sufficient permissions. - $this->performUnsafeOperation('POST'); - $this->assertSame(201, $this->getSession()->getStatusCode()); - $this->assertSession()->responseContains('…'); + // DX: 415 when no Content-Type request header. + $response = $this->request('POST', $url, $request_options); + // @todo update once https://www.drupal.org/node/2811133 lands. + $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: "', $response); + + + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + + + // DX: 400 when no request body. + $response = $this->request('POST', $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('POST', $url, $request_options); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813853 lands. +// $this->assertResourceErrorResponse(400, 'Syntax error', $response); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['error' => 'Syntax error'], static::$format), (string) $response->getBody()); + + + + $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; + + + if (static::$auth) { + // DX: forgetting authentication: authentication provider-specific error + // response. + $response = $this->request('POST', $url, $request_options); + $this->verifyResponseWhenMissingAuthentication($response); + } + + + $request_options = array_merge_recursive($request_options, $this->getAuthenticationRequestOptions()); + + + // DX: 403 when unauthorized. + $response = $this->request('POST', $url, $request_options); + // @todo Update this to the improved error message when https://www.drupal.org/node/2808233 lands. + $this->assertResourceErrorResponse(403, '', $response); + + + $this->setUpAuthorization('POST'); + + + // DX: 422 when invalid entity. + $response = $this->request('POST', $url, $request_options); + $label_field = $this->entity->getEntityType()->getKey('label'); + $label_field_capitalized = ucfirst($label_field); + // @todo use this commented line instead of the 3 lines thereafter once https://www.drupal.org/node/2813755 lands. +// $this->assertErrorResponse(422, "Unprocessable Entity: validation failed.\ntitle: Title: this field cannot hold more than 1 values.\n", $response); + $this->assertSame(422, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame($this->serializer->encode(['message' => "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n"], static::$format), (string) $response->getBody()); + + + $request_options[RequestOptions::BODY] = $parseable_valid_request_body; + + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + $this->assertSame([str_replace($this->entity->id(), 2, $this->entity->toUrl('canonical')->setAbsolute(TRUE)->toString())], $response->getHeader('Location')); + } + + /** + * Gets an entity resource's POST URL. + * + * @return \Drupal\Core\Url + * The URL to POST to. + */ + protected function getPostUrl() { + $has_canonical_url = $this->entity->hasLinkTemplate('https://www.drupal.org/link-relations/create'); + return $has_canonical_url ? $this->entity->toUrl() : Url::fromUri('base:entity/' . static::$entityType); + } + + /** + * Decorates ::getNormalizedEntityToCreate(). + */ + protected function getInvalidNormalizedEntityToCreate() { + $normalization = $this->getNormalizedEntityToCreate(); + + // Add a second label to this entity to make it invalid. + $label_field = $this->entity->getEntityType()->getKey('label'); + $normalization[$label_field][1]['value'] = 'Second Title'; + + return $normalization; } /** diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php index f4fc77f..deaf1e8 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityTest/EntityTestResourceTestBase.php @@ -21,8 +21,15 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { - $this->grantPermissionsToTestedRole(['view test entity']); + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['view test entity']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities']); + break; + } } /** @@ -87,4 +94,18 @@ protected function getExpectedNormalizedEntity() { return $normalization; } + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return [ + 'type' => 'entity_test', + 'name' => [ + [ + 'value' => 'Dramallama', + ], + ], + ]; + } + } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php index e77d1ff..f32105e 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Node/NodeResourceTestBase.php @@ -22,8 +22,15 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { - $this->grantPermissionsToTestedRole(['access content']); + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access content']); + break; + case 'POST': + $this->grantPermissionsToTestedRole(['access content', 'create camelids content']); + break; + } } /** @@ -139,5 +146,22 @@ protected function getExpectedNormalizedEntity() { ]; } - // methods to provide one-off expectations/data/… + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return [ + 'type' => [ + [ + 'target_id' => 'camelids', + ], + ], + 'title' => [ + [ + 'value' => 'Dramallama', + ], + ], + ]; + } + } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php index 091a84a..f5406d4 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Role/RoleResourceTestBase.php @@ -21,7 +21,7 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { + protected function setUpAuthorization($method) { $this->grantPermissionsToTestedRole(['administer permissions']); } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php index cddb4c8..77372df 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Term/TermResourceTestBase.php @@ -21,8 +21,16 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { - $this->grantPermissionsToTestedRole(['access content']); + protected function setUpAuthorization($method) { + switch ($method) { + case 'GET': + $this->grantPermissionsToTestedRole(['access content']); + break; + case 'POST': + // @todo Create issue similar to https://www.drupal.org/node/2808217. + $this->grantPermissionsToTestedRole(['administer taxonomy']); + break; + } } /** @@ -92,4 +100,22 @@ protected function getExpectedNormalizedEntity() { ]; } + /** + * {@inheritdoc} + */ + protected function getNormalizedEntityToCreate() { + return [ + 'vid' => [ + [ + 'target_id' => 'camelids', + ], + ], + 'name' => [ + [ + 'value' => 'Dramallama', + ], + ], + ]; + } + } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php index d2522ba..235dc7d 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php @@ -23,7 +23,7 @@ /** * {@inheritdoc} */ - protected function setUpAuthorization() { + protected function setUpAuthorization($method) { $this->grantPermissionsToTestedRole(['administer taxonomy']); } diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php index 39f7da3..a4d133b 100644 --- a/core/modules/rest/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php @@ -2,10 +2,12 @@ namespace Drupal\Tests\rest\Functional; +use Drupal\Core\Url; use Drupal\rest\RestResourceConfigInterface; use Drupal\Tests\BrowserTestBase; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; +use GuzzleHttp\Exception\ClientException; use Psr\Http\Message\ResponseInterface; /** @@ -140,8 +142,8 @@ protected function provisionResource($resource_type, $formats = [], $authenticat 'authentication' => $authentication, ] ])->save(); - - $this->container->get('router.builder')->rebuild(); + // @todo Remove this in https://www.drupal.org/node/2815845. + drupal_flush_all_caches(); } /** @@ -153,12 +155,19 @@ protected function provisionResource($resource_type, $formats = [], $authenticat * In case of a test verifying behavior when using a particular authentication * provider: create a user with a particular set of permissions. * + * Because of the $method parameter, it's possible to first set up + * authentication for only GET, then add POST, et cetera. This then also + * allows for verifying a 403 in case of missing authorization. + * + * @param string $method + * The HTTP method for which to set up authentication. + * * @return void * * @see ::grantPermissionsToAnonymousRole() * @see ::grantPermissionsToAuthenticatedRole() */ - abstract protected function setUpAuthorization(); + abstract protected function setUpAuthorization($method); /** * Verifies the error response in case of missing authentication. @@ -217,6 +226,33 @@ protected function grantPermissionsToTestedRole(array $permissions) { } } + /** + * Performs a HTTP request. Wraps the Guzzle HTTP client. + * + * Why wrap the Guzzle HTTP client? Because any error response is returned via + * an exception, which would make the tests unnecessarily complex to read. + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + * + * @return \Psr\Http\Message\ResponseInterface + */ + protected function request($method, Url $url, array $request_options) { + try { + $response = $this->httpClient->request($method, $url->toString(), $request_options); + } + catch (ClientException $e) { + $response = $e->getResponse(); + } + return $response; + } + /** * Asserts that a resource response has the given status code and body. *